Compare commits

...

57 Commits

Author SHA1 Message Date
Claude
e80de242cb Run a single live Kimi scenario: kimi-k3 over the Anthropic shape
The Moonshot platform serves this account exactly one model — kimi-k3.
kimi-k2-thinking and the kimi-latest alias both return
resource_not_found_error on either API surface, so two concurrent Kimi
provider records would claim the same model and route ambiguously
(the router's path-prefix tiebreak needs the client to author the
/anthropic prefix, which the standard wire shapes don't). Keep the
flagship Claude Code path — kimi-k3 via /anthropic, green in run #74 —
as the live scenario; the OpenAI wire shape stays covered by the other
chat-shaped matrix providers and passed with kimi-k3 in run #73.
2026-07-21 13:02:03 +00:00
Claude
b3b70fe581 Use kimi-latest for the OpenAI-shape Kimi e2e case
kimi-k2-thinking returns resource_not_found_error on both Moonshot
surfaces for this account — K2-era models aren't served to newer
platform accounts — so the OpenAI-shape case rides the always-available
kimi-latest alias instead. kimi-k3 stays on the Anthropic shape, which
passed in run #74. Model strings remain distinct for routing.
2026-07-21 12:37:28 +00:00
Claude
014dcd4698 Pair each Kimi wire shape with a model its endpoint serves
Moonshot's Anthropic-compatible /anthropic surface only serves the
current coding flagship: requesting kimi-k2-thinking there returns
resource_not_found_error ('Not found the model kimi-k2-thinking or
Permission denied'), while kimi-k3 works — it's the model Moonshot's
own Claude Code guide configures. Swap the pairing: kimi-k3 rides the
Anthropic shape and kimi-k2-thinking the OpenAI shape (which serves
every platform model). Model strings stay distinct across the two
provider records, keeping routing unambiguous.
2026-07-21 12:14:15 +00:00
Claude
4d88973bb2 Use the full Bedrock inference-profile id in the e2e matrix
The region-prefix fix wasn't sufficient: Bedrock ids also carry a
date/version suffix (…-20251001-v1:0), and AWS rejects the bare form
with 'The provided model identifier is invalid' regardless of region.
Send the id exactly as AWS issues it and mirror the proxy's
NormalizeBedrockModel (prefix + version-suffix strip, not importable
from here) when deriving the guardrail allowlist entry — which is also
precisely the round-trip the normalization change in 82fdfa8 exists to
handle.
2026-07-21 11:49:42 +00:00
Claude
1af45da3a4 Re-run agent-network e2e after Kimi account top-up 2026-07-21 11:41:35 +00:00
Claude
7d9f7d0d69 Match the Bedrock e2e inference profile to the configured region
The matrix hardcoded the us. cross-region inference profile while the
upstream host follows AWS_REGION, so any non-US region made Bedrock
reject every request with 'The provided model identifier is invalid' —
including the allowlisted call in TestModelAllowlistEnforced, which
asserts a 200. Derive the profile prefix (us/eu/apac/us-gov) from the
region, default the region to eu-central-1 to match the CI credentials,
and strip whichever prefix was chosen when building the guardrail
allowlist.
2026-07-21 11:26:37 +00:00
Claude
a79363d801 Force eager peer connections in the agent-network e2e harness
The nightly agent-network e2e has been red for ~6 days: every scenario
that calls WaitProxyPeer times out with 'client did not see the proxy
peer'. The proxy's overlay peer comes up fine, but the client now puts
it into the lazy connection manager (server-driven feature flag), so no
connection is dialed until traffic flows — and WaitProxyPeer polls
'netbird status' for a Connected peer before sending any traffic.

Set NB_LAZY_CONN=false on the harness client and proxy containers so
the tunnel dials eagerly and status reflects it, restoring the
harness's readiness signal.
2026-07-21 10:03:00 +00:00
Claude
e7412361c7 Add trustedproxy to the proxy image build context
724c6a0 introduced the top-level trustedproxy package and the proxy
imports it, but proxy/Dockerfile.multistage enumerates copied
directories and was not updated, so the agent-network e2e harness
failed to build the proxy image (module provides no such package).
The combined and client images COPY the whole tree and are unaffected.
2026-07-21 09:34:39 +00:00
Claude
46c8b08c55 Temporarily run agent-network e2e on pushes to this branch
Remove before merge — workflow_dispatch isn't available to this
session's tooling, so a push trigger stands in for the manual run.
2026-07-21 09:08:22 +00:00
Claude
d3af2297d3 Merge remote-tracking branch 'origin/main' into feat/kimi-3-agent-networks 2026-07-21 09:05:41 +00:00
Maycon Santos
82fdfa84b8 [proxy] match Bedrock provider models against the normalized request model (#6773)
## Describe your changes

Native AWS Bedrock requests carry the model in the URL path as a
cross-region inference-profile id (e.g.
`us.anthropic.claude-haiku-4-5`). The request parser normalizes that to
the catalog key (`anthropic.claude-haiku-4-5`) before the router runs,
but the router matched it against the operator's registered provider
models with exact string equality. So a Bedrock provider registered with
the id Bedrock actually uses (`us.anthropic…`) never matched a
normalized request → the request denied with
`llm_policy.model_not_routable` ("no provider configured for model …").
Only a provider registered with the already-stripped catalog id worked,
which is not how Bedrock ids appear.

Fix: introduce a single shared `llm.NormalizeBedrockModel` (the same
ARN/region-prefix/version-suffix stripping the parser already does) and,
in the router's `routeClaimsModel`, normalize a **Bedrock** route's
candidate models before comparing. Now a Bedrock provider registered
with either the raw inference-profile id or the normalized catalog id
matches the request. Non-Bedrock routes keep exact matching.

Surfaced by the new native-Bedrock e2e (`WireBedrock`,
`/model/{id}/invoke`); the old e2e used the Anthropic body shape, which
never normalized either side and so hid this.

The request parser keeps its own identical normalizer for now;
de-duplicating it onto `llm.NormalizeBedrockModel` is a trivial
follow-up.

## Issue ticket number and link

N/A — follow-up to the Agent Network Bedrock support / model-allowlist
work.

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal routing correctness fix; no user-facing surface change.

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

## Tests

- `proxy/internal/llm`: `NormalizeBedrockModel` unit cases (region
prefixes, version suffixes, ARN).
- `proxy/internal/middleware/builtin/llm_router`: `routeClaimsModel`
matches a Bedrock route registered with the raw `us.anthropic…` id
against a normalized request model; non-Bedrock routes still match
exactly.

Note: full through-tunnel e2e verification of this (the native-Bedrock
`TestProvidersMatrix/bedrock`) also needs the DNS lazy-connection
warm-up (separate PR) to get the client past the proxy-peer gate; they
converge once both land.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved Amazon Bedrock model matching across ARN formats, regional
prefixes, and version or throughput suffixes.
* Bedrock routes now correctly match equivalent model identifiers even
when requests and route configurations use different formats.
  * Non-Bedrock model matching remains exact.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 10:10:12 +02:00
Zoltan Papp
ca80e49aa0 [client] Refresh WireGuard stats in mobile debug bundles (#6814)
The iOS and Android DebugBundle paths built GeneratorDependencies
without setting RefreshStatus, so the bundle's status.txt read the
cached peer state instead of live WireGuard interface stats. When the
periodic health probe had not run yet, connected relayed peers showed
"handshake: -" and "0 B/0 B" even though the interface was passing
traffic.

Wire RefreshStatus to RunHealthProbes on both platforms, matching the
desktop daemon path in client/server/debug.go. The engine reference is
already available in the cc.Engine() block used for client metrics.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6814"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786881440&installation_id=146802194&pr_number=6814&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6814&signature=c07cbcef1028a366312f7743e6479f2fddafd568aac498ab95828a372ac6843c"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved debug bundle generation on Android and iOS by refreshing
connection health status before collecting diagnostic information.
* Ensured debug bundles include more current health-related data for
troubleshooting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 09:25:33 +02:00
Claude
8434880ec4 Add Kimi provider scenarios to agent-network e2e matrix
Gated on KIMI_TOKEN (CI: E2E_KIMI_TOKEN, a Moonshot platform key), like
the other provider credentials — the matrix skips Kimi when unset. Two
cases cover Moonshot's two wire shapes through the kimi_api catalog
entry: OpenAI Chat Completions against the bare host with kimi-k3, and
the Anthropic Messages API against the /anthropic path prefix with
kimi-k2-thinking, keeping model->provider routing unambiguous while
both records are enabled.
2026-07-21 06:54:40 +00:00
Claude
1a6968c8fd Add CLAUDE.md with git conventions for AI-assisted commits 2026-07-21 05:16:37 +00:00
Claude
9f02148688 Add Kimi (Moonshot AI) provider to Agent Network catalog
Adds a first-class kimi_api catalog entry so operators can govern Kimi K3
and K2 Thinking traffic through the Agent Network proxy instead of riding
the generic custom/vllm entry.

ParserID is left empty so the proxy's URL sniffer dispatches both body
shapes Moonshot serves on the same host and key: the OpenAI-compatible
/v1/chat/completions endpoint and the Anthropic-compatible
/anthropic/v1/messages endpoint that Moonshot's official Claude Code
setup uses (same pattern as the Bifrost gateway entry).

Pricing defaults cover both parser surfaces, including the kimi-k3[1m]
model id some Claude Code guides use for the 1M-context alias, so cost
metering doesn't silently skip those requests.

The dashboard's PROVIDER_CATALOG needs a matching entry (separate repo).
2026-07-21 04:04:01 +00:00
Zoltan Papp
51f17bf919 [client] Update wails to v3.0.0-alpha2.117 (#6837)
## Describe your changes

Update wails to v3.0.0-alpha2.117

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6837"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787156447&installation_model_id=427504&pr_number=6837&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6837&signature=600f7cb9ecda9ee75b7fe841ed81149507d00dbf6323c61aa92ac2acdaea2487"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Updated the application framework dependency to a newer release.
  * Removed an obsolete supporting dependency requirement.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-20 21:12:22 +02:00
Zoltan Papp
724c6a06e6 [relay] only trust X-Real-Ip headers from configured trusted proxies (#6833)
The WS listener unconditionally trusted X-Real-Ip/X-Real-Port headers,
letting any client forge the source address the relay logs. Gate header
trust behind a trusted-proxy allowlist; ignore the headers unless the
immediate peer matches a configured prefix. Defaults to never trusting
the headers when the allowlist is empty.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6833"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787141580&installation_id=146802194&pr_number=6833&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6833&signature=9cf182cc7be248e457dfdb56e8a047401276d8cc567ed8ae715ec1cc809f1b6a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added `--trusted-proxies` to configure a comma-separated allowlist of
trusted upstream proxy IPs/CIDRs.
* **Behavior Changes**
* Relay WebSocket now uses `X-Real-Ip` / `X-Real-Port` only when the
immediate peer is from the configured trusted set; otherwise it falls
back to the direct remote address.
* Proxy client IP resolution is now consistent and honors
`X-Forwarded-For` only through trusted hops.
* **Operational**
  * Invalid `--trusted-proxies` values fail fast on startup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-20 18:15:03 +02:00
Maycon Santos
d64e9542eb [proxy] Bedrock cost-allocation metadata + per-provider metadata_disabled (#6791) 2026-07-20 14:45:50 +02:00
Viktor Liu
3fb26d458e [relay] Remove deprecated Hello handshake and gob token decode (#6783) 2026-07-20 14:13:40 +02:00
s-shimizu-clpl
a411fd300c [client] Add Japanese (ja) UI translation (#6790)
## Describe your changes
Adds a Japanese (ja) locale for the desktop UI, following the procedure
in `client/ui/i18n/TRANSLATING.md`.

- New `client/ui/i18n/locales/ja/common.json` — all 441 keys from the
`en` source bundle, `message` only, same key order.
- Registered in `client/ui/i18n/locales/_index.json`: `{"code": "ja",
"displayName": "日本語", "englishName": "Japanese"}`.

Translation notes:
- Placeholders (`{version}`, `{count}`, `{name}`, `{remaining}`, ...),
`\n`, trailing `…`/`...`, the leading space in
`notify.update.enforcedSuffix`, and the `<docs>…</docs>` inline-link
tags are all preserved verbatim.
- Brands kept as-is: NetBird, WireGuard® (® preserved), Rosenpass,
GitHub, NetBird Cloud. Acronyms kept: SSO, DNS, IP/IPv6, ACL, SSH, JWT,
TTL, SFTP, MTU, PSK, LAN, P2P, ICE, IdP.
- Formal register (です・ます); short labels for buttons/tray; quoted UI
labels use Japanese brackets 「」.

No code changes are required: the React frontend auto-loads every
`locales/*/common.json` via `import.meta.glob`, the tray/Go side embeds
the tree via `//go:embed all:i18n/locales`, and the language picker
lists whatever `_index.json` declares.

### Verification
- `go test ./client/ui/i18n/` passes.
- JSON valid; key set and order identical to `en`; no `description`
fields; no empty messages; placeholder/`\n` counts match `en`.
- Built the Windows UI and confirmed the General, Network, Security, and
Troubleshooting settings tabs plus the main window render correctly in
Japanese (language picker shows "日本語 (Japanese)"), with no text
truncation, overflow, or leaked placeholders.

## Issue ticket number and link
N/A

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— adding a shipped UI locale is the documented extension path in
`client/ui/i18n/TRANSLATING.md`.

> 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change — UI translation
strings only; no user-facing product docs are affected.

### Docs PR URL (required if "docs added" is checked)
N/A

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6790"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786742881&installation_id=146802194&pr_number=6790&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6790&signature=a1dcd7faf63ae10071ca2da3639b4daf6505eeb0258790e622222e742a8ebcb6"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added Japanese as a supported interface language.
* **Localization**
* Provided full Japanese translations for the UI, covering navigation,
settings, onboarding, desktop/tray notifications, connection and status
messaging, troubleshooting, update/about screens, and
authentication/error dialogs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-07-20 14:12:13 +02:00
Viktor Liu
92a5ed19d3 [client] Merge 0.74.7 to main (#6819)
## Describe your changes

Merge 0.74.7 to main

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6819"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786890471&installation_id=146802194&pr_number=6819&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6819&signature=6078105908fabf6520f504b594cc394dc2a2d749a709c88c22f2d187301f6c71"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Firewall now safely evaluates fragmented inbound traffic while
blocking unsupported, malformed, overlapping, or unapproved fragments.
* SOCKS5 listener address and port can be configured through environment
variables, defaulting safely to loopback.
* **Bug Fixes**
* Strengthened token validation for malformed authentication responses.
* Prevented invalid hostnames from injecting settings into generated SSH
configurations.
* Improved handling of command-line-like account names and concurrent
QUIC connections.
* **Tests**
* Added coverage and performance benchmarks for fragmentation, listener
configuration, and security validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-18 00:07:44 +09:00
Viktor Liu
be6777427d Merge remote-tracking branch 'origin/main' into 0.74.7-branch-sync
# Conflicts:
#	client/internal/peer/conn.go
#	client/internal/peer/conn_test.go
#	client/internal/peer/wg_watcher.go
#	client/internal/peer/wg_watcher_test.go
#	go.mod
#	go.sum
2026-07-17 16:34:58 +02:00
Viktor Liu
a1c9427d80 [client] Evaluate IP fragments against firewall ACLs (#6781) 2026-07-17 16:05:32 +02:00
Pascal Fischer
a59d7fba95 [management] propagate auth grant types for combined server (#6817) 2026-07-17 15:41:57 +02:00
Zoltan Papp
41d7bf4bbd [client] Diagnose empty vs corrupt state (#6816)
## Describe your changes
When loadStateFile fails to unmarshal the state file, log whether the
file is empty (0 bytes) or has malformed content, including the byte
size.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
aste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved state-file loading warnings by distinguishing empty files
from files containing malformed content.
  * Preserved existing recovery behavior for corrupted state files.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-17 15:31:07 +02:00
Riccardo Manfrin
b7b0d5796e [client] Bind netstack SOCKS5 proxy to 127.0.0.1 by default (#6812)
## Describe your changes

In netstack mode the SOCKS5 proxy bridges local host applications into
the
userspace WireGuard stack (`client/iface/netstack/proxy.go`), so it only
needs
to be reachable from the same machine. It was binding to `0.0.0.0`,
making an
unauthenticated proxy reachable from the network — any host able to
reach the
port could relay traffic through the client into its NetBird overlay.

Bind to `127.0.0.1` by default. Add `NB_SOCKS5_LISTENER_ADDRESS` to
override the
bind host for the rare case the proxy must be reachable from other hosts
(e.g. a
container gateway); it is validated as an IP and falls back to loopback.
`ListenAddr` is split into `listenHost`/`listenPort` helpers, with
tests.

Behavior change: setups that relied on reaching the netstack SOCKS5
proxy from
another host must now set `NB_SOCKS5_LISTENER_ADDRESS=0.0.0.0`
explicitly.

## Issue ticket number and link

Internal security hardening of the netstack SOCKS5 listener bind address

([client/iface/netstack/env.go](https://github.com/netbirdio/netbird/blob/main/client/iface/netstack/env.go)).

## Stack

- `0.74.7-branch` - ⚠️ No PR associated with branch <!--
branch-stack -->
  - \#6812 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] 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).

## Documentation

Select exactly one:

- [x] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

<https://github.com/netbirdio/docs/pull/860>

<!-- codesmith:footer -->

***

<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6812"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786872199&installation_id=146802194&pr_number=6812&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6812&signature=6e78df77b784915baee9647b555483a8f7550a604d5393edcc501dd8f58b1395"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->

<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added configuration options for the SOCKS5 listener’s bind address and
port.
- SOCKS5 now defaults to listening only on the local machine for
improved security.
- Valid address and port overrides are supported, with safe defaults
used for invalid values.

- **Tests**
- Added coverage for default settings and valid or invalid address and
port configurations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-17 15:09:09 +02:00
Pascal Fischer
21fc5b81f6 [management] allow disabling device code flow when using dex (#6809) 2026-07-17 12:41:08 +02:00
dmitri-netbird
9906b9b1a1 [management] fix a flake in account_test (#6811)
## Describe your changes
In
"TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved",
in the beginning of the test, during account creation a random /16
subnet from 10.64.0./10 network is used. Later in the test a new range
(10.99.0.0/16) is assigned to the account, but it's one of the possible
subnets used during account creation, which sometimes leads to a
collision and failed test.
Using a network outside of the range of networks used during account
creation fixes the issue.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6811"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786868463&installation_id=146802194&pr_number=6811&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6811&signature=c0ded1a8ecc05686b6ba19eb366519852ac35e170da4f8522fb7aec2062d4a3e"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated account network range test coverage to verify peer IP
reallocation with a distinct network range.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-17 11:21:11 +02:00
Riccardo Manfrin
3f8c447378 [client] Rename isValidAccessToken to reflect audience-only check (#6806)
## Describe your changes

`isValidAccessToken` only decodes the JWT payload and checks the
audience
claim, but its name suggested full token validation. Rename it to
`validateTokenAudience` and document what it does: a client-side
audience/shape
check on a token just obtained from the IdP over TLS. Token authenticity
is
enforced server-side by the management server, which verifies the
signature
against the IdP's JWKS (`shared/auth/jwt/validator.go`) on every
request.

Also harden the parser: a non-empty token lacking the three-part JWT
structure
caused an index-out-of-range panic (`strings.Split(token, ".")[1]`); the
shape
is now validated first. `parseEmailFromIDToken` is documented as
best-effort UX
data (login hint/display), never used for authorization. Added tests for
audience matching, malformed tokens, and the panic regression.

Changes:

- Rename `isValidAccessToken` → `validateTokenAudience`; document that
it does
not verify the signature and that authenticity is enforced server-side.
- Fix an index-out-of-range panic on a non-empty token lacking JWT
structure
(`strings.Split(token, ".")[1]`) by validating the three-part shape
first.
- Document `parseEmailFromIDToken` as best-effort/unverified, used only
for the
  login-hint/display UX, never for an authorization decision.
- Add `util_test.go` covering audience matching (string and array),
missing
  audience, malformed payloads, and the panic regression.

## Issue ticket number and link

Internal cleanup: rename a misleadingly-named client-side helper and
harden JWT
parsing against malformed input (`client/internal/auth/util.go`).

## Stack

- `0.74.7-branch` - ⚠️ No PR associated with branch <!--
branch-stack -->
  - \#6806 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)

> 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).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal client-side helper rename plus a panic hardening fix. No public
API,
gRPC, CLI/service flag, or configuration change; token authenticity
enforcement
(server-side JWKS verification) is unchanged.

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

N/A

<!-- codesmith:footer -->

***

<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6806"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786811124&installation_id=146802194&pr_number=6806&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6806&signature=fe45ce9f6df46a609594f84037aaab2a21893621f29d2be48d7f8b347c3c8776"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->

<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved access-token audience validation during device and PKCE
authentication flows.
- Malformed tokens now return clear validation errors instead of risking
runtime failures.
  - Added support for validating both string and array audience claims.

- **Tests**
- Added coverage for malformed tokens, invalid claims, missing
audiences, and panic prevention.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-17 11:00:55 +02:00
Viktor Liu
6e3f4d8722 [client] Disable gVisor TCP RACK loss detection on Windows (#6808) 2026-07-17 10:45:30 +02:00
dmitri-netbird
877e889250 [management] fix fetching of missing settings in GetAccount call (#6800)
## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [x] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6800"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786797566&installation_id=146802194&pr_number=6800&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6800&signature=36a7053e1029e5de1d496fbd9c428a1745d0ec22b3e6479d64aafb1b8350b0ef"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Ensure account settings are fully preserved through save/load,
including automatic update and peer exposure preferences.

* **Tests**
* Added coverage to verify account settings remain unchanged after
database persistence and retrieval (skipped on Windows due to SQLite
limitations).
* Introduced deterministic test-data population helpers to reliably set
struct fields for deeper settings verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-17 10:38:43 +02:00
Riccardo Manfrin
099ae4bc6c [client] Sanitize peer FQDN/hostname in generated SSH config (#6805)
## Describe your changes

Validate peer-supplied FQDN and hostname before they are written into
the
generated NetBird SSH client config (`client/ssh/config/manager.go`).
These
values originate from remote peers and were previously written verbatim
into
the config; malformed values (e.g. containing unexpected characters)
could
produce a broken or unintended config. FQDN/hostname are now checked
with
`domain.IsValidDomainNoWildcard`, and invalid, non-empty values are
dropped
with a warning. IPs are unaffected (already validated `netip.Addr`).
Added a
test covering malformed hostnames.

## Issue ticket number and link

Internal input-validation hardening for peer-supplied hostnames in the
generated SSH client config (`client/ssh/config/manager.go`).

## Stack

- \#6726 <!-- branch-stack -->
  - \#6805 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)

> 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).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal client SSH config generation. No public API, gRPC, CLI/service
flag,
or configuration change — only input validation on peer-supplied
hostnames
before they are written to the generated ssh\_config.

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

N/A
2026-07-17 10:10:22 +02:00
Viktor Liu
63d60ba490 [client] Reject leading hyphen in getent input to prevent flag injection (#6787) 2026-07-17 09:28:33 +02:00
Zoltan Papp
d15830a2d0 [client] Sync 0.74.6 fix/ios-relogin (#6795)
## sync 0.74.6 fix/ios-relogin

NewAuth built a fresh in-memory config on every call via
CreateInMemoryConfig, which generates a new WireGuard private key when
none is set. The iOS Swift layer calls this on interactive re-login and
writes the resulting config back to the profile's netbird.cfg, so each
re-auth replaced the peer's persisted private key with a new one. A new
key means a new public key, so the management server registered a
brand-new peer on every re-authentication — named after the fallback
hostname.

Load the existing config with DirectUpdateOrCreateConfig when a config
file is already present so re-login reuses the peer's persisted private
key (and its identity). Only fall back to a fresh in-memory config for
the first-time login when no config file exists yet (or after logout,
which deletes the file). DirectUpdateOrCreateConfig uses non-atomic
writes so it also works inside the tvOS App Group sandbox. This matches
what Run() and LoginForMobile() already do.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked) Paste the PR link
from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6795"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786784867&installation_id=146802194&pr_number=6795&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6795&signature=01bd94ea58128257c33f4d430ab3b71ce21eac02577ce90c009105b8c0027d9c"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved iOS login handling when a configuration location is provided.
- Existing WireGuard keys can now be reused across subsequent logins,
helping avoid unnecessary key regeneration.
- Login continues to support temporary in-memory configuration when no
persistent location is available.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-16 14:38:01 +02:00
Zoltan Papp
141f3d0390 [client] Fix DNS probe listener impossible panic on unparseable local address (#6797)
generateFreePort used netip.MustParseAddrPort on the OS-produced
LocalAddr().String(), which panics on address strings that don't parse.
Eliminate the parsing entirely by reading the port from the concrete
*net.UDPAddr that net.ListenUDP returns, and construct the bind address
directly. The probe listener is bound with udp4 so only an IPv4 wildcard
address is ever used.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved reliability when selecting an ephemeral UDP port.
  * Avoided potential failures when determining the assigned port.
* Preserved existing error handling and diagnostic logging for listener
operations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-16 14:37:27 +02:00
Pascal Fischer
e1a24376ab [management] build routes for peer cache on network map components (#6780) 2026-07-15 18:24:48 +02:00
Viktor Liu
62fc8d254e [relay] Handle QUIC connections concurrently to prevent handshake head-of-line blocking (#6784) 2026-07-15 18:18:47 +02:00
Zoltan Papp
3a2f773d65 [client] preserve WireGuard key on interactive re-login (#6777)
NewAuth built a fresh in-memory config on every call via
CreateInMemoryConfig, which generates a new WireGuard private key when
none is set. The iOS Swift layer calls this on interactive re-login and
writes the resulting config back to the profile's netbird.cfg, so each
re-auth replaced the peer's persisted private key with a new one. A new
key means a new public key, so the management server registered a
brand-new peer on every re-authentication — named after the fallback
hostname.

Load the existing config with DirectUpdateOrCreateConfig when a config
file is already present so re-login reuses the peer's persisted private
key (and its identity). Only fall back to a fresh in-memory config for
the first-time login when no config file exists yet (or after logout,
which deletes the file). DirectUpdateOrCreateConfig uses non-atomic
writes so it also works inside the tvOS App Group sandbox. This matches
what Run() and LoginForMobile() already do.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for loading or creating persistent configuration when a
configuration file path is provided.
* Continued support for in-memory configuration for temporary or
first-time use.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 14:12:12 +02:00
Pascal Fischer
8f901f8899 [management] enable pprof via env var (#6778) 2026-07-15 12:05:40 +02:00
Maycon Santos
c6bf5fbbfb [management,client] 0.74.5 branch sync (#6769)
## Describe your changes
* [proxy] enforce model allowlist for URL-routed providers
(Bedrock/Vertex) by @mlsmaycon in
https://github.com/netbirdio/netbird/pull/6764
* [management] Remove proxy peer stale deduplication logic by @mlsmaycon
in https://github.com/netbirdio/netbird/pull/6768
## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added model-allowlist guardrails for path-routed providers, including
Bedrock and Vertex.
  - Added Bedrock request support for chat interactions.
  - Added guardrail management capabilities.

- **Bug Fixes**
- Requests with missing or blank model identifiers are now denied when a
model allowlist is configured, improving fail-closed protection.
- Corrected provider-specific request handling and session tracking for
Bedrock interactions.

- **Tests**
- Expanded coverage for allowlist enforcement and provider routing
scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Theodor Midtlien <theodor@midtlien.com>
Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com>
Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
2026-07-14 21:22:40 +02:00
Maycon Santos
f0eed7564f [management] Remove proxy peer stale deduplication logic (#6768)
## Describe your changes

Removing a leftover from an initial implementation. We ended up
resolving it on the client with status checks on the DNS response

## Issue ticket number and link

## Stack

- \#6726 <!-- branch-stack -->
  - \#6768 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] 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).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

<https://github.com/netbirdio/docs/pull/>\_\_

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added support for Bedrock-native request routing in agent network
scenarios.
- Added guardrail management capabilities for creating and removing
model allowlists.

- **Bug Fixes**
- Model allowlists now reject requests when the model is missing or
blank.
- Improved Rosenpass and WireGuard recovery after repeated handshake
failures.
- Improved relay connection handling so status and cleanup operations
remain responsive during stalled connections.
- Updated private service DNS zones to avoid unintended search-domain
behavior.

- **Tests**
- Expanded coverage for model allowlists, handshake recovery, relay
concurrency, and Bedrock routing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 20:13:00 +02:00
Maycon Santos
277d8e4c53 [proxy] enforce model allowlist for URL-routed providers (Bedrock/Vertex) (#6764)
## Describe your changes

The Agent Network policy Guardrail "Model Allowlist" was not enforced
for providers whose model travels in the URL/path rather than the JSON
body — most visibly AWS Bedrock (reported in netbirdio/netbird#6751),
and the same class applies to Google Vertex.

Root cause: the `llm_guardrail` allowlist check **failed open**.
`evaluateAllowlist` returned allow whenever the request model was absent
from the metadata bag (`middleware.go`, `if !modelPresent { return nil
}`). The model is stamped upstream by `llm_request_parser`; for
body-routed providers (OpenAI/Anthropic) it comes from the JSON body,
but for path-routed providers the model is recovered only when the
request matches a recognized path shape (Bedrock
`/model/{id}/{invoke|converse|...}`, Vertex
`/v1/projects/.../publishers/.../models/...`). Any shape the parser did
not recognize reached the guardrail with no model and was allowed
regardless of the allowlist.

Fix (provider-agnostic): **fail closed**. When an allowlist is
configured and the model cannot be determined (absent or empty), the
request is denied `403` with a distinct `llm_policy.model_unknown`
reason. This closes the bypass for Bedrock, Vertex, and any future
URL-routed provider in one place. When no allowlist is configured,
behavior is unchanged.

The model allowlist is enforced solely in the proxy `llm_guardrail`;
management's `CheckLLMPolicyLimits` handles only token/budget caps, so
no management change is required.

## Issue ticket number and link

<https://github.com/netbirdio/netbird/discussions/6751>

## Stack

- \#6726 <!-- branch-stack -->
  - \#6764 :point\_left:

### Checklist

- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] 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).

## Documentation

Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Bug fix that restores the documented allowlist behavior; no user-facing
surface changes.

### Docs PR URL (required if "docs added" is checked)

Paste the PR link from <https://github.com/netbirdio/docs> here:

<https://github.com/netbirdio/docs/pull/>\_\_

## Tests

- `llm_guardrail`: absent/empty model under a configured allowlist now
denies (`model_unknown`); empty allowlist still allows a missing model
(fail-closed only applies when a list is set); existing
allow/deny/case-insensitive cases retained.
- `llm_request_parser`: new parser→guardrail integration test drives
real **Bedrock** (`/model/{id}/invoke`) and **Vertex**
(`/v1/projects/.../models/...`) URL shapes and asserts allowed→200,
disallowed→403 (`model_blocked`), and an unrecognized Bedrock action→403
(`model_unknown`, the #6751 regression guard).

Note: a full through-tunnel e2e for the allowlist is intentionally
deferred — the agent-network e2e (`WaitProxyPeer`) is currently red on
`main`/`0.74.x` for an unrelated lazy-connection reason; it will be
added once that harness gate is fixed.
2026-07-14 19:03:01 +02:00
David Fry
e70a69bbcf [client] Restore residual state in foreground mode before login (#6707)
* Improved residual state restoration during foreground startup and
foreground login, ensuring consistent recovery with stale states.
* Foreground flows now initialize advanced routing so stale routes 
are bypassed during login.
2026-07-14 17:43:59 +02:00
Riccardo Manfrin
a48618c074 [client] Fix forwarder peers never excluded from lazy connections (#6674)
* [client] Extract peerRoutesAddr helper in toExcludedLazyPeers

Refactor: pull the AllowedIPs match into a named
peerRoutesAddr helper and document why forward-target peers are excluded
from lazy connections. No behavior change; the existing address match is
preserved as-is.

* [client] Add failing test for lazy-conn forward-target exclusion

toExcludedLazyPeers compares AllowedIPs (CIDR) against the unmasked
TranslatedAddress, so forward-target peers are never excluded. This test
asserts the peer is excluded and fails on the current behavior; the fix
follows.

* [client] Fix lazy-conn exclusion for ingress forward peers

peerRoutesAddr compared AllowedIPs (CIDR, e.g. a peer's overlay IP as /32)
against the unmasked TranslatedAddress string, so the match never fired and
forward-target peers were never excluded from lazy connections. Use prefix
containment so a routed address matches the peer's AllowedIP

* [client] Reuse parsed AllowedIPs from peerStore in lazy exclusion

Instead of re-parsing the network map AllowedIPs strings, look up the
already-parsed []netip.Prefix from peerStore.AllowedIPs (the same typed
value the lazy manager itself consumes). A down/lazy peer still has its
conn in the store, so exclusion is unaffected by connection state. Extract
a pure prefixesContain helper and unit-test it.
2026-07-14 12:12:37 +02:00
Riccardo Manfrin
39193396f5 [client] Fix WGWatcher silently failing to restart on fast disconnect/reconnect (#6664)
* Stick new watcher creation to actual existence of af the conn

and its removal to the removal of such same conn.
Avoid debouncing and cross lock dead locking

* Discriminate not updated from timeout handshakes

* [Recheck watcher ctx cancellation under conn.mu in onWGDisconnected

onWGDisconnected only checked conn.ctx (the engine-scoped context), never
the watcher's own context. disableWgWatcherIfNeeded cancels the wgWatcherCtx,
not conn.ctx, so a disabled watcher's timeout callback did not see the
cancellation.

handshakeCheck runs lock-free, so between the ctx check in periodicHandshakeCheck
and acquiring conn.mu a fast disconnect/reconnect can slip in: the stale watcher
then acquires the lock and tears down the *new*, healthy connection based on the
old timeout, forcing the guard into an unnecessary reconnect (flap).

Recheck watcherCtx.Err() under conn.mu so a superseded watcher exits without
touching the connection that replaced it.

* Remove verbose comments

* Fixup merge conflict leftovers

* Fixup context brought by onWGDisconnected
2026-07-14 10:21:59 +02:00
Riccardo Manfrin
5343402385 [client, relay] Increase early-message buffer cap to 10000 to avoid dropping relayed handshakes 2026-07-13 22:46:03 +02:00
Pascal Fischer
62703ca23e [management] add logs to ephemeral delete (#6747) 2026-07-13 19:06:22 +02:00
Viktor Liu
cc64a93953 [client] Include system events in ToProtoFullStatus conversion (#6746) 2026-07-13 18:49:17 +02:00
Maycon Santos
831325d6e2 [management] require dashboard_features.agent_network when enabling agent_network_only (#6750)
Adds a settings constraint: enabling `agent_network_only` requires `dashboard_features.agent_network` to be `true` in the same account update. Without the Agent Network menu flag, a focused account that later turns the focused view off would lose access to the Agent Network menu entirely, so the two must be set together.

The check runs in `updateAccountRequestSettings` against the parsed request state: if the resulting settings have `agent_network_only == true` but `dashboard_features.agent_network` is not `true`, the update is rejected with `status.InvalidArgument` (HTTP 422) before anything is persisted.

The OpenAPI field descriptions for `agent_network_only` and `dashboard_features.agent_network` document the requirement. Only the descriptions changed — `required` and the schema `$ref` are untouched — and `types.gen.go` was regenerated from the spec (diff is the two comment lines).
2026-07-13 17:28:19 +02:00
Maycon Santos
8f64173574 [client] Enable launch-on-login by default on fresh GUI installs (#6738)
* [client] Add autostart preference marker and MDM disableAutostart key

Adds the autostartInitialized marker to the Wails UI preferences store so
the one-time autostart default decision can persist per OS user, and a
UI-only disableAutostart MDM policy key that suppresses the default and
flows into GetConfigResponse.mDMManagedFields like disableAutoConnect.

* [client] Enable launch-on-login by default on fresh GUI installs

On the first interactive run the GUI persists the autostartInitialized
marker before any enable attempt, then enables autostart only when the
platform supports it, MDM policy does not disable it, the process was not
relaunched by an installer/updater (--post-update), and the installer's
fresh-install breadcrumb is present. Upgrading users have no breadcrumb,
so an update can never write login items, and a user's disable in
Settings is never overridden.

* [release] Write fresh-install breadcrumb from installers

Installers write a .fresh-install breadcrumb on fresh installs only and
delete stale breadcrumbs on upgrade; none of them writes login items or
registry Run keys. Windows NSIS detects upgrades via the uninstall
registry entry or an existing installed executable; the macOS pkg via the
previous pkgutil receipt; Linux deb/rpm via the standard postinstall
arguments. Post-update GUI relaunches (macOS open, Linux
ui-post-install.sh) pass --post-update so the first-run autostart default
cannot fire on updates.

* Revert installer breadcrumb changes

The real Windows installer does uninstall-then-install and deletes
$INSTDIR, so a breadcrumb written there cannot survive or discriminate
a fresh install from an upgrade. Restore the three installer files to
their main versions; no installer or updater writes an autostart entry.

* Detect fresh install from NetBird footprint instead of installer breadcrumb

Replace the installer-written breadcrumb discriminator with a GUI-side
check. netbirdFootprintExists inspects the daemon config/state files
(default.json, legacy config.json, state.json) under profilemanager's
default config dir; combined with whether the UI preferences file already
existed, this tells a genuinely fresh machine from an existing or
upgrading user. Only the signed GUI, via Wails, ever enables
launch-on-login, and a user's later manual disable is never overridden.
The preferences store now exposes ExistedAtLoad and the --post-update
flag is dropped.

* Update tests for footprint-based autostart default

Table tests for shouldEnableAutostartDefault now cover supported,
mdmDisabled, and priorInstall guards plus precedence; breadcrumb and
post-update cases are removed. Add a store test asserting ExistedAtLoad
is false with no file and true after persisting and reopening.
2026-07-13 13:39:57 +02:00
Maycon Santos
76877e83c4 [client] Bring the connection up in Go after SSO login (#6744)
* [client] Bring the connection up in Go after SSO login

The post-login Up ran as a frontend promise continuation after WaitSSOLogin
resolved. During SSO the tray window is hidden and the webview is suspended
(macOS App Nap / hidden-window timer throttling), so that continuation didn't
run until the user woke the window (e.g. hovering the tray icon), leaving the
client not connected for a long time. Combine WaitSSOLogin and Up in a single
Go method so the daemon connects the moment SSO completes, independent of
webview state. The frontend no longer issues a separate Up on the SSO path.

* [client] unexport waitSSOLogin and move below exported methods
2026-07-13 13:32:47 +02:00
Maycon Santos
ecd398d895 [management] Add dashboard_features account setting (#6742)
Introduce a nullable dashboard_features object on account settings, serialized
to a single JSON column so new dashboard sections can be added without schema
changes. Starts with agent_network (show the Agent Network menu for an account
without the deployment flag). Wires the API handler mapping, the pgx GetAccount
loader, and adds store round-trip and handler tests.
2026-07-12 21:16:37 +02:00
Viktor Liu
3d87547d95 [client] Bump golang.org/x/crypto to v0.54.0 and Go toolchain to 1.25.12 (#6709) 2026-07-10 17:42:06 +02:00
Viktor Liu
4d4cc551fd [client] Recover from rosenpass key desync (#6714) 2026-07-10 17:38:29 +02:00
blaugrau90
08e46aa62f [management] fix: prevent reverse proxy domain from being pushed as DNS search domain (#6498)
SynthesizePrivateServiceZones created CustomZones for private services
without setting SearchDomainDisabled, causing the reverse proxy domain
to be injected as a search domain suffix on all connected peers.

This broke local hostname resolution: short names like 'myserver' were
expanded to 'myserver.app.example.com' (matching the reverse proxy
domain) before local DNS search domains were tried.

Fix: set SearchDomainDisabled: true so the zone is registered as a
match-only supplemental resolver, consistent with the NonAuthoritative
intent already expressed on the same zone.
2026-07-10 12:20:57 +02:00
Theodor Midtlien
7cd5c1732b [client] Fix hanging status command during relay dial (#6694)
* Add regression test for relay state lock
* Make connect not hold a lock in openConnVia
2026-07-08 14:36:42 +02:00
Maycon Santos
816d80602f [client] Update gopsutil to v4 (#6688) 2026-07-08 10:15:31 +02:00
120 changed files with 5262 additions and 1194 deletions

View File

@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
# TEMPORARY: run on pushes to the Kimi feature branch to exercise the new
# provider scenarios with E2E_KIMI_TOKEN. Remove before merging.
push:
branches: [feat/kimi-3-agent-networks]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -51,6 +55,9 @@ jobs:
# token (and URL, for gateways) is unset, so partial coverage is fine.
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}

6
CLAUDE.md Normal file
View File

@@ -0,0 +1,6 @@
# Repository guidance for Claude
## Git conventions
- Do not add `Co-Authored-By` or other AI attribution trailers to commit messages.
- Do not use `claude/` (or otherwise Claude-named) branch names; use conventional prefixes like `feat/`, `fix/`, `chore/`.

View File

@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
deps.RefreshStatus = func() {
e.RunHealthProbes(context.Background(), true)
}
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}

View File

@@ -17,7 +17,9 @@ import (
"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"
)
@@ -331,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
// 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)

View File

@@ -22,6 +22,8 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/util"
@@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
// 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)

View File

@@ -121,6 +121,7 @@ type Manager struct {
udpTracker *conntrack.UDPTracker
icmpTracker *conntrack.ICMPTracker
tcpTracker *conntrack.TCPTracker
fragments *fragmentTracker
forwarder atomic.Pointer[forwarder.Forwarder]
pendingCapture atomic.Pointer[forwarder.PacketCapture]
logger *nblog.Logger
@@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error {
}
}
// 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)
@@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
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 {
@@ -299,6 +337,7 @@ 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
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
m.tcpTracker.Close()
}
if m.fragments != nil {
m.fragments.Close()
}
if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(nil)
fwder.Stop()
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
return true
}
// TODO: pass fragments of routed packets to forwarder
// gopacket does not decode the transport header of any IP fragment, so
// fragments take a dedicated path: the first fragment's header is decoded
// and ACL-evaluated here, and the remaining fragments inherit its verdict.
if fragment {
if m.logger.Enabled(nblog.LevelTrace) {
if d.decoded[0] == layers.LayerTypeIPv4 {
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
} else {
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
}
}
return false
return m.filterInboundFragment(d, srcIP, dstIP, size)
}
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
}
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
// decoded (non-fragment) inbound packet. It returns true if the packet should
// be dropped.
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
// TODO: optimize port DNAT by caching matched rules in conntrack
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
// Re-decode after port DNAT translation to update port information
@@ -1089,33 +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)
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
}
m.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: uuid.New(),
Type: nftypes.TypeDrop,
RuleID: ruleID,
Direction: nftypes.Ingress,
Protocol: pnum,
SourceIP: srcIP,
DestIP: dstIP,
SourcePort: srcPort,
DestPort: dstPort,
// TODO: icmp type/code
RxPackets: 1,
RxBytes: uint64(size),
})
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
return true
}
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
if !pass {
proto := getProtocolFromPacket(d)
if m.logger.Enabled(nblog.LevelTrace) {
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
}
m.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: uuid.New(),
Type: nftypes.TypeDrop,
RuleID: ruleID,
Direction: nftypes.Ingress,
Protocol: proto,
SourceIP: srcIP,
DestIP: dstIP,
SourcePort: srcPort,
DestPort: dstPort,
// TODO: icmp type/code
RxPackets: 1,
RxBytes: uint64(size),
})
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
d, srcIP, dstIP, ruleID, size)
return true
}

View File

@@ -5,7 +5,9 @@ import (
"fmt"
"net"
"net/netip"
"os"
"runtime"
"strconv"
"sync"
"time"
@@ -31,6 +33,11 @@ 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 {
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
maxInFlight = iosMaxInFlight
}
configureTCPRecovery(s)
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
@@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
logger.Debug1("forwarder: raw %s socket access available", network)
return true
}
// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
// it interacts poorly with the host and collapses throughput on routed TCP
// connections (gVisor issue #9778). Other platforms keep the default. The
// EnvForceTCPRACK environment variable overrides the platform default.
func configureTCPRecovery(s *stack.Stack) {
disableRACK := runtime.GOOS == "windows"
if val := os.Getenv(envForceTCPRACK); val != "" {
force, err := strconv.ParseBool(val)
if err != nil {
log.Warnf("parse %s: %v", envForceTCPRACK, err)
} else {
disableRACK = !force
}
}
if !disableRACK {
return
}
opt := tcpip.TCPRecovery(0)
if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
log.Warnf("disable TCP RACK loss detection: %v", err)
return
}
log.Info("forwarder: TCP RACK loss detection disabled")
}

View File

@@ -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()
}

View File

@@ -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))
}
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -299,7 +299,7 @@ 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)
}

View File

@@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
audience = p.providerConfig.ClientID
}
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
}
@@ -320,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
return tokenInfo, nil
}
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
// without verifying its signature. The value is best-effort and used only as a
// UX convenience (login hint prefill and display); it never drives an
// authorization decision. The authoritative identity is established server-side
// from the signature-verified token.
func parseEmailFromIDToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -480,7 +480,6 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize,
ProfileName: profName,

View File

@@ -292,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
}

View File

@@ -2605,13 +2605,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
}
@@ -2621,6 +2622,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 {

View File

@@ -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
}

View File

@@ -203,7 +203,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
statusICE: worker.NewAtomicStatus(),
dumpState: dumpState,
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
metricsRecorder: services.MetricsRecorder,
}
@@ -671,11 +670,12 @@ func (conn *Conn) onGuardEvent() {
}
}
func (conn *Conn) onWGDisconnected() {
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.mu.Lock()
defer conn.mu.Unlock()
if conn.ctx.Err() != nil {
// watcherCtx guards against a stale watcher tearing down a connection that already superseded it.
if conn.ctx.Err() != nil || watcherCtx.Err() != nil {
return
}
@@ -833,25 +833,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
})
}
// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its
// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown.
// Caller must hold conn.mu.
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
if !conn.wgWatcher.PrepareInitialHandshake() {
if conn.wgWatcher != nil {
return
}
watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState)
watcher.PrepareInitialHandshake()
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
conn.wgWatcher = watcher
conn.wgWatcherCancel = wgWatcherCancel
conn.wgWatcherWg.Add(1)
go func() {
defer conn.wgWatcherWg.Done()
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) }
watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess)
}()
}
// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never
// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so
// blocking would deadlock. Caller must hold conn.mu.
func (conn *Conn) disableWgWatcherIfNeeded() {
if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil {
conn.wgWatcherCancel()
conn.wgWatcherCancel = nil
if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil {
return
}
conn.wgWatcherCancel()
conn.wgWatcher = nil
conn.wgWatcherCancel = nil
}
func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
@@ -874,7 +888,9 @@ func (conn *Conn) resetEndpoint() {
return
}
conn.Log.Infof("reset wg endpoint")
conn.wgWatcher.Reset()
if conn.wgWatcher != nil {
conn.wgWatcher.Reset()
}
if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil {
conn.Log.Warnf("failed to remove endpoint address before update: %v", err)
}

View File

@@ -339,20 +339,20 @@ func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
conn.onWGDisconnected()
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.onWGDisconnected(conn.ctx)
}
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
conn.onWGDisconnected()
conn.onWGDisconnected(conn.ctx)
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
}
@@ -364,12 +364,12 @@ func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
conn.onWGDisconnected(conn.ctx)
}
conn.onWGCheckSuccess()
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected()
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
}
@@ -382,7 +382,7 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
conn := newWGTimeoutTestConn(false, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
conn.onWGDisconnected()
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
}

View File

@@ -3,7 +3,6 @@ package peer
import (
"context"
"fmt"
"sync"
"time"
log "github.com/sirupsen/logrus"
@@ -24,14 +23,14 @@ type WGInterfaceStater interface {
GetStats() (map[string]configurer.WGStats, error)
}
// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded.
// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale.
type WGWatcher struct {
log *log.Entry
wgIfaceStater WGInterfaceStater
peerKey string
stateDump *stateDump
enabled bool
muEnabled sync.Mutex
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
initialHandshake time.Time
@@ -48,25 +47,14 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
}
}
// PrepareInitialHandshake reserves the watcher and 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. Returns ok=false if the watcher is already
// running, in which case EnableWgWatcher must not be called.
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
w.muEnabled.Lock()
if w.enabled {
w.muEnabled.Unlock()
return false
}
// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be
// called before the peer is (re)configured on the WireGuard interface, so the captured
// baseline reflects the state prior to this connection attempt instead of racing with
// that configuration.
func (w *WGWatcher) PrepareInitialHandshake() {
w.log.Debugf("enable WireGuard watcher")
w.enabled = true
w.muEnabled.Unlock()
handshake, _ := w.wgState()
w.initialHandshake = handshake
return true
}
// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
@@ -76,10 +64,6 @@ func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
// 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)
w.muEnabled.Lock()
w.enabled = false
w.muEnabled.Unlock()
}
// Reset signals the watcher that the WireGuard peer has been reset and a new
@@ -105,6 +89,7 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
case <-timer.C:
handshake, ok := w.handshakeCheck(lastHandshake)
if !ok {
// early ctx cancel check return
if ctx.Err() != nil {
return
}
@@ -153,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) {
w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake)
// the current know handshake did not change
// the current known handshake did not change
if handshake.Equal(lastHandshake) {
w.log.Warnf("WireGuard handshake timed out: %v", handshake)
w.log.Warnf("WireGuard handshake not updated: %v", handshake)
return nil, false
}

View File

@@ -7,7 +7,6 @@ import (
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/configurer"
)
@@ -62,7 +61,7 @@ func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
require.True(t, watcher.PrepareInitialHandshake())
watcher.PrepareInitialHandshake()
firstHandshake := make(chan struct{}, 1)
checkSuccess := make(chan struct{}, 1)
@@ -101,8 +100,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ok := watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should not be enabled yet")
watcher.PrepareInitialHandshake()
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
@@ -132,8 +130,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
ok := watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should not be enabled yet")
watcher.PrepareInitialHandshake()
wg := &sync.WaitGroup{}
wg.Add(1)
@@ -149,8 +146,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
ok = watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")
watcher.PrepareInitialHandshake()
onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {

View File

@@ -1,6 +1,7 @@
package statemanager
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
var rawStates map[string]json.RawMessage
if err := json.Unmarshal(data, &rawStates); err != nil {
if len(bytes.TrimSpace(data)) == 0 {
log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data))
} else {
log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data))
}
m.handleCorruptedState(deleteCorrupt)
return nil, fmt.Errorf("unmarshal states: %w", err)
}

View File

@@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
deps.RefreshStatus = func() {
e.RunHealthProbes(context.Background(), true)
}
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}

View File

@@ -44,10 +44,25 @@ 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
}

View File

@@ -22,6 +22,7 @@ var allKeys = []string{
KeyDisableMetricsCollection,
KeyAllowServerSSH,
KeyDisableAutoConnect,
KeyDisableAutostart,
KeyPreSharedKey,
KeyRosenpassEnabled,
KeyRosenpassPermissive,

View File

@@ -20,10 +20,10 @@ import (
// names (lowerCamelCase) so the daemon can map a Policy key directly to a
// configuration field.
const (
KeyManagementURL = "managementURL"
KeyDisableUpdateSettings = "disableUpdateSettings"
KeyDisableProfiles = "disableProfiles"
KeyDisableNetworks = "disableNetworks"
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
@@ -37,10 +37,16 @@ const (
KeyDisableMetricsCollection = "disableMetricsCollection"
KeyAllowServerSSH = "allowServerSSH"
KeyDisableAutoConnect = "disableAutoConnect"
KeyPreSharedKey = "preSharedKey"
KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort"
// 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

View File

@@ -181,7 +181,7 @@ func (s *Server) Start() error {
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)
}
@@ -551,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)
}
@@ -858,7 +858,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return s.waitForUp(callerCtx)
}
if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
log.Warnf(errRestoreResidualState, err)
}

View File

@@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (
if req.All {
// Reuse existing cleanup logic for all states
if err := restoreResidualState(ctx, statePath); err != nil {
if err := RestoreResidualState(ctx, statePath); err != nil {
return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err)
}
@@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest)
}, nil
}
// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
// Otherwise, we might not be able to connect to the management server to retrieve new config.
func restoreResidualState(ctx context.Context, statePath string) error {
func RestoreResidualState(ctx context.Context, statePath string) error {
if statePath == "" {
return nil
}

View File

@@ -14,6 +14,7 @@ import (
log "github.com/sirupsen/logrus"
nbssh "github.com/netbirdio/netbird/client/ssh"
"github.com/netbirdio/netbird/shared/management/domain"
)
const (
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
if peer.IPv6.IsValid() {
hostPatterns = append(hostPatterns, peer.IPv6.String())
}
if peer.FQDN != "" {
// Peer FQDNs and hostnames originate from remote peers, so they must be
// validated as plain DNS names before being embedded in the ssh_config
// "Match host" pattern list. This prevents injection of arbitrary
// ssh_config directives via embedded quotes, whitespace, newlines, the
// comma pattern separator, or the "*"/"?" pattern metacharacters.
if domain.IsValidDomainNoWildcard(peer.FQDN) {
hostPatterns = append(hostPatterns, peer.FQDN)
} else if peer.FQDN != "" {
log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN)
}
if peer.Hostname != "" && peer.Hostname != peer.FQDN {
if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
hostPatterns = append(hostPatterns, peer.Hostname)
} else if peer.Hostname != "" && peer.Hostname != peer.FQDN {
log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname)
}
return hostPatterns
}

View File

@@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) {
"should use Match host with comma-separated patterns")
}
func TestManager_HostPatternInjection(t *testing.T) {
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
require.NoError(t, err)
defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }()
manager := &Manager{
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
sshConfigFile: "99-netbird.conf",
}
// A malicious peer FQDN/hostname attempts to break out of the Match host
// directive and inject arbitrary ssh_config (a ProxyCommand executing a
// command). It must be rejected, not written to the config.
peers := []PeerSSHInfo{
{
Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x",
IP: netip.MustParseAddr("100.125.1.1"),
FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal",
},
{Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"},
}
err = manager.SetupSSHClientConfig(peers)
require.NoError(t, err)
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
content, err := os.ReadFile(configPath)
require.NoError(t, err)
configStr := string(content)
assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned",
"injected directive must not appear in generated config")
assert.NotContains(t, configStr, "evil",
"malicious pattern must be dropped entirely")
// The valid peer must still be present, on a single Match host line.
assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"",
"valid peers must survive, injected patterns dropped")
}
func TestManager_ForcedSSHConfig(t *testing.T) {
// Set force environment variable
t.Setenv(EnvForceSSHConfig, "true")

View File

@@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
// 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).
// (@ 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" {
@@ -80,6 +81,10 @@ func validateGetentInput(input string) bool {
return false
}
if input[0] == '-' {
return false
}
for _, r := range input {
if isAllowedGetentChar(r) {
continue

View File

@@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) {
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
{"leading hyphen rejected", "-i", false},
{"leading double hyphen rejected", "--no-idn", false},
{"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},

View File

@@ -746,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
pbFullStatus.Events = fullStatus.Events
return &pbFullStatus
}

View File

@@ -0,0 +1,107 @@
//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 login-item 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 login-item 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) {
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: autostartDisabledByMDM(mdm.LoadPolicy()),
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
}

View File

@@ -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)
})
}
}

View File

@@ -51,7 +51,14 @@ async function runSsoLogin(
if (uri) await openBrowserLoginUri(uri);
const cancelPromise = buildSsoCancelPromise(state, signal);
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
// 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]);
@@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
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);
}
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
} else {
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
}
}
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);

View File

@@ -8,6 +8,7 @@
{"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": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,9 @@ func main() {
// 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 {

View File

@@ -54,6 +54,10 @@ 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.
@@ -72,8 +76,9 @@ type Emitter interface {
type Store struct {
path string
mu sync.RWMutex
current UIPreferences
mu sync.RWMutex
current UIPreferences
existedAtLoad bool
subsMu sync.Mutex
subs []chan UIPreferences
@@ -157,6 +162,27 @@ func (s *Store) SetOnboardingCompleted(done bool) error {
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 == "" {
@@ -206,13 +232,29 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
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); errors.Is(err, os.ErrNotExist) {
return nil
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

View File

@@ -215,6 +215,46 @@ func TestStore_FileShapeIsJSON(t *testing.T) {
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

View File

@@ -35,7 +35,7 @@ type LoginResult struct {
VerificationURIComplete string `json:"verificationUriComplete"`
}
// WaitSSOParams are the inputs to WaitSSOLogin.
// WaitSSOParams are the inputs to waitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
@@ -125,23 +125,6 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
}, nil
}
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
}
func (s *Connection) Up(ctx context.Context, p UpParams) error {
cli, err := s.conn.Client()
if err != nil {
@@ -162,6 +145,27 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
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 {
@@ -221,6 +225,26 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
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)

View File

@@ -20,11 +20,12 @@ type MDMFields struct {
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"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
type Features struct {

View File

@@ -145,6 +145,7 @@ type AuthConfig struct {
CLIRedirectURIs []string `yaml:"cliRedirectURIs"`
Owner *AuthOwnerConfig `yaml:"owner,omitempty"`
DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"`
GrantTypes []string `yaml:"grantTypes"`
}
// AuthStorageConfig contains auth storage settings
@@ -604,6 +605,7 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
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 != "" {

View File

@@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
}
hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret))
authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour)
authenticator := auth.NewTimedHMACValidator(hashedSecret[:])
relayCfg := relayServer.Config{
Meter: s.metricsServer.Meter,

View File

@@ -39,6 +39,21 @@ func availableProviders() []providerCase {
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 platform serves this
// account exactly ONE model — kimi-k3 (kimi-k2-thinking and even
// kimi-latest return resource_not_found_error on both surfaces) — so
// two concurrent provider records would claim the same model and
// route ambiguously. 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).
ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai/anthropic", apiKey: k, model: "kimi-k3", kind: harness.WireMessages})
}
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})
}
@@ -84,18 +99,38 @@ func availableProviders() []providerCase {
}
}
// Bedrock: path-routed, bearer auth. Model is a cross-region inference
// profile id (distinct string from the first-party Anthropic case).
// 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 = "us-east-1"
region = "eu-central-1"
}
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireMessages})
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: bedrockProfilePrefix(region) + ".anthropic.claude-haiku-4-5-20251001-v1:0", kind: harness.WireBedrock})
}
return ps
}
// bedrockProfilePrefix maps an AWS region to its cross-region inference
// profile prefix: us-east-1 -> us, eu-central-1 -> eu, ap-southeast-1 -> apac,
// us-gov-west-1 -> us-gov.
func bedrockProfilePrefix(region string) string {
switch {
case strings.HasPrefix(region, "us-gov-"):
return "us-gov"
case strings.HasPrefix(region, "ap-"):
return "apac"
default:
return strings.SplitN(region, "-", 2)[0]
}
}
// 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).
@@ -224,9 +259,12 @@ func TestProvidersMatrix(t *testing.T) {
var c int
var b string
var cerr error
if pc.kind == harness.WireVertex {
switch pc.kind {
case harness.WireVertex:
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID)
} else {
case harness.WireBedrock:
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
default:
c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
}
if cerr == nil {

View File

@@ -0,0 +1,187 @@
//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"
)
// bedrockVersionSuffix mirrors the proxy's normalizer: 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+)?$`)
// catalogModel returns the normalized catalog id the proxy stamps for a
// path-routed provider's configured model — the form the guardrail allowlist is
// compared against (region prefix / @version stripped).
func catalogModel(pc providerCase) string {
switch pc.kind {
case harness.WireBedrock:
// Mirror the proxy's llm.NormalizeBedrockModel (not importable from
// here — proxy/internal): strip the region-family prefix and the
// date/version suffix so the allowlist carries the catalog key the
// guardrail compares against.
m := pc.model
for _, p := range []string{"us.", "eu.", "apac.", "global."} {
if strings.HasPrefix(m, p) {
m = strings.TrimPrefix(m, 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.Chat(ctx, endpoint, proxyIP, 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")
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()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
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)
})
}
}

View File

@@ -107,6 +107,17 @@ 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) {

View File

@@ -56,6 +56,11 @@ func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, er
// 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",
// Lazy connections defer the peer dial until traffic flows, which
// leaves `netbird status` at "0/N Connected" and starves
// WaitProxyPeer. Force eager connections so the proxy peer shows
// Connected as soon as the tunnel is up.
"NB_LAZY_CONN": "false",
},
HostConfigModifier: func(hc *container.HostConfig) {
hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE")
@@ -194,6 +199,11 @@ const (
// 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
@@ -226,6 +236,17 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
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":64,"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 == "" {

View File

@@ -77,6 +77,10 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, er
// Management is plain HTTP in-cluster, so allow the proxy token to
// ride a non-TLS gRPC connection.
"NB_PROXY_ALLOW_INSECURE": "true",
// Keep the embedded overlay peer eager (see the client env note):
// lazy connections leave peers unconnected until traffic, which
// starves the harness's WaitProxyPeer.
"NB_LAZY_CONN": "false",
// 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

5
go.mod
View File

@@ -113,7 +113,7 @@ require (
github.com/ti-mo/conntrack v0.5.1
github.com/ti-mo/netfilter v0.5.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111
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.67.0
@@ -303,7 +303,6 @@ require (
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/wailsapp/wails/webview2 v1.0.27 // 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
@@ -335,7 +334,7 @@ replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801
replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51
replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1
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

10
go.sum
View File

@@ -476,8 +476,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs=
github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg=
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=
@@ -660,10 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
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/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60=
github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns=
github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o=
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=

View File

@@ -613,6 +613,10 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s
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 {

View File

@@ -21,7 +21,7 @@ import (
"github.com/dexidp/dex/server/signer"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/crypto/bcrypt"

View File

@@ -595,3 +595,90 @@ enablePasswordDB: true
assert.True(t, cfg.ContinueOnConnectorFailure,
"buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down")
}
func TestToServerConfig_WiresGrantTypes(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dex-grants-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
stor := openTestStorage(t, tmpDir)
defer stor.Close()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
grants := []string{"authorization_code", "refresh_token"}
cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}}
assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes)
empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"}
assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes)
}
func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider {
t.Helper()
tmpDir, err := os.MkdirTemp("", "dex-devguard-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
yamlContent := `
issuer: http://localhost:5599/oauth2
storage:
type: sqlite3
config:
file: ` + filepath.Join(tmpDir, "dex.db") + `
web:
http: 127.0.0.1:5599
enablePasswordDB: true
` + grantTypesYAML
configPath := filepath.Join(tmpDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644))
yamlConfig, err := LoadConfig(configPath)
require.NoError(t, err)
provider, err := NewProviderFromYAML(context.Background(), yamlConfig)
require.NoError(t, err)
t.Cleanup(func() { _ = provider.Stop(context.Background()) })
return provider
}
func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) {
provider := newDeviceGuardProvider(t, `
oauth2:
grantTypes:
- authorization_code
- refresh_token
`)
devicePaths := []string{
"/oauth2/device",
"/oauth2/device/code",
"/oauth2/device/token",
"/oauth2/device/auth/verify_code",
"/oauth2/device/callback",
}
for _, path := range devicePaths {
for _, method := range []string{http.MethodGet, http.MethodPost} {
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path)
}
}
req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) {
provider := newDeviceGuardProvider(t, "")
req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
assert.NotEqual(t, http.StatusNotFound, rec.Code)
}

View File

@@ -197,6 +197,12 @@ type JSONMetadataInjection struct {
// 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.
@@ -329,6 +335,18 @@ var providers = []Provider{
{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",
@@ -402,6 +420,45 @@ var providers = []Provider{
{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):
// K3 $3/$15 per MTok with $0.30 cached input, flat across the
// 1M-token window; K2 Thinking $0.60/$2.50 with $0.15 cache hits.
// Kimi K3 has a single always-on-reasoning SKU — no mini/turbo
// variants at launch. 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: "kimi-k2-thinking", Label: "Kimi K2 Thinking", InputPer1k: 0.0006, OutputPer1k: 0.0025, ContextWindow: 262144},
},
},
{
ID: "litellm_proxy",
Kind: KindGateway,

View File

@@ -540,6 +540,7 @@ type identityInjectJSONMetadata struct {
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
@@ -583,9 +584,11 @@ func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[s
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 — extras
// can still apply, see below.
if entry.IdentityInjection != nil {
// 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)
@@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject
UserKey: userKey,
GroupsKey: groupsKey,
MaxValueLength: jm.MaxValueLength,
Sanitize: jm.Sanitize,
}
}

View File

@@ -698,6 +698,94 @@ func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T)
"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 /

View File

@@ -51,6 +51,12 @@ type Provider struct {
// 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
@@ -137,6 +143,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
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
@@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
Models: models,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
}

View File

@@ -42,3 +42,38 @@ func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) {
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")
}

View File

@@ -215,7 +215,7 @@ func (e *EphemeralManager) cleanup(ctx context.Context) {
}
for accountID, peerIDs := range peerIDsPerAccount {
log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID)
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)

View File

@@ -184,6 +184,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs
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))
@@ -224,30 +226,6 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
return nil
}
// Dedupe stale embedded peer records for the same (account, cluster).
// The proxy generates a fresh WireGuard keypair on every startup
// (proxy/internal/roundtrip/netbird.go), so without this sweep the
// prior embedded peer would linger forever — holding its CGNAT IP
// allocation, polluting other peers' rosters, and (most visibly)
// leaving the synth DNS pointing at the dead address. The
// (account, cluster) tuple identifies "the embedded peer for this
// proxy instance at this cluster"; any record matching that tuple
// with a different pubkey is by definition stale and must go.
staleIDs, err := m.findStaleEmbeddedProxyPeers(ctx, accountID, cluster, peerKey)
if err != nil {
return fmt.Errorf("scan for stale embedded proxy peers: %w", err)
}
if len(staleIDs) > 0 {
// userID="" + checkConnected=false: the deletion is initiated
// by management itself on behalf of the freshly-registering
// proxy, not by an end user; the stale peer may still be
// marked Connected from its prior session, but its session is
// dead by definition (its key no longer exists).
if err := m.DeletePeers(ctx, accountID, staleIDs, "", false); err != nil {
return fmt.Errorf("delete stale embedded proxy peers %v: %w", staleIDs, err)
}
}
name := fmt.Sprintf("proxy-%s", xid.New().String())
newPeer := &peer.Peer{
Ephemeral: true,
@@ -273,29 +251,3 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
return nil
}
// findStaleEmbeddedProxyPeers returns the peer IDs of embedded proxy peer
// records in accountID that target the same cluster but carry a different
// WireGuard pubkey than the freshly-registering one. Used by CreateProxyPeer
// to garbage-collect stale records left behind when the proxy restarts with a
// regenerated keypair.
func (m *managerImpl) findStaleEmbeddedProxyPeers(ctx context.Context, accountID, cluster, newKey string) ([]string, error) {
account, err := m.store.GetAccount(ctx, accountID)
if err != nil {
return nil, err
}
var stale []string
for _, p := range account.Peers {
if p == nil || !p.ProxyMeta.Embedded {
continue
}
if p.ProxyMeta.Cluster != cluster {
continue
}
if p.Key == newKey {
continue
}
stale = append(stale, p.ID)
}
return stale, nil
}

View File

@@ -161,6 +161,8 @@ 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 {
@@ -168,6 +170,8 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI
m.relayCancelMap[peerID] = relayCancel
go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel)
log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID)
} else {
log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID)
}
}

View File

@@ -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)
}

View File

@@ -4269,7 +4269,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te
}
// Sanity: an actually different range still triggers reallocation.
newRange := netip.MustParsePrefix("100.99.0.0/16")
newRange := netip.MustParsePrefix("100.60.0.0/16")
_, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: types.DefaultPeerLoginExpiration,

View File

@@ -1,199 +0,0 @@
package server
import (
"context"
"testing"
"time"
"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/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"
)
// TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale is the no-mock
// regression guard for the bug the user reported: restarting the proxy creates
// a fresh embedded peer with a NEW WireGuard public key (the proxy generates
// the keypair on every startup at proxy/internal/roundtrip/netbird.go:312).
// The PRIOR embedded peer record is never deleted on management, so the
// account accumulates a stale peer holding a stale CGNAT IP. Other peers
// in the account either keep routing to the dead IP, or — if synth DNS
// picks the wrong record — never see the new IP at all.
//
// What this test exercises (no mocks):
// - real SQLite test store
// - real DefaultAccountManager, network-map controller, peer-update channels
// - real peers.Manager.CreateProxyPeer path (the very method the proxy
// invokes over gRPC on every startup)
// - real agentnetwork.Manager + synth chain so the client receives a
// concrete DNS record that must point at the LATEST proxy peer.
//
// Pre-fix expected behavior (red): two embedded peers exist after the
// "restart"; the synth DNS record points at the stale one; the client
// receives an update reflecting the new peer but the old one lingers.
// Post-fix expected behavior (green): exactly one embedded peer exists
// after restart (with the new key) AND the client's network map carries
// the synth DNS pointing at that new peer's CGNAT IP.
func TestAgentNetwork_ProxyRestart_PropagatesNewPeerAndDropsStale(t *testing.T) {
am, updateManager, err := createManager(t)
require.NoError(t, err, "createManager must succeed")
ctx := context.Background()
const (
accountID = "an-restart-acct"
adminUserID = "an-restart-admin"
groupAID = "an-restart-grp-A"
clusterAddr = "eu.proxy.netbird.io"
clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
// Two different proxy pubkeys — the "before" and "after" of a
// proxy-process restart with fresh-keypair generation.
proxyKey1 = "Aaaaa1aaaaYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
proxyKey2 = "Bbbbb2bbbbYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
)
// --- Account scaffold ---
account := newAccountWithId(ctx, accountID, adminUserID, "an-restart.test", "", "", false)
require.NoError(t, am.Store.SaveAccount(ctx, account))
clientPeer := &nbpeer.Peer{
Key: clientKey,
Name: "an-restart-client",
DNSLabel: "an-restart-client",
Meta: nbpeer.PeerSystemMeta{Hostname: "an-restart-client", GoOS: "linux", WtVersion: "development"},
}
addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false)
require.NoError(t, err, "AddPeer for client must succeed")
require.NoError(t, am.MarkPeerConnected(ctx, clientKey, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
"MarkPeerConnected for the client peer must succeed (affected-peer fan-out skips disconnected peers)")
// Place the client in group A so the synth policy reaches it.
account, err = am.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}}
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist group A")
// --- Real peers + agent-network managers ---
permMgr := permissions.NewManager(am.Store)
peersMgr := peers.NewManager(am.Store, permMgr)
peersMgr.SetAccountManager(am)
peersMgr.SetNetworkMapController(am.networkMapController)
agentMgr := agentnetwork.NewManager(am.Store, permMgr, am, nil)
// Subscribe BEFORE any state-mutating call so we don't lose the update
// that contains the synth DNS record.
clientCh := updateManager.CreateChannel(ctx, addedClient.ID)
t.Cleanup(func() { updateManager.CloseChannel(ctx, addedClient.ID) })
drain(clientCh)
// --- First proxy startup: register peer key K1, then mark it
// connected. In production the proxy follows CreateProxyPeer with the
// regular sync stream which lands on MarkPeerConnected; the synth DNS
// path filters out peers that aren't Connected (types/account.go:323),
// so without this step no DNS record would be emitted.
require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey1, clusterAddr),
"first CreateProxyPeer (proxy startup) must succeed")
peer1ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1)
require.NoError(t, err, "proxy peer for K1 must be persisted after CreateProxyPeer")
require.NotEmpty(t, peer1ID)
require.NoError(t, am.MarkPeerConnected(ctx, proxyKey1, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
"MarkPeerConnected for K1 must succeed")
account, err = am.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
proxyIP1 := account.Peers[peer1ID].IP.String()
require.NotEmpty(t, proxyIP1, "K1 must have an assigned overlay IP")
// --- Provider + policy. CreateProvider / CreatePolicy trigger the
// agentnetwork reconcile which runs UpdateAccountPeers; the resulting
// NetworkMap delivered to the client carries the synth DNS record
// pointing at K1's IP. ---
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")
_, 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()
rdata1 := awaitZoneRData(clientCh, clusterAddr, fqdn, true)
require.Equal(t, proxyIP1, rdata1,
"client must receive a synth DNS record pointing at K1's overlay IP after the synth path runs")
drain(clientCh)
// --- Proxy restart: NEW keypair K2, same account, same cluster ---
require.NoError(t, peersMgr.CreateProxyPeer(ctx, accountID, proxyKey2, clusterAddr),
"second CreateProxyPeer (proxy restart with fresh keypair) must succeed")
peer2ID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey2)
require.NoError(t, err, "proxy peer for K2 must be persisted after restart")
require.NotEmpty(t, peer2ID)
require.NoError(t, am.MarkPeerConnected(ctx, proxyKey2, accountID, time.Now().UnixNano(), &types.NetworkMap{}),
"MarkPeerConnected for K2 must succeed")
// In production the agent's sync stream pulls a fresh NetworkMap as
// part of its normal reconcile cadence; in this isolated test
// MarkPeerConnected's affected-peer fan-out can race the channel-side
// buffer in a way that swallows the synth-DNS-bearing update before
// our await reads it. Trigger an explicit account-wide fan-out so the
// assertion below tests what production actually delivers, not the
// in-test buffer race.
am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
account, err = am.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
proxyIP2 := account.Peers[peer2ID].IP.String()
require.NotEmpty(t, proxyIP2, "K2 must have an assigned overlay IP")
require.NotEqual(t, proxyIP1, proxyIP2, "K2 must get a different overlay IP than K1 (sanity)")
// CRITICAL ASSERTION 1: K1 must no longer be in the store. The SqlStore
// returns ("", nil) for a missing key rather than NotFound, so assert
// on the returned ID being empty.
staleID, err := am.Store.GetPeerIDByKey(ctx, store.LockingStrengthNone, proxyKey1)
require.NoError(t, err, "GetPeerIDByKey for a missing peer must not error")
assert.Empty(t, staleID,
"stale embedded proxy peer K1 must be removed when a new embedded peer registers for the same (account, cluster); pre-fix this assertion fails because management never cleans up the prior peer record")
// CRITICAL ASSERTION 2: exactly one embedded proxy peer remains, and it
// is K2.
account, err = am.Store.GetAccount(ctx, accountID)
require.NoError(t, err)
embeddedKeys := []string{}
for _, p := range account.Peers {
if p.ProxyMeta.Embedded {
embeddedKeys = append(embeddedKeys, p.Key)
}
}
assert.Equal(t, []string{proxyKey2}, embeddedKeys,
"after a proxy restart exactly one embedded proxy peer should remain — the one with the new key K2")
// CRITICAL ASSERTION 3: the synth DNS record the client receives now
// points at K2's IP, not K1's.
rdata2 := awaitZoneRData(clientCh, clusterAddr, fqdn, true)
assert.Equal(t, proxyIP2, rdata2,
"after proxy restart, the client's synth DNS record must point at the NEW embedded peer's IP, not the stale K1 IP")
}

View File

@@ -289,6 +289,18 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
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
}
@@ -434,6 +446,11 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
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,

View File

@@ -288,7 +288,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
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}}"),
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,
@@ -305,9 +305,53 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(true),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
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,

View File

@@ -76,6 +76,9 @@ type EmbeddedIdPConfig struct {
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.
@@ -175,6 +178,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
},
OAuth2: dex.OAuth2{
SkipApprovalScreen: true,
GrantTypes: c.GrantTypes,
},
Frontend: dex.Frontend{
Issuer: "NetBird",

View File

@@ -106,11 +106,13 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
}
if !updated {
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale)
log.WithContext(ctx).Tracef("peer %s already has a newer session in store, skipping connect", peer.ID)
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)
log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID)
if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
return err
}
@@ -180,12 +182,14 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
}
if !updated {
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale)
log.WithContext(ctx).Tracef("peer %s session token mismatch on disconnect (token=%d), skipping",
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.

View File

@@ -1606,6 +1606,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
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
@@ -1630,6 +1632,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
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
@@ -1653,6 +1660,8 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
&sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways,
&peerExposeEnabled, &peerExposeGroups,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
)
@@ -1724,6 +1733,11 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
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)
}
@@ -1739,6 +1753,18 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
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

View File

@@ -9,6 +9,7 @@ import (
"net"
"net/netip"
"os"
"reflect"
"runtime"
"sort"
"sync"
@@ -34,6 +35,7 @@ import (
"github.com/netbirdio/netbird/management/server/util"
nbroute "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"
)
@@ -296,6 +298,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")
@@ -1270,6 +1319,36 @@ func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) {
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)

View File

@@ -7,6 +7,7 @@ import (
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/netbirdio/netbird/client/ssh/auth"
@@ -42,6 +43,14 @@ type NetworkMapComponents struct {
PostureFailedPeers map[string]map[string]struct{}
RouterPeers map[string]*nbpeer.Peer
routesByPeerOnce sync.Once
routesByPeerIdx map[string][]routeIndexEntry
}
type routeIndexEntry struct {
route *route.Route
viaGroup bool
}
type AccountSettingsInfo struct {
@@ -530,33 +539,43 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
disabledRoutes = append(disabledRoutes, r)
}
for _, r := range c.Routes {
for _, groupID := range r.PeerGroups {
group := c.GetGroupInfo(groupID)
if group == nil {
continue
}
for _, id := range group.Peers {
if id != peerID {
continue
}
newPeerRoute := r.Copy()
newPeerRoute.Peer = id
newPeerRoute.PeerGroups = nil
newPeerRoute.ID = route.ID(string(r.ID) + ":" + id)
takeRoute(newPeerRoute)
break
}
}
if r.Peer == peerID {
takeRoute(r.Copy())
for _, entry := range c.routesByPeer()[peerID] {
if entry.viaGroup {
newPeerRoute := entry.route.Copy()
newPeerRoute.PeerGroups = nil
newPeerRoute.ID = route.ID(string(entry.route.ID) + ":" + peerID)
takeRoute(newPeerRoute)
continue
}
takeRoute(entry.route.Copy())
}
return enabledRoutes, disabledRoutes
}
func (c *NetworkMapComponents) routesByPeer() map[string][]routeIndexEntry {
c.routesByPeerOnce.Do(func() {
idx := make(map[string][]routeIndexEntry)
for _, r := range c.Routes {
for _, groupID := range r.PeerGroups {
group := c.GetGroupInfo(groupID)
if group == nil {
continue
}
for _, id := range group.Peers {
idx[id] = append(idx[id], routeIndexEntry{route: r, viaGroup: true})
}
}
if r.Peer != "" {
idx[r.Peer] = append(idx[r.Peer], routeIndexEntry{route: r})
}
}
c.routesByPeerIdx = idx
})
return c.routesByPeerIdx
}
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {

View File

@@ -80,6 +80,11 @@ type Settings struct {
// 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:"-"`
@@ -126,9 +131,31 @@ func (s *Settings) Copy() *Settings {
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

View File

@@ -14,6 +14,7 @@ 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

View File

@@ -18,6 +18,7 @@ import (
"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"
)
@@ -209,7 +210,7 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err)
}
parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies)
parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies)
if err != nil {
return fmt.Errorf("invalid --trusted-proxies: %w", err)
}

View File

@@ -16,6 +16,7 @@ import (
"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 (
@@ -66,7 +67,7 @@ type denyBucket struct {
type Logger struct {
client gRPCClient
logger *log.Logger
trustedProxies []netip.Prefix
trustedProxies *trustedproxy.List
usageMux sync.Mutex
domainUsage map[string]*domainUsage
@@ -82,7 +83,7 @@ type Logger struct {
// NewLogger creates a new access log Logger. The trustedProxies parameter
// configures which upstream proxy IP ranges are trusted for extracting
// the real client IP from X-Forwarded-For headers.
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger {
func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger {
if logger == nil {
logger = log.StandardLogger()
}

View File

@@ -4,13 +4,13 @@ import (
"net/http"
"net/netip"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"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 []netip.Prefix) netip.Addr {
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr {
return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
}

View File

@@ -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, "")
}

View File

@@ -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)
}
}

View File

@@ -161,6 +161,18 @@ openai:
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 for K3 ($0.30 vs $3.00 per MTok) and $0.15/MTok for K2 Thinking.
kimi-k3:
input_per_1k: 0.003
output_per_1k: 0.015
cached_input_per_1k: 0.0003
kimi-k2-thinking:
input_per_1k: 0.0006
output_per_1k: 0.0025
cached_input_per_1k: 0.00015
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,
@@ -206,6 +218,24 @@ anthropic:
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 entries 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
kimi-k2-thinking:
input_per_1k: 0.0006
output_per_1k: 0.0025
cache_read_per_1k: 0.00015
bedrock:
# AWS Bedrock model ids, normalised by the request parser (cross-region
# inference-profile prefix + version/throughput suffix stripped), e.g.

View File

@@ -25,6 +25,14 @@ const (
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
@@ -108,23 +116,37 @@ func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middlew
if len(m.cfg.ModelAllowlist) == 0 {
return nil
}
if !modelPresent {
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: denyCodeModel,
Message: denyMessageModel,
Details: map[string]string{"model": model},
Code: code,
Message: message,
Details: details,
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel},
{Key: middleware.KeyLLMPolicyReason, Value: reason},
},
}
}

View File

@@ -102,13 +102,44 @@ func TestAllowlistCaseInsensitive(t *testing.T) {
}
}
func TestAllowlistMissingModelKeyAllows(t *testing.T) {
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)
assert.Equal(t, middleware.DecisionAllow, out.Decision, "missing model key must allow even with non-empty allowlist")
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, "allow", dec, "decision must be allow when model key is absent")
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) {

View File

@@ -64,6 +64,11 @@ type JSONMetadataRule struct {
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

View File

@@ -292,15 +292,21 @@ func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware
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] = truncate(identity, rule.MaxValueLength)
payload[rule.UserKey] = emit(identity)
}
}
if rule.GroupsKey != "" {
if csv := authorisingTagsCSV(in); csv != "" {
payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength)
payload[rule.GroupsKey] = emit(csv)
}
}
if len(payload) == 0 {
@@ -359,6 +365,36 @@ func truncate(s string, maxBytes int) string {
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

View File

@@ -304,6 +304,46 @@ func TestInject_JSONMetadata_TruncatesValues(t *testing.T) {
"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) {

View File

@@ -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)
}
})
}
}

View File

@@ -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")
}

View File

@@ -23,6 +23,7 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/netbirdio/netbird/proxy/internal/llm"
"github.com/netbirdio/netbird/proxy/internal/middleware"
)
@@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
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
}

View File

@@ -22,6 +22,7 @@ import (
"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 {
@@ -29,10 +30,10 @@ type ReverseProxy struct {
// forwardedProto overrides the X-Forwarded-Proto header value.
// Valid values: "auto" (detect from TLS), "http", "https".
forwardedProto string
// trustedProxies is a list of IP prefixes for trusted upstream proxies.
// When the direct connection comes from a trusted proxy, forwarding
// headers are preserved and appended to instead of being stripped.
trustedProxies []netip.Prefix
// 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
@@ -63,7 +64,7 @@ func WithMiddlewareManager(m *middleware.Manager) Option {
// between requested URLs and targets.
// The internal mappings can be modified using the AddMapping
// and RemoveMapping functions.
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger, opts ...Option) *ReverseProxy {
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy {
if logger == nil {
logger = log.StandardLogger()
}
@@ -527,7 +528,7 @@ func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
if !types.IsOverlayOrigin(r.Context()) {
return false
}
srcIP := extractHostIP(r.RemoteAddr)
srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr)
if !srcIP.IsValid() {
return false
}
@@ -578,9 +579,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
stampNetBirdIdentity(r)
clientIP := extractHostIP(r.In.RemoteAddr)
clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr)
if isTrustedAddr(clientIP, p.trustedProxies) {
if p.trustedProxies.Contains(clientIP) {
p.setTrustedForwardingHeaders(r, clientIP)
} else {
p.setUntrustedForwardingHeaders(r, clientIP)
@@ -664,7 +665,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
r.Out.Header.Set("X-Real-IP", realIP)
} else {
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"))
r.Out.Header.Set("X-Real-IP", resolved.String())
}

View File

@@ -23,6 +23,7 @@ import (
"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) {
@@ -302,7 +303,7 @@ func TestExtractHostIP(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr))
})
}
}
@@ -330,7 +331,7 @@ func TestExtractForwardedPort(t *testing.T) {
func TestRewriteFunc_TrustedProxy(t *testing.T) {
target, _ := url.Parse("http://backend.internal:8080")
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
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}

View File

@@ -1,81 +0,0 @@
package proxy
import (
"net/netip"
"strings"
)
// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes.
func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
addr, err := netip.ParseAddr(ipStr)
if err != nil || len(trusted) == 0 {
return false
}
return isTrustedAddr(addr.Unmap(), trusted)
}
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list.
// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes.
// The first untrusted IP is the real client.
//
// If the trusted list is empty or remoteAddr is not trusted, it returns the
// remoteAddr IP directly (ignoring any forwarding headers).
func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr {
remoteIP := extractHostIP(remoteAddr)
if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) {
return remoteIP
}
if xff == "" {
return remoteIP
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" {
continue
}
addr, err := netip.ParseAddr(ip)
if err != nil {
continue
}
addr = addr.Unmap()
if !isTrustedAddr(addr, trusted) {
return addr
}
}
// All IPs in XFF are trusted; return the leftmost as best guess.
if first := strings.TrimSpace(parts[0]); first != "" {
if addr, err := netip.ParseAddr(first); err == nil {
return addr.Unmap()
}
}
return remoteIP
}
// extractHostIP parses the IP from a host:port string and returns it unmapped.
func extractHostIP(hostPort string) netip.Addr {
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
return ap.Addr().Unmap()
}
if addr, err := netip.ParseAddr(hostPort); err == nil {
return addr.Unmap()
}
return netip.Addr{}
}
// isTrustedAddr checks if the given address falls within any of the trusted prefixes.
func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool {
if !addr.IsValid() {
return false
}
for _, prefix := range trusted {
if prefix.Contains(addr) {
return true
}
}
return false
}

View File

@@ -1,129 +0,0 @@
package proxy
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsTrustedProxy(t *testing.T) {
trusted := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("fd00::/8"),
}
tests := []struct {
name string
ip string
trusted []netip.Prefix
want bool
}{
{"empty trusted list", "10.0.0.1", nil, false},
{"IP within /8 prefix", "10.1.2.3", trusted, true},
{"IP within /24 prefix", "192.168.1.100", trusted, true},
{"IP outside all prefixes", "203.0.113.50", trusted, false},
{"boundary IP just outside prefix", "192.168.2.1", trusted, false},
{"unparsable IP", "not-an-ip", trusted, false},
{"IPv6 in trusted range", "fd00::1", trusted, true},
{"IPv6 outside range", "2001:db8::1", trusted, false},
{"empty string", "", trusted, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted))
})
}
}
func TestResolveClientIP(t *testing.T) {
trusted := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
}
tests := []struct {
name string
remoteAddr string
xff string
trusted []netip.Prefix
want netip.Addr
}{
{
name: "empty trusted list returns RemoteAddr",
remoteAddr: "203.0.113.50:9999",
xff: "1.2.3.4",
trusted: nil,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "untrusted RemoteAddr ignores XFF",
remoteAddr: "203.0.113.50:9999",
xff: "1.2.3.4, 10.0.0.1",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "trusted RemoteAddr with single client in XFF",
remoteAddr: "10.0.0.1:5000",
xff: "203.0.113.50",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "trusted RemoteAddr walks past trusted entries in XFF",
remoteAddr: "10.0.0.1:5000",
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
remoteAddr: "10.0.0.1:5000",
xff: "",
trusted: trusted,
want: netip.MustParseAddr("10.0.0.1"),
},
{
name: "all XFF IPs trusted returns leftmost",
remoteAddr: "10.0.0.1:5000",
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
trusted: trusted,
want: netip.MustParseAddr("10.0.0.2"),
},
{
name: "XFF with whitespace",
remoteAddr: "10.0.0.1:5000",
xff: " 203.0.113.50 , 10.0.0.2 ",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "XFF with empty segments",
remoteAddr: "10.0.0.1:5000",
xff: "203.0.113.50,,10.0.0.2",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "multi-hop with mixed trust",
remoteAddr: "10.0.0.1:5000",
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
{
name: "RemoteAddr without port",
remoteAddr: "10.0.0.1",
xff: "203.0.113.50",
trusted: trusted,
want: netip.MustParseAddr("203.0.113.50"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted))
})
}
}

View File

@@ -2,13 +2,13 @@ package proxy
import (
"context"
"net/netip"
"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
@@ -83,9 +83,9 @@ type Config struct {
// ForwardedProto overrides the X-Forwarded-Proto value sent to
// backends. Valid values: "auto", "http", "https".
ForwardedProto string
// TrustedProxies is a list of IP prefixes for trusted upstream
// proxies that may set forwarding headers.
TrustedProxies []netip.Prefix
// 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

View File

@@ -10,12 +10,14 @@ import (
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: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}),
ProxyProtocol: true,
}
@@ -66,7 +68,7 @@ func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
srv := &Server{
Logger: log.StandardLogger(),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
}
opts := proxyproto.ConnPolicyOptions{
@@ -80,7 +82,7 @@ func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
srv := &Server{
Logger: log.StandardLogger(),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
}
opts := proxyproto.ConnPolicyOptions{
@@ -94,7 +96,7 @@ func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
srv := &Server{
Logger: log.StandardLogger(),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}),
}
opts := proxyproto.ConnPolicyOptions{

View File

@@ -67,6 +67,7 @@ import (
"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"
)
@@ -79,19 +80,19 @@ type portRouter struct {
type Server struct {
ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
acme *acme.Manager
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
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.
@@ -99,16 +100,16 @@ type Server struct {
// 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
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
@@ -192,10 +193,10 @@ type Server struct {
// ForwardedProto overrides the X-Forwarded-Proto value sent to backends.
// Valid values: "auto" (detect from TLS), "http", "https".
ForwardedProto string
// TrustedProxies is a list of IP prefixes for trusted upstream proxies.
// When set, forwarding headers from these sources are preserved and
// appended to instead of being stripped.
TrustedProxies []netip.Prefix
// 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
@@ -718,7 +719,7 @@ func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener {
Listener: ln,
ReadHeaderTimeout: proxyProtoHeaderTimeout,
}
if len(s.TrustedProxies) > 0 {
if !s.TrustedProxies.Empty() {
ppListener.ConnPolicy = s.proxyProtocolPolicy
} else {
s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers")
@@ -742,10 +743,8 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
addr = addr.Unmap()
// called per accept
for _, prefix := range s.TrustedProxies {
if prefix.Contains(addr) {
return proxyproto.REQUIRE, nil
}
if s.TrustedProxies.Contains(addr) {
return proxyproto.REQUIRE, nil
}
return proxyproto.IGNORE, nil
}

View File

@@ -1,43 +0,0 @@
package proxy
import (
"fmt"
"net/netip"
"strings"
)
// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs
// into a slice of netip.Prefix values suitable for trusted proxy configuration.
// Bare IPs are converted to single-host prefixes (/32 or /128).
func ParseTrustedProxies(raw string) ([]netip.Prefix, error) {
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
prefixes := make([]netip.Prefix, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
prefix, err := netip.ParsePrefix(part)
if err == nil {
prefixes = append(prefixes, prefix)
continue
}
addr, addrErr := netip.ParseAddr(part)
if addrErr != nil {
return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr)
}
bits := 32
if addr.Is6() {
bits = 128
}
prefixes = append(prefixes, netip.PrefixFrom(addr, bits))
}
return prefixes, nil
}

Some files were not shown because too many files have changed in this diff Show More