Compare commits

...

52 Commits

Author SHA1 Message Date
dependabot[bot]
4e2978a1b1 Bump golang.zx2c4.com/wireguard/windows
Bumps the wireguard group with 1 update in the / directory: golang.zx2c4.com/wireguard/windows.


Updates `golang.zx2c4.com/wireguard/windows` from 0.5.3 to 1.0.1

---
updated-dependencies:
- dependency-name: golang.zx2c4.com/wireguard/windows
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: wireguard
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 02:44:00 +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
Maycon Santos
aa92ad3fb1 [management] Add agent_network_only account setting (#6736)
* [management] Add agent_network_only account setting

* [management] Load agent_network_only in pgx account loader and cover persistence
2026-07-12 14:46:08 +02:00
Sufiyan Khan
fd94fdb42b [management] fix duplicate operationId in OpenAPI spec (#6734) 2026-07-12 14:15:55 +02:00
Maycon Santos
30d15ecc3d [client,management] sync 0.74.4 changes (#6727)
* [management] fix: prevent reverse proxy domain from being pushed as DNS search domain by @blaugrau90 in https://github.com/netbirdio/netbird/pull/6498
* [client] Recover from rosenpass key desync by @lixmal in https://github.com/netbirdio/netbird/pull/6714
* [client] Bump golang.org/x/crypto to v0.54.0 by @lixmal in https://github.com/netbirdio/netbird/pull/6709
* [client] fix MDM managementURL conflict on default-port URL echo by @riccardomanfrin in https://github.com/netbirdio/netbird/pull/6672
* [client] Update gopsutil to v4 by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6688
* [client] Fix hanging status command during relay dial by @theodorsm in https://github.com/netbirdio/netbird/pull/6694

---------

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-11 11:03:55 +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
Maycon Santos
8e02154bf5 [client] Add SSO login flow timing instrumentation (#6717)
Users reported long delays between finishing browser authentication and
the client connecting. Logs could not attribute the time: the PKCE and
device flows were silent between issuing the auth URL and returning the
token, and nothing recorded when the GUI issued the Up request after
WaitSSOLogin completed.

Add log lines covering the full chain: PKCE callback arrival and token
exchange duration, device-flow polling and approval timing, GUI-side
brackets around WaitSSOLogin and Up, daemon-side Up arrival and
WaitSSOLogin return, and a frontend stall detector that reports when
webview timers were suspended (macOS App Nap / hidden-window
throttling), which delays the WaitSSOLogin-to-Up handoff.
2026-07-10 16:11:27 +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
dmitri-netbird
e0c25ba4ba [client] fix flaky test around event aggregation (#6710)
* fix flaky test around event aggregation: control time.Now() from the test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* actually use passed in func to generate time

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-09 18:17:28 +02:00
Pascal Fischer
2560c6bd6c [management] add traffic filters for source and dest id (#6697) 2026-07-09 14:37:31 +02:00
Viktor Liu
96ac15d292 [client] Fix js relay WebSocket close, raise RDP dial timeout, adjust WASM log levels (#6684) 2026-07-09 13:21:08 +02:00
Misha Bragin
488bbcb22b [doc] Update Agent Network Readme (#6699) 2026-07-08 17:53:55 +02:00
Theodor Midtlien
b7bbb44286 [client] Merge v0.74.x branch (#6700)
* [client] Update gopsutil to v4 (#6688)
* [client] Fix hanging status command during relay dial (#6694)

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2026-07-08 17:52:50 +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
134 changed files with 6138 additions and 1331 deletions

View File

@@ -1,16 +1,47 @@
# NetBird Agent Network
Agent Network is NetBird's access control layer for AI agents and the people who run
them. It gives every agent a real identity, tied to your identity provider (IdP), and
governs what it can reach — the LLM APIs and AI gateways it can call, and the internal
resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by
policy, with no API keys to leak.
Agent Network is NetBird's access control layer for AI agents and the people who run them.
It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and
AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel,
scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage.
> **Beta.** Agent Network is open source and can be self-hosted on your own
> infrastructure.
Because every LLM request passes through an
identity-aware proxy, you can:
- **Set spending and rate limits** per agent, per user, or per team — with hard caps
that stop requests once a budget is reached.
- **Restrict models and providers** so agents can only call approved (and cost-appropriate)
endpoints, keeping expensive models off-limits unless explicitly allowed.
- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every
request is tied back to the agent and person responsible.
- **Reuse your existing AI gateway** — point the proxy at a gateway you already run,
keeping its routing and config in place while it adds identity on top, so you skip
API key distribution.
https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268
> **Beta.** Agent Network is in beta, but it's stable and already running in
> production environments. It's fully open source and can be self-hosted on your own
> infrastructure, with no vendor lock-in and no data leaving your environment.
## How it works
Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets.
How can you do that without creating a dedicated API key for every team?
With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai
Teams configure their agents to point to that endpoint instead of using individual API keys directly.
This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet.
You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool.
## Quickstart
Full step-by-step setup:
**https://docs.netbird.io/agent-network/quickstart**
## Architecture
Agent Network is built on two existing NetBird capabilities:
- **Overlay network** — the encrypted WireGuard mesh between peers.
@@ -22,6 +53,9 @@ LLM traffic is routed through the proxy's identity-aware pipeline, while interna
resources (databases, internal APIs, self-hosted models) are reached directly over
peer-to-peer WireGuard tunnels, governed by the same identities and access policies.
<img width="4720" height="2218" alt="image" src="https://github.com/user-attachments/assets/1afa5da1-4b82-4f8a-a7a8-f417efadf1eb" />
## Where the code lives
There is no separate "agent-network" service — it reuses the reverse-proxy and management

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

@@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout)
start := time.Now()
polls := 0
for {
select {
case <-waitCtx.Done():
return TokenInfo{}, waitCtx.Err()
case <-ticker.C:
polls++
tokenResponse, err := d.requestToken(info)
if err != nil {
return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err)
@@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
if tokenResponse.Error != "" {
if tokenResponse.Error == "authorization_pending" {
log.Tracef("device flow: authorization still pending after poll %d", polls)
continue
} else if tokenResponse.Error == "slow_down" {
interval += (3 * time.Second)
ticker.Reset(interval)
log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval)
continue
}
@@ -291,11 +299,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
UseIDToken: d.providerConfig.UseIDToken,
}
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
if err != nil {
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
}
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
return tokenInfo, err
}
}

View File

@@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout)
tokenChan := make(chan *oauth2.Token, 1)
errChan := make(chan error, 1)
@@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo
func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
log.Infof("pkce flow: received authorization callback from IdP")
cert := p.providerConfig.ClientCertPair
if cert != nil {
tr := &http.Transport{
@@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token,
return nil, fmt.Errorf("authentication failed: missing code")
}
return p.oAuthConfig.Exchange(
exchangeStart := time.Now()
token, err := p.oAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", p.codeVerifier),
)
if err != nil {
return nil, err
}
log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond))
return token, nil
}
func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) {
@@ -296,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
audience = p.providerConfig.ClientID
}
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
}
@@ -310,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
return tokenInfo, nil
}
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
// without verifying its signature. The value is best-effort and used only as a
// UX convenience (login hint prefill and display); it never drives an
// authorization decision. The authoritative identity is established server-side
// from the signature-verified token.
func parseEmailFromIDToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {

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

@@ -109,7 +109,7 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er
return nil
}
log.Warnf("lazy connection manager is enabled by management feature flag")
log.Infof("lazy connection manager is enabled by the management feature flag")
e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true)
return e.addPeersToLazyConnManager()

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

@@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName)
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}
@@ -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

@@ -175,7 +175,9 @@ func TestFlowAggregationOfUnknownProtocols(t *testing.T) {
}
func TestResetAggregationWindow(t *testing.T) {
store := NewAggregatingMemoryStore()
now := time.Now()
nowFunc := func() time.Time { return now }
store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc)
store.StoreEvent(&types.Event{
ID: uuid.New(),
Timestamp: time.Now(),
@@ -198,6 +200,7 @@ func TestResetAggregationWindow(t *testing.T) {
},
})
now = now.Add(1 * time.Second)
reset := store.ResetAggregationWindow()
previousEvents, ok := reset.(*AggregatingMemory)
assert.True(t, ok)

View File

@@ -29,6 +29,7 @@ type AggregatingMemory struct {
WindowStart time.Time
WindowEnd time.Time
rnd *v2.PCG
nowFunc func() time.Time
}
func (m *Memory) StoreEvent(event *types.Event) {
@@ -62,14 +63,19 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) {
}
func NewAggregatingMemoryStore() *AggregatingMemory {
return &AggregatingMemory{WindowStart: time.Now(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc)
}
// used in tests when deterministic (less random) time intervals are required
func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory {
return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
}
func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator {
am.mux.Lock()
defer am.mux.Unlock()
now := time.Now()
now := am.nowFunc()
toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())}
am.events = make(map[uuid.UUID]*types.Event)
@@ -152,3 +158,7 @@ func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event {
return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here
}
func defaultNowFunc() time.Time {
return time.Now()
}

View File

@@ -30,6 +30,11 @@ import (
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
// wgTimeoutEscalationThreshold is the number of consecutive WireGuard
// handshake timeouts after which the rosenpass state for the peer is
// considered desynced and gets reset.
const wgTimeoutEscalationThreshold = 3
// MetricsRecorder is an interface for recording peer connection metrics
type MetricsRecorder interface {
RecordConnectionStages(
@@ -118,6 +123,9 @@ type Conn struct {
wgWatcher *WGWatcher
wgWatcherWg sync.WaitGroup
wgWatcherCancel context.CancelFunc
// wgTimeouts counts consecutive WireGuard handshake timeouts without a
// successful handshake in between. Guarded by mu.
wgTimeouts int
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
@@ -195,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,
}
@@ -663,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
}
@@ -683,6 +691,29 @@ func (conn *Conn) onWGDisconnected() {
default:
conn.Log.Debugf("No active connection to close on WG timeout")
}
conn.escalateWGTimeoutLocked()
}
// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated
// handshake timeouts. With rosenpass enabled, persistent timeouts mean the
// preshared keys have desynced; the renewal exchange runs over the dead
// tunnel and cannot resync them. Reporting the peer disconnected drops its
// rosenpass state, so the next connection configuration programs the
// rendezvous key and the tunnel can bootstrap again. Callers must hold mu.
func (conn *Conn) escalateWGTimeoutLocked() {
if conn.config.RosenpassConfig.PubKey == nil {
return
}
conn.wgTimeouts++
if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil {
return
}
conn.wgTimeouts = 0
conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold)
conn.onDisconnected(conn.config.WgConfig.RemoteKey)
}
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
@@ -802,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)
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) {
@@ -843,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)
}
@@ -892,6 +939,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
conn.recordConnectionMetrics()
}
// onWGCheckSuccess is called for every watcher check that observed a fresh
// handshake, including handshakes of connections that were already up when
// the watcher started.
func (conn *Conn) onWGCheckSuccess() {
conn.mu.Lock()
conn.wgTimeouts = 0
conn.mu.Unlock()
}
// recordConnectionMetrics records connection stage timestamps as metrics
func (conn *Conn) recordConnectionMetrics() {
if conn.metricsRecorder == nil {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/client/iface"
@@ -304,3 +305,84 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
}
}
func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
cfg := ConnConfig{
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="},
}
if rosenpassEnabled {
cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")}
}
conn := &Conn{
ctx: context.Background(),
config: cfg,
Log: log.WithField("peer", cfg.Key),
metricsStages: &MetricsStages{},
}
conn.SetOnDisconnected(func(remotePeer string) {
*disconnected = append(*disconnected, remotePeer)
})
return conn
}
// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake
// timeouts with rosenpass enabled mean the preshared keys have desynced. The
// renewal exchange runs over the dead tunnel and cannot resync them, so after
// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the
// peer disconnected, dropping its rosenpass state so the next configuration
// programs the rendezvous key.
func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "escalation must not fire below the threshold")
conn.onWGDisconnected(conn.ctx)
assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected,
"reaching the threshold must report the peer disconnected once")
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
}
assert.Len(t, disconnected, 1, "escalation must restart counting after firing")
conn.onWGDisconnected(conn.ctx)
assert.Len(t, disconnected, 2, "continued timeouts must escalate again")
}
// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful
// handshake between timeouts means the tunnel recovered; the counter must
// start over.
func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(true, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
}
conn.onWGCheckSuccess()
for i := 0; i < wgTimeoutEscalationThreshold-1; i++ {
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "handshake success must reset the timeout count")
}
// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass
// there is no per-peer key state to reset; repeated timeouts must not report
// disconnects.
func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) {
var disconnected []string
conn := newWGTimeoutTestConn(false, &disconnected)
for i := 0; i < wgTimeoutEscalationThreshold*3; i++ {
conn.onWGDisconnected(conn.ctx)
}
assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections")
}

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,36 +47,23 @@ 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
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
// for context lifecycle management.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
w.muEnabled.Lock()
w.enabled = false
w.muEnabled.Unlock()
// for context lifecycle management. onHandshakeSuccessFn is called only for the first
// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh
// handshake, including the first.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) {
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake)
}
// Reset signals the watcher that the WireGuard peer has been reset and a new
@@ -90,7 +76,7 @@ func (w *WGWatcher) Reset() {
}
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) {
w.log.Infof("WireGuard watcher started")
timer := time.NewTimer(wgHandshakeOvertime)
@@ -103,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
}
@@ -117,6 +104,10 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
}
}
if onCheckSuccessFn != nil && ctx.Err() == nil {
onCheckSuccessFn()
}
lastHandshake = *handshake
resetTime := time.Until(handshake.Add(checkPeriod))
@@ -147,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"
)
@@ -24,6 +23,72 @@ func (m *MocWgIface) disconnect() {
m.stop = true
}
type mockHandshakeStats struct {
mu sync.Mutex
handshake time.Time
}
func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) {
m.mu.Lock()
defer m.mu.Unlock()
return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil
}
func (m *mockHandshakeStats) advance() {
m.mu.Lock()
defer m.mu.Unlock()
m.handshake = time.Now()
}
// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh
// handshake even when the watcher started with an existing handshake baseline,
// the case where onHandshakeSuccessFn stays silent.
func TestWGWatcher_CheckSuccessCallback(t *testing.T) {
// checkPeriod bounds how stale a handshake may be before the watcher treats it
// as a suspended-machine timeout. The first check fires after wgHandshakeOvertime,
// so keep checkPeriod well above any scheduling jitter to avoid a false timeout
// converting the expected success into a disconnect on a loaded runner.
checkPeriod = 1 * time.Minute
wgHandshakeOvertime = 1 * time.Second
mlog := log.WithField("peer", "tet")
// Use an old baseline so advance() yields a strictly newer handshake even on
// platforms with coarse clock resolution (Windows), where two time.Now() calls
// microseconds apart can return the same instant and read as a timed-out handshake.
stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)}
watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{}))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watcher.PrepareInitialHandshake()
firstHandshake := make(chan struct{}, 1)
checkSuccess := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {
firstHandshake <- struct{}{}
}, func() {
select {
case checkSuccess <- struct{}{}:
default:
}
})
stats.advance()
select {
case <-checkSuccess:
case <-time.After(10 * time.Second):
t.Errorf("timeout waiting for check success callback")
}
select {
case <-firstHandshake:
t.Errorf("first-handshake callback must not fire for a non-zero baseline")
default:
}
}
func TestWGWatcher_EnableWgWatcher(t *testing.T) {
checkPeriod = 5 * time.Second
wgHandshakeOvertime = 1 * time.Second
@@ -35,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() {
@@ -44,7 +108,7 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
onDisconnected <- struct{}{}
}, func(when time.Time) {
mlog.Infof("onHandshakeSuccess: %v", when)
})
}, nil)
// wait for initial reading
time.Sleep(2 * time.Second)
@@ -66,14 +130,13 @@ 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)
go func() {
defer wg.Done()
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil)
}()
cancel()
@@ -83,13 +146,12 @@ 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() {
onDisconnected <- struct{}{}
}, func(when time.Time) {})
}, func(when time.Time) {}, nil)
time.Sleep(2 * time.Second)
mocWgIface.disconnect()

View File

@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
@@ -439,7 +440,11 @@ func (s *ServiceManager) GetStatePath() string {
activeProf, err := s.GetActiveProfileState()
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("active profile state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to get active profile state: %v", err)
}
return defaultStatePath
}

View File

@@ -39,6 +39,7 @@ type rpServer interface {
type Manager struct {
ifaceName string
localWgKey wgtypes.Key
spk []byte
ssk []byte
rpKeyHash string
@@ -51,8 +52,9 @@ type Manager struct {
wgIface PresharedKeySetter
}
// NewManager creates a new Rosenpass manager
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
// NewManager creates a new Rosenpass manager. localWgKey is the local
// WireGuard public key, used to derive the per-peer rendezvous key.
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
public, secret, err := rp.GenerateKeyPair()
if err != nil {
return nil, err
@@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
return &Manager{
ifaceName: wgIfaceName,
localWgKey: localWgKey,
rpKeyHash: rpKeyHash,
spk: public,
ssk: secret,
@@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
// replace it with a fresh handler on each Run() to clear stale peer
// state from previous engine sessions.
rpWgHandler: NewNetbirdHandler(),
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
}, nil
}
@@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) {
cfg.Peers = []rp.PeerConfig{}
m.lock.Lock()
m.rpWgHandler = NewNetbirdHandler()
m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey)
if m.wgIface != nil {
m.rpWgHandler.SetInterface(m.wgIface)
}

View File

@@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
ssk: make([]byte, 32),
rpKeyHash: "test-hash",
rpPeerIDs: make(map[string]*rp.PeerID),
rpWgHandler: NewNetbirdHandler(),
rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
server: mock,
}
}
@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
// issue #4341 cannot occur in the window between NewManager and Run().
func TestNewManager_PreInitializesHandler(t *testing.T) {
psk := wgtypes.Key{}
m, err := NewManager(&psk, "wt0")
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
require.NoError(t, err)
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
}
@@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing
require.False(t, m.IsPresharedKeyInitialized(wgKey))
}
// --- NetbirdHandler.outputKey ----------------------------------------------
// --- NetbirdHandler.applyKey ----------------------------------------------
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
}
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
}
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
// no SetInterface — iface remains nil
pid := rp.PeerID{0x03}
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
@@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
h.HandshakeCompleted(pid, rp.Key{})
}
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
h := NewNetbirdHandler()
func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
}
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
h := NewNetbirdHandler()
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
iface := &mockIface{}
h.SetInterface(iface)
@@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
}
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
h := NewNetbirdHandler()
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
pid := rp.PeerID{0x05}
wgKey := wgtypes.Key{0xEE}
h.AddPeer(pid, "wt0", rp.Key(wgKey))

View File

@@ -18,19 +18,34 @@ type PresharedKeySetter interface {
type wireGuardPeer struct {
Interface string
PublicKey rp.Key
// initialized is true once a completed exchange has set a
// Rosenpass-managed PSK for this peer.
initialized bool
// chainKey is the key output by the last completed exchange, advanced by
// one ratchet step on expiry. Nil until the first exchange completes and
// after the peer has fallen back to the rendezvous key.
chainKey *wgtypes.Key
// expiries counts failed renewals since the last completed exchange.
expiries int
}
type NetbirdHandler struct {
mu sync.Mutex
iface PresharedKeySetter
peers map[rp.PeerID]wireGuardPeer
initializedPeers map[rp.PeerID]bool
mu sync.Mutex
iface PresharedKeySetter
// preSharedKey is the account-level preshared key, used as the rendezvous
// key when set. Nil means the deterministic seed key is used instead.
preSharedKey *[32]byte
// localWgKey is the local WireGuard public key, one of the two inputs to
// the deterministic seed key.
localWgKey wgtypes.Key
peers map[rp.PeerID]*wireGuardPeer
}
func NewNetbirdHandler() *NetbirdHandler {
func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler {
return &NetbirdHandler{
peers: map[rp.PeerID]wireGuardPeer{},
initializedPeers: map[rp.PeerID]bool{},
preSharedKey: preSharedKey,
localWgKey: localWgKey,
peers: map[rp.PeerID]*wireGuardPeer{},
}
}
@@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
h.iface = iface
}
// AddPeer registers a peer with the handler. Re-adding a known peer (every
// reconnection does) keeps its key recovery state.
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
h.mu.Lock()
defer h.mu.Unlock()
h.peers[pid] = wireGuardPeer{
if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk {
existing.Interface = intf
return
}
h.peers[pid] = &wireGuardPeer{
Interface: intf,
PublicKey: pk,
}
@@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.peers, pid)
delete(h.initializedPeers, pid)
}
// IsPeerInitialized returns true if Rosenpass has completed a handshake
@@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.initializedPeers[pid]
peer, ok := h.peers[pid]
return ok && peer.initialized
}
// HandshakeCompleted programs the freshly exchanged output key and resets the
// peer's key recovery state.
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
psk := wgtypes.Key(key)
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
key, _ := rp.GeneratePresharedKey()
h.outputKey(rp.KeyOutputReasonStale, pid, key)
}
func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
h.mu.Lock()
iface := h.iface
wg, ok := h.peers[pid]
isInitialized := h.initializedPeers[pid]
h.mu.Unlock()
defer h.mu.Unlock()
if iface == nil {
log.Warn("rosenpass: interface not set, cannot update preshared key")
peer, ok := h.peers[pid]
if !ok {
return
}
if peer.expiries > 0 {
log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries)
}
// chainKey tracks the shared exchange output regardless of the local write
// outcome, so both ends still converge on the next expiry.
peer.chainKey = &psk
peer.expiries = 0
if !h.applyKeyLocked(pid, psk, peer.initialized) {
return
}
peer.initialized = true
}
// HandshakeExpired replaces the expired key. The renewal exchange runs over
// the tunnel keyed by the PSK itself, so the replacement must be derivable on
// both ends without communication: the first expiry ratchets the last shared
// key forward, repeated expiries (and expiries without a completed exchange)
// fall back to the rendezvous key and drop the peer out of the initialized
// state so connection reconfigurations reprogram the rendezvous key as well.
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
h.mu.Lock()
defer h.mu.Unlock()
peer, ok := h.peers[pid]
if !ok {
return
}
peerKey := wgtypes.Key(wg.PublicKey).String()
pskKey := wgtypes.Key(psk)
peer.expiries++
// Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
// Use updateOnly=false for first rotation (peer has original/empty PSK)
if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
var psk wgtypes.Key
if peer.chainKey != nil && peer.expiries == 1 {
log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey))
psk = RatchetKey(*peer.chainKey)
peer.chainKey = &psk
} else {
rendezvous, err := h.rendezvousKey(peer)
if err != nil {
// Fail closed: without a rendezvous key the expired key must
// still be rotated out, even if the replacement is unusable.
log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err)
h.applyRandomKeyLocked(pid)
return
}
log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries)
psk = rendezvous
peer.chainKey = nil
peer.initialized = false
}
h.applyKeyLocked(pid, psk, true)
}
// rendezvousKey returns the key both ends converge on without communication:
// the account-level preshared key when configured, the deterministic seed key
// otherwise. It mirrors the key that peer connections program when Rosenpass
// does not manage the peer yet.
func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) {
if h.preSharedKey != nil {
return *h.preSharedKey, nil
}
seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String())
if err != nil {
return wgtypes.Key{}, err
}
return *seed, nil
}
// applyKeyLocked writes the preshared key for the peer to the WireGuard
// interface and reports whether the write succeeded. Callers must hold h.mu
// for the whole state-mutation-plus-write so that a concurrent completion and
// expiry cannot reorder their writes relative to the in-memory chain key.
func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool {
peer, ok := h.peers[pid]
if !ok {
return false
}
if h.iface == nil {
log.Warn("rosenpass: interface not set, cannot update preshared key")
return false
}
peerKey := wgtypes.Key(peer.PublicKey).String()
if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil {
log.Errorf("Failed to apply rosenpass key: %v", err)
return false
}
return true
}
func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) {
key, err := rp.GeneratePresharedKey()
if err != nil {
log.Errorf("failed to generate random preshared key: %v", err)
return
}
// Mark peer as isInitialized after the successful first rotation
if !isInitialized {
h.mu.Lock()
if _, exists := h.peers[pid]; exists {
h.initializedPeers[pid] = true
}
h.mu.Unlock()
}
h.applyKeyLocked(pid, wgtypes.Key(key), true)
}

View File

@@ -0,0 +1,250 @@
package rosenpass
import (
"testing"
rp "cunicu.li/go-rosenpass"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// handlerTestLink wires two NetbirdHandlers as the two ends of a single
// tunnel: handler A manages the rosenpass peer B and vice versa, the way two
// NetBird clients see each other.
type handlerTestLink struct {
handlerA, handlerB *NetbirdHandler
ifaceA, ifaceB *mockIface
pidA, pidB rp.PeerID
wgKeyA, wgKeyB wgtypes.Key
}
func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink {
t.Helper()
link := &handlerTestLink{
ifaceA: &mockIface{},
ifaceB: &mockIface{},
}
link.pidA[0] = 0xaa
link.pidB[0] = 0xbb
link.wgKeyA[31] = 1
link.wgKeyB[31] = 2
link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA)
link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB)
link.handlerA.SetInterface(link.ifaceA)
link.handlerB.SetInterface(link.ifaceB)
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA))
return link
}
// complete simulates a completed rosenpass exchange: both ends derive the
// same output key.
func (l *handlerTestLink) complete(osk rp.Key) {
l.handlerA.HandshakeCompleted(l.pidB, osk)
l.handlerB.HandshakeCompleted(l.pidA, osk)
}
// expire simulates a failed key renewal on both ends.
func (l *handlerTestLink) expire() {
l.handlerA.HandshakeExpired(l.pidB)
l.handlerB.HandshakeExpired(l.pidA)
}
func lastPSK(t *testing.T, m *mockIface) wgtypes.Key {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call")
return m.calls[len(m.calls)-1].psk
}
func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk")
require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange")
link.complete(osk)
require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only")
}
// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant:
// rosenpass renewals run over the tunnel that the PSK itself keys, so when a
// renewal fails on both ends, both ends must fall back to the same key or the
// tunnel can never handshake again.
func TestHandshakeExpired_BothSidesConverge(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
keyA := lastPSK(t, link.ifaceA)
keyB := lastPSK(t, link.ifaceB)
require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out")
require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry")
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"both ends must still converge after repeated expiries")
}
// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap
// case: the initial exchange never completed (the tunnel ran on the rendezvous
// key), so an expiry must not replace the working key with an unrecoverable
// one on either end.
func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) {
link := newHandlerTestLink(t, nil)
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"both ends must converge when the exchange never completed")
}
// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep
// failing, the peer must drop out of the initialized state so the next
// connection reconfiguration reprograms the rendezvous key instead of
// preserving a poisoned rosenpass-managed key.
func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
require.False(t, link.handlerA.IsPeerInitialized(link.pidB),
"repeated expiries must clear the initialized state")
require.False(t, link.handlerB.IsPeerInitialized(link.pidA),
"repeated expiries must clear the initialized state")
}
// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a
// desync must fully reset the recovery state.
func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk1, osk2 rp.Key
osk1[0] = 1
osk2[0] = 2
link.complete(osk1)
link.expire()
link.expire()
link.complete(osk2)
require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery")
link.expire()
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
"recovered link must converge again on the next expiry")
require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out")
}
// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must
// derive the replacement from the last shared key, so an attacker who only
// blocks the renewal exchange gains nothing over the previous key.
func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA),
"first expiry must program the ratcheted key")
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
"ratchet step must keep the peer initialized so reconfigurations preserve the key")
}
// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key
// also fails, both ends must land on the same key that peer connections
// program for uninitialized peers, so a reconnect completes the recovery.
func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
require.NoError(t, err)
require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key")
require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key")
}
// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level
// preshared key configured, the fallback must be that key, matching what peer
// connections program for uninitialized peers.
func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) {
psk := &[32]byte{0x77}
link := newHandlerTestLink(t, psk)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA),
"fallback must be the configured preshared key")
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB),
"fallback must be the configured preshared key on both ends")
}
// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must
// never create a WireGuard peer that connection management has removed.
func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.expire()
for _, call := range link.ifaceA.calls[1:] {
require.True(t, call.updateOnly, "expiry writes must be update-only")
}
}
// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every
// OnConnected; that must not reset the expiry chain state.
func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) {
link := newHandlerTestLink(t, nil)
var osk rp.Key
osk[0] = 0x42
link.complete(osk)
link.expire()
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
"re-adding a known peer must keep its state")
link.expire()
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
require.NoError(t, err)
require.Equal(t, *seed, lastPSK(t, link.ifaceA),
"second expiry after re-add must continue to the seed fallback")
}

View File

@@ -1,11 +1,28 @@
package rosenpass
import (
"crypto/sha256"
"fmt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// ratchetLabel domain-separates the expiry ratchet from other uses of the
// rosenpass output key.
const ratchetLabel = "netbird-rosenpass-expiry-ratchet"
// RatchetKey derives the successor preshared key from the previous Rosenpass
// output key. When a key expires without a completed renewal, both peers
// advance their last shared key by one ratchet step: the expired key is
// rotated out while both ends still converge on an identical, non-public
// replacement without communicating.
func RatchetKey(prev wgtypes.Key) wgtypes.Key {
input := make([]byte, 0, len(ratchetLabel)+len(prev))
input = append(input, ratchetLabel...)
input = append(input, prev[:]...)
return sha256.Sum256(input)
}
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
// of peer public keys. Both peers, given the same key pair, produce the same
// output regardless of which side runs the function: the inputs are ordered

View File

@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
@@ -264,7 +265,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector {
// restore selector state if it exists
if err := m.stateManager.LoadState(state); err != nil {
log.Warnf("failed to load state: %v", err)
if errors.Is(err, syscall.ENOSYS) {
log.Debugf("route selector state unavailable on this platform: %v", err)
} else {
log.Warnf("failed to load state: %v", err)
}
return routeselector.NewRouteSelector()
}

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

@@ -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)
}
@@ -828,6 +828,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
return nil, err
}
log.Infof("SSO login flow finished, returning success to caller")
return &proto.WaitSSOLoginResponse{
Email: tokenInfo.Email,
}, nil
@@ -835,6 +836,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
log.Infof("up request received")
s.mutex.Lock()
// clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
// by Down). connectionGoroutineRunning() reports whether the previous retry-loop
@@ -856,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

@@ -7,7 +7,7 @@ import (
"os"
"slices"
"github.com/shirou/gopsutil/v3/process"
"github.com/shirou/gopsutil/v4/process"
)
// getRunningProcesses returns a list of running process paths. The context bounds the work:

View File

@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/shirou/gopsutil/v3/process"
"github.com/shirou/gopsutil/v4/process"
)
func Benchmark_getRunningProcesses(b *testing.B) {

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

@@ -16,10 +16,13 @@ import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowser
import { initI18n } from "@/lib/i18n";
import { initPlatform } from "@/lib/platform";
import { initLogForwarding } from "@/lib/logs";
import { initStallWatch } from "@/lib/stallwatch";
// Must run first so even init-time logs reach the Go log pipeline.
initLogForwarding();
initStallWatch();
welcome();
Promise.all([

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

@@ -0,0 +1,31 @@
// Detects webview suspension (macOS App Nap / hidden-window timer throttling).
// While the webview is suspended no JS runs at all, so detection happens on
// resume: a 1s interval measures wall-clock drift and reports how long timers
// were frozen. Silent unless a stall actually occurred; a stalled webview is
// what delays promise continuations such as the WaitSSOLogin → Up handoff.
const INTERVAL_MS = 1000;
const STALL_THRESHOLD_MS = 5000;
const REPORT_COOLDOWN_MS = 60_000;
let started = false;
export function initStallWatch() {
if (started) return;
started = true;
let last = Date.now();
let lastReport = 0;
setInterval(() => {
const now = Date.now();
const stall = now - last - INTERVAL_MS;
last = now;
if (stall < STALL_THRESHOLD_MS) return;
if (now - lastReport < REPORT_COOLDOWN_MS) return;
lastReport = now;
console.warn(
`webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` +
`(App Nap / hidden-window throttling); pending UI work ran late`,
);
}, INTERVAL_MS);
}

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"`
@@ -116,6 +116,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
if err != nil {
return LoginResult{}, s.classifyDaemonError(err)
}
log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin())
return LoginResult{
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
UserCode: resp.GetUserCode(),
@@ -124,26 +125,12 @@ 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
}
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
UserCode: p.UserCode,
Hostname: p.Hostname,
})
if err != nil {
return "", s.classifyDaemonError(err)
}
return resp.GetEmail(), nil
}
func (s *Connection) Up(ctx context.Context, p UpParams) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
log.Infof("sending up request to daemon")
// Always async: status updates flow via SubscribeStatus.
req := &proto.UpRequest{Async: true}
if p.ProfileName != "" {
@@ -158,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 {
@@ -217,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

@@ -23,7 +23,7 @@ const (
RDCleanPathProxyHost = "rdcleanpath.proxy.local"
RDCleanPathProxyScheme = "ws"
rdpDialTimeout = 15 * time.Second
rdpDialTimeout = 30 * time.Second
GeneralErrorCode = 1
WSAETimedOut = 10060

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

@@ -91,7 +91,7 @@ func availableProviders() []providerCase {
if region == "" {
region = "us-east-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: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
}
return ps
}
@@ -224,9 +224,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,168 @@
//go:build e2e
package agentnetwork
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// 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:
return strings.TrimPrefix(pc.model, "us.")
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:
return "us.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

@@ -194,6 +194,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 +231,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 == "" {

29
go.mod
View File

@@ -2,7 +2,7 @@ module github.com/netbirdio/netbird
go 1.25.5
toolchain go1.25.11
toolchain go1.25.12
require (
cunicu.li/go-rosenpass v0.5.42
@@ -19,11 +19,11 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
golang.zx2c4.com/wireguard/windows v1.0.1
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
@@ -101,7 +101,7 @@ require (
github.com/quic-go/quic-go v0.55.0
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/xid v1.3.0
github.com/shirou/gopsutil/v3 v3.24.4
github.com/shirou/gopsutil/v4 v4.25.8
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/stretchr/testify v1.11.1
@@ -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
@@ -126,11 +126,11 @@ require (
goauthentik.io/api/v3 v3.2023051.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.42.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/api v0.276.0
gopkg.in/yaml.v3 v3.0.1
@@ -295,8 +295,6 @@ require (
github.com/prometheus/procfs v0.19.2 // indirect
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.8 // indirect
github.com/shoenig/go-m1cpu v0.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
@@ -305,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
@@ -316,8 +313,8 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
@@ -337,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

62
go.sum
View File

@@ -271,9 +271,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -417,7 +415,6 @@ github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT
github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA=
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU=
github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
@@ -479,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=
@@ -569,7 +566,6 @@ github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkk
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -599,16 +595,8 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4=
github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
@@ -640,7 +628,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
@@ -659,10 +646,8 @@ github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40
github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
@@ -675,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=
@@ -747,8 +730,8 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -763,8 +746,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -783,8 +766,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -799,8 +782,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -834,11 +817,10 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -851,8 +833,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -864,8 +846,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -879,8 +861,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -889,8 +871,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=

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",

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

@@ -15,7 +15,7 @@ import (
"go.opentelemetry.io/otel/metric"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/net/http2/h2c" //nolint:staticcheck
"google.golang.org/grpc"
"github.com/netbirdio/netbird/encryption"
@@ -382,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
// the following magic is needed to support HTTP2 without TLS
// and still share a single port between gRPC and HTTP APIs
h1s := &http.Server{
//nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not
Handler: h2c.NewHandler(handler, &http2.Server{}),
}
err = h1s.Serve(listener)

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

@@ -286,6 +286,21 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
if req.Settings.MetricsPushEnabled != nil {
returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled
}
if req.Settings.AgentNetworkOnly != nil {
returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly
}
if req.Settings.DashboardFeatures != nil {
returnSettings.DashboardFeatures = &types.DashboardFeatures{
AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork,
}
}
if returnSettings.AgentNetworkOnly &&
(returnSettings.DashboardFeatures == nil ||
returnSettings.DashboardFeatures.AgentNetwork == nil ||
!*returnSettings.DashboardFeatures.AgentNetwork) {
return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled")
}
return returnSettings, nil
}
@@ -417,6 +432,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
AutoUpdateAlways: &settings.AutoUpdateAlways,
Ipv6EnabledGroups: &settings.IPv6EnabledGroups,
MetricsPushEnabled: &settings.MetricsPushEnabled,
AgentNetworkOnly: &settings.AgentNetworkOnly,
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
LocalAuthDisabled: &settings.LocalAuthDisabled,
LocalMfaEnabled: &settings.LocalMfaEnabled,
@@ -430,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

@@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -158,6 +159,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -186,6 +188,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr("latest"),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -214,6 +217,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -242,6 +246,7 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
@@ -270,6 +275,109 @@ func TestAccounts_AccountsHandler(t *testing.T) {
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount OK enabling agent_network_only",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(true),
DashboardFeatures: &api.AccountDashboardFeatures{
AgentNetwork: br(true),
},
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount fails enabling agent_network_only without dashboard_features",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusUnprocessableEntity,
expectedArray: false,
},
{
name: "PutAccount OK setting dashboard_features agent_network",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
DashboardFeatures: &api.AccountDashboardFeatures{
AgentNetwork: br(true),
},
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),
},
expectedArray: false,
expectedID: accountID,
},
{
name: "PutAccount OK disabling agent_network_only again",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": false},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
GroupsPropagationEnabled: br(false),
JwtGroupsClaimName: sr(""),
JwtGroupsEnabled: br(false),
JwtAllowGroups: &[]string{},
RegularUsersViewBlocked: false,
RoutingPeerDnsResolutionEnabled: br(false),
LazyConnectionEnabled: br(false),
DnsDomain: sr(""),
AutoUpdateAlways: br(false),
AutoUpdateVersion: sr(""),
MetricsPushEnabled: br(false),
AgentNetworkOnly: br(false),
EmbeddedIdpEnabled: br(false),
LocalAuthDisabled: br(false),
LocalMfaEnabled: br(false),

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

@@ -1605,7 +1605,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups,
settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range,
settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled,
settings_local_mfa_enabled, settings_metrics_push_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
@@ -1629,6 +1631,12 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
sLazyConnectionEnabled sql.NullBool
sLocalMFAEnabled sql.NullBool
sMetricsPushEnabled sql.NullBool
sAgentNetworkOnly sql.NullBool
sDashboardFeatures sql.NullString
autoUpdateVersion sql.NullString
autoUpdateAlways sql.NullBool
peerExposeEnabled sql.NullBool
peerExposeGroups sql.NullString
sExtraPeerApprovalEnabled sql.NullBool
sExtraUserApprovalRequired sql.NullBool
sExtraIntegratedValidator sql.NullString
@@ -1651,7 +1659,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
&sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups,
&sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange,
&sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled,
&sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly,
&sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways,
&peerExposeEnabled, &peerExposeGroups,
&sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired,
&sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups,
)
@@ -1720,6 +1730,14 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc
if sMetricsPushEnabled.Valid {
account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool
}
if sAgentNetworkOnly.Valid {
account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool
}
if sDashboardFeatures.Valid && sDashboardFeatures.String != "" {
if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil {
log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err)
}
}
if sJWTAllowGroups.Valid {
_ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups)
}
@@ -1735,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")
@@ -1245,6 +1294,61 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) {
assert.NoError(t, err)
}
func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
account, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false")
account.Settings.AgentNetworkOnly = true
require.NoError(t, store.SaveAccount(context.Background(), account))
reloaded, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip")
reloaded.Settings.AgentNetworkOnly = false
require.NoError(t, store.SaveAccount(context.Background(), reloaded))
disabled, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist")
}
func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
account, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset")
agentNetwork := true
account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork}
require.NoError(t, store.SaveAccount(context.Background(), account))
reloaded, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip")
require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set")
require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true")
disabled := false
reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled}
require.NoError(t, store.SaveAccount(context.Background(), reloaded))
reloadedDisabled, err := store.GetAccount(context.Background(), accountID)
require.NoError(t, err)
require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set")
require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist")
}
func TestSqlStore_GetAccountUsers(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir())
t.Cleanup(cleanup)

View File

@@ -305,7 +305,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
zone = &nbdns.CustomZone{
Domain: dns.Fqdn(serviceDomainZone),
Records: []nbdns.SimpleRecord{},
NonAuthoritative: true,
NonAuthoritative: true,
SearchDomainDisabled: true,
}
zonesByApex[serviceDomainZone] = zone
}

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

@@ -76,6 +76,15 @@ type Settings struct {
// MetricsPushEnabled globally enables or disables client metrics push for the account
MetricsPushEnabled bool `gorm:"default:false"`
// AgentNetworkOnly limits the dashboard to the Agent Network surface for this account.
// Set for accounts created via netbird.ai signups; users can disable it later.
AgentNetworkOnly bool `gorm:"default:false"`
// DashboardFeatures holds per-account dashboard section visibility overrides.
// It serializes to a single JSON column so new sections can be added without
// a schema change.
DashboardFeatures *DashboardFeatures `gorm:"serializer:json"`
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
// This is a runtime-only field, not stored in the database.
EmbeddedIdpEnabled bool `gorm:"-"`
@@ -114,6 +123,7 @@ func (s *Settings) Copy() *Settings {
AutoUpdateAlways: s.AutoUpdateAlways,
IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups),
MetricsPushEnabled: s.MetricsPushEnabled,
AgentNetworkOnly: s.AgentNetworkOnly,
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
LocalAuthDisabled: s.LocalAuthDisabled,
LocalMfaEnabled: s.LocalMfaEnabled,
@@ -121,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

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

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

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

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