Compare commits

...

69 Commits

Author SHA1 Message Date
riccardom
4e3805f535 Renames SetRemotePort to SetRemoteAddr 2026-07-27 17:09:36 +02:00
riccardom
80c7bb195e pqkem: clock data-path PSK rotation from WireGuard handshakes
Source OnDataPathRekeyed from the WGWatcher's per-handshake callback
(onWGCheckSuccess), which fires only on a fresh handshake, and OnDataPathDown
from the handshake-timeout path. A fresh handshake clocks the next chained
KEM exchange pushed over the data-path UDP transport.
2026-07-27 17:09:36 +02:00
riccardom
cac03bd80c pqkem: register data-path endpoint from signalling
Learn the peer's data-path endpoint from the signalling offer/answer: its WG
overlay IP combined with the advertised pq UDP port (SetRemotePort -> AddPeer).
Registering here is safe before the tunnel is up because sends only ever fire
once it is (clocked by OnDataPathRekeyed). RemovePeer is wired at peer teardown
(engine.removePeer), not on transient disconnect.
2026-07-27 17:09:36 +02:00
riccardom
2681f5f8e6 pqkem: apply derived PSK at WG peer-config time (pull) + keep push for rekey 2026-07-27 17:09:36 +02:00
riccardom
905b7f7914 pqkem: carry KEM offer/answer over the signalling exchange 2026-07-27 17:09:36 +02:00
riccardom
20ae4325ff pqkem: dedicated slog logger via NB_PQ_MLKEM_LOG_LEVEL 2026-07-27 17:09:36 +02:00
riccardom
4189937ac6 Homogeneous logs prefix 2026-07-27 17:09:36 +02:00
riccardom
1bccd71954 Bit of renaming
peer -> peerAddrs
have types for remoteID and localID
t.Close log error
Manager SetTransport -> Start
2026-07-27 17:09:36 +02:00
riccardom
f91e1e34ce Typo 2026-07-27 17:09:36 +02:00
riccardom
d544bfa15e Race fix 2026-07-27 17:09:36 +02:00
riccardom
a9fb4e9f0a Makes Transport just a UDP socket.
Manager owns maps for remoteID <-> remote UDP addr
Engine talks to manager only
2026-07-27 17:09:36 +02:00
riccardom
9074f36761 Adds transport 2026-07-27 17:09:36 +02:00
riccardom
a165eec3ba Communicate the port over the signal exchange 2026-07-27 17:09:36 +02:00
riccardom
4e7cbe2ef8 Ensure iface is up and with overlay ip assigned to get a valid UDP port 2026-07-27 17:09:36 +02:00
riccardom
8157b6d78f Adds real callback setter for PSK on ready 2026-07-27 17:09:36 +02:00
riccardom
b132eff867 Initializes PQ ML-KEM manager 2026-07-27 17:09:35 +02:00
riccardom
e98019bafc Adds no-op Transports and callbacks 2026-07-27 17:09:35 +02:00
riccardom
f4fddce174 Added enabled env var 2026-07-27 17:09:35 +02:00
riccardom
94adc454c1 Adds MLKEM Payload placeholder to client internals 2026-07-27 17:09:35 +02:00
riccardom
80f415519b Invert order of keys as per draft 2026-07-27 17:09:35 +02:00
riccardom
2e7436f49b Protocol update 2026-07-27 17:09:35 +02:00
riccardom
010f5281ce Removes confirm. Uses next offer to deliver confirmation/ack of previous round
We clock the next Offer initiation to the OnDataPathRekeyed, so we have 2 minutes
ahead of us to do our attempts and stuff before to give up.
On failure, we will know because we will not receive a new answer.. but more importantly
the wg handshake will fail :D
2026-07-27 17:09:35 +02:00
riccardom
e88fc05575 Leave signal offer/answer as a pull/push operation not as an actual transport 2026-07-27 17:09:35 +02:00
riccardom
7d2c71f84f Assume two transports: initial "signal" (control plane) one (no data path established yet) + data path one
Define OnDataPathRekeyed event to transition from control plane path to data plane path over the WG tunnel.

Keep confirm ALWAYS on NEW established WG tunnel (posthandshake with rekeying). We keep an active method
irrelevant of the WG handshake (we might decide that the indirect wg handshake is sufficient in the future).

Optimistic commit on responder(when sending answer), while on initiator we set it on getting the answer
2026-07-27 17:09:35 +02:00
riccardom
3e4652e528 Epurate wg refs 2026-07-27 17:09:35 +02:00
riccardom
46d4e4585d Collapse Driver and Manager in one.
- Have just one manager => one lock
 - Session state is needed in driver to => we have it available now.
 - Isomorphically align to rosenpass components and functionality

File	Role	                                  rosenpass equivalent
kem.go	primitive pure X25519MLKEM768	          crypto.go/handshake
message.go	Offer/Answer/Confirm + Encode/Decode  messages.go
manager.go	Manager stateful, single lock	      server logic
callbacks.go	WGCallbackHandler (seam output)	  Handler
Transport (interfaccia)	seam trasporto pluggable  Conn
2026-07-27 17:09:35 +02:00
riccardom
9edf2f4dea [squash] isInitial and answered can be inferred without state variables 2026-07-27 17:09:35 +02:00
riccardom
43d43e2a97 Manages convergence 2026-07-27 17:09:35 +02:00
riccardom
bca463d4c8 Models reattempts 2026-07-27 17:09:35 +02:00
riccardom
3ad12f0e58 Reuse answer, don't calculate again 2026-07-27 17:09:35 +02:00
riccardom
85df3abf0f Adds driver to glue together manager and outside world 2026-07-27 17:09:35 +02:00
riccardom
34f5756117 Defines event callbacks 2026-07-27 17:09:35 +02:00
riccardom
b728542d40 Admits possible errors on Encode 2026-07-27 17:09:35 +02:00
riccardom
b95e1aafe3 Bench key material boilerplate time/allocs
CGO_ENABLED=1 go test ./client/internal/pqkem/ -run '^$' -bench . -benchmem 2>&1 | grep -E "Benchmark|ns/op|PASS|ok" | head -20

BenchmarkX25519Keygen-14    	   33795	     34966 ns/op	     224 B/op	       5 allocs/op
BenchmarkX25519ECDH-14      	   33855	     33973 ns/op	      32 B/op	       1 allocs/op
BenchmarkMLKEMKeygen-14     	   21817	     67778 ns/op	    8200 B/op	       2 allocs/op
BenchmarkMLKEMEncaps-14     	   29918	     43235 ns/op	    1216 B/op	       2 allocs/op
BenchmarkMLKEMDecaps-14     	   26048	     56291 ns/op	      64 B/op	       2 allocs/op
PASS
ok  	github.com/netbirdio/netbird/client/internal/pqkem	9.751s
Shell cwd was reset to /home/riccardo/Desktop/Personal/netbirdio/netbird
2026-07-27 17:09:35 +02:00
riccardom
35ec435658 Pure mechanics of manager 2026-07-27 17:09:35 +02:00
riccardom
49922a8831 Messages definition 2026-07-27 17:09:35 +02:00
riccardom
3e7f52d80b ML-KEM encapsulate/decapsulate module 2026-07-27 17:09:35 +02:00
Misha Bragin
1816a020c4 [management, proxy] Add Claude Opus 5 (#6895) 2026-07-27 16:15:36 +02:00
Zoltan Papp
aa13928b76 [client] Export agent version info for iOS (#6918)
## Describe your changes

Export agent version info for iOS

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [x] 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/6918"><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 [code]smith"
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=1787750541&installation_model_id=427504&pr_number=6918&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6918&signature=c2de74d89c36b01aac5866422b0be0b7525f7c6e26e46174efc280339eb864b6"><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 [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
2026-07-27 15:53:09 +02:00
Viktor Liu
d681670a9d [misc] Restore the rootless-latest docker tag (#6914) 2026-07-27 12:07:58 +02:00
Maycon Santos
4f6247b5c3 [management, proxy] Add prompt-cache token and cost accounting to agent network usage (#6900)
Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-26 21:42:41 +02:00
Riccardo Manfrin
1e5b0a5c89 [client] Make Test_ConnectPeers deterministic under Docker/eBPF kernel / Darwin CI (#6884)
## Describe your changes

Make `Test_ConnectPeers` deterministic. Two issues, both surfaced once
the
privileged suite moved into a `--privileged` Docker container (#6425):

1. The peers used `getLocalIP()` as their WireGuard endpoint, i.e. the
host's
routable NIC IP (the docker bridge IP `172.17.0.2` in CI). That address
might
not hairpin reliably inside the container, so the handshake
intermittently
timed out (flaky). Use loopback (`127.1.0.x`) instead — always
self-reachable.
2. On a Linux runner with the WG kernel module the iface uses the eBPF
proxy
factory. Its manager is a singleton with one shared XDP program +
settings
map, so bringing up the two ifaces makes the second factory overwrite
the
first's `wg_port`/`proxy_port` and the handshake is dropped. The test is
   incompatible with the eBPF factory, so disable it via
`NB_DISABLE_EBPF_WG_PROXY` (peers then handshake directly over
loopback).
Running the suite across all three modes (eBPF / UDP proxy / ICE bind)
would
   need a larger refactor.

Also fixes a typo in the `ErrSharedSockStopped` message (`socked` →
`socket`).

## Issue ticket number and link

No public issue — CI flakiness follow-up to #6871 on `Test_ConnectPeers`

(https://github.com/netbirdio/netbird/blob/main/client/iface/iface_test.go).

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

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

Test-only change (plus a log-string typo). No public API, CLI, config,
or
behavior change.

### 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/6884"><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 [code]smith"
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=1787485967&installation_model_id=427504&pr_number=6884&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6884&signature=09fb996715d039bd02775a140e8cc03deca5cb42540790b82a744527264a30ad"><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 [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</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**
* Corrected the “shared socket stopped” error message for clearer
output.
* **Tests**
* Improved peer connection test reliability in privileged CI by
disabling the eBPF WireGuard proxy and using fixed loopback UDP
endpoints for deterministic setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-24 15:23:22 +02:00
Riccardo Manfrin
b65ec8b68a [client] Restores lost backup.Reset from #4935 (#6883)
## Describe your changes

Restore the management gRPC client's backoff reset after a stream
connects.

This is a regression: PR #4935 originally added this reset; it was then
lost in
commit `58daa674e` during the `withMgmtStream` refactor (Sync/Job
unification).

Compared to before the reset was done AFTER receiveEvents, whereas 
now it's done before. 

Now should cover for Sync and Job (which didn't exist in #4935. Hold for
long living stream that breaks.

Reset prevents two things

1. long living conns going wrong from waiting a long backoff time window
before to drive reconn
2. past MaxElapsedTime (3months) long lived conns failures from being
treated as "unrecoverable" (hence triggering an full restart of the
engine)

## Issue ticket number and link

Internal support case (customer agents lost data-plane connectivity
during a
management maintenance/release window). No public issue. Regression
introduced
in commit `58daa674e`, which removed the `backOff.Reset()` added by PR
#4935.

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

> 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 reconnection-behavior fix in the management gRPC client. No
public
NetBird CLI, API, or configuration surface changes.

### 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/6883"><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 [code]smith"
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=1787484622&installation_model_id=427504&pr_number=6883&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6883&signature=525b08edbcb94ee6f4ae4226fb1fe8ddb829caa7d4489e8efa639770af9dccdf"><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 [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</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 management stream retry behavior.
* Retry delays now reset after a stream is successfully established,
helping subsequent connection attempts recover more quickly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-24 13:53:53 +02:00
Zoltan Papp
e13bcdbd44 [client] Fetch FreeBSD port files from GitHub mirror instead of cgit (#6880)
cgit.freebsd.org now sits behind an Anubis anti-bot challenge and
intermittently returns an HTML challenge page with HTTP 200 instead of
the requested file, breaking the FreeBSD port release job. Fetch the
port Makefile and distinfo from the official freebsd-ports GitHub
mirror, retry transient failures, and fail loudly if HTML is returned
instead of the expected file.

## 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/6880"><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 [code]smith"
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=1787478670&installation_model_id=427504&pr_number=6880&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6880&signature=503a5d79f1671c21cebedac66516c7d7298d401a6382d9bf483813f9e7ee7591"><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 [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</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 reliability when retrieving FreeBSD port metadata during
release and issue preparation.
* Added automatic retries and HTTPS-only redirect handling for safer
downloads.
* Validates fetched content to detect unexpected HTML responses and
avoids processing invalid data.
* Updated port metadata retrieval to use a more reliable mirror,
improving version extraction and the resulting comparisons and
regenerated release information.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-24 13:10:53 +02:00
Riccardo Manfrin
46568f7af8 [client] Reconcile routed allowed IPs when a lazy connection goes idle (#6863)
## Describe your changes

Under lazy connections, when a routing peer goes idle its WireGuard peer
is torn down and re-created with a wake endpoint by
the activity listener, carrying only the overlay /32
(`peerCfg.AllowedIPs`). The routed subnet prefixes are dropped from
  the device on the Connected→Idle transition.

They are meant to be restored by the route watcher, which reacts to the
peer's status change and calls `recalculateRoutes` →
  `AddAllowedIP`. Two things prevent that from healing the peer:

- `AddAllowedIP` uses `update_only`, which is a silent no-op (no error)
when the peer does not exist. While the peer is
being torn down and re-armed with its wake endpoint, it is briefly
absent, so a re-add that lands in that window is lost.
- The allowed-IP refcounter only calls its add function on a prefix's
0→1 transition. The routed prefix stays referenced
across the idle cycle, so once the device entry is gone the refcounter
does not re-push it on its own, and nothing retries.

As a result, traffic to the routed subnet is black-holed while the peer
is idle. Because the wake endpoint only fires when a
packet matches the peer's AllowedIPs, a packet to the subnet is dropped
before reaching the wake endpoint, so it cannot
wake the peer. The peer only recovers when woken by other means (e.g. a
ping to its overlay IP).

  ## Approach

This change keeps the existing Connected→Idle transition as-is and
reconciles the AllowedIPs afterwards, avoiding any
additional locking on the transition path. The peer is torn down and
re-armed with its wake endpoint as today; the routed
prefixes are then re-applied from the route manager's allowed-IP
refcounter once the wake endpoint has been (re)armed.

A single add-only method, `ReconcilePeerAllowedIPs(peerKey)`, re-applies
every routed prefix currently tracked for the peer
in the refcounter (the authoritative store; it already covers static,
dynamic and dnsinterceptor routes). It runs whenever
the peer's wake endpoint is (re)created in the lazy manager — every
point where the activity listener builds it with the
  overlay /32 only:

- **initial registration** (`AddPeer`, cold start): the route manager
may have already pushed the peer's routes before the
wake endpoint existed, so those `AddAllowedIP` calls no-op'd; the
reconcile installs them on the freshly created wake
  endpoint.
- **the two paths into idle** (`DeactivatePeer` on a remote GOAWAY,
`onPeerInactivityTimedOut` on local inactivity): the
peer is torn down and re-armed, so the routed prefixes must be
re-applied.

In every case the routed prefixes end up on the wake endpoint, so
traffic to a routed subnet can wake the peer. Arming the
wake endpoint and reconciling are wrapped in a single
`armActivityListener` helper so the two always happen together.

New helper: `refcounter.Counter.KeysMatching(pred)` to enumerate a
peer's prefixes under the counter lock.

Note on scope: the reconcile restores what the refcounter tracks. All
routed AllowedIPs currently go through it, so this
covers the routed-prefix case; it does not attempt to reconcile
AllowedIPs installed outside the refcounter. The
Idle→Connected (wake) path does not need this: the peer is not removed
there (the listener close leaves it in place and only
the endpoint is updated), so a concurrent `AddAllowedIP` lands normally.

  ## Testing

Reproduced deterministically in a local dev setup (userspace client,
`NB_WG_KERNEL_DISABLED=true`, `B_LAZY_CONN_INACTIVITY_THRESHOLD=1`
inactivity threshold 1
min). A temporary 30s sleep in the tear-down → re-arm window widens the
race so the route watcher's async `AddAllowedIP`
reliably lands while the peer is absent and no-ops (the sleep is a test
aid, not part of the change):

- **without the reconcile:** after the peer goes idle, a ping to any
routed IP — both a pre-existing route and one added
  during the window — black-holes; the peer never wakes.
  - **with the reconcile:** the same ping wakes the peer and passes.

Added unit tests: `ReconcilePeerAllowedIPs` (re-applies all of a peer's
tracked prefixes, scoped to that peer) and
  `refcounter.Counter.KeysMatching`.

Note: `netbird status -d` is not a reliable signal for this —
`AddPeerStateRoute` records the route regardless of whether
the underlying `AddAllowedIP` no-op'd, so it reflects the route
manager's intent rather than device state. The reliable
  signal is functional (ping the subnet from idle).
  
  ## 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 (unit tests for the
reconcile + `KeysMatching`)

  ## Documentation

- [x] Documentation is **not needed** for this change (internal client
behavior, no API / gRPC / CLI / flag change)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6863"><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=1787330960&installation_model_id=427504&pr_number=6863&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6863&signature=f3d6a97d7db82e92b3939fdd0f159c5ee74913ff88f4eb82e41e88fcb787aff4"><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**
* Routed IP assignments are automatically reconciled and restored
whenever a peer’s lazy wake endpoint is armed or re-armed.
* Routed allowed IPs are re-applied after inactivity transitions and
monitoring re-initialization.
* If reconciliation can’t be performed, the client safely skips it; if
reconciliation encounters issues, failures are logged without stopping
connection monitoring.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 18:40:54 +02:00
Riccardo Manfrin
3358138ccc [client] Fix flaky Test_ConnectPeers busy-loop handshake wait (#6871)
## Describe your changes

  `Test_ConnectPeers` in `client/iface` is flaky in CI, timing out with
  `waiting for peer handshake timeout after 30s`.

  The wait loop polled `getPeer()` in a tight busy-loop with no sleep
(a `select` with a `default` branch), pegging a CPU core. The peers run
  userspace WireGuard (stdnet transport), so the spin starved the
wireguard-go goroutines that actually process the handshake, making the
  30s wait flaky under CI load.

Poll on a 500ms ticker instead so the CPU is yielded between checks, and
  check the handshake state before waiting. Same logic, no busy-spin.

  ## Issue ticket number and link

  N/A — CI flakiness fix.

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

  ## Documentation

  - [x] Documentation is **not needed** for this change (test-only fix)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6871"><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 [code]smith"
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=1787405146&installation_model_id=427504&pr_number=6871&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6871&signature=a68daf1747583280b5d1d083c6b45ade4c0d441a38fd25ab9a2ca01f87511ad9"><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 [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</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**
* Improved peer connection test waiting behavior to avoid busy spinning.
* Added a timeout and periodic checks, with clearer failure handling
when a handshake does not complete.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 15:48:16 +02:00
Viktor Liu
6d15d0729a [client] Clear stale UDP checksum in eBPF XDP proxy after port rewrite (#6861) 2026-07-23 14:37:52 +02:00
Maycon Santos
0936918d24 [management, proxy] add Kimi (Moonshot AI) provider to Agent Network (#6853) 2026-07-23 13:32:32 +02:00
Bethuel Mmbaga
d4a4418969 [misc] Simplify enterprise bootstrap (#6869) 2026-07-23 12:41:13 +03:00
Pascal Fischer
31ed241a1a [management] component types (#6866) 2026-07-23 11:37:09 +02:00
Maycon Santos
178e6a8530 [misc] Probe the agent-network endpoint with a GET instead of getent (#6867)
## Describe your changes

Use http get to trigger lazy connections as e2e will have single proxy
and status checks would fail without proper lazy wake-up
2026-07-23 15:30:40 +09:00
Maycon Santos
96963b6751 [proxy] fix proxy multistage build (#6864) 2026-07-22 21:24:44 +02:00
Maycon Santos
9770814f39 [client] warm lazy connections from the DNS resolver (#6854)
## Describe your changes

Supersedes #6767 (same change, moved to an unprefixed branch; review
feedback from there is addressed here).

With lazy connections enabled, a peer is not dialed until on-demand
traffic arrives, so the first request to a peer resolved by name (e.g.
the agent-network reverse-proxy) races — or loses to — the WireGuard
handshake. Activation was previously reactive only: a data-path packet
or an inbound signal.

This adds a proactive, DNS-time trigger. When the local resolver answers
for an overlay name, it now warms the lazy connection to the peer(s) the
answer points at and waits briefly for one to connect before returning
the response — so by the time the client sends its first packet the
tunnel is already up.

- `dns/local`: new `PeerActivator` capability + `SetPeerActivator`
setter (mirrors the existing `PeerConnectivity`/`SetPeerConnectivity`
injection). `ServeDNS` warms on the pre-filter answer, so activating a
lazily-idle peer also lets it survive the disconnected-peer filter.
Warm-up is scoped to match-only (non-authoritative) zones — the
synthesized private-service zones and user-created zones — so plain
peer-name lookups in the account's authoritative peer zone never wake
idle peers. No-op when no activator is wired (lazy off) or the answer
carries no peer IPs. Budget is `NB_DNS_LAZY_WARMUP_TIMEOUT` (default 2s,
parsed once at construction, invalid values logged); on timeout the
answer is returned anyway (never SERVFAIL).
- The resolver-facing interfaces (`PeerActivator`, `PeerConnectivity`)
take `netip.Addr` instead of string IPs; record addresses are extracted
as `netip.Addr` (v4-mapped forms unmapped) and converted to string only
at the `peer.Status` boundary.
- `SetPeerActivator` is part of the `dns.Server` interface (no-op on the
mock), so the engine wires it without a type assertion.
- `client/internal`: a small engine-side adapter (`dnsPeerActivator`)
resolves answer IPs to peers via `Status.PeerStateByIP`, activates them
through `ConnMgr.ActivatePeer` (HA fan-out included), and polls
`PeerStateByIP` until one is connected. The activation dial is tied to
the engine's long-lived context so a handshake that outlasts the
per-query wait still completes in the background. `ConnMgr.ActivatePeer`
is safe for concurrent use (the lazy manager pointer is guarded by a
dedicated RWMutex and the manager is internally synchronized), so the
DNS path never contends with network-map processing on `syncMsgMux`.

Scope is overlay-only for free: the trigger lives in the local resolver,
which only answers for NetBird-managed names; public/upstream DNS is
unaffected. Already-connected peers short-circuit, so steady-state DNS
latency is unchanged.

## Issue ticket number and link

N/A — follow-up to the lazy-connection rollout; fixes the agent-network
cold-start observed in the e2e (proxy peer stuck disconnected until
traffic).

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

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

Internal client behavior; the only knob is the optional
`NB_DNS_LAZY_WARMUP_TIMEOUT` tuning env var with a safe default.

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

- `dns/local`: warm-up invokes the activator with the answer's peer
address in match-only zones; authoritative-zone answers never trigger
warm-up; no-activator path unchanged; no-answer queries don't invoke the
activator; `NB_DNS_LAZY_WARMUP_TIMEOUT` parsing
(valid/invalid/non-positive); `extractRecordAddr` unmaps v4-mapped
record data.
- `client/internal`: `dnsPeerActivator` skips
connected/unknown/conn-less peers with no wait, returns as soon as a
pending peer connects, and releases the DNS response at the budget when
the peer stays idle; `ConnMgr.ActivatePeer` races the manager lifecycle
cleanly under `-race`.
- Agent-network e2e green on this change (with lazy connections
enabled): https://github.com/netbirdio/netbird/actions/runs/29891467544
2026-07-22 18:47:48 +02:00
Nicolas Frati
b0c1ed31b8 [infrastructure] Add unified admin CLI for self-hosted helpers (#6507)
## Summary

Adds a unified `admin` CLI for self-hosted instance administrators in
both the management and combined binaries.

## User Management

### `admin user change-password`
- Changes a local embedded IdP user's password.
- Selects the user with `--email` or `--user-id`.
- Reads the new password from `--password` or `--password-file`.
- Clears the user's local authentication session so the new password is
required on the next login.
- **Alias:** `admin user set-password`.

### `admin user reset-mfa`
- Resets a local embedded IdP user's MFA enrollment.
- Selects the user with `--email` or `--user-id`.
- Clears TOTP/WebAuthn enrollment data and removes the local
authentication session.
- The user will re-enroll MFA on the next login.

## MFA Management

### `admin mfa status`
- Shows whether local MFA is enabled in the account settings.
- Checks the embedded IdP client configuration and reports whether MFA
is enabled there.

### `admin mfa enable`
- Enables local MFA for embedded IdP users.
- Updates embedded IdP clients and saves the account MFA setting.
- Records an audit event on a best-effort basis.

### `admin mfa disable`
- Disables local MFA for embedded IdP users.
- Updates embedded IdP clients and saves the account MFA setting.
- Records an audit event on a best-effort basis.

## Reverse Proxy Tokens

### `admin token create --name <name> [--expires-in <duration>]`
- Creates a reverse proxy access token.
- Prints the plaintext token once, along with the token ID.
- `--expires-in` supports values such as `24h`, `30d`, or `365d`. If
omitted, the token never expires.

### `admin token list`
- Lists reverse proxy access tokens.
- Shows the token ID, name, creation date, expiration, last-used time,
and revocation status.
- **Alias:** `admin token ls`.

### `admin token revoke <token-id>`
- Revokes a reverse proxy access token.
- Revoked tokens can no longer authenticate reverse proxy instances.

## Reverse Proxy Management

### `admin proxy disconnect-all`
- Lists registered reverse proxy instances and force-marks all connected
instances as disconnected.
- Useful for repairing stale proxy state after an unclean management
server shutdown.
- Prompts for confirmation by default.
- `--dry-run` previews the changes without applying them.
- `--force` skips the confirmation prompt.
- Live proxies may appear again after their next heartbeat, reconnect,
or re-registration.

## Compatibility Commands

### `token ...`
- Deprecated top-level compatibility path.
- Behaves the same as `admin token ...`.
- Retained so existing scripts using `token create`, `token list`, or
`token revoke` continue to work.

## Changes

- Adds reusable `management/cmd/admin` command package.
- Wires `admin` into `netbird-mgmt` and `combined`.
- Adds local user password reset with existing password strength
validation.
- Adds local MFA enrollment reset by clearing Dex TOTP/WebAuthn
credentials and local auth sessions.
- Adds local MFA enable/disable/status helpers for embedded IdP
deployments.
- Moves proxy access token commands under `admin token` for a single
admin-focused CLI entry point.
- Exports `server.ValidatePassword` for reuse by CLI helpers.

## Tests

```bash
go test ./management/cmd/...
go test ./management/cmd/admin ./management/cmd ./combined/cmd
go test ./management/server -run TestValidatePassword
```

Pre-push lint also passed.


### 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:

- [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/832

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

## Release Notes

* **New Features**
* Added self-hosted admin CLI commands for changing passwords, resetting
MFA (including WebAuthn), and managing embedded IdP client MFA
(enable/disable/status).
* Introduced a unified admin command entry point and improved
data-directory handling for embedded IdP storage.

* **Refactor**
* Centralized password strength validation into a shared exported
validator.

* **Tests**
* Added a comprehensive admin command test suite covering password
input, selectors, MFA reset, and client MFA state handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 18:47:19 +02:00
dmitri-netbird
8435682ac8 [client, management] offload client config generation to the client (#6711)
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
Co-authored-by: crn4 <vladimir@netbird.io>
Co-authored-by: pascal <pascal@netbird.io>
2026-07-22 18:20:27 +02:00
Zoltan Papp
ed682fad87 [client] Run pnpm install with --ignore-scripts in frontend CI (#6859)
Prevent dependency lifecycle scripts (preinstall/postinstall/prepare)
from executing during install in the UI frontend CI job, closing the
most common npm supply-chain vector at build time. The frontend build
does not rely on any dependency install scripts.

## 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/6859"><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=1787319533&installation_model_id=427504&pr_number=6859&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6859&signature=f8285bf8f6381eebd80d2dc801748b2e1dedea6385f3ced7da6bcdc788eb0580"><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 UI frontend installation workflow to skip package
installation scripts while preserving the locked dependency versions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 16:01:33 +02:00
Zoltan Papp
a12a3e4603 [client] Keep session deadline visible across reconnects (#6847)
## Describe your changes

The tray session row flapped on every transient engine restart and went
blank at the exact moment the session expired:

- Watcher.Close zeroed the server-scoped recorder deadline on every
engine restart (sleep/wake, network change, stream errors), hiding the
tray row until the next successful login re-seeded it. Close now only
stops the timers; the client run loop clears the recorder when it exits
for real.
- Status.GetSessionExpiresAt masked past deadlines as zero, so the
status snapshot dropped the field right when the UI should have shown
"session expired". The getter now returns the raw value and the tray
renders past deadlines as "Session expired — sign in again", routing the
click to the login flow.
- The watcher rejected recently-expired deadlines (30s skew) delivered
by login/sync, clearing the recorder instead of surfacing the expiry.
Deadlines up to 30 days past are now recorded as expired; only older
values are rejected as garbage.

## 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/6847"><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=1787220767&installation_model_id=427504&pr_number=6847&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6847&signature=77977ad47c5eda51e1d76e59bc736203b4592462bf94076811a20ea0f7f23aee"><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**
* Tray “session expires” and countdown now accurately reflect expired
sessions and remaining time near boundary moments (including improved
rounding).
* **Bug Fixes**
* Recently expired deadlines are retained for correct status reporting,
while very old past deadlines are rejected and cleared.
* Session expiry “expires at” is preserved when the session watcher
closes during reconnect scenarios.
* Clicking an already-expired session in the tray now routes the user to
the login screen.
* **Tests**
* Updated and expanded coverage for recent/ancient past handling and
watcher close behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 15:22:16 +02:00
Riccardo Manfrin
dc89b471fa [client] checks/enforce MDM disableAutostart on every GUI launch, not just fresh installs (#6782)
## Describe your changes

`applyAutostartDefault` gated all MDM enforcement behind the one-time
`AutostartInitialized` marker, so MDM `disableAutostart` only affected
fresh installs — a policy pushed after autostart had been enabled could
not
revoke the OS login-item. 

The PR adds follow-up MDM enforcements at the top of
the function: if at any time MDM sets `disableAutostart=true` and the OS
registration is
present, force `SetEnabled(false)` to align it.

Trade-off: once the admin lifts the policy, autostart stays off until
the user re-toggles
in Settings — consistent with "MDM always wins" behavior of the other
managed keys.

## Issue ticket number and link

Follow-up to PR https://github.com/netbirdio/netbird/pull/6738
(introduced
the `disableAutostart` MDM key with fresh-install-only semantics).

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

> 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/855>


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

- **New Features**
- Added an administrative policy to disable client autostart (“Disable
Autostart”).
- Added support for configuring this policy via macOS MDM, Windows Group
Policy (ADMX/ADML), and registry settings.
- When enforced, the client prevents new autostart registration on fresh
installs and removes existing autostart on the next GUI launch, keeping
it disabled until the policy is lifted.

- **Bug Fixes**
- Improved enforcement logic for managed autostart defaults so policy
state is applied consistently during startup and first-run setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 11:34:13 +02:00
Zoltan Papp
0e520ee9f5 [client] Copy trustedproxy package into Docker build context (#6851)
## 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/6851"><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=1787231318&installation_model_id=427504&pr_number=6851&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6851&signature=dcbd1f845098d270ded2fec67182952868fbcb9f5838ef1fe5f3b6f6406f01d4"><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**
* Updated the build process to include trusted proxy configuration in
the application image.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 16:21:49 +02:00
Zoltan Papp
9620890b65 [client] Always connect on profile selection except in manage profiles (#6838)
Selecting a profile from the header dropdown or the tray submenu now
always brings the connection up after the switch, regardless of the
previous daemon state. Switching from the manage-profiles screen
(including profile creation) never connects, leaving a chance to adjust
the management URL first.

## Describe your changes

## 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/6838"><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=1787157550&installation_model_id=427504&pr_number=6838&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6838&signature=8fe9c5f0779df46c76b4135a373591762eb1498d890ff6996626fb75d6433e0a"><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 the ability to switch profiles without automatically
establishing a connection.
* Existing profile switching continues to connect when appropriate,
while safely handling active or pending connections during the switch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 15:28:11 +02:00
Eduard Gert
69c35e31b4 [client] Fix browser dialog not closing on renew session flow (#6745)
## 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

* **Bug Fixes**
* SSO browser-login popups now open centered on the display where the
cursor is located, and they recenter correctly on subsequent opens.
* Programmatic cleanup no longer triggers “login canceled” behavior;
cancel is emitted only when the user closes the active popup.
* **New Features**
* Added a streamlined “close renewal flow” action that tears down the
session-renewal UI by closing both the login and session-expiration
popups.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:01:31 +02:00
Zoltan Papp
b6cd8944b1 [client] Fix nil context panic in iOS dynamic route resolver (#6848)
## Describe your changes

getIPsFromResolver passed a nil context to ExchangeWithFallback, which
net.Dialer.DialContext rejects with panic("nil context"). On iOS this
crashed the whole network extension (SIGABRT) ~2 seconds after connect
whenever the network map contained a domain-based (dynamic) route, as
the resolver goroutine panicked on its first DNS query.

Passing nil used to be a documented input of ExchangeWithFallback ("If
the passed context is nil, this will use Exchange instead of
ExchangeContext") since #3632. 9ed2e2a5b (#5971) removed the nil-context
branch, but this iOS-only caller was not updated — it never fails CI
since route_ios.go only builds with GOOS=ios. Broken since v0.71.1.

Pass a context bounded by the existing dialTimeout instead, matching the
dnsinterceptor pattern (context.Background() + timeout).

Captured panic (netbird.err):
  panic: nil context
  net.(*Dialer).DialContext -> miekg/dns ExchangeContext ->
nbdns.ExchangeWithFallback(nil, ...) ->
dynamic.(*Route).getIPsFromResolver
  route_ios.go:35


## 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/6848"><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=1787223752&installation_model_id=427504&pr_number=6848&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6848&signature=881d3b30aa4bd15001562ad417ff946b70c7c108856a1b3808b776c12b7022a9"><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**
* Fixed iOS dynamic DNS resolution by ensuring DNS queries use a proper
resolver context instead of a nil one.
* Improved DNS resolution reliability by propagating
cancellation/timeouts through all domain IP lookups, including fallback
system resolver queries.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 13:37:29 +02:00
Zoltan Papp
6fc05efa6c [client] Disconnect daemon on GUI quit via async Down (#6796)
The tray Quit menu now disconnects the daemon before exiting instead of
only tearing down the GUI. A new DownAsync RPC lets the daemon start the
teardown and return immediately: beginDown cancels the connection under
the mutex (so it cannot reconnect), then finishDown (the retry-goroutine
wait and status reset) runs on a background goroutine. handleQuit aborts
any in-flight profile switch first (so a queued Up cannot reconnect
during teardown) and calls DownAsync so quitting never blocks on the
engine shutdown.

## Describe your changes

## 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/6796"><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=1786785261&installation_id=146802194&pr_number=6796&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6796&signature=4fc95ebfed320240170c8318a2bc8750acfa5ad131454e3011c43bc8a5a09ed1"><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 shutdown reliability by continuing teardown even when
stopping the service fails (stop errors are logged but not returned).
* Refined connection shutdown to return “service not up” errors directly
for clearer, more immediate RPC behavior.
* Prevented shutdown hangs by making the tray Quit disconnect
time-bounded (5 seconds).
* Ensured any in-flight profile switch is cancelled before exiting, with
quit serialized to avoid races.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 13:34:30 +02:00
Eduard Gert
3cda14d7f2 [client] Use menu bar wording on macOS welcome screen (#6810)
## Describe your changes

The post-install welcome step said "Look for NetBird in your tray" on
every OS, but macOS has no system tray — the icon sits in the menu bar.
The tray wording stays correct on Windows and Linux.

- Add `welcome.titleMac` and `welcome.descriptionMac` (following the
existing `settings.advanced.interfaceName.errorMac` key convention) to
`en` and all nine translated bundles (`de`, `es`, `fr`, `hu`, `it`,
`ja`, `pt`, `ru`, `zh-CN`), each using that language's Apple term for
the menu bar (Menüleiste, barra de menús, barre des menus, menüsor,
barra dei menu, メニューバー, barra de menus, строка меню, 菜单栏). The `ja`
bundle landed on main (#6790) after the initial commit and was covered
after merging main back in.
- `WelcomeStepTray.tsx` picks the key via `isMacOS()`, which it already
uses to choose the per-OS screenshot.

Verified: `go test ./client/ui/i18n/...`, `tsc --noEmit`, `eslint`, and
`prettier --check` all pass; key set and placement verified identical
across all ten bundles. Not visually verified in the running app
(headless session) — the welcome dialog only shows on first launch.

## Issue ticket number and link

Fixes NET-1411

## 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 (UI copy fix only;
no behavior, API, or configuration change)

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

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

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6810"><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=1786865844&installation_id=146802194&pr_number=6810&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6810&signature=c18b58a9624d319bff3010aeb92c7b11cc7e99f82ffa5e15a32053500d620af4"><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 macOS-specific onboarding text that directs users to find
NetBird in the menu bar.
* Updated localized welcome content across supported languages, while
retaining platform-specific tray guidance where applicable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:26:27 +02:00
Eduard Gert
d9392fdbb8 [client] Clarify outdated NetBird client overlay (#6718)
## Describe your changes
Add more information about the current client and GUI versions if the
user is running an older client.
Update the URL to download the latest RC if the user is running any RC
build.

<img width="348" height="497" alt="CleanShot 2026-07-10 at 15 11 54"
src="https://github.com/user-attachments/assets/2579f5f3-5056-4184-96bb-9f93d5860328"
/>

## 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/__
2026-07-21 11:26:16 +02:00
Maycon Santos
82fdfa84b8 [proxy] match Bedrock provider models against the normalized request model (#6773)
## Describe your changes

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

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

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

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

## Issue ticket number and link

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

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

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

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

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

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

## Tests

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

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


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

## Summary by CodeRabbit

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

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

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

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

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

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

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

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

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

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

## Summary by CodeRabbit

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 09:25:33 +02:00
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
257 changed files with 18030 additions and 3502 deletions

View File

@@ -5,6 +5,13 @@ on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
bedrock_model:
description: >-
Bedrock inference-profile id to drive the matrix with, exactly as
AWS issues it. Leave empty for the Sonnet 4.6 default.
required: false
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -51,6 +58,9 @@ jobs:
# token (and URL, for gateways) is unset, so partial coverage is fine.
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
@@ -59,6 +69,8 @@ jobs:
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
# to "global", model to a pinned claude snapshot.
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}

View File

@@ -86,7 +86,7 @@ jobs:
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Generate Wails bindings
run: pnpm run bindings

View File

@@ -45,7 +45,7 @@ jobs:
display_name: Linux
name: ${{ matrix.display_name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -79,4 +79,4 @@ jobs:
skip-cache: true
skip-save-cache: true
cache-invalidation-interval: 0
args: --timeout=12m
args: --timeout=20m

View File

@@ -273,8 +273,8 @@ dockers_v2:
- netbirdio/netbird
- ghcr.io/netbirdio/netbird
tags:
- "v{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
- "{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh

View File

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

View File

@@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
}
func Test_ConnectPeers(t *testing.T) {
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
peer1Key, _ := wgtypes.GeneratePrivateKey()
@@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
localIP, err := getLocalIP()
if err != nil {
t.Fatal(err)
}
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
localIP1 := "127.0.0.1"
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
if err != nil {
t.Fatal(err)
}
@@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
localIP2 := "127.0.0.1"
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
if err != nil {
t.Fatal(err)
}
@@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// todo: investigate why in some tests execution we need 30s
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
// here starves the wireguard-go goroutines that process the handshake, so
// poll on a ticker instead and yield the CPU between checks. WireGuard also
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
// is why the overall wait can occasionally stretch to tens of seconds.
timeout := 30 * time.Second
timeoutChannel := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
default:
}
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
if gpErr != nil {
t.Fatal(gpErr)
@@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
t.Log("peers successfully handshake")
break
}
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
case <-ticker.C:
}
}
}
@@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
}
return wgtypes.Peer{}, fmt.Errorf("peer not found")
}
func getLocalIP() (string, error) {
// Get all interfaces
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.IsLoopback() {
continue
}
if ipNet.IP.To4() == nil {
continue
}
return ipNet.IP.String(), nil
}
return "", fmt.Errorf("no local IP found")
}

View File

@@ -351,6 +351,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
a.config.SyncMessageVersion,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,

View File

@@ -24,11 +24,7 @@ import (
)
const (
// Skew tolerates a small clock difference between the management
// server and this peer before treating a deadline as "in the past".
// Slightly above typical NTP drift; tight enough that the UI doesn't
// paint a stale expiry as if it were valid.
Skew = 30 * time.Second
maxPastHorizon = 30 * 24 * time.Hour
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
@@ -57,7 +53,7 @@ var (
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
// ErrDeadlineInPast is returned by Update when the supplied deadline
// is more than Skew in the past.
// is more than maxPastHorizon in the past.
ErrDeadlineInPast = errors.New("session deadline in the past")
)
@@ -66,15 +62,14 @@ var (
// for deadline change/clear, PublishEvent for the two warnings); tests pass
// a fake recorder so the same surface is observable without an engine.
//
// The watcher is the single owner of the deadline propagated to the
// recorder: every set, clear, sanity-check rejection and Close routes the
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
// fans out its own state-change notification, so no separate notify is
// needed.) The recorder is server-scoped and outlives this engine-scoped
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
// a profile switch) would leave the next session showing the previous one's
// stale "expires in" value.
// While the watcher runs, it owns the deadline propagated to the recorder:
// every set, clear and sanity-check rejection routes the value through
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
// out its own state-change notification, so no separate notify is needed.)
// The recorder is server-scoped and outlives this engine-scoped watcher;
// Close deliberately leaves the recorder value in place so transient engine
// restarts don't blank it — the client run loop clears it on real teardown.
//
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
// composes the metadata internally so the wire format (MetaSession*) is
@@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
// was disabled).
//
// Same-value updates are no-ops. A different non-zero value cancels any
// pending timer, resets the "already fired" guard, and arms a new one.
// pending timer, resets the "already fired" guards, and — when the
// deadline lies in the future — arms fresh warning timers. A deadline
// already in the past (within maxPastHorizon) is recorded as-is with no
// timers: the session has expired and consumers render it that way.
//
// Returns one of the sentinel Err* values when the deadline fails the
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
// In every error case the watcher first clears its state so it stays
// consistent with what the caller will push into its other sinks (e.g.
// applySessionDeadline forces a zero deadline into the status recorder
@@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error {
case deadline.After(now.Add(maxDeadlineHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
case deadline.Before(now.Add(-Skew)):
case deadline.Before(now.Add(-maxPastHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
}
@@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error {
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
w.armTimerLocked(deadline)
if deadline.After(now) {
w.armTimerLocked(deadline)
}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil {
@@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() {
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
}
// Close stops any pending timer and drops the deadline on the status
// recorder. Update calls after Close are ignored. Clearing the recorder
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
// from leaving the next session showing this one's stale "expires in"
// value — the recorder is server-scoped and outlives this engine-scoped
// watcher, so nothing else drops the anchor on teardown.
// Close stops any pending timer. Update calls after Close are ignored.
// The recorder keeps its deadline: the watcher is engine-scoped and closes
// on every engine restart (network change, sleep/wake, stream errors)
// while the SSO deadline stays valid across those, so clearing here would
// blank the UI's "expires in" row on every transient reconnect. The
// client run loop clears the server-scoped recorder when it exits for
// real (Down, profile switch, permanent login failure).
func (w *Watcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
w.mu.Unlock()
return
}
w.closed = true
w.stopTimerLocked()
hadDeadline := !w.current.IsZero()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil && hadDeadline {
recorder.SetSessionExpiresAt(time.Time{})
}
}
// clearLocked drops the tracked deadline and notifies the recorder so

View File

@@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
r := &fakeRecorder{}
lead := 30 * time.Millisecond
lead := 150 * time.Millisecond
w := newWatcher(lead, r)
defer w.Close()
first := time.Now().Add(50 * time.Millisecond)
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
// replacement below lands well before it.
first := time.Now().Add(170 * time.Millisecond)
_ = w.Update(first)
// Wait for stateChange + warning of the first cycle.
@@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
}
}
func TestUpdateInPastClearsDeadline(t *testing.T) {
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(-1 * time.Hour)
if err := w.Update(d); err != nil {
t.Fatalf("recent-past Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
}
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline = %v, want %v", got, d)
}
time.Sleep(80 * time.Millisecond)
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
}
}
func TestUpdateAncientPastRejected(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
@@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
// Drain the stateChange from the seed.
waitForEvents(t, r, 1)
err := w.Update(time.Now().Add(-1 * time.Hour))
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
if !errors.Is(err, ErrDeadlineInPast) {
t.Fatalf("want ErrDeadlineInPast, got %v", err)
}
if !w.Deadline().IsZero() {
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
}
events := waitForEvents(t, r, 2)
if events[1].kind != stateChange {
@@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
}
}
func TestUpdateWithinSkewAccepted(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
d := time.Now().Add(-5 * time.Second)
if err := w.Update(d); err != nil {
t.Fatalf("within-skew Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
}
}
func TestCloseSilencesUpdates(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
w.Close()
_ = w.Update(time.Now().Add(time.Hour))
time.Sleep(20 * time.Millisecond)
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
t.Fatalf("Update after Close: want nil, got %v", err)
}
if got := r.snapshot(); len(got) != 0 {
t.Fatalf("expected no events after Close, got %+v", got)
}
}
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
// holding a live deadline must zero the recorder on Close so the next
// engine's watcher (and the UI reading the shared server-scoped recorder)
// doesn't start out showing the previous session's stale "expires in".
func TestCloseClearsRecorderDeadline(t *testing.T) {
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
// closes on every engine restart (network change, sleep/wake) while the
// SSO deadline stays valid across those, so Close must leave the
// server-scoped recorder's value in place. The client run loop clears the
// recorder when it exits for real.
func TestCloseKeepsRecorderDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r)
@@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) {
w.Close()
if got := r.deadline(); !got.IsZero() {
t.Fatalf("recorder deadline after Close = %v, want zero", got)
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
}
}

View File

@@ -34,6 +34,8 @@ const (
// - Handling connection establishment based on peer signaling
//
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
// The only exception is ActivatePeer, which is safe for concurrent use so the
// DNS warm-up path can call it without contending on the engine mutex.
type ConnMgr struct {
peerStore *peerstore.Store
statusRecorder *peer.Status
@@ -42,12 +44,26 @@ type ConnMgr struct {
rosenpassEnabled bool
lazyConnMgr *manager.Manager
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
// engine loop (ActivatePeer). Writers hold it in addition to
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
lazyConnMgrMu sync.RWMutex
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
reconcileRoutedIPs func(peerKey string) error
wg sync.WaitGroup
lazyCtx context.Context
lazyCtxCancel context.CancelFunc
}
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
e.reconcileRoutedIPs = fn
}
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
e := &ConnMgr{
peerStore: peerStore,
@@ -238,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
conn.Log.Infof("removed peer from lazy conn manager")
}
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
// and the manager itself is internally synchronized, so callers outside the
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
if !e.isStartedWithLazyMgr() {
e.lazyConnMgrMu.RLock()
lazyConnMgr := e.lazyConnMgr
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
e.lazyConnMgrMu.RUnlock()
if !started {
return
}
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if err := conn.Open(ctx); err != nil {
conn.Log.Errorf("failed to open connection: %v", err)
}
@@ -268,16 +292,22 @@ func (e *ConnMgr) Close() {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
}
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
cfg := manager.Config{
InactivityThreshold: inactivityThresholdEnv(),
ReconcileAllowedIPs: e.reconcileRoutedIPs,
}
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
e.lazyConnMgrMu.Unlock()
e.wg.Add(1)
go func() {
@@ -316,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
for _, peerID := range e.peerStore.PeersPubKey() {
e.peerStore.PeerConnOpen(ctx, peerID)

View File

@@ -1,10 +1,21 @@
package internal
import (
"context"
"net"
"net/netip"
"os"
"sync"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/monotime"
)
func TestResolveLazyForce(t *testing.T) {
@@ -38,3 +49,58 @@ func TestResolveLazyForce(t *testing.T) {
})
}
}
type mockLazyWGIface struct{}
func (mockLazyWGIface) RemovePeer(string) error { return nil }
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
func (mockLazyWGIface) MTU() uint16 { return 1280 }
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
// still requires engine.syncMsgMux for safety.
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
t.Setenv(lazyconn.EnvLazyConn, "on")
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
conn := newTestPeerConn(t, "peerA")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
connMgr.Start(ctx)
done := make(chan struct{})
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
connMgr.ActivatePeer(ctx, conn)
}
}
}()
}
// Let the activators spin against the started manager, then tear it down
// underneath them and let them spin against the stopped manager.
time.Sleep(100 * time.Millisecond)
connMgr.Close()
time.Sleep(50 * time.Millisecond)
close(done)
wg.Wait()
}

View File

@@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
defer c.statusRecorder.ClientStop()
defer func() {
c.statusRecorder.SetSessionExpiresAt(time.Time{})
c.statusRecorder.ClientStop()
}()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {
@@ -618,6 +621,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
BlockLANAccess: config.BlockLANAccess,
BlockInbound: config.BlockInbound,
DisableIPv6: config.DisableIPv6,
SyncMessageVersion: config.SyncMessageVersion,
LazyConnection: lazyconn.ParseState(config.LazyConnection),
@@ -693,6 +697,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.DisableIPv6,
config.SyncMessageVersion,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,

View File

@@ -676,6 +676,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))

View File

@@ -887,6 +887,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
}
for _, anonymize := range []bool{false, true} {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"os"
"slices"
"strings"
"sync"
@@ -36,7 +37,43 @@ type resolver interface {
// record is left alone (it points at something outside our mesh, e.g.
// a non-peer upstream).
type PeerConnectivity interface {
IsConnectedByIP(ip string) (known, connected bool)
IsConnectedByIP(ip netip.Addr) (known, connected bool)
}
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
// disconnected) starts connecting at DNS-resolution time rather than racing the
// client's first request packet. nil disables warm-up.
type PeerActivator interface {
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
// until one is connected or ctx (a short per-query budget) expires. It is a
// fast no-op for unknown or already-connected addresses.
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
}
const (
defaultLazyWarmupTimeout = 2 * time.Second
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
)
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
// lazy-connection peer a DNS answer points at. Tunable via
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
func lazyWarmupTimeoutFromEnv() time.Duration {
v := os.Getenv(envLazyWarmupTimeout)
if v == "" {
return defaultLazyWarmupTimeout
}
d, err := time.ParseDuration(v)
if err != nil {
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
return defaultLazyWarmupTimeout
}
if d <= 0 {
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
return defaultLazyWarmupTimeout
}
return d
}
type Resolver struct {
@@ -51,6 +88,12 @@ type Resolver struct {
// filter and preserves the legacy "return whatever is registered"
// behaviour for callers that never wire a status source.
peerConn PeerConnectivity
// peerActivator, when non-nil, is called at resolution time to warm the
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
peerActivator PeerActivator
// warmupTimeout is the per-query budget for the lazy-connection warm-up
// wait, resolved from the environment once at construction time.
warmupTimeout time.Duration
ctx context.Context
cancel context.CancelFunc
@@ -59,11 +102,12 @@ type Resolver struct {
func NewResolver() *Resolver {
ctx, cancel := context.WithCancel(context.Background())
return &Resolver{
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
ctx: ctx,
cancel: cancel,
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
warmupTimeout: lazyWarmupTimeoutFromEnv(),
ctx: ctx,
cancel: cancel,
}
}
@@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
d.peerConn = p
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
// disable. Safe to call multiple times; the latest value wins.
func (d *Resolver) SetPeerActivator(a PeerActivator) {
d.mu.Lock()
defer d.mu.Unlock()
d.peerActivator = a
}
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
// Warm before filtering: activation flips a lazily-idle target to connected,
// which then lets it survive the disconnected-peer filter below.
d.warmLazyPeers(question, result.records)
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
@@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
kept := make([]dns.RR, 0, len(records))
var dropped int
for _, rr := range records {
ip := extractRecordIP(rr)
if ip == "" {
ip, ok := extractRecordAddr(rr)
if !ok {
kept = append(kept, rr)
continue
}
@@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
return kept
}
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
// an A or AAAA record, or "" for any other record type.
func extractRecordIP(rr dns.RR) string {
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
// answer points at and waits briefly for one to connect, so the caller's first
// request doesn't race the connection establishment. Warm-up is scoped to
// match-only (non-authoritative) zones — the synthesized private-service zones
// and user-created zones whose records point at specific peers. The account's
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
// otherwise resolving any peer's name would wake its idle connection, defeating
// laziness mesh-wide. No-op when no activator is wired (lazy connections
// disabled) or the answer carries no peer IPs.
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
if len(records) < 2 {
return
}
d.mu.RLock()
activator := d.peerActivator
var nonAuth, found bool
if activator != nil {
nonAuth, found = d.findZone(question.Name)
}
d.mu.RUnlock()
if activator == nil || !found || !nonAuth {
return
}
var addrs []netip.Addr
for _, rr := range records {
if addr, ok := extractRecordAddr(rr); ok {
addrs = append(addrs, addr)
}
}
if len(addrs) == 0 {
return
}
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
defer cancel()
activator.ActivatePeersByIP(ctx, addrs)
}
// extractRecordAddr returns the IP address carried by an A or AAAA record.
// ok is false for any other record type or a record with no address.
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
switch r := rr.(type) {
case *dns.A:
if r.A == nil {
return ""
}
return r.A.String()
addr, ok := netip.AddrFromSlice(r.A)
return addr.Unmap(), ok
case *dns.AAAA:
if r.AAAA == nil {
return ""
}
return r.AAAA.String()
addr, ok := netip.AddrFromSlice(r.AAAA)
return addr.Unmap(), ok
}
return ""
return netip.Addr{}, false
}
// Update replaces all zones and their records

View File

@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
byIP map[string]struct{ known, connected bool }
}
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
v, ok := m.byIP[ip]
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
v, ok := m.byIP[ip.String()]
if !ok {
return false, false
}

View File

@@ -0,0 +1,204 @@
package local
import (
"context"
"net"
"net/netip"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/dns/test"
nbdns "github.com/netbirdio/netbird/dns"
)
// recordingActivator records the addresses it was asked to warm and returns
// immediately, so ServeDNS is not blocked by the test.
type recordingActivator struct {
mu sync.Mutex
called bool
addrs []netip.Addr
}
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
r.mu.Lock()
defer r.mu.Unlock()
r.called = true
r.addrs = append(r.addrs, addrs...)
}
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
t.Helper()
var resp *dns.Msg
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
return resp
}
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
// the synthesized private-service zones arrive in.
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
t.Helper()
resolver.Update([]nbdns.CustomZone{{
Domain: zone,
Records: records,
NonAuthoritative: true,
}})
}
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
// the synthesized private-service zones), so register two peer targets.
const name = "svc.proxy.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
}
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
// A single-record answer does not trigger warm-up; the resolver only warms
// multi-record answers.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
}
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
// With no activator wired the resolver behaves exactly as before.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must still answer without an activator")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
}
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
// A query that resolves to nothing must not invoke the activator (no IPs).
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud",
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
serveA(t, resolver, "absent.proxy.netbird.cloud.")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked when there is no answer")
}
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
// The account's peer zone is authoritative; resolving a peer's name there
// must not wake its lazy connection — warm-up is scoped to match-only
// (non-authoritative) zones such as the synthesized private-service zones.
// Use a multi-record answer so the authoritative-zone scoping is the only
// reason warm-up is skipped, not the single-record guard.
const name = "peer.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
}
resolver := NewResolver()
resolver.Update([]nbdns.CustomZone{{
Domain: "netbird.cloud",
Records: recs,
}})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
}
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
tests := []struct {
name string
value string
envSet bool
want time.Duration
}{
{name: "unset uses default", want: defaultLazyWarmupTimeout},
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envSet {
t.Setenv(envLazyWarmupTimeout, tt.value)
}
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
})
}
}
func TestExtractRecordAddr(t *testing.T) {
t.Run("A record yields unmapped v4", func(t *testing.T) {
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
// miekg/dns stores after parsing an A record; the extracted address
// must compare equal to a plain v4 netip.Addr.
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
require.True(t, ok)
assert.True(t, addr.Is4())
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
})
t.Run("AAAA record yields v6", func(t *testing.T) {
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
require.True(t, ok)
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
})
t.Run("A record without address", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.A{})
assert.False(t, ok)
})
t.Run("non-address record", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
assert.False(t, ok)
})
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/miekg/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dns/local"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) {
// Mock implementation - no-op
}
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
// Mock implementation - no-op
}
// BeginBatch mock implementation of BeginBatch from Server interface
func (m *MockServer) BeginBatch() {
// Mock implementation - no-op

View File

@@ -82,6 +82,7 @@ type Server interface {
PopulateManagementDomain(mgmtURL *url.URL) error
SetRouteSources(selected, active func() route.HAMap)
SetFirewall(Firewall)
SetPeerActivator(local.PeerActivator)
}
type nsGroupsByDomain struct {
@@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
}
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
// resolver. Injected after the connection manager exists (it does not at
// DNS-server construction time). Pass nil to disable.
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
s.localResolver.SetPeerActivator(a)
}
// Stop stops the server
func (s *DefaultServer) Stop() {
s.ctxCancel()
@@ -1435,11 +1443,11 @@ type localPeerConnectivity struct {
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
if l.status == nil {
return false, false
}
state, ok := l.status.PeerStateByIP(ip)
state, ok := l.status.PeerStateByIP(ip.String())
if !ok {
return false, false
}

View File

@@ -0,0 +1,76 @@
package internal
import (
"context"
"net/netip"
"time"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
const dnsActivationPollInterval = 50 * time.Millisecond
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
// keeping DNS resolution from contending with network-map processing.
type dnsPeerActivator struct {
connMgr *ConnMgr
peerStore *peerstore.Store
status *peer.Status
// ctx is the engine's long-lived context. The connection dial is tied to it
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
// still completes in the background rather than being cancelled at the deadline.
ctx context.Context
}
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
// until one is connected or ctx (the per-query DNS wait budget) expires.
// Activation itself is tied to the engine's long-lived context so the dial
// survives a wait that times out. Unknown or already-connected addresses are
// skipped, so the steady-state (warm) path adds no latency.
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
if a == nil || a.connMgr == nil {
return
}
var pending []string
for _, addr := range addrs {
ip := addr.String()
st, ok := a.status.PeerStateByIP(ip)
if !ok || st.ConnStatus == peer.StatusConnected {
continue
}
conn, ok := a.peerStore.PeerConn(st.PubKey)
if !ok {
continue
}
a.connMgr.ActivatePeer(a.ctx, conn)
pending = append(pending, ip)
}
if len(pending) == 0 {
return
}
a.waitConnected(ctx, pending)
}
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
ticker := time.NewTicker(dnsActivationPollInterval)
defer ticker.Stop()
for {
for _, ip := range ips {
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
return
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@@ -0,0 +1,129 @@
package internal
import (
"context"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
t.Helper()
conn, err := peer.NewConn(peer.ConnConfig{
Key: key,
LocalKey: "local",
WgConfig: peer.WgConfig{
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
}, peer.ServiceDependencies{})
require.NoError(t, err)
return conn
}
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
t.Helper()
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
// no-op — these tests exercise the activator's skip/wait logic.
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
return &dnsPeerActivator{
connMgr: connMgr,
peerStore: store,
status: status,
ctx: context.Background(),
}, status, store
}
func TestDNSPeerActivator_NilSafe(t *testing.T) {
var a *dnsPeerActivator
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
}
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
// (warm) path adds no latency: already-connected and unknown addresses never
// enter the wait loop.
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
})
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
}
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
// returns as soon as a pending peer reports connected, well before the
// per-query budget expires.
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
go func() {
time.Sleep(150 * time.Millisecond)
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
}
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
// never connects releases the DNS response at the per-query budget instead of
// blocking it indefinitely.
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
}
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
// with no connection object in the store is not waited on: there is nothing to
// activate, so waiting could only ever time out.
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
a, status, _ := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
}

View File

@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}

View File

@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}

View File

@@ -50,6 +50,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/pqkem"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
@@ -64,7 +65,10 @@ import (
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -147,6 +151,7 @@ type EngineConfig struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
// the env var and management feature flag.
@@ -193,6 +198,10 @@ type Engine struct {
// rpManager is a Rosenpass manager
rpManager *rosenpass.Manager
// pqkemManager runs the ML-KEM post-quantum PSK exchange (gated by NB_ENABLE_PQ_MLKEM).
// It owns the data-path transport and peer endpoint routing.
pqkemManager *pqkem.Manager
// syncMsgMux is used to guarantee sequential Management Service message processing
syncMsgMux *sync.Mutex
@@ -220,6 +229,13 @@ type Engine struct {
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
// latestComponents is the most-recent NetworkMapComponents decoded from
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
// NetworkMap that Calculate() produced from it so future incremental
// updates have a base to apply changes against. nil for legacy-format
// peers. Guarded by syncMsgMux.
latestComponents *types.NetworkMapComponents
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
@@ -640,6 +656,19 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.rpManager.SetInterface(e.wgInterface)
}
// Start the ML-KEM PQ manager after the interface is up so its dedicated UDP
// transport can bind on the WG overlay IP.
if pqkem.Enabled() {
tr, pqErr := newPQTransport(e.config.WgAddr.IP)
if pqErr != nil {
log.Errorf("pqkem: transport bind failed, exchange disabled: %v", pqErr)
} else {
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), pqCallbackHandler{wg: e.wgInterface}, pqkem.NewLogger())
e.pqkemManager.Start(tr)
log.Infof("pqkem: enabled (udp port %d on overlay %s)", e.pqkemManager.LocalPort(), e.config.WgAddr.IP)
}
}
// if inbound conns are blocked there is no need to create the ACL manager
if e.firewall != nil && !e.config.BlockInbound {
e.acl = acl.NewDefaultManager(e.firewall)
@@ -652,8 +681,24 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
iceCfg := e.createICEConfig()
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
if e.routeManager == nil {
return nil
}
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
})
e.connMgr.Start(e.ctx)
// Wire DNS-time lazy-connection warm-up now that the connection manager
// exists (it does not at DNS-server construction time). A DNS answer that
// points at an idle peer then wakes it before the client's first request.
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
connMgr: e.connMgr,
peerStore: e.peerStore,
status: e.statusRecorder,
ctx: e.ctx,
})
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
@@ -887,6 +932,10 @@ func (e *Engine) removePeer(peerKey string) error {
e.connMgr.RemovePeerConn(peerKey)
if e.pqkemManager != nil {
e.pqkemManager.RemovePeer(pqkem.RemoteID(peerKey))
}
err := e.statusRecorder.RemovePeer(peerKey)
if err != nil {
log.Warnf("received error when removing peer %s from status recorder: %v", peerKey, err)
@@ -963,8 +1012,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.ApplySessionDeadline(update.GetSessionExpiresAt())
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
// Envelope sync responses carry PeerConfig at the top level; legacy
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
if pc := update.GetPeerConfig(); pc != nil {
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
}
done := e.phase("netbird_config")
@@ -974,12 +1027,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}
// Decode the network map from either the components envelope or the
// legacy proto.NetworkMap before the posture-check gating below, so the
// "is there a network map" decision covers both wire shapes.
var (
nm *mgmProto.NetworkMap
components *types.NetworkMapComponents
)
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
// Components-format peer: decode the envelope back to typed
// components, run Calculate() locally, and convert to the wire
// NetworkMap shape the rest of the engine consumes. Components are
// retained so future incremental updates can apply deltas instead
// of doing a full reconstruction.
envelope := update.GetNetworkMapEnvelope()
if envelope == nil {
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
}
localKey := e.config.WgPrivateKey.PublicKey().String()
dnsName := ""
if pc := update.GetPeerConfig(); pc != nil {
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
// shared domain by stripping the peer's own label prefix. Falls
// back to empty if the FQDN doesn't have the expected shape.
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
}
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
if err != nil {
return fmt.Errorf("decode network map envelope: %w", err)
}
nm = result.NetworkMap
components = result.Components
} else {
nm = update.GetNetworkMap()
}
// Posture checks are bound to the network map presence:
// NetworkMap != nil, checks present -> apply the received checks
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
// leave the previously applied checks untouched
nm := update.GetNetworkMap()
if nm == nil {
return nil
}
@@ -992,6 +1080,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
}
done = e.phase("persist")
// Only retain the components view when the server sent the envelope
// path. A legacy proto.NetworkMap means components == nil; writing it
// here would clobber a previously-cached snapshot, breaking the
// incremental-delta base on a future envelope sync.
if components != nil {
e.latestComponents = components
}
e.persistSyncResponse(update)
done()
@@ -1005,6 +1101,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
// receiving peer's FQDN — the same value the management server fills as
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
// "netbird.cloud". An empty string is returned for unrecognized formats.
func extractDNSDomainFromFQDN(fqdn string) string {
for i := 0; i < len(fqdn); i++ {
if fqdn[i] == '.' && i+1 < len(fqdn) {
return fqdn[i+1:]
}
}
return ""
}
// updateNetbirdConfig applies the management-provided NetBird configuration:
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
// which is the case for sync updates carrying only a network map.
@@ -1164,6 +1273,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -1812,6 +1922,9 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
},
ICEConfig: e.createICEConfig(),
}
if e.pqkemManager != nil {
config.PQ = pqHandshaker{mgr: e.pqkemManager}
}
serviceDependencies := peer.ServiceDependencies{
StatusRecorder: e.statusRecorder,
@@ -1995,6 +2108,10 @@ func (e *Engine) close() {
_ = e.rpManager.Close()
}
if e.pqkemManager != nil {
e.pqkemManager.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
@@ -2032,6 +2149,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -2806,6 +2924,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
Version: msg.GetBody().GetNetBirdVersion(),
RosenpassPubKey: rosenpassPubKey,
RosenpassAddr: rosenpassAddr,
MlkemPayload: msg.GetBody().GetMlkemPayload(),
MlkemPort: int(msg.GetBody().GetMlkemPort()),
RelaySrvAddress: msg.GetBody().GetRelayServerAddress(),
RelaySrvIP: relayIP,
SessionID: sessionID,

View File

@@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
"invalid timestamp must clear the deadline")
})
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
e := newEngine()
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
e.ApplySessionDeadline(timestamppb.New(expired))
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
"recently-expired deadline must stay on the recorder so consumers render it as expired")
})
}

View File

@@ -29,6 +29,11 @@ type managedPeer struct {
type Config struct {
InactivityThreshold *time.Duration
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
ReconcileAllowedIPs func(peerKey string) error
}
// Manager manages lazy connections
@@ -56,6 +61,9 @@ type Manager struct {
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
routesMu sync.RWMutex
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
reconcileAllowedIPs func(peerKey string) error
}
// NewManager creates a new lazy connection manager
@@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
activityManager: activity.NewManager(wgIface),
peerToHAGroups: make(map[string][]route.HAUniqueID),
haGroupToPeers: make(map[route.HAUniqueID][]string),
reconcileAllowedIPs: config.ReconcileAllowedIPs,
}
if wgIface.IsUserspaceBind() {
@@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
return false, nil
}
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
if err := m.armActivityListener(peerCfg); err != nil {
return false, err
}
@@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
if err := m.armActivityListener(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
return
}
@@ -465,6 +474,31 @@ func (m *Manager) close() {
}
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
// idle routing peer. It is a no-op when no reconciler is configured.
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
// listener creates it with the overlay /32 only.
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
return err
}
m.armRoutedAllowedIPs(&peerCfg)
return nil
}
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
if m.reconcileAllowedIPs == nil {
return
}
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
}
}
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
m.routesMu.RLock()
defer m.routesMu.RUnlock()
@@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
mp.peerCfg.Log.Infof("start activity monitor")
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
if err := m.armActivityListener(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
continue
}

View File

@@ -74,6 +74,32 @@ type RosenpassConfig struct {
PermissiveMode bool
}
// PQHandshaker attaches post-quantum ML-KEM material to signalling offers/answers and
// feeds received material back. It is implemented by the engine over the pqkem
// manager and is nil when the PQ exchange is disabled. remoteKey is the peer's
// WireGuard public key.
type PQHandshaker interface {
// OfferPayload returns the KEM offer to embed in an outgoing offer (nil if this
// peer is not the KEM initiator) and the local PQ data-path port to announce.
OfferPayload(remoteKey string) (payload []byte, port int)
// AnswerPayload processes a received KEM offer (nil if absent) and returns the KEM
// answer to embed in the outgoing answer (nil if none) and the local PQ port.
AnswerPayload(remoteKey string, recvOffer []byte) (payload []byte, port int)
// OnAnswer feeds a received KEM answer (nil if absent).
OnAnswer(remoteKey string, recvAnswer []byte)
// PSK returns the peer's latest derived post-quantum PSK to program at WG
// peer-config time (the pull path). ok is false until one has been derived.
PSK(remoteKey string) (wgtypes.Key, bool)
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling:
// its WG overlay IP with the advertised pq UDP port.
SetRemoteAddr(remoteKey string, addr netip.AddrPort)
// OnDataPathRekeyed signals a fresh WireGuard handshake for the peer; it clocks the
// next chained PSK rotation pushed over the data path.
OnDataPathRekeyed(remoteKey string)
// OnDataPathDown signals the peer's tunnel went down.
OnDataPathDown(remoteKey string)
}
// ConnConfig is a peer Connection configuration
type ConnConfig struct {
// Key is a public key of a remote peer
@@ -91,6 +117,9 @@ type ConnConfig struct {
RosenpassConfig RosenpassConfig
// PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled.
PQ PQHandshaker
// ICEConfig ICE protocol configuration
ICEConfig icemaker.Config
}
@@ -681,6 +710,10 @@ func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection")
if conn.config.PQ != nil {
conn.config.PQ.OnDataPathDown(conn.config.Key)
}
// Close the active connection based on current priority
switch conn.currentConnPriority {
case conntype.Relay:
@@ -946,6 +979,11 @@ func (conn *Conn) onWGCheckSuccess() {
conn.mu.Lock()
conn.wgTimeouts = 0
conn.mu.Unlock()
// A fresh WireGuard handshake is the clock for the post-quantum PSK rotation.
if conn.config.PQ != nil {
conn.config.PQ.OnDataPathRekeyed(conn.config.Key)
}
}
// recordConnectionMetrics records connection stage timestamps as metrics
@@ -987,6 +1025,15 @@ func (conn *Conn) AgentVersionString() string {
}
func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
// Post-quantum: once the ML-KEM exchange has derived a PSK for this peer, program
// it here so the peer's next WireGuard handshake adopts it. Applied at peer-config
// time (bootstrap / reconnect); steady-state rotation is pushed separately.
if conn.config.PQ != nil {
if psk, ok := conn.config.PQ.PSK(conn.config.Key); ok {
return &psk
}
}
if conn.config.RosenpassConfig.PubKey == nil {
return conn.config.WgConfig.PreSharedKey
}

View File

@@ -39,6 +39,16 @@ type OfferAnswer struct {
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
// MlkemPayload carries the post-quantum X25519MLKEM768 handshake message
// (pqkem-framed offer on an OFFER, answer on an ANSWER) that seeds the
// WireGuard PSK. Opaque here — the pqkem library frames and parses it. Nil
// when the peer does not run the ML-KEM PQ exchange.
MlkemPayload []byte
// MlkemPort is the peer's ML-KEM PQ service UDP port (bound on its WG overlay
// IP) where data-path rekey messages are sent. Zero when not running the exchange.
MlkemPort int
// relay server address
RelaySrvAddress string
// RelaySrvIP is the IP the remote peer is connected to on its
@@ -120,6 +130,8 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -128,7 +140,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.iceListener(&remoteOfferAnswer)
}
if err := h.sendAnswer(); err != nil {
if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
@@ -142,6 +154,8 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -149,6 +163,10 @@ func (h *Handshaker) Listen(ctx context.Context) {
if h.iceListener != nil && h.RemoteICESupported() {
h.iceListener(&remoteOfferAnswer)
}
if h.config.PQ != nil {
h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload)
}
case <-ctx.Done():
h.log.Infof("stop listening for remote offers and answers")
return
@@ -156,6 +174,16 @@ func (h *Handshaker) Listen(ctx context.Context) {
}
}
// pqRegisterEndpoint feeds the post-quantum handshaker the peer's data-path endpoint
// (its WG overlay IP plus the advertised pq UDP port) learned from a remote offer/answer.
func (h *Handshaker) pqRegisterEndpoint(remotePort int) {
if h.config.PQ == nil || remotePort <= 0 || remotePort > 65535 || len(h.config.WgConfig.AllowedIps) == 0 {
return
}
addr := netip.AddrPortFrom(h.config.WgConfig.AllowedIps[0].Addr(), uint16(remotePort))
h.config.PQ.SetRemoteAddr(h.config.Key, addr)
}
func (h *Handshaker) SendOffer() error {
h.mu.Lock()
defer h.mu.Unlock()
@@ -195,13 +223,23 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
if h.config.PQ != nil {
offer.MlkemPayload, offer.MlkemPort = h.config.PQ.OfferPayload(h.config.Key)
}
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
func (h *Handshaker) sendAnswer(remoteOffer *OfferAnswer) error {
answer := h.buildOfferAnswer()
if h.config.PQ != nil {
var recvOffer []byte
if remoteOffer != nil {
recvOffer = remoteOffer.MlkemPayload
}
answer.MlkemPayload, answer.MlkemPort = h.config.PQ.AnswerPayload(h.config.Key, recvOffer)
}
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)

View File

@@ -63,6 +63,8 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
},
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassAddr: offerAnswer.RosenpassAddr,
MlkemPayload: offerAnswer.MlkemPayload,
MlkemPort: offerAnswer.MlkemPort,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,

View File

@@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
}
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
// or the zero value when no deadline is tracked. A deadline that has already
// slipped into the past reports as "none": once the session has expired it is
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
// arm a timer at the deadline itself to clear it (only the two pre-expiry
// warnings). Without this guard the UI would keep painting a stale
// "expires in …" against a moment that has passed until the next login,
// extend, or teardown rewrote the value.
// or the zero value when no deadline is tracked. A deadline in the past is
// returned as-is: it means the session has expired, and consumers (tray row,
// CLI status) render it as "expired" rather than hiding it — masking it as
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
return time.Time{}
}
return d.sessionExpiresAt
}

View File

@@ -0,0 +1,59 @@
package pqkem
import (
"crypto/ecdh"
"crypto/mlkem"
"crypto/rand"
"testing"
)
func BenchmarkX25519Keygen(b *testing.B) {
c := ecdh.X25519()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := c.GenerateKey(rand.Reader); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkX25519ECDH(b *testing.B) {
c := ecdh.X25519()
a, _ := c.GenerateKey(rand.Reader)
p, _ := c.GenerateKey(rand.Reader)
pub := p.PublicKey()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := a.ECDH(pub); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMLKEMKeygen(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := mlkem.GenerateKey768(); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMLKEMEncaps(b *testing.B) {
dk, _ := mlkem.GenerateKey768()
ek := dk.EncapsulationKey()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ek.Encapsulate()
}
}
func BenchmarkMLKEMDecaps(b *testing.B) {
dk, _ := mlkem.GenerateKey768()
_, ct := dk.EncapsulationKey().Encapsulate()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := dk.Decapsulate(ct); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,18 @@
package pqkem
// CallbackHandler is implemented by the host and invoked by the library. The
// library only reports events; the host owns the reaction. Keeping this an
// interface — rather than touching the transport or keying directly — is what lets
// the KEM code be extracted as a standalone library.
type CallbackHandler interface {
// OnNewPSKReady fires when a fresh post-quantum PSK has been derived for a peer
// and must be programmed into the consumer's secure channel. It is invoked at
// the commit point of each side: the initiator on receiving the answer, the
// responder on receiving the confirm.
OnNewPSKReady(remoteID RemoteID, psk PSK) error
// OnRekeyFailed fires when an exchange fails to converge within the allotted
// time. The host should tear the peer connection down so it re-establishes, and
// log a WARN. The library reports the event; it does not dictate the reaction.
OnRekeyFailed(remoteID RemoteID) error
}

View File

@@ -0,0 +1,224 @@
package pqkem
import (
"context"
"time"
)
// startExchange creates a fresh initiator exchange (acknowledging ackID, zero for a
// bootstrap) and returns the framed offer for the caller to send — pushed over the
// data path for a chained rekey, or handed to the host for signalling when viaSignal
// is set. Any previous in-flight exchange for the peer is cancelled.
func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID ExchangeID) ([]byte, error) {
init, err := NewInitiator()
if err != nil {
return nil, err
}
id, err := newExchangeID()
if err != nil {
return nil, err
}
raw, err := (&OfferMsg{ExchangeID: id, AckID: ackID, KEMOffer: init.Offer()}).Encode()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(m.rootCtx)
m.mu.Lock()
if old := m.exchanges[remoteID]; old != nil && old.cancel != nil {
old.cancel()
}
m.exchanges[remoteID] = &exchangeCtl{
id: id,
state: stateAwaitingAnswer,
startedAt: time.Now(),
cancel: cancel,
lastSent: raw,
initiator: init,
viaSignal: viaSignal,
}
m.mu.Unlock()
m.wait.Add(1)
go m.initiatorLoop(ctx, remoteID, id)
return raw, nil
}
// processOffer (responder) first acknowledges the previous exchange the offer names
// (that offer riding the data path under the freshly adopted key proves it worked),
// then derives the PSK for the new offer, commits it optimistically, and returns the
// framed answer. A duplicate offer returns the cached answer without re-deriving.
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
if o.AckID != (ExchangeID{}) {
m.ackConverged(remoteID, o.AckID)
}
m.mu.Lock()
if ex := m.exchanges[remoteID]; ex != nil && ex.id == o.ExchangeID {
state, last := ex.state, ex.lastSent
m.mu.Unlock()
if state == stateReserved {
return nil, nil
}
return last, nil
}
// Reserve the slot so a concurrent duplicate offer bails.
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()}
m.mu.Unlock()
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
if err != nil {
return nil, err
}
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
if err != nil {
return nil, err
}
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != o.ExchangeID {
m.mu.Unlock()
return nil, nil
}
ex.state = stateAwaitingAck
ex.lastSent = raw
ex.pendingPSK = psk
m.psks[remoteID] = psk
m.mu.Unlock()
// Commit optimistically so our data path can rekey to the new PSK.
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
return nil, err
}
return raw, nil
}
// processAnswer (initiator) derives and commits the PSK and parks in
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
// lock makes a concurrent/duplicate answer bail.
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
m.mu.Unlock()
return nil
}
ex.state = stateAwaitingRekey
init := ex.initiator
ex.initiator = nil
m.mu.Unlock()
psk, err := init.Finish(a.KEMAnswer, m.binding(remoteID))
if err != nil {
return err
}
// The initiator has converged: the responder must have derived the key to answer.
m.mu.Lock()
m.established[remoteID] = true
m.failures[remoteID] = 0
m.psks[remoteID] = psk
m.mu.Unlock()
return m.cbHandler.OnNewPSKReady(remoteID, psk)
}
// ackConverged (responder) records convergence of the exchange named by ackID: a
// later offer acknowledging it proves both sides operate on that exchange's key. Only
// acts on a matching stateAwaitingAck exchange; anything else is ignored.
func (m *Manager) ackConverged(remoteID RemoteID, ackID ExchangeID) {
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != ackID || ex.state != stateAwaitingAck {
m.mu.Unlock()
return
}
delete(m.exchanges, remoteID)
m.established[remoteID] = true
m.failures[remoteID] = 0
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
m.mu.Unlock()
}
// initiatorLoop enforces the offer->answer convergence deadline and retransmits the
// initiator's outstanding data-path offer while awaiting the answer (a
// signalling-bootstrapped offer is retransmitted by the host, so it is not resent
// here). Exhausting the deadline before the answer arrives is a failure. Once the
// answer is in (state past awaitingAnswer) the loop exits: the next rotation is driven
// by OnDataPathRekeyed, and the idle wait for it has no deadline.
func (m *Manager) initiatorLoop(ctx context.Context, remoteID RemoteID, id ExchangeID) {
defer m.wait.Done()
t := time.NewTicker(m.retryInterval)
defer t.Stop()
attempts := 0
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.mu.Lock()
ex := m.exchanges[remoteID]
if ex == nil || ex.id != id {
m.mu.Unlock()
return
}
switch ex.state {
case stateAwaitingAnswer:
if attempts >= m.maxRetries {
delete(m.exchanges, remoteID)
fail := m.registerFailureLocked(remoteID)
m.mu.Unlock()
m.raiseFailure(remoteID, fail)
return
}
viaSignal := ex.viaSignal
msg := ex.lastSent
attempts++
m.mu.Unlock()
if !viaSignal {
if err := m.pushDataPath(remoteID, msg); err != nil {
m.logger.Warn("pqkem: offer retransmit failed", "peer", remoteID, "err", err)
}
}
default:
// Past awaiting the answer (converged) or superseded: the loop's job
// is done. The next rotation is driven externally by OnDataPathRekeyed,
// so there is no deadline while idle-waiting for it (that wait can be
// as long as the transport's natural rekey interval).
m.mu.Unlock()
return
}
}
}
}
// registerFailureLocked applies policy B and reports whether OnRekeyFailed is due:
// an initial exchange (peer never established) fails immediately; a rekey tolerates
// up to maxRekeyFailures consecutive misses (we stay on the still-valid previous
// PSK) before failing. Assumes m.mu is held.
func (m *Manager) registerFailureLocked(remoteID RemoteID) bool {
if !m.established[remoteID] {
return true
}
m.failures[remoteID]++
if m.failures[remoteID] >= m.maxRekeyFailures {
m.failures[remoteID] = 0
return true
}
return false
}
func (m *Manager) raiseFailure(remoteID RemoteID, fail bool) {
if !fail {
m.logger.Warn("pqkem: rekey attempt timed out, will retry next cycle", "peer", remoteID)
return
}
if err := m.cbHandler.OnRekeyFailed(remoteID); err != nil {
m.logger.Error("pqkem: OnRekeyFailed handler error", "peer", remoteID, "err", err)
}
}

View File

@@ -0,0 +1,74 @@
package pqkem
import (
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// dropTransport is a pqkem.Transport that silently discards everything.
type dropTransport struct{}
func (dropTransport) Send(netip.AddrPort, []byte) error { return nil }
func (dropTransport) LocalPort() int { return 0 }
func (dropTransport) Run(func(netip.AddrPort, []byte)) {}
func (dropTransport) Close() error { return nil }
func failedCount(f *fakeWG) int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.failed)
}
func TestManager_InitialTimeoutFailsImmediately(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil) // bbbb > aaaa -> initiator
d.Start(dropTransport{})
d.retryInterval = 5 * time.Millisecond
d.maxRetries = 3
defer d.Stop()
// Bootstrap offer is produced for signalling; no answer ever comes back -> the
// initial exchange fails fast.
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
require.Eventually(t, func() bool { return failedCount(wg) == 1 }, time.Second, 5*time.Millisecond)
}
func TestManager_RekeyToleratesKFailures(t *testing.T) {
dA, dB, _, wgB, lbB := pair(t)
defer dA.Stop()
defer dB.Stop()
// Tighten B's timings before any exchange loop spawns (the loop reads these
// fields, so writing them after a loop is running would race).
dB.retryInterval = 5 * time.Millisecond
dB.maxRetries = 2
// Establish: bootstrap + data-path-rekeyed so B becomes established and its data
// path is usable.
bootstrap(t, dA, dB)
dA.OnDataPathRekeyed("bbbb")
dB.OnDataPathRekeyed("aaaa")
require.NotEqual(t, PSK{}, wgB.psk("aaaa"))
// Drop B's outbound so rekeys can no longer converge.
lbB.drop.Store(true)
// K-1 data-path rekeys must NOT raise OnRekeyFailed.
for i := 0; i < DefaultMaxRekeyFailures-1; i++ {
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
}
require.Equal(t, 0, failedCount(wgB), "no failure before K attempts")
// The K-th failure raises it once.
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
require.Eventually(t, func() bool { return failedCount(wgB) == 1 }, time.Second, 5*time.Millisecond)
}

View File

@@ -0,0 +1,59 @@
package pqkem
import (
"log/slog"
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
// EnvEnabled is the environment variable that turns the ML-KEM post-quantum
// exchange on for this client. Accepts on/off aliases plus anything
// strconv.ParseBool understands (true/false/1/0).
const EnvEnabled = "NB_ENABLE_PQ_MLKEM"
// Enabled reports whether the ML-KEM PQ exchange is enabled via the environment.
// An empty or unrecognized value is treated as disabled.
func Enabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(EnvEnabled)))
switch raw {
case "":
return false
case "on":
return true
case "off":
return false
}
enabled, err := strconv.ParseBool(raw)
if err != nil {
log.Warnf("failed to parse %s value %q: %v", EnvEnabled, raw, err)
return false
}
return enabled
}
// EnvLogLevel overrides the ML-KEM manager's slog level (debug/info/warn/error).
// Defaults to info.
const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"
// NewLogger builds the slog logger for the ML-KEM manager: a text handler to stdout
// at the level from EnvLogLevel. Mirrors the Rosenpass manager's logger setup so PQ
// components log consistently.
func NewLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel()}))
}
func logLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvLogLevel))) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,167 @@
// Package pqkem is a spike (NET-1406) for a post-quantum pre-shared-key exchange
// that could replace Rosenpass. It performs an X25519MLKEM768 hybrid key
// encapsulation and derives a 32-byte pre-shared key (PSK).
//
// The exchange is a single round trip designed to ride the (already
// authenticated) Signal offer/answer channel:
//
// initiator --Offer(1216B)--> responder
// initiator <--Answer(1120B)-- responder
//
// Both sides then hold the same PSK, which is bound to the two peers' identities
// (their peer identity keys) so the derived key cannot be transplanted
// to a different peer pair even if the transport authentication were bypassed.
//
// Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on
// the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for
// this group), and ML-KEM_ss ‖ X25519_ss fed into the KDF. The spike uses SHA-256
// (also binding the transcript and peer identities); a production version should
// use HKDF — see TODO below.
package pqkem
import (
"crypto/ecdh"
"crypto/mlkem"
"crypto/rand"
"crypto/sha256"
"fmt"
)
const (
// OfferSize is the initiator message: ML-KEM-768 encapsulation key ‖ X25519 public key
// (share order per draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768).
OfferSize = mlkem.EncapsulationKeySize768 + 32 // 1216
// AnswerSize is the responder message: ML-KEM-768 ciphertext ‖ X25519 public key.
AnswerSize = mlkem.CiphertextSize768 + 32 // 1120
pskLabel = "netbird-pq-psk-v1"
)
// PSK is the 32-byte derived pre-shared key handed to the consumer to key its channel.
type PSK [32]byte
// Binding identifies the peer pair the PSK is derived for. Callers set both
// peer identity keys; the order does not matter (it is canonicalised).
type Binding struct {
LocalID []byte
RemoteID []byte
}
// Initiator holds the ephemeral secrets between Offer and Finish.
type Initiator struct {
x25519 *ecdh.PrivateKey
mlkemDK *mlkem.DecapsulationKey768
offer []byte
}
// NewInitiator generates the ephemeral X25519 + ML-KEM-768 keypairs.
func NewInitiator() (*Initiator, error) {
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("x25519 keygen: %w", err)
}
dk, err := mlkem.GenerateKey768()
if err != nil {
return nil, fmt.Errorf("ml-kem keygen: %w", err)
}
offer := make([]byte, 0, OfferSize)
offer = append(offer, dk.EncapsulationKey().Bytes()...)
offer = append(offer, x.PublicKey().Bytes()...)
return &Initiator{x25519: x, mlkemDK: dk, offer: offer}, nil
}
// Offer returns the initiator message to send over Signal.
func (i *Initiator) Offer() []byte {
return i.offer
}
// Finish consumes the responder's answer and derives the PSK.
func (i *Initiator) Finish(answer []byte, b Binding) (PSK, error) {
if len(answer) != AnswerSize {
return PSK{}, fmt.Errorf("answer: got %d bytes, want %d", len(answer), AnswerSize)
}
ct := answer[:mlkem.CiphertextSize768]
peerX := answer[mlkem.CiphertextSize768:]
ssMLKEM, err := i.mlkemDK.Decapsulate(ct)
if err != nil {
return PSK{}, fmt.Errorf("ml-kem decapsulate: %w", err)
}
pub, err := ecdh.X25519().NewPublicKey(peerX)
if err != nil {
return PSK{}, fmt.Errorf("parse peer x25519: %w", err)
}
ssX, err := i.x25519.ECDH(pub)
if err != nil {
return PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
}
return derivePSK(ssMLKEM, ssX, i.offer, answer, b), nil
}
// Respond consumes an initiator offer, produces the answer, and derives the PSK.
func Respond(offer []byte, b Binding) (answer []byte, psk PSK, err error) {
if len(offer) != OfferSize {
return nil, PSK{}, fmt.Errorf("offer: got %d bytes, want %d", len(offer), OfferSize)
}
peerEK := offer[:mlkem.EncapsulationKeySize768]
peerX := offer[mlkem.EncapsulationKeySize768:]
ek, err := mlkem.NewEncapsulationKey768(peerEK)
if err != nil {
return nil, PSK{}, fmt.Errorf("parse peer ml-kem key: %w", err)
}
ssMLKEM, ct := ek.Encapsulate()
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, PSK{}, fmt.Errorf("x25519 keygen: %w", err)
}
pub, err := ecdh.X25519().NewPublicKey(peerX)
if err != nil {
return nil, PSK{}, fmt.Errorf("parse peer x25519: %w", err)
}
ssX, err := x.ECDH(pub)
if err != nil {
return nil, PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
}
answer = make([]byte, 0, AnswerSize)
answer = append(answer, ct...)
answer = append(answer, x.PublicKey().Bytes()...)
// derivePSK uses the same argument order on both sides; the responder's local
// binding is the mirror of the initiator's, canonicalised inside derivePSK.
return answer, derivePSK(ssMLKEM, ssX, offer, answer, b), nil
}
// derivePSK combines the two shared secrets and binds the result to the full
// transcript (offer ‖ answer) and the canonicalised peer identities.
//
// TODO(NET-1406): replace the SHA-256 concat with the RFC HKDF combiner
// (crypto/hkdf, Go 1.24+) and proper labels before this leaves spike status.
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) PSK {
lo, hi := canonicalPair(b.LocalID, b.RemoteID)
h := sha256.New()
h.Write([]byte(pskLabel))
h.Write(ssMLKEM)
h.Write(ssX)
h.Write(offer)
h.Write(answer)
h.Write(lo)
h.Write(hi)
var psk PSK
copy(psk[:], h.Sum(nil))
return psk
}
func canonicalPair(a, b []byte) (lo, hi []byte) {
if string(a) <= string(b) {
return a, b
}
return b, a
}

View File

@@ -0,0 +1,89 @@
package pqkem
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
var (
wgA = []byte("peer-A-wireguard-pubkey-32bytes!")
wgB = []byte("peer-B-wireguard-pubkey-32bytes!")
)
func TestExchange_DerivesMatchingPSK(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
require.Len(t, init.Offer(), OfferSize)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
require.Len(t, answer, AnswerSize)
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
require.Equal(t, pskB, pskA, "both sides must derive the same PSK")
require.NotEqual(t, PSK{}, pskA, "PSK must not be zero")
}
func TestExchange_PSKBoundToPeerIdentities(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
// responder computes with the honest pair...
_, pskHonest, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
// ...a second responder run with a different peer identity yields a different PSK,
// even though the KEM material would otherwise combine identically.
wgC := []byte("peer-C-wireguard-pubkey-32bytes!")
_, pskWrong, err := Respond(init.Offer(), Binding{LocalID: wgC, RemoteID: wgA})
require.NoError(t, err)
require.NotEqual(t, pskHonest, pskWrong, "PSK must be bound to the peer pair")
}
func TestExchange_RejectsMalformedMessages(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
_, _, err = Respond(init.Offer()[:10], Binding{})
require.Error(t, err)
_, err = init.Finish([]byte("too short"), Binding{})
require.Error(t, err)
}
// TestExchange_ReportSizesAndTiming is a spike measurement, not a pass/fail gate.
// Run with: go test -run TestExchange_ReportSizesAndTiming -v ./client/internal/pqkem/
func TestExchange_ReportSizesAndTiming(t *testing.T) {
const iters = 200
var tInit, tResp, tFinish time.Duration
for i := 0; i < iters; i++ {
s0 := time.Now()
init, err := NewInitiator()
require.NoError(t, err)
tInit += time.Since(s0)
s1 := time.Now()
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tResp += time.Since(s1)
s2 := time.Now()
_, err = init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
tFinish += time.Since(s2)
}
t.Logf("wire sizes: offer=%d B answer=%d B (Rosenpass static pubkey ~524160 B)", OfferSize, AnswerSize)
t.Logf("total on-wire per handshake: %d B (~%.0fx smaller than RP static key)", OfferSize+AnswerSize, 524160.0/float64(OfferSize+AnswerSize))
t.Logf("avg NewInitiator (keygen): %s", tInit/iters)
t.Logf("avg Respond (encaps+dh): %s", tResp/iters)
t.Logf("avg Finish (decaps+dh): %s", tFinish/iters)
t.Logf("avg full handshake CPU: %s", (tInit+tResp+tFinish)/iters)
}

View File

@@ -0,0 +1,372 @@
package pqkem
import (
"context"
"crypto/rand"
"fmt"
"log/slog"
"net/netip"
"sync"
"time"
)
const (
// DefaultRetryInterval is how often the initiator retransmits its outstanding
// data-path offer while awaiting the answer.
DefaultRetryInterval = 2 * time.Second
// DefaultMaxRetries bounds how many ticks an exchange may run before it is
// declared failed. The convergence deadline is thus MaxRetries * RetryInterval.
DefaultMaxRetries = 10
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
DefaultMaxRekeyFailures = 3
)
// LocalID and RemoteID are peer identity keys (e.g. WireGuard public keys). They are
// distinct types so the local and a remote identity cannot be mixed up.
type (
LocalID string
RemoteID string
)
// Transport is the data-path socket the Manager drives (the analogue of
// go-rosenpass's Conn). It is a dumb mover of bytes to/from endpoints: the Manager
// owns the remoteID<->endpoint routing and hands the transport a resolved endpoint
// to Send, and reverse-resolves the source of each inbound datagram. Its lifecycle
// belongs to the Manager (Run at Start, Close at Stop).
type Transport interface {
// Send delivers msg to the given data-path endpoint.
Send(endpoint netip.AddrPort, msg []byte) error
// LocalPort is the bound local UDP port, announced to peers so they know where
// to send data-path messages.
LocalPort() int
// Run starts delivering inbound datagrams as (source endpoint, msg) to onInbound
// and returns immediately; it runs until Close.
Run(onInbound func(src netip.AddrPort, msg []byte))
// Close stops delivery and releases the socket.
Close() error
}
// exchangeState is the single source of truth for an exchange's role and phase.
type exchangeState uint8
const (
stateReserved exchangeState = iota // responder: deriving the answer
stateAwaitingAnswer // initiator: offer sent, awaiting the answer
stateAwaitingRekey // initiator: PSK derived+set, awaiting OnDataPathRekeyed to chain the next offer
stateAwaitingAck // responder: answer sent, awaiting the next offer that acks this exchange
)
// exchangeCtl holds all state for one in-flight exchange with a peer, under the
// Manager's single lock. state drives every decision. lastSent is the current
// data-path retransmit payload (the offer, for the initiator). initiator is the
// ephemeral handle used at Finish; pendingPSK is the responder's derived key.
// viaSignal records that the offer went to the host for the signalling channel, so
// the loop does not retransmit it on the data path. Only the initiator runs a
// retransmit loop, so only it sets cancel.
type exchangeCtl struct {
id ExchangeID
state exchangeState
startedAt time.Time
cancel context.CancelFunc
lastSent []byte
initiator *Initiator
pendingPSK PSK
viaSignal bool
}
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
// drives the X25519MLKEM768 exchange, owns the peer endpoint routing and the data-path
// transport, and surfaces the derived PSK and convergence to the host via
// CallbackHandler. It is event-driven: the bootstrap is triggered by the host
// (SignalOffer) and each rotation is clocked by OnDataPathRekeyed. The cryptography is
// the pure kem.go primitives; all state lives here under one lock.
type Manager struct {
localID LocalID
cbHandler CallbackHandler
logger *slog.Logger
retryInterval time.Duration
maxRetries int
maxRekeyFailures int
rootCtx context.Context
rootCancel context.CancelFunc
mu sync.Mutex
transport Transport
exchanges map[RemoteID]*exchangeCtl // in-flight exchange per peer
established map[RemoteID]bool // peer has completed at least one exchange
failures map[RemoteID]int // consecutive rekey failures per peer
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
wait sync.WaitGroup
}
// NewManager builds a manager for the local peer identified by its peer identity key
// (used for the deterministic initiator role and the identity binding). A nil logger
// falls back to slog.Default(). Install the data-path transport with Start.
func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.Default()
}
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
localID: localID,
cbHandler: h,
logger: logger,
retryInterval: DefaultRetryInterval,
maxRetries: DefaultMaxRetries,
maxRekeyFailures: DefaultMaxRekeyFailures,
rootCtx: ctx,
rootCancel: cancel,
exchanges: make(map[RemoteID]*exchangeCtl),
established: make(map[RemoteID]bool),
failures: make(map[RemoteID]int),
psks: make(map[RemoteID]PSK),
peerAddrs: make(map[RemoteID]netip.AddrPort),
peersByAddr: make(map[netip.AddrPort]RemoteID),
}
}
// Start installs the data-path transport and begins its inbound delivery. The Manager
// owns it from here; Stop closes it. Start/Stop are the transport lifecycle pair.
func (m *Manager) Start(t Transport) {
m.mu.Lock()
m.transport = t
m.mu.Unlock()
if t != nil {
t.Run(m.onDataPathInbound)
}
}
// LocalPort is the data-path transport's bound UDP port (0 if no transport), to be
// announced to peers.
func (m *Manager) LocalPort() int {
m.mu.Lock()
t := m.transport
m.mu.Unlock()
if t == nil {
return 0
}
return t.LocalPort()
}
// IsInitiator reports whether the local peer drives the exchange for this remote
// peer. Roles are deterministic (lexicographic identity-key compare) so exactly one
// side initiates, mirroring how Rosenpass picks its handshake initiator.
func (m *Manager) IsInitiator(remoteID RemoteID) bool {
return string(m.localID) > string(remoteID)
}
// PSK returns the latest PSK derived for the peer, for the host to program at WG
// peer-config time (the pull path). ok is false until an exchange has derived one.
func (m *Manager) PSK(remoteID RemoteID) (PSK, bool) {
m.mu.Lock()
defer m.mu.Unlock()
psk, ok := m.psks[remoteID]
return psk, ok
}
// AddPeer registers where a peer's data-path messages are sent and received: its
// overlay endpoint (IP:port). Re-adding updates the endpoint.
func (m *Manager) AddPeer(remoteID RemoteID, endpoint netip.AddrPort) {
if !endpoint.IsValid() {
return
}
m.mu.Lock()
if old, ok := m.peerAddrs[remoteID]; ok {
delete(m.peersByAddr, old)
}
m.peerAddrs[remoteID] = endpoint
m.peersByAddr[endpoint] = remoteID
m.mu.Unlock()
}
// RemovePeer stops any in-flight exchange for a peer and drops its state and routing.
func (m *Manager) RemovePeer(remoteID RemoteID) {
m.mu.Lock()
if ex, ok := m.exchanges[remoteID]; ok {
if ex.cancel != nil {
ex.cancel()
}
delete(m.exchanges, remoteID)
}
delete(m.established, remoteID)
delete(m.failures, remoteID)
delete(m.psks, remoteID)
if ep, ok := m.peerAddrs[remoteID]; ok {
delete(m.peersByAddr, ep)
delete(m.peerAddrs, remoteID)
}
m.mu.Unlock()
}
// Stop cancels all in-flight exchanges, closes the transport, and waits for the
// exchange goroutines to exit.
func (m *Manager) Stop() {
m.rootCancel()
m.wait.Wait()
m.mu.Lock()
t := m.transport
m.transport = nil
m.exchanges = make(map[RemoteID]*exchangeCtl)
m.psks = make(map[RemoteID]PSK)
m.mu.Unlock()
if t != nil {
if err := t.Close(); err != nil {
m.logger.Warn("pqkem: closing data-path transport", "err", err)
}
}
}
// ---- Signalling channel (host-driven; rides the host's negotiation) ----
// SignalOffer returns the KEM offer for the host to embed in its outgoing offer to
// remoteID (bootstrap). It returns (nil, nil) when the local peer is not the
// initiator. It is idempotent for an in-flight bootstrap: a repeat call returns the
// same offer rather than starting a new exchange.
func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
if !m.IsInitiator(remoteID) {
return nil, nil
}
m.mu.Lock()
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && ex.state == stateAwaitingAnswer {
last := ex.lastSent
m.mu.Unlock()
return last, nil
}
m.mu.Unlock()
// bootstrap offer acknowledges nothing (zero AckID).
return m.startExchange(remoteID, true, ExchangeID{})
}
// SignalOnOffer processes a KEM offer the host extracted from an incoming offer and
// returns the KEM answer for the host to embed in its outgoing answer.
func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error) {
typ, msg, err := Decode(offer)
if err != nil {
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
}
if typ != MsgOffer {
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
}
return m.processOffer(remoteID, msg.(*OfferMsg))
}
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
// There is no reply: the next offer (over the data path) acknowledges this exchange.
func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
typ, msg, err := Decode(answer)
if err != nil {
return fmt.Errorf("decode signal answer from %s: %w", remoteID, err)
}
if typ != MsgAnswer {
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
}
return m.processAnswer(remoteID, msg.(*AnswerMsg))
}
// ---- Data path ----
// onDataPathInbound is the transport's inbound handler: it reverse-resolves the
// source endpoint to a peer and dispatches. Unknown sources are dropped.
func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
m.mu.Lock()
remoteID, ok := m.peersByAddr[src]
m.mu.Unlock()
if !ok {
return
}
if err := m.OnDataPathMessage(remoteID, msg); err != nil {
m.logger.Debug("pqkem: inbound", "peer", remoteID, "err", err)
}
}
// OnDataPathMessage handles a KEM message received over the data path from remoteID
// and pushes any reply back over the data path.
func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
typ, msg, err := Decode(raw)
if err != nil {
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
}
switch typ {
case MsgOffer:
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
if err != nil {
return err
}
if answer == nil {
return nil
}
return m.pushDataPath(remoteID, answer)
case MsgAnswer:
return m.processAnswer(remoteID, msg.(*AnswerMsg))
default:
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
}
}
// OnDataPathRekeyed notifies that the peer's data path is up and freshly keyed with
// the latest PSK (fired on first establishment AND every rekey). If we are the
// initiator that just derived a PSK, it chains the next exchange: a fresh offer over
// the data path that acknowledges the just-completed one (its arrival under the new
// key proves to the responder that the key works).
func (m *Manager) OnDataPathRekeyed(remoteID RemoteID) {
m.mu.Lock()
ex := m.exchanges[remoteID]
chain := ex != nil && ex.state == stateAwaitingRekey
var ackID ExchangeID
if chain {
ackID = ex.id
}
m.mu.Unlock()
if !chain {
return
}
offer, err := m.startExchange(remoteID, false, ackID)
if err != nil {
m.logger.Error("pqkem: chain offer failed to start", "peer", remoteID, "err", err)
return
}
if err := m.pushDataPath(remoteID, offer); err != nil {
m.logger.Warn("pqkem: send chain offer failed", "peer", remoteID, "err", err)
}
}
// OnDataPathDown notifies that the peer's data path went down. Rotations resume once
// the host re-bootstraps over signalling on reconnect; in-flight data-path sends will
// simply fail until then. Reserved as an explicit hook.
func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
// ---- internals ----
// pushDataPath resolves the peer's endpoint and sends over the data-path transport,
// erroring if the peer is unknown or no transport is set.
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
m.mu.Lock()
ep, ok := m.peerAddrs[remoteID]
t := m.transport
m.mu.Unlock()
if !ok {
return fmt.Errorf("no data-path endpoint for peer %s", remoteID)
}
if t == nil {
return fmt.Errorf("no data-path transport")
}
return t.Send(ep, msg)
}
func (m *Manager) binding(remoteID RemoteID) Binding {
return Binding{LocalID: []byte(m.localID), RemoteID: []byte(remoteID)}
}
func newExchangeID() (ExchangeID, error) {
var id ExchangeID
if _, err := rand.Read(id[:]); err != nil {
return ExchangeID{}, fmt.Errorf("generate exchange id: %w", err)
}
return id, nil
}

View File

@@ -0,0 +1,169 @@
package pqkem
import (
"fmt"
"net/netip"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// netSwitch is an in-memory UDP fabric: transports register their endpoint and get
// datagrams delivered to their inbound handler.
type netSwitch struct {
mu sync.Mutex
h map[netip.AddrPort]func(netip.AddrPort, []byte)
}
func newSwitch() *netSwitch {
return &netSwitch{h: map[netip.AddrPort]func(netip.AddrPort, []byte){}}
}
func (s *netSwitch) register(ep netip.AddrPort, fn func(netip.AddrPort, []byte)) {
s.mu.Lock()
s.h[ep] = fn
s.mu.Unlock()
}
func (s *netSwitch) deliver(dst, src netip.AddrPort, msg []byte) error {
s.mu.Lock()
fn := s.h[dst]
s.mu.Unlock()
if fn == nil {
return fmt.Errorf("no route to %s", dst)
}
fn(src, msg)
return nil
}
// loopback is an endpoint-based pqkem.Transport over a netSwitch, with a switchable
// drop flag.
type loopback struct {
ep netip.AddrPort
sw *netSwitch
drop atomic.Bool
}
func (l *loopback) Send(dst netip.AddrPort, msg []byte) error {
if l.drop.Load() {
return nil
}
return l.sw.deliver(dst, l.ep, append([]byte(nil), msg...))
}
func (l *loopback) LocalPort() int { return int(l.ep.Port()) }
func (l *loopback) Run(onInbound func(netip.AddrPort, []byte)) { l.sw.register(l.ep, onInbound) }
func (l *loopback) Close() error { return nil }
type fakeWG struct {
mu sync.Mutex
psks map[RemoteID]PSK
failed []RemoteID
}
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
f.mu.Lock()
defer f.mu.Unlock()
f.psks[remoteID] = psk
return nil
}
func (f *fakeWG) OnRekeyFailed(remoteID RemoteID) error {
f.mu.Lock()
defer f.mu.Unlock()
f.failed = append(f.failed, remoteID)
return nil
}
func (f *fakeWG) psk(peer RemoteID) PSK {
f.mu.Lock()
defer f.mu.Unlock()
return f.psks[peer]
}
var (
epA = netip.MustParseAddrPort("100.64.0.1:51833")
epB = netip.MustParseAddrPort("100.64.0.2:51833")
)
// pair builds two wired managers (B is the initiator, "bbbb" > "aaaa") sharing a
// netSwitch, with each peer's data-path endpoint registered. lbB is B's loopback
// (for toggling drop).
func pair(t *testing.T) (dA, dB *Manager, wgA, wgB *fakeWG, lbB *loopback) {
t.Helper()
sw := newSwitch()
wgA = newFakeWG()
wgB = newFakeWG()
dA = NewManager("aaaa", wgA, nil)
dB = NewManager("bbbb", wgB, nil)
dA.Start(&loopback{ep: epA, sw: sw})
lbB = &loopback{ep: epB, sw: sw}
dB.Start(lbB)
dA.AddPeer("bbbb", epB)
dB.AddPeer("aaaa", epA)
return dA, dB, wgA, wgB, lbB
}
// bootstrap runs the signalling offer/answer (the test plays the host carrying bytes).
func bootstrap(t *testing.T, dA, dB *Manager) {
t.Helper()
offer, err := dB.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
answer, err := dA.SignalOnOffer("bbbb", offer)
require.NoError(t, err)
require.NotNil(t, answer)
require.NoError(t, dB.SignalOnAnswer("aaaa", answer))
}
func TestManager_BootstrapDerivesSamePSK(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
pskA := wgA.psk("bbbb")
pskB := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, pskA)
require.Equal(t, pskB, pskA, "both sides derive the same PSK from the bootstrap exchange")
}
func TestManager_ChainRotatesAndAcks(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk1 := wgB.psk("aaaa")
// Data path up: B (initiator) chains the next offer over the data path, which
// rotates both to a fresh PSK and acknowledges A.
dA.OnDataPathRekeyed("bbbb")
dB.OnDataPathRekeyed("aaaa")
psk2A := wgA.psk("bbbb")
psk2B := wgB.psk("aaaa")
require.Equal(t, psk2B, psk2A, "both sides converge on the rotated PSK")
require.NotEqual(t, psk1, psk2B, "the chain rotated to a new PSK")
}
func TestManager_NonInitiatorReturnsNoOffer(t *testing.T) {
dA := NewManager("aaaa", newFakeWG(), nil)
defer dA.Stop()
offer, err := dA.SignalOffer("bbbb") // not the initiator vs "bbbb"
require.NoError(t, err)
require.Nil(t, offer)
}
func TestManager_StopIsIdempotent(t *testing.T) {
dA := NewManager("aaaa", newFakeWG(), nil)
dA.Start(&loopback{ep: epA, sw: newSwitch()})
dA.Stop()
dA.Stop() // must not panic or hang
}

View File

@@ -0,0 +1,121 @@
package pqkem
import (
"crypto/mlkem"
"fmt"
)
// Wire framing for the PQ-KEM exchange. Messages are self-contained, versioned,
// transport-agnostic byte blobs: the same bytes ride the signalling channel
// (initial bootstrap) or a data-tunnel packet (rekey). The library only ever sees
// opaque []byte at the transport seam.
//
// Layout (all messages): [type:1][version:1][exchangeID:16][payload...]
//
// There is no confirm message: an exchange is acknowledged by the NEXT offer, which
// carries the acked exchange's id (see OfferMsg.AckID) and — riding the data path
// under the freshly adopted key — proves that key works.
const (
// ProtocolVersion is bumped on any wire-incompatible change; a peer rejects
// messages it does not understand rather than misparsing them.
ProtocolVersion uint8 = 1
// ExchangeIDSize identifies one exchange so answers/acks correlate and stale
// messages are dropped.
ExchangeIDSize = 16
headerSize = 1 + 1 + ExchangeIDSize
)
// MsgType tags the two message kinds of the exchange.
type MsgType uint8
const (
MsgOffer MsgType = iota + 1
MsgAnswer
)
// ExchangeID is the per-exchange correlator. The zero value means "none" (an offer
// that acknowledges nothing, i.e. the first exchange of a connection).
type ExchangeID [ExchangeIDSize]byte
// OfferMsg carries the initiator's public material (X25519 pub ‖ ML-KEM encap key)
// and AckID, the id of the previous exchange this offer acknowledges (zero if none).
type OfferMsg struct {
ExchangeID ExchangeID
AckID ExchangeID
// KEMOffer is the raw Initiator.Offer() blob (OfferSize bytes).
KEMOffer []byte
}
// AnswerMsg carries the responder's reply (ML-KEM ciphertext ‖ X25519 pub) for the
// round identified by ExchangeID.
type AnswerMsg struct {
ExchangeID ExchangeID
// KEMAnswer is the raw Respond() answer blob (AnswerSize bytes).
KEMAnswer []byte
}
// Encode serialises the offer with its framed header (payload = AckID ‖ KEMOffer).
func (m *OfferMsg) Encode() ([]byte, error) {
if len(m.KEMOffer) != OfferSize {
return nil, fmt.Errorf("offer payload: got %d, want %d", len(m.KEMOffer), OfferSize)
}
payload := make([]byte, 0, ExchangeIDSize+OfferSize)
payload = append(payload, m.AckID[:]...)
payload = append(payload, m.KEMOffer...)
return frame(MsgOffer, m.ExchangeID, payload), nil
}
// Encode serialises the answer with its framed header.
func (m *AnswerMsg) Encode() ([]byte, error) {
if len(m.KEMAnswer) != AnswerSize {
return nil, fmt.Errorf("answer payload: got %d, want %d", len(m.KEMAnswer), AnswerSize)
}
return frame(MsgAnswer, m.ExchangeID, m.KEMAnswer), nil
}
// Decode parses a framed message into one of *OfferMsg / *AnswerMsg.
func Decode(buf []byte) (MsgType, any, error) {
if len(buf) < headerSize {
return 0, nil, fmt.Errorf("message too short: %d bytes", len(buf))
}
typ := MsgType(buf[0])
if ver := buf[1]; ver != ProtocolVersion {
return typ, nil, fmt.Errorf("unsupported protocol version %d (want %d)", ver, ProtocolVersion)
}
var id ExchangeID
copy(id[:], buf[2:headerSize])
payload := buf[headerSize:]
switch typ {
case MsgOffer:
if len(payload) != ExchangeIDSize+OfferSize {
return typ, nil, fmt.Errorf("offer payload: got %d, want %d", len(payload), ExchangeIDSize+OfferSize)
}
var ack ExchangeID
copy(ack[:], payload[:ExchangeIDSize])
return typ, &OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: payload[ExchangeIDSize:]}, nil
case MsgAnswer:
if len(payload) != AnswerSize {
return typ, nil, fmt.Errorf("answer payload: got %d, want %d", len(payload), AnswerSize)
}
return typ, &AnswerMsg{ExchangeID: id, KEMAnswer: payload}, nil
default:
return typ, nil, fmt.Errorf("unknown message type %d", typ)
}
}
func frame(typ MsgType, id ExchangeID, payload []byte) []byte {
buf := make([]byte, headerSize+len(payload))
buf[0] = byte(typ)
buf[1] = ProtocolVersion
copy(buf[2:], id[:])
copy(buf[headerSize:], payload)
return buf
}
// compile-time assurance the KEM blob sizes referenced here stay in sync with kem.go.
var _ = [1]struct{}{}[OfferSize-(32+mlkem.EncapsulationKeySize768)]

View File

@@ -0,0 +1,57 @@
package pqkem
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMessageRoundTrip(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
id := ExchangeID{1, 2, 3, 4}
ack := ExchangeID{9, 9, 9}
offBytes, err := (&OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: init.Offer()}).Encode()
require.NoError(t, err)
typ, decoded, err := Decode(offBytes)
require.NoError(t, err)
require.Equal(t, MsgOffer, typ)
require.Equal(t, id, decoded.(*OfferMsg).ExchangeID)
require.Equal(t, ack, decoded.(*OfferMsg).AckID)
require.Equal(t, init.Offer(), decoded.(*OfferMsg).KEMOffer)
ansBytes, err := (&AnswerMsg{ExchangeID: id, KEMAnswer: answer}).Encode()
require.NoError(t, err)
typ, decoded, err = Decode(ansBytes)
require.NoError(t, err)
require.Equal(t, MsgAnswer, typ)
require.Equal(t, answer, decoded.(*AnswerMsg).KEMAnswer)
}
func TestDecodeRejects(t *testing.T) {
// too short
_, _, err := Decode([]byte{1, 1})
require.Error(t, err)
// wrong version
bad := make([]byte, headerSize+ExchangeIDSize+OfferSize)
bad[0] = byte(MsgOffer)
bad[1] = ProtocolVersion + 1
_, _, err = Decode(bad)
require.Error(t, err)
// unknown type
bad2 := make([]byte, headerSize)
bad2[0] = 99
bad2[1] = ProtocolVersion
_, _, err = Decode(bad2)
require.Error(t, err)
// offer with wrong payload size
_, err = (&OfferMsg{KEMOffer: []byte{1, 2, 3}}).Encode()
require.Error(t, err)
}

View File

@@ -0,0 +1,102 @@
package internal
import (
"net/netip"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
// pqPresharedKeySetter is the subset of the WireGuard interface the ML-KEM callback
// needs: programming a peer's preshared key. *iface.WGIface satisfies it.
type pqPresharedKeySetter interface {
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
}
// pqCallbackHandler programs the derived PQ PSK onto the WireGuard peer. It is the
// engine-side implementation of pqkem.CallbackHandler.
type pqCallbackHandler struct {
wg pqPresharedKeySetter
}
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
// if the peer is not present, mirroring Rosenpass). remoteID is the peer's WG pubkey.
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) error {
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
// peer is not configured yet, so this is a no-op there and the PSK is instead
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
log.Debugf("pqkem: programming PSK for peer %s", remoteID)
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
}
// OnRekeyFailed reports a failed PQ (re)key convergence.
// TODO(NET-1406): tear the peer connection down / trigger ICE reconnect.
func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error {
log.Warnf("pqkem: post-quantum rekey failed for peer %s", remoteID)
return nil
}
// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys),
// wiring the host's signalling offers/answers to the KEM exchange.
type pqHandshaker struct {
mgr *pqkem.Manager
}
func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, int) {
payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey))
if err != nil {
log.Warnf("pqkem: build offer for %s: %v", remoteKey, err)
}
return payload, p.mgr.LocalPort()
}
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) {
if len(recvOffer) == 0 {
return nil, p.mgr.LocalPort()
}
payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer)
if err != nil {
log.Warnf("pqkem: build answer for %s: %v", remoteKey, err)
}
return payload, p.mgr.LocalPort()
}
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
if len(recvAnswer) == 0 {
return
}
if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil {
log.Warnf("pqkem: process answer from %s: %v", remoteKey, err)
}
}
// PSK exposes the peer's derived PSK for the conn to program at WG peer-config time.
func (p pqHandshaker) PSK(remoteKey string) (wgtypes.Key, bool) {
psk, ok := p.mgr.PSK(pqkem.RemoteID(remoteKey))
if !ok {
return wgtypes.Key{}, false
}
return wgtypes.Key(psk), true
}
// SetRemoteAddr registers the peer's data-path endpoint (overlay IP + pq UDP port)
// learned from signalling. Sends only ever fire once the tunnel is up (clocked by
// OnDataPathRekeyed), so registering here is safe even before connection-up.
func (p pqHandshaker) SetRemoteAddr(remoteKey string, addr netip.AddrPort) {
if !addr.IsValid() || addr.Port() == 0 {
return
}
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), addr)
}
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string) {
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey))
}
// OnDataPathDown signals the peer's tunnel went down.
func (p pqHandshaker) OnDataPathDown(remoteKey string) {
p.mgr.OnDataPathDown(pqkem.RemoteID(remoteKey))
}

View File

@@ -0,0 +1,72 @@
package internal
import (
"fmt"
"net"
"net/netip"
log "github.com/sirupsen/logrus"
)
// DefaultPort is the preferred UDP port for the ML-KEM data-path service, bound on
// the WG overlay IP. Since each client owns a distinct overlay IP, this port is
// almost always free, so it need not be announced (peers assume it). A peer only
// announces Body.mlkemPort when a collision forced it onto a different port.
const DefaultPort = 51833
// pqTransport is the ML-KEM data-path transport: a dumb UDP socket bound on the WG
// overlay IP. It implements pqkem.Transport — the manager owns the remoteID<->endpoint
// routing and drives this socket's lifecycle (Run / Close).
type pqTransport struct {
conn *net.UDPConn
port int
}
// newPQTransport binds a UDP socket on the WG overlay IP, preferring DefaultPort and
// falling back to an OS-assigned ephemeral port if it is in use. Call it after the WG
// interface is up so the overlay IP is assigned; when the bound port is not
// DefaultPort it must be announced to peers via Body.mlkemPort.
func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) {
if !overlayIP.IsValid() {
return nil, fmt.Errorf("invalid overlay IP for pqkem transport")
}
ip := net.IP(overlayIP.AsSlice())
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: DefaultPort})
if err != nil {
log.Debugf("pqkem: default port %d unavailable on %s (%v), using an ephemeral port", DefaultPort, overlayIP, err)
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
if err != nil {
return nil, fmt.Errorf("bind pqkem udp on overlay %s: %w", overlayIP, err)
}
}
return &pqTransport{conn: conn, port: conn.LocalAddr().(*net.UDPAddr).Port}, nil
}
// Send implements pqkem.Transport.
func (t *pqTransport) Send(endpoint netip.AddrPort, msg []byte) error {
_, err := t.conn.WriteToUDPAddrPort(msg, endpoint)
return err
}
// LocalPort implements pqkem.Transport.
func (t *pqTransport) LocalPort() int { return t.port }
// Run implements pqkem.Transport: the receive loop, delivering each datagram as
// (source endpoint, msg). Exits when the socket is closed.
func (t *pqTransport) Run(onInbound func(src netip.AddrPort, msg []byte)) {
go func() {
buf := make([]byte, 2048)
for {
n, src, err := t.conn.ReadFromUDPAddrPort(buf)
if err != nil {
return
}
msg := make([]byte, n)
copy(msg, buf[:n])
onInbound(src, msg)
}
}()
}
// Close implements pqkem.Transport.
func (t *pqTransport) Close() error { return t.conn.Close() }

View File

@@ -96,6 +96,7 @@ type ConfigInput struct {
BlockLANAccess *bool
BlockInbound *bool
DisableIPv6 *bool
SyncMessageVersion *int
DisableNotifications *bool
@@ -137,6 +138,7 @@ type Config struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
DisableNotifications *bool
@@ -587,6 +589,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
*config.SyncMessageVersion = *input.SyncMessageVersion
updated = true
}
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
if *input.DisableNotifications {
log.Infof("disabling notifications")

View File

@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
}
func (r *Route) update(ctx context.Context) error {
resolved, err := r.resolveDomains()
resolved, err := r.resolveDomains(ctx)
if err != nil {
if len(resolved) == 0 {
return fmt.Errorf("resolve domains: %w", err)
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
return nil
}
func (r *Route) resolveDomains() (domainMap, error) {
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
results := make(chan resolveResult)
go r.resolve(results)
go r.resolve(ctx, results)
resolved := domainMap{}
var merr *multierror.Error
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
return resolved, nberrors.FormatErrorOrNil(merr)
}
func (r *Route) resolve(results chan resolveResult) {
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
var wg sync.WaitGroup
for _, d := range r.route.Domains {
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
go func(domain domain.Domain) {
defer wg.Done()
ips, err := r.getIPsFromResolver(domain)
ips, err := r.getIPsFromResolver(ctx, domain)
if err != nil {
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
ips, err = net.LookupIP(domain.PunycodeString())
ips, err = lookupHostIPs(ctx, domain)
if err != nil {
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
return
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, addr := range addrs {
ips = append(ips, addr.IP)
}
return ips, nil
}
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
prefixSet := make(map[netip.Prefix]struct{})
for _, prefix := range oldPrefixes {

View File

@@ -3,11 +3,12 @@
package dynamic
import (
"context"
"net"
"github.com/netbirdio/netbird/shared/management/domain"
)
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
return net.LookupIP(domain.PunycodeString())
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
return lookupHostIPs(ctx, domain)
}

View File

@@ -3,6 +3,7 @@
package dynamic
import (
"context"
"fmt"
"net"
"time"
@@ -16,7 +17,7 @@ import (
const dialTimeout = 10 * time.Second
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
if err != nil {
return nil, fmt.Errorf("error while creating private client: %s", err)
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
if err != nil {
if queryErr == nil {
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)

View File

@@ -61,6 +61,7 @@ type Manager interface {
InitialRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
Stop(stateManager *statemanager.Manager)
}
@@ -232,6 +233,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
// prefixes are re-added to an existing peer and an absent peer is left untouched.
func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
if m.allowedIPsRefCounter == nil {
return nil
}
return m.allowedIPsRefCounter.ReapplyMatching(
func(out string) bool { return out == peerKey },
func(prefix netip.Prefix) error {
if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
}
return nil
},
)
}
// Init sets up the routing
func (m *DefaultManager) Init() error {
m.routeSelector = m.initSelector()

View File

@@ -112,6 +112,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
func (m *MockManager) SetDNSForwarderPort(port uint16) {
}
// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
return nil
}
// Stop mock implementation of Stop from Manager interface
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
if m.StopFunc != nil {

View File

@@ -0,0 +1,90 @@
//go:build !windows
package routemanager
import (
"net"
"net/netip"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
)
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
type reconcileWGMock struct {
mu sync.Mutex
adds map[string][]netip.Prefix
}
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.adds == nil {
m.adds = map[string][]netip.Prefix{}
}
m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
return nil
}
func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
m.mu.Lock()
defer m.mu.Unlock()
return m.adds[peerKey]
}
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
func (m *reconcileWGMock) Name() string { return "utun-test" }
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
func TestReconcilePeerAllowedIPs(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
m.allowedIPsRefCounter = refcounter.New[netip.Prefix, string, string](
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := m.allowedIPsRefCounter.Increment(prefix, peer)
require.NoError(t, err)
}
// Extra reference: reconcile must still re-apply the prefix even though its refcount never
// hit 0 again (the exact case the plain incremental path skips).
_, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
require.NoError(t, err)
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
"reconcile must re-apply all routed prefixes of the peer")
assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
}
// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
// set up.
func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.Empty(t, wg.added("peerA"))
}

View File

@@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
return ref, ok
}
// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
// pred and apply are invoked under the lock, so they must not call back into the counter.
func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
rm.mu.Lock()
defer rm.mu.Unlock()
var merr *multierror.Error
for key, ref := range rm.refCountMap {
if pred(ref.Out) {
if err := apply(key); err != nil {
merr = multierror.Append(merr, err)
}
}
}
return nberrors.FormatErrorOrNil(merr)
}
// Increment increments the reference count for the given key.
// If this is the first reference to the key, the AddFunc is called.
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {

View File

@@ -0,0 +1,47 @@
package refcounter
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
func TestReapplyMatching(t *testing.T) {
rc := New[netip.Prefix, string, string](
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := rc.Increment(prefix, peer)
require.NoError(t, err)
}
// a second reference must not make the key applied twice
_, err := rc.Increment(peerA1, "peerA")
require.NoError(t, err)
var applied []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "peerA" },
func(key netip.Prefix) error { applied = append(applied, key); return nil },
)
require.NoError(t, err)
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
var none []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "missing" },
func(key netip.Prefix) error { none = append(none, key); return nil },
)
require.NoError(t, err)
assert.Empty(t, none)
}

View File

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

View File

@@ -0,0 +1,12 @@
//go:build ios
package NetBirdSDK
import "github.com/netbirdio/netbird/version"
// GoClientVersion returns the NetBird Go client version that was baked into
// the framework at compile time via
// -ldflags "-X github.com/netbirdio/netbird/version.version=<version>".
func GoClientVersion() string {
return version.NetbirdVersion()
}

View File

@@ -1081,7 +1081,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
if err := s.cleanupConnection(); err != nil {
s.mutex.Unlock()
// todo review to update the status in case any type of error
if errors.Is(err, ErrServiceNotUp) {
log.Debugf("Down called while service not up: %v", err)
return nil, err
}
log.Errorf("failed to shut down properly: %v", err)
return nil, err
}
@@ -1154,7 +1157,7 @@ func (s *Server) cleanupConnection() error {
// making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
return err
log.Errorf("failed to stop engine during cleanup: %v", err)
}
}

View File

@@ -79,13 +79,15 @@ type Info struct {
EnableSSHLocalPortForwarding bool
EnableSSHRemotePortForwarding bool
DisableSSHAuth bool
SyncMessageVersion *int
}
func (i *Info) SetFlags(
rosenpassEnabled, rosenpassPermissive bool,
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
) {
@@ -103,6 +105,8 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.DisableIPv6 = disableIPv6
i.SyncMessageVersion = syncMessageVersion
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
}

View File

@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
// 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.
// have a footprint, so an update can never trigger a autostart entry write.
func netbirdFootprintExists() bool {
candidates := []string{
profilemanager.DefaultConfigPath,
@@ -69,9 +69,23 @@ func netbirdFootprintExists() bool {
// 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
// retrying autostart entry writes on every launch. A user's later disable in
// Settings is never overridden: the marker guarantees at-most-once, ever.
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
if mdmDisabled {
if enabled, err := autostart.IsEnabled(ctx); err != nil {
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
} else if enabled {
if err := autostart.SetEnabled(ctx, false); err != nil {
log.Warnf("MDM disableAutostart: force off failed: %v", err)
} else {
log.Info("MDM disableAutostart enforced: autostart turned off")
}
}
}
priorFootprint := netbirdFootprintExists() || prefsFileExisted
if prefs.Get().AutostartInitialized {
@@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
state := autostartDefaultState{
supported: autostart.Supported(ctx),
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
mdmDisabled: mdmDisabled,
priorInstall: priorFootprint,
}
enable, reason := shouldEnableAutostartDefault(state)

View File

@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Version } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
@@ -12,7 +15,26 @@ function openUrl(url: string) {
export const DaemonOutdatedOverlay = () => {
const { t } = useTranslation();
const { isDaemonOutdated } = useStatus();
const { status, isDaemonOutdated } = useStatus();
const [guiVersion, setGuiVersion] = useState<string>("-");
const clientVersion = status?.daemonVersion ?? "—";
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
useEffect(() => {
if (!isDaemonOutdated) return;
let cancelled = false;
Version.GUI()
.then((v) => {
if (!cancelled) setGuiVersion(v);
})
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
return () => {
cancelled = true;
};
}, [isDaemonOutdated]);
if (!isDaemonOutdated) return null;
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p className={"text-sm font-semibold text-nb-gray-100"}>
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: clientVersion })
)}
</p>
<p className={"text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
{t("daemon.outdated.download")}
</Button>
</div>
</div>

View File

@@ -28,6 +28,7 @@ type ProfileContextValue = {
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
switchProfileNoConnect: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
// Manage-profiles variant: switches without connecting, so the user can
// still adjust the management URL before bringing the connection up.
const switchProfileNoConnect = useCallback(
async (id: string) => {
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,

View File

@@ -45,7 +45,7 @@ export function ProfilesTab() {
activeProfileId,
loaded,
username,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -100,7 +100,7 @@ export function ProfilesTab() {
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
};
const handleDeregister = async (id: string, name: string) => {
@@ -129,14 +129,13 @@ export function ProfilesTab() {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
// not-yet-active profile before the switch makes it current.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(id);
await switchProfileNoConnect(id);
});
};

View File

@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
let offCancel: (() => void) | undefined;
// Return the dialog to its interactive state and dismiss the browser popup
const resetDialog = () => {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
};
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
if (outcome.kind === "cancel") {
waitPromise.cancel?.();
waitPromise.catch(() => {});
resetDialog();
return;
}
// Another surface owns this flow; keep the dialog open to retry.
if (outcome.result.preempted) {
resetDialog();
return;
}
// Close before the popup so the restore can't flash this window back.
WindowManager.CloseSessionExpiration().catch(console.error);
WindowManager.CloseRenewFlow().catch(console.error);
} catch (e) {
resetDialog();
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
}
}, [busy, t]);
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
});
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);

View File

@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
return (
<>
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Suchen Sie NetBird in der Taskleiste"
},
"welcome.titleMac": {
"message": "Suchen Sie NetBird in der Menüleiste"
},
"welcome.description": {
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.descriptionMac": {
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.continue": {
"message": "Weiter"
},
@@ -1293,10 +1299,13 @@
"message": "Dokumentation"
},
"daemon.outdated.title": {
"message": "NetBird-Dienst ist veraltet"
"message": "NetBird Client ist veraltet"
},
"daemon.outdated.description": {
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
},
"daemon.outdated.download": {
"message": "Neueste Version herunterladen"
},
"error.jwt_clock_skew": {
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."

View File

@@ -1377,11 +1377,19 @@
},
"welcome.title": {
"message": "Look for NetBird in your tray",
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
},
"welcome.titleMac": {
"message": "Look for NetBird in your menu bar",
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
},
"welcome.description": {
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step explaining the tray icon."
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
},
"welcome.descriptionMac": {
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
},
"welcome.continue": {
"message": "Continue",
@@ -1724,12 +1732,16 @@
"description": "Documentation link on the daemon-unavailable overlay."
},
"daemon.outdated.title": {
"message": "NetBird Service Is Outdated",
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
"message": "NetBird Client Is Outdated",
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
},
"daemon.outdated.description": {
"message": "Update the NetBird service to use this app.",
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
},
"daemon.outdated.download": {
"message": "Download Latest",
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
},
"error.jwt_clock_skew": {
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Busque NetBird en su bandeja del sistema"
},
"welcome.titleMac": {
"message": "Busque NetBird en su barra de menús"
},
"welcome.description": {
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.descriptionMac": {
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1293,10 +1299,13 @@
"message": "Documentación"
},
"daemon.outdated.title": {
"message": "El servicio de NetBird está desactualizado"
"message": "NetBird Client está desactualizado"
},
"daemon.outdated.description": {
"message": "Actualice el servicio de NetBird para usar esta aplicación."
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
},
"daemon.outdated.download": {
"message": "Descargar la última versión"
},
"error.jwt_clock_skew": {
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Cherchez NetBird dans votre barre détat système"
},
"welcome.titleMac": {
"message": "Cherchez NetBird dans votre barre des menus"
},
"welcome.description": {
"message": "NetBird se trouve dans votre barre détat système. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.descriptionMac": {
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.continue": {
"message": "Continuer"
},
@@ -1293,10 +1299,13 @@
"message": "Documentation"
},
"daemon.outdated.title": {
"message": "Le service NetBird est obsolète"
"message": "Le Client NetBird est obsolète"
},
"daemon.outdated.description": {
"message": "Mettez à jour le service NetBird pour utiliser cette application."
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
},
"daemon.outdated.download": {
"message": "Télécharger la dernière version"
},
"error.jwt_clock_skew": {
"message": "Échec de la connexion : lhorloge de cet appareil nest pas synchronisée avec le serveur. Veuillez synchroniser lhorloge de votre système et réessayer."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Keresse a NetBirdöt a tálcán"
},
"welcome.titleMac": {
"message": "Keresse a NetBirdöt a menüsorban"
},
"welcome.description": {
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.descriptionMac": {
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.continue": {
"message": "Folytatás"
},
@@ -1293,10 +1299,13 @@
"message": "Dokumentáció"
},
"daemon.outdated.title": {
"message": "A NetBird szolgáltatás elavult"
"message": "A NetBird Kliens elavult"
},
"daemon.outdated.description": {
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
},
"daemon.outdated.download": {
"message": "Legújabb letöltése"
},
"error.jwt_clock_skew": {
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Cerchi NetBird nella tray"
},
"welcome.titleMac": {
"message": "Cerchi NetBird nella barra dei menu"
},
"welcome.description": {
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.descriptionMac": {
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.continue": {
"message": "Continua"
},
@@ -1293,10 +1299,13 @@
"message": "Documentazione"
},
"daemon.outdated.title": {
"message": "Il servizio NetBird è obsoleto"
"message": "NetBird Client è obsoleto"
},
"daemon.outdated.description": {
"message": "Aggiorna il servizio NetBird per usare questa app."
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
},
"daemon.outdated.download": {
"message": "Scarica l'ultima versione"
},
"error.jwt_clock_skew": {
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "トレイの NetBird を確認してください"
},
"welcome.titleMac": {
"message": "メニューバーの NetBird を確認してください"
},
"welcome.description": {
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.descriptionMac": {
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.continue": {
"message": "続ける"
},

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Procure o NetBird na sua bandeja"
},
"welcome.titleMac": {
"message": "Procure o NetBird na sua barra de menus"
},
"welcome.description": {
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.descriptionMac": {
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1293,10 +1299,13 @@
"message": "Documentação"
},
"daemon.outdated.title": {
"message": "O serviço NetBird está desatualizado"
"message": "O NetBird Client está desatualizado"
},
"daemon.outdated.description": {
"message": "Atualize o serviço NetBird para usar este aplicativo."
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
},
"daemon.outdated.download": {
"message": "Baixar a versão mais recente"
},
"error.jwt_clock_skew": {
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Найдите NetBird в системном трее"
},
"welcome.titleMac": {
"message": "Найдите NetBird в строке меню"
},
"welcome.description": {
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.descriptionMac": {
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.continue": {
"message": "Продолжить"
},
@@ -1293,10 +1299,13 @@
"message": "Документация"
},
"daemon.outdated.title": {
"message": "Служба NetBird устарела"
"message": "Клиент NetBird устарел"
},
"daemon.outdated.description": {
"message": "Обновите службу NetBird, чтобы использовать это приложение."
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
},
"daemon.outdated.download": {
"message": "Скачать последнюю версию"
},
"error.jwt_clock_skew": {
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "在托盘中查找 NetBird"
},
"welcome.titleMac": {
"message": "在菜单栏中查找 NetBird"
},
"welcome.description": {
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.descriptionMac": {
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.continue": {
"message": "继续"
},
@@ -1293,10 +1299,13 @@
"message": "文档"
},
"daemon.outdated.title": {
"message": "NetBird 服务版本过旧"
"message": "NetBird 客户端版本过旧"
},
"daemon.outdated.description": {
"message": "请更新 NetBird 服务以使用应用。"
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用应用。"
},
"daemon.outdated.download": {
"message": "下载最新版本"
},
"error.jwt_clock_skew": {
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"

View File

@@ -10,7 +10,7 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// Autostart facade over Wails' AutostartManager. The OS login-item registration
// Autostart facade over Wails' AutostartManager. The OS autostart entry registration
// is the single source of truth; nothing is mirrored to preferences.
type Autostart struct {
mgr *application.AutostartManager

View File

@@ -12,13 +12,15 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// ProfileSwitcher holds the reconnect policy shared by the tray and React
// frontend so both flip profiles identically. The policy keys off prevStatus
// from DaemonFeed.Get at SwitchActive entry:
// ProfileSwitcher holds the switch policy shared by the tray and React
// frontend so both flip profiles identically. SwitchActive (plain selection:
// header dropdown, tray submenu) always connects after the switch;
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
// still adjust the management URL before connecting. prevStatus from
// DaemonFeed.Get at entry only decides the teardown:
//
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
// Idle → Switch only.
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
// Idle → no Down.
type ProfileSwitcher struct {
profiles *Profiles
connection *Connection
@@ -29,29 +31,40 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
}
// SwitchActive switches to the named profile applying the reconnect policy.
// SwitchActive switches to the named profile and always connects afterwards.
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
return s.switchActive(ctx, p, true)
}
// SwitchActiveNoConnect switches to the named profile without connecting,
// tearing down any existing connection first.
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
return s.switchActive(ctx, p, false)
}
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
prevStatus := ""
if st, err := s.feed.Get(ctx); err == nil {
prevStatus = st.Status
} else {
log.Warnf("profileswitcher: get status: %v", err)
if s.feed != nil {
if st, err := s.feed.Get(ctx); err == nil {
prevStatus = st.Status
} else {
log.Warnf("profileswitcher: get status: %v", err)
}
}
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
strings.EqualFold(prevStatus, StatusConnecting)
needsDown := wasActive ||
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
strings.EqualFold(prevStatus, StatusConnecting) ||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
strings.EqualFold(prevStatus, StatusSessionExpired)
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
p.ProfileName, prevStatus, wasActive, needsDown)
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
p.ProfileName, prevStatus, connect, needsDown)
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
// stale Connected + transient Idle pushes during Down that must be
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
if wasActive {
// Optimistic Connecting paint plus stale-push suppression during Down (see
// DaemonFeed suppression table); also arms the login-watch that pops
// browser-login when the new profile turns out to need SSO.
if connect && s.feed != nil {
s.feed.BeginProfileSwitch()
}
@@ -76,9 +89,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
}
}
if wasActive {
if connect {
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
}
}

View File

@@ -185,37 +185,38 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.hideOtherWindowsLocked("browser-login")
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
var screen *application.Screen
if s.mainWindow != nil {
if sc, err := s.mainWindow.GetScreen(); err == nil {
screen = sc
}
}
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
// Not always-on-top: it would obscure the browser tab the user logs in through.
opts.AlwaysOnTop = false
opts.InitialPosition = application.WindowCentered
opts.Screen = screen
// Open on the active (where users cursor is) display, like the session-expiration dialog.
opts.Screen = s.getScreenBasedOnCursorPosition()
s.browserLogin = s.app.Window.NewWithOptions(opts)
bl := s.browserLogin
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.app.Event.Emit(EventBrowserLoginCancel)
s.mu.Lock()
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
// Only a live user red-X still has this registered; programmatic closers
// nil s.browserLogin first and clean up themselves. Guarding here stops a
// stale close event from wiping a replacement popup's state.
userClosed := s.browserLogin == bl
if userClosed {
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
}
s.mu.Unlock()
if userClosed {
s.app.Event.Emit(EventBrowserLoginCancel)
}
})
s.centerWhenReady(s.browserLogin)
s.centerOnCursorScreen(s.browserLogin)
return
}
if uri != "" {
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
}
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.centerWhenReady(s.browserLogin)
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -238,6 +239,15 @@ func (s *WindowManager) CloseBrowserLogin() {
s.mu.Lock()
w := s.browserLogin
s.browserLogin = nil
// The WindowClosing hook no-ops on a programmatic close, so restore here —
// but only if a popup was actually open. The frontend calls this even when no
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
// or connection.ts's catch path), and hiddenForLogin is shared with
// OpenInstallProgress, so an unconditional restore could re-show windows a
// still-running install-progress is hiding.
if w != nil {
s.restoreHiddenWindowsLocked()
}
s.mu.Unlock()
if w != nil {
w.Close()
@@ -279,6 +289,35 @@ func (s *WindowManager) CloseSessionExpiration() {
}
}
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
// closes the browser-login popup and the session-expiration window together.
func (s *WindowManager) CloseRenewFlow() {
s.mu.Lock()
bl := s.browserLogin
se := s.sessionExpiration
s.browserLogin = nil
s.sessionExpiration = nil
if se != nil {
kept := s.hiddenForLogin[:0]
for _, w := range s.hiddenForLogin {
if w != se {
kept = append(kept, w)
}
}
s.hiddenForLogin = kept
}
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
// Close after unlock so the re-entrant handlers can take s.mu.
if bl != nil {
bl.Close()
}
if se != nil {
se.Close()
}
}
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
func (s *WindowManager) OpenInstallProgress(version string) {

View File

@@ -30,6 +30,8 @@ const (
statusError = "Error"
quitDownTimeout = 5 * time.Second
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
urlDocs = "https://docs.netbird.io"
@@ -315,8 +317,7 @@ func (t *Tray) relayoutMenu() {
if sessionDeadline.IsZero() {
t.sessionExpiresItem.SetHidden(true)
} else {
remaining := t.formatSessionRemaining(time.Until(sessionDeadline))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline))
t.sessionExpiresItem.SetHidden(false)
}
}
@@ -446,11 +447,28 @@ func (t *Tray) buildMenu() *application.Menu {
menu.AddSeparator()
menu.Add(t.loc.T("tray.menu.quit")).
SetAccelerator("CmdOrCtrl+Q").
OnClick(func(*application.Context) { t.app.Quit() })
OnClick(func(*application.Context) { t.handleQuit() })
return menu
}
func (t *Tray) handleQuit() {
t.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
t.switchCancel = nil
}
t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch()
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
}
t.app.Quit()
}
// handleConnect receives the clicked item from the buildMenu closure —
// t.upItem is menuMu-guarded and must not be read here.
func (t *Tray) handleConnect(upItem *application.MenuItem) {

View File

@@ -64,11 +64,42 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool {
return changed
}
// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit.
// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit.
// The interval scales with the remaining time: coarse when the deadline is far off,
// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded
// countdown near expiry. The cached deadline is re-read every iteration, so an extend
// or reconnect that moves it is picked up on the next tick.
func (t *Tray) runSessionExpiryTicker() {
tk := time.NewTicker(30 * time.Second)
for range tk.C {
tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining()))
defer tm.Stop()
for range tm.C {
t.refreshSessionExpiresLabel()
tm.Reset(sessionRefreshInterval(t.sessionRemaining()))
}
}
// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown.
func (t *Tray) sessionRemaining() time.Duration {
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return 0
}
return time.Until(deadline)
}
// sessionRefreshInterval picks how long to wait before the next label recompute.
func sessionRefreshInterval(remaining time.Duration) time.Duration {
switch {
case remaining <= 0:
return 30 * time.Second
case remaining <= 2*time.Minute:
return 10 * time.Second
case remaining <= time.Hour:
return 30 * time.Second
default:
return time.Minute
}
}
@@ -87,30 +118,39 @@ func (t *Tray) refreshSessionExpiresLabel() {
if deadline.IsZero() {
return
}
remaining := t.formatSessionRemaining(time.Until(deadline))
item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
item.SetLabel(t.sessionRowLabel(deadline))
}
func (t *Tray) sessionRowLabel(deadline time.Time) string {
remaining := time.Until(deadline)
if remaining <= 0 {
return t.loc.T("tray.status.sessionExpired")
}
return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining))
}
// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit.
// Each unit is rounded up so the label never claims less time than actually remains, matching the
// upper-bound sense of the sub-minute "less than a minute" fragment.
// Singular/plural keys are split per language for proper translation.
func (t *Tray) formatSessionRemaining(d time.Duration) string {
switch {
case d < time.Minute:
return t.loc.T("tray.session.unit.lessThanMinute")
case d < time.Hour:
m := int(d / time.Minute)
case d <= 59*time.Minute:
m := ceilDiv(d, time.Minute)
if m == 1 {
return t.loc.T("tray.session.unit.minute")
}
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
case d < 24*time.Hour:
h := int((d + 30*time.Minute) / time.Hour)
case d <= 23*time.Hour:
h := ceilDiv(d, time.Hour)
if h == 1 {
return t.loc.T("tray.session.unit.hour")
}
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
default:
days := int((d + 12*time.Hour) / (24 * time.Hour))
days := ceilDiv(d, 24*time.Hour)
if days == 1 {
return t.loc.T("tray.session.unit.day")
}
@@ -118,6 +158,11 @@ func (t *Tray) formatSessionRemaining(d time.Duration) string {
}
}
// ceilDiv divides d by unit rounding up, assuming d > 0.
func ceilDiv(d, unit time.Duration) int {
return int((d + unit - time.Nanosecond) / unit)
}
// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning.
// Errors are swallowed since the worst case is a plain notification without buttons.
func (t *Tray) registerSessionWarningCategory() {
@@ -252,11 +297,9 @@ func (t *Tray) openSessionExpiration() {
}
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed.
// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the
// click routes to the login flow instead. No-op when the deadline is unknown.
func (t *Tray) openSessionExtendFlow() {
if t.svc.WindowManager == nil {
return
}
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
@@ -265,6 +308,14 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
return
}
if t.svc.WindowManager == nil {
return
}
t.svc.WindowManager.OpenSessionExpiration(seconds)

151
combined/cmd/admin.go Normal file
View File

@@ -0,0 +1,151 @@
package cmd
import (
"context"
"fmt"
"github.com/dexidp/dex/storage"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/formatter/hook"
admincmd "github.com/netbirdio/netbird/management/cmd/admin"
tokencmd "github.com/netbirdio/netbird/management/cmd/token"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
)
// newAdminCommands creates the admin command tree with combined-specific resource openers.
func newAdminCommands() *cobra.Command {
return admincmd.NewCommands(admincmd.Openers{
Resources: withAdminResources,
Store: withAdminStoreOnly,
IDP: withAdminIDPOnly,
})
}
func newLegacyTokenCommand() *cobra.Command {
cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly))
cmd.Deprecated = "use 'admin token' instead"
return cmd
}
// withAdminResources loads the combined YAML config, initializes stores, and calls fn.
func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
mgmtConfig, err := adminManagementConfig(cfg)
if err != nil {
return err
}
managementStore, err := openAdminStore(ctx, cfg)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig)
if esErr != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr)
}
if eventStore != nil {
defer func() {
if err := eventStore.Close(ctx); err != nil {
log.Debugf("close activity event store: %v", err)
}
}()
}
return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore})
})
}
// withAdminStoreOnly opens only the management store for admin subcommands that do not
// need embedded IdP storage.
func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
managementStore, err := openAdminStore(ctx, cfg)
if err != nil {
return err
}
defer admincmd.CloseStore(ctx, managementStore)
return fn(ctx, managementStore)
})
}
func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error {
mgmtConfig, err := adminManagementConfig(cfg)
if err != nil {
return err
}
idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig)
if err != nil {
return err
}
defer admincmd.CloseIDPStorage(idpStorage)
return fn(ctx, idpStorage, idpStorageFile)
})
}
func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) error) error {
if err := util.InitLog("error", "console"); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck
cfg, err := LoadConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg.ApplyAdminDefaults()
applyServerStoreEnv(cfg.Server.Store)
return fn(ctx, cfg)
}
func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) {
mgmtConfig, err := cfg.ToManagementConfig()
if err != nil {
return nil, fmt.Errorf("create management config: %w", err)
}
return mgmtConfig, nil
}
func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) {
managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true)
if err != nil {
return nil, fmt.Errorf("create store: %w", err)
}
return managementStore, nil
}
func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) {
if config.DataStoreEncryptionKey == "" {
return nil, fmt.Errorf("data store encryption key is not configured")
}
if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil {
return nil, fmt.Errorf("configure activity event store: %w", err)
}
eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey)
if err != nil {
return nil, fmt.Errorf("open activity event store: %w", err)
}
if eventStore == nil {
return nil, fmt.Errorf("open activity event store: returned nil store")
}
return eventStore, nil
}

View File

@@ -0,0 +1,47 @@
package cmd
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/require"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
)
func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) {
cfg := DefaultConfig()
cfg.Server.ExposedAddress = ""
cfg.Server.DataDir = "/srv/netbird"
cfg.Server.Store = StoreConfig{
Engine: "postgres",
DSN: "postgres://user:pass@example.com/netbird",
}
cfg.ApplyAdminDefaults()
require.Equal(t, "/srv/netbird", cfg.Management.DataDir)
require.Equal(t, "postgres", cfg.Management.Store.Engine)
require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN)
}
func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) {
eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{})
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key")
require.Nil(t, eventStore)
}
func TestApplyServerStoreEnv(t *testing.T) {
t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "")
t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "")
t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "")
applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"})
require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN"))
require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE"))
applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"})
require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN"))
}

View File

@@ -6,8 +6,7 @@ import (
"net"
"net/netip"
"os"
"path"
"path/filepath"
filePath "path/filepath"
"strings"
"time"
@@ -74,6 +73,9 @@ type ServerConfig struct {
ActivityStore StoreConfig `yaml:"activityStore"`
AuthStore StoreConfig `yaml:"authStore"`
ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"`
SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"`
PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"`
}
// TLSConfig contains TLS/HTTPS settings
@@ -300,6 +302,19 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() {
c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal)
}
// ApplyAdminDefaults applies the management settings needed by admin commands even
// when the full server config is invalid and ApplySimplifiedDefaults cannot run.
func (c *CombinedConfig) ApplyAdminDefaults() {
if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" {
c.Management.DataDir = c.Server.DataDir
}
if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" {
if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" {
c.Management.Store = c.Server.Store
}
}
}
// applyRelayDefaults configures the relay service if no external relay is configured.
func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) {
if hasExternalRelay {
@@ -577,11 +592,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres")
}
} else {
authStorageFile = path.Join(mgmt.DataDir, "idp.db")
authStorageFile = filePath.Join(mgmt.DataDir, "idp.db")
if c.Server.AuthStore.File != "" {
authStorageFile = c.Server.AuthStore.File
if !filepath.IsAbs(authStorageFile) {
authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile)
if !filePath.IsAbs(authStorageFile) {
authStorageFile = filePath.Join(mgmt.DataDir, authStorageFile)
}
}
}
@@ -696,16 +711,18 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull
return &nbconfig.Config{
Stuns: stuns,
Relay: relayConfig,
Signal: signalConfig,
Datadir: mgmt.DataDir,
DataStoreEncryptionKey: mgmt.Store.EncryptionKey,
HttpConfig: httpConfig,
StoreConfig: storeConfig,
ReverseProxy: reverseProxy,
DisableDefaultPolicy: mgmt.DisableDefaultPolicy,
EmbeddedIdP: embeddedIdP,
Stuns: stuns,
Relay: relayConfig,
Signal: signalConfig,
Datadir: mgmt.DataDir,
DataStoreEncryptionKey: mgmt.Store.EncryptionKey,
HttpConfig: httpConfig,
StoreConfig: storeConfig,
ReverseProxy: reverseProxy,
DisableDefaultPolicy: mgmt.DisableDefaultPolicy,
EmbeddedIdP: embeddedIdP,
HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions,
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
}, nil
}
@@ -729,7 +746,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort
cfg.EmbeddedIdP.Storage.Type = "sqlite3"
}
if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" {
cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db")
cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db")
}
issuer := cfg.EmbeddedIdP.Issuer

View File

@@ -31,6 +31,7 @@ import (
relayServer "github.com/netbirdio/netbird/relay/server"
"github.com/netbirdio/netbird/relay/server/listener"
"github.com/netbirdio/netbird/relay/server/listener/ws"
syncgrpc "github.com/netbirdio/netbird/shared/management/grpc"
sharedMetrics "github.com/netbirdio/netbird/shared/metrics"
"github.com/netbirdio/netbird/shared/relay/auth"
"github.com/netbirdio/netbird/shared/signal/proto"
@@ -64,7 +65,8 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)")
_ = rootCmd.MarkPersistentFlagRequired("config")
rootCmd.AddCommand(newTokenCommands())
rootCmd.AddCommand(newAdminCommands())
rootCmd.AddCommand(newLegacyTokenCommand())
}
func RootCmd() *cobra.Command {
@@ -122,6 +124,37 @@ func execute(cmd *cobra.Command, _ []string) error {
}
// initializeConfig loads and validates the configuration, then initializes logging.
func applyServerStoreEnv(storeConfig StoreConfig) {
if dsn := storeConfig.DSN; dsn != "" {
switch strings.ToLower(storeConfig.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := storeConfig.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
}
func applyActivityStoreEnv(storeConfig StoreConfig) error {
if engine := storeConfig.Engine; engine != "" {
engineLower := strings.ToLower(engine)
if engineLower == "postgres" && storeConfig.DSN == "" {
return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres")
}
os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower)
if dsn := storeConfig.DSN; dsn != "" {
os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn)
}
}
if file := storeConfig.File; file != "" {
os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file)
}
return nil
}
func initializeConfig() error {
var err error
config, err = LoadConfig(configPath)
@@ -137,30 +170,10 @@ func initializeConfig() error {
return fmt.Errorf("failed to initialize log: %w", err)
}
if dsn := config.Server.Store.DSN; dsn != "" {
switch strings.ToLower(config.Server.Store.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := config.Server.Store.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
applyServerStoreEnv(config.Server.Store)
if engine := config.Server.ActivityStore.Engine; engine != "" {
engineLower := strings.ToLower(engine)
if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" {
return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres")
}
os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower)
if dsn := config.Server.ActivityStore.DSN; dsn != "" {
os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn)
}
}
if file := config.Server.ActivityStore.File; file != "" {
os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file)
if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil {
return err
}
log.Infof("Starting combined NetBird server")
@@ -505,6 +518,16 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
}
mgmtPort, _ := strconv.Atoi(portStr)
if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil {
return nil, err
}
for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion {
if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil {
return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err)
}
}
mgmtSrv := newServer(
&mgmtServer.Config{
NbConfig: mgmtConfig,

View File

@@ -1,63 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/formatter/hook"
tokencmd "github.com/netbirdio/netbird/management/cmd/token"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/util"
)
// newTokenCommands creates the token command tree with combined-specific store opener.
func newTokenCommands() *cobra.Command {
return tokencmd.NewCommands(withTokenStore)
}
// withTokenStore loads the combined YAML config, initializes the store, and calls fn.
func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
if err := util.InitLog("error", "console"); err != nil {
return fmt.Errorf("init log: %w", err)
}
ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck
cfg, err := LoadConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if dsn := cfg.Server.Store.DSN; dsn != "" {
switch strings.ToLower(cfg.Server.Store.Engine) {
case "postgres":
os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn)
case "mysql":
os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn)
}
}
if file := cfg.Server.Store.File; file != "" {
os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file)
}
datadir := cfg.Management.DataDir
engine := types.Engine(cfg.Management.Store.Engine)
s, err := store.NewStore(ctx, engine, datadir, nil, true)
if err != nil {
return fmt.Errorf("create store: %w", err)
}
defer func() {
if err := s.Close(ctx); err != nil {
log.Debugf("close store: %v", err)
}
}()
return fn(ctx, s)
}

View File

@@ -53,6 +53,7 @@ type NameServerGroup struct {
ID string `gorm:"primaryKey"`
// AccountID is a reference to Account that this object belongs
AccountID string `gorm:"index"`
PublicID string `json:"-"`
// Name group name
Name string
// Description group description

View File

@@ -66,6 +66,9 @@
<key>disableAutoConnect</key>
<false/>
<key>disableAutostart</key>
<false/>
<key>disableClientRoutes</key>
<false/>

View File

@@ -103,6 +103,8 @@
<!--
<key>disableAutoConnect</key>
<false/>
<key>disableAutostart</key>
<false/>
<key>disableClientRoutes</key>
<false/>
<key>disableServerRoutes</key>

View File

@@ -58,6 +58,7 @@ preSharedKey="$NULL" # secret; redacted in log
allowServerSSH='true'
blockInbound="$NULL"
disableAutoConnect="$NULL"
disableAutostart="$NULL"
disableClientRoutes="$NULL"
disableServerRoutes="$NULL"
disableMetricsCollection="$NULL"
@@ -155,6 +156,7 @@ main() {
is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart"
is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"

Binary file not shown.

View File

@@ -24,6 +24,9 @@
<string id="DisableAutoConnect_Name">Disable auto-connect</string>
<string id="DisableAutoConnect_Help">When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.</string>
<string id="DisableAutostart_Name">Disable autostart</string>
<string id="DisableAutostart_Help">When enabled, the NetBird GUI is prevented from registering itself as an OS autostart entry on fresh installs, and any existing OS autostart entry registration is removed on the next GUI launch (Windows Registry Run key, macOS Login Item, Linux .desktop). Once the admin lifts the policy, the setting stays off until the user re-enables it in Settings.</string>
<string id="DisableClientRoutes_Name">Disable client routes</string>
<string id="DisableClientRoutes_Help">When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.</string>

View File

@@ -64,6 +64,18 @@
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableAutostart"
class="Machine"
displayName="$(string.DisableAutostart_Name)"
explainText="$(string.DisableAutostart_Help)"
key="Software\Policies\NetBird"
valueName="DisableAutostart">
<parentCategory ref="NetBird" />
<supportedOn ref="SUPPORTED_NetBird_All" />
<enabledValue><decimal value="1" /></enabledValue>
<disabledValue><decimal value="0" /></disabledValue>
</policy>
<policy name="DisableClientRoutes"
class="Machine"
displayName="$(string.DisableClientRoutes_Name)"

View File

@@ -9,25 +9,234 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate
// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists.
type per1k struct{ in, out, read, write float64 }
// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive,
// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's
// pricing table so a wrong embedded rate or a broken normalization fails the run.
var publishedPer1k = map[string]per1k{
"gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
"gpt-4o": {0.0025, 0.01, 0.00125, 0},
"claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
"claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
"claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
"kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate
"anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
"anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
"anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
}
// rawCostVerificationSQL is the operator-facing double-check, run straight against the management
// sqlite store: recompute each usage row's expected total and cache cost from its own persisted
// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input;
// Anthropic-shape providers count cache buckets additively.
const rawCostVerificationSQL = `
WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
VALUES
('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0),
('gpt-4o', 0.0025, 0.01, 0.00125, 0.0),
('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375),
('kimi-k3', 0.003, 0.015, 0.0003, 0.003),
('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375)
)
SELECT
u.provider,
u.model,
u.input_tokens,
u.output_tokens,
u.cached_input_tokens,
u.cache_creation_tokens,
u.input_cost_usd,
u.cached_input_cost_usd,
u.cache_creation_cost_usd,
u.output_cost_usd,
-- No cost_usd / cache_cost_usd columns are stored: both are derived from the
-- four per-bucket columns above, exactly as the API renders them.
(u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd,
(u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd,
CASE WHEN u.provider = 'openai' THEN
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
ELSE
u.input_tokens*r.in_rate/1000.0
END AS expected_input,
CASE WHEN u.provider = 'openai' THEN
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
ELSE
u.cached_input_tokens*r.read_rate/1000.0
END AS expected_cached_input,
CASE WHEN u.provider = 'openai' THEN
0.0
ELSE
u.cache_creation_tokens*r.write_rate/1000.0
END AS expected_cache_creation,
u.output_tokens*r.out_rate/1000.0 AS expected_output,
CASE WHEN u.provider = 'openai' THEN
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
+ MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
+ u.output_tokens*r.out_rate/1000.0
ELSE
u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0
+ u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0
END AS expected_total,
CASE WHEN u.provider = 'openai' THEN
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
ELSE
u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0
END AS expected_cache
FROM agent_network_request_usage u
JOIN rates r ON r.model = u.model
ORDER BY u.timestamp`
// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store,
// bypassing the API path — the same audit an operator can run on a production store.db.
func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
t.Helper()
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
require.NoError(t, err, "snapshot management sqlite store")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err, "open store snapshot")
sqlDB, err := db.DB()
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
rows, err := db.Raw(rawCostVerificationSQL).Rows()
require.NoError(t, err, "run raw cost verification query")
defer func() { _ = rows.Close() }()
verified := 0
for rows.Next() {
var provider, model string
var inTok, outTok, readTok, writeTok int64
var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64
var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64
require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok,
&inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost,
&wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row")
t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f",
provider, model, inTok, outTok, readTok, writeTok,
inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache)
assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9,
"stored buckets must sum to the derived cost_usd for %s/%s", provider, model)
verified++
}
require.NoError(t, rows.Err(), "iterate usage rows")
require.Positive(t, verified, "raw SQL check must cover at least one usage row")
t.Logf("[sql] verified %d usage rows in store.db against published rates", verified)
gwRows, err := db.Raw(`SELECT model,
(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd
FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows()
require.NoError(t, err, "query gateway-prefixed usage rows")
defer func() { _ = gwRows.Close() }()
for gwRows.Next() {
var model string
var cost float64
require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row")
t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost)
assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model)
}
require.NoError(t, gwRows.Err(), "iterate gateway usage rows")
}
// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the
// published per-1k rates and the row's persisted token buckets, and asserts both stored values.
// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0.
func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) {
t.Helper()
model := catalogModel(pc)
provider := ""
if row.Provider != nil {
provider = *row.Provider
}
t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f",
pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens,
row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd)
rates, known := publishedPer1k[model]
if !known {
if strings.Contains(model, "/") {
assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model)
return
}
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
return
}
// input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache
// buckets only. Output and total must always be present on a priced row.
require.Positive(t, row.OutputTokens, "priced row must carry output tokens")
require.Positive(t, row.TotalTokens, "priced row must carry total tokens")
var wantInput, wantCachedInput, wantCacheCreation float64
if provider == "openai" {
cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input
wantInput = float64(row.InputTokens-cached) / 1000 * rates.in
wantCachedInput = float64(cached) / 1000 * rates.read
// OpenAI has no cache-write bucket; wantCacheCreation stays 0.
} else {
// Anthropic / Bedrock shape: cache buckets are additive to input_tokens.
wantInput = float64(row.InputTokens) / 1000 * rates.in
wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read
wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write
}
wantOutput := float64(row.OutputTokens) / 1000 * rates.out
wantCache := wantCachedInput + wantCacheCreation
wantTotal := wantInput + wantCache + wantOutput
t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates",
pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache)
assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model)
// The aggregates must be exactly the sum of the stored components, not an
// independently-computed figure that could drift from the breakdown.
assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd,
row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd,
row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model)
}
// providerCase is one entry in the live provider matrix. The same scenario runs
// for every available provider; availability is keyed off env vars so the suite
// covers whatever credentials are present (source ~/.llm-keys locally / set the
// Actions secrets in CI).
type providerCase struct {
name string
catalogID string
upstream string
apiKey string
model string // body model (chat/messages) or path model@version (vertex)
kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex
project string // vertex only: GCP project for the rawPredict path
region string // vertex only: GCP region for the rawPredict path
name string
catalogID string
upstream string
apiKey string
model string // body model (chat/messages) or path model@version (vertex)
kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex
project string // vertex only: GCP project for the rawPredict path
region string // vertex only: GCP region for the rawPredict path
pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi)
}
// availableProviders builds the matrix from the provider env vars that are set.
@@ -39,6 +248,24 @@ func availableProviders() []providerCase {
if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" {
ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages})
}
if k := os.Getenv("KIMI_TOKEN"); k != "" {
// Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI
// Chat Completions on the bare host (/v1/...) and the Anthropic
// Messages API under the /anthropic path prefix (the endpoint
// Moonshot's Claude Code guide uses). The provider keeps the bare
// default upstream and the AGENT carries the /anthropic prefix in
// its base URL — exactly the documented Claude Code / Kimi CLI
// setup (ANTHROPIC_BASE_URL=https://<endpoint>/anthropic) — so one
// provider serves both shapes and the prefix rides through to
// Moonshot. Run the Anthropic shape, the flagship Claude Code path;
// the OpenAI wire shape is covered live by the other chat-shaped
// matrix providers, and Kimi-over-chat passed with kimi-k3 before
// the single-model constraint surfaced (run #73 on the kimi feature
// branch). The platform serves this account exactly ONE model —
// kimi-k3 (kimi-k2-thinking and even kimi-latest return
// resource_not_found_error on both surfaces).
ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"})
}
if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" {
ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat})
}
@@ -84,14 +311,27 @@ func availableProviders() []providerCase {
}
}
// Bedrock: path-routed, bearer auth. Model is a cross-region inference
// profile id (distinct string from the first-party Anthropic case).
// Bedrock: path-routed, bearer auth. Model is the FULL cross-region
// inference-profile id exactly as AWS issues it — region-family prefix
// plus the date/version suffix. A bare or wrong-region id makes Bedrock
// reject the request with "The provided model identifier is invalid"
// before any inference runs. The proxy normalizes this id to the catalog
// key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists.
// Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides
// the region and the prefix follows its family.
if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" {
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-1"
region = "eu-central-1"
}
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: "us.anthropic.claude-haiku-4-5", kind: harness.WireBedrock})
// A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the
// workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to
// Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries.
model := os.Getenv("AWS_BEDROCK_MODEL")
if model == "" {
model = "global.anthropic.claude-sonnet-4-6"
}
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
}
return ps
}
@@ -108,8 +348,16 @@ func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
Enabled: ptr(true),
}
if pc.kind != harness.WireVertex {
// The router matches the normalized catalog id. Bedrock's request model
// travels as a region-prefixed inference-profile id in the URL path
// (us.anthropic...), which the router strips before matching, so register
// the normalized form here or routing fails as model_not_routable.
modelID := pc.model
if pc.kind == harness.WireBedrock {
modelID = catalogModel(pc)
}
req.Models = &[]api.AgentNetworkProviderModel{
{Id: pc.model, InputPer1k: 0.001, OutputPer1k: 0.002},
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
}
}
return req
@@ -201,11 +449,12 @@ func TestProvidersMatrix(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
for _, pc := range matrix {
pc := pc
@@ -216,6 +465,10 @@ func TestProvidersMatrix(t *testing.T) {
// session id and confirm the marker propagated end-to-end.
sessionID := "e2e-session-" + pc.name
// A long-form prompt so completions carry realistic token counts for cost validation;
// max_tokens in the harness bodies (2048) lets the full answer through.
const matrixPrompt = "explain GitHub workflow in 1000 words"
// Retry briefly to absorb tunnel/DNS jitter on the first call.
var code int
var body string
@@ -226,11 +479,11 @@ func TestProvidersMatrix(t *testing.T) {
var cerr error
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)
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID)
case harness.WireBedrock:
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID)
default:
c, b, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID)
}
if cerr == nil {
code, body = c, b
@@ -249,6 +502,7 @@ func TestProvidersMatrix(t *testing.T) {
// The session id sent as x-session-id must round-trip into the
// access-log row for this provider.
var row api.AgentNetworkAccessLog
require.Eventually(t, func() bool {
logs, lerr := srv.ListAccessLogs(ctx)
if lerr != nil {
@@ -256,11 +510,15 @@ func TestProvidersMatrix(t *testing.T) {
}
for _, r := range logs.Data {
if r.SessionId != nil && *r.SessionId == sessionID {
row = r
return true
}
}
return false
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name)
// Stored total and cache cost must match the published rates applied to the row's buckets.
validateAccessLogCost(t, pc, row)
})
}
@@ -281,4 +539,7 @@ func TestProvidersMatrix(t *testing.T) {
}
return false
}, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic")
// Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store.
verifyUsageRowsSQL(t, srv)
}

View File

@@ -4,6 +4,7 @@ package agentnetwork
import (
"context"
"regexp"
"strings"
"testing"
"time"
@@ -15,13 +16,29 @@ import (
"github.com/netbirdio/netbird/shared/management/http/api"
)
// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock
// model normalization (region/inference-profile prefix + version suffix) so the
// provider is registered under the same catalog key the router matches against.
var (
bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
)
// catalogModel returns the normalized catalog id the proxy stamps for a
// path-routed provider's configured model — the form the guardrail allowlist is
// compared against (region prefix / @version stripped).
// path-routed provider's configured model — the form the router and guardrail
// allowlist compare against (Bedrock region prefix + version stripped, Vertex
// @version stripped).
func catalogModel(pc providerCase) string {
switch pc.kind {
case harness.WireBedrock:
return strings.TrimPrefix(pc.model, "us.")
m := pc.model
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
return bedrockVersionSuffix.ReplaceAllString(m, "")
case harness.WireVertex:
return strings.SplitN(pc.model, "@", 2)[0]
default:
@@ -35,7 +52,9 @@ func catalogModel(pc providerCase) string {
func disallowedModel(pc providerCase) string {
switch pc.kind {
case harness.WireBedrock:
return "us.anthropic.claude-opus-4-8"
// Same profile prefix as the allowed model so only the model name
// differs; the guardrail must deny it before it reaches AWS.
return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8"
case harness.WireVertex:
return "claude-opus-4-8@20250101"
default:
@@ -55,7 +74,7 @@ func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint,
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", "")
code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "")
}
require.NoError(t, err, "request must reach the proxy for %s", pc.name)
return code
@@ -147,11 +166,12 @@ func TestModelAllowlistEnforced(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
for _, pc := range providers {
pc := pc

View File

@@ -104,11 +104,12 @@ func TestProviderSkipTLSVerification(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
// Positive: skip=true reaches the self-signed upstream. Retry to absorb
// tunnel/DNS jitter on the first call; success also proves the path works.

View File

@@ -106,11 +106,12 @@ func TestVLLMProvider(t *testing.T) {
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
}
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
require.NoError(t, err, "resolve endpoint to proxy IP")
before, _ := srv.ListAccessLogs(ctx)
sessionID := "e2e-session-vllm"

View File

@@ -4,6 +4,7 @@ package harness
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
@@ -167,22 +168,54 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st
return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last)
}
// ResolveProxyIP resolves the agent-network endpoint to the proxy peer's
// NetBird IP from inside the client (via magic DNS).
const (
// curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures.
curlExitCouldNotResolve = 6
// dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure.
dnsProbeRetryWindow = 30 * time.Second
dnsProbeRetryInterval = 2 * time.Second
)
// ResolveProxyIP GETs https://<endpoint>/ from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning.
func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) {
code, reader, err := cl.container.Exec(ctx, []string{"getent", "hosts", endpoint}, tcexec.Multiplexed())
if err != nil {
return "", err
args := []string{
"run", "--rm",
"--network", "container:" + cl.container.GetContainerID(),
curlImage,
"-ksS", "-o", "/dev/null",
"--connect-timeout", "30", "--max-time", "60",
"-w", "%{remote_ip}",
"https://" + endpoint + "/",
}
out, _ := io.ReadAll(reader)
if code != 0 {
return "", fmt.Errorf("getent hosts %s exited %d", endpoint, code)
deadline := time.Now().Add(dnsProbeRetryWindow)
for {
cmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
ip := strings.TrimSpace(stdout.String())
if ip == "" {
return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint)
}
return ip, nil
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve {
return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String()))
}
dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String()))
if time.Until(deadline) < dnsProbeRetryInterval {
return "", dnsErr
}
select {
case <-ctx.Done():
return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err())
case <-time.After(dnsProbeRetryInterval):
}
}
fields := strings.Fields(string(out))
if len(fields) == 0 {
return "", fmt.Errorf("no address for %s", endpoint)
}
return fields[0], nil
}
// Wire shapes for Chat.
@@ -206,18 +239,29 @@ const (
// the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty
// sessionID is sent as the universal x-session-id header the proxy records.
func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) {
return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID)
}
// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire
// path, mirroring agents whose base URL carries a shape-selecting prefix that
// rides through to the upstream — e.g. Claude Code against a Kimi provider
// sets ANTHROPIC_BASE_URL=https://<endpoint>/anthropic so the proxy forwards
// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's
// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain
// Chat.
func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) {
var path, body string
var headers []string
switch kind {
case WireMessages:
path = "/v1/messages"
headers = []string{"anthropic-version: 2023-06-01"}
body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt)
body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt)
default:
path = "/v1/chat/completions"
body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt)
}
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID))
return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID))
}
// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike
@@ -227,7 +271,7 @@ func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prom
// is sent as the universal x-session-id header the proxy records.
func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) {
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model)
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
}
@@ -238,7 +282,7 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
// 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)
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
}
@@ -297,7 +341,7 @@ func (cl *Client) Terminate(ctx context.Context) error {
return cl.container.Terminate(ctx)
}
// containerLogs reads up to 256 KiB of a container's logs for diagnostics.
// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run.
func containerLogs(ctx context.Context, c testcontainers.Container) string {
if c == nil {
return ""
@@ -307,6 +351,6 @@ func containerLogs(ctx context.Context, c testcontainers.Container) string {
return fmt.Sprintf("<logs error: %v>", err)
}
defer r.Close()
b, _ := io.ReadAll(io.LimitReader(r, 256<<10))
b, _ := io.ReadAll(io.LimitReader(r, 4<<20))
return string(b)
}

View File

@@ -221,6 +221,29 @@ func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string
return "", fmt.Errorf("token not found in CLI output: %s", string(out))
}
// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted
// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes.
func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) {
src := filepath.Join(c.workDir, "data", "store.db")
if _, err := os.Stat(src); err != nil {
return "", fmt.Errorf("management store not found at %s: %w", src, err)
}
dst := filepath.Join(dstDir, "store.db")
for _, suffix := range []string{"", "-wal", "-shm"} {
data, err := os.ReadFile(src + suffix)
if err != nil {
if os.IsNotExist(err) && suffix != "" {
continue // sidecar only exists in WAL mode
}
return "", fmt.Errorf("read %s: %w", src+suffix, err)
}
if err := os.WriteFile(dst+suffix, data, 0o600); err != nil {
return "", fmt.Errorf("write %s: %w", dst+suffix, err)
}
}
return dst, nil
}
// Logs returns the combined server container logs, for diagnostics.
func (c *Combined) Logs(ctx context.Context) string {
return containerLogs(ctx, c.container)

3
go.mod
View File

@@ -113,7 +113,7 @@ require (
github.com/ti-mo/conntrack v0.5.1
github.com/ti-mo/netfilter v0.5.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
@@ -303,7 +303,6 @@ require (
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wailsapp/wails/webview2 v1.0.27 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect

6
go.sum
View File

@@ -660,10 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111 h1:MKx1nOnhnDuEGrRBmtxLOJq1NERwailu2cI4BvzWhi4=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.111/go.mod h1:wrdvmyeCsB/K3YqJDoH8E3MwcN8NXAMnEFaDTW46w60=
github.com/wailsapp/wails/webview2 v1.0.27 h1:wjgAi/I8BBZ7kUGU8um3XF3ILEfzr96Q2Q1G4GPjMns=
github.com/wailsapp/wails/webview2 v1.0.27/go.mod h1:zdM4jcO1IaC61RiJL5F1BzgoqBHFIdacz8gPr5exr0o=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=

View File

@@ -308,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) {
if file == "" {
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
}
return (&sql.SQLite3{File: file}).Open(logger)
return newSQLite3(file).Open(logger)
case "postgres":
dsn, _ := s.Config["dsn"].(string)
if dsn == "" {

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