Compare commits

...

80 Commits

Author SHA1 Message Date
riccardom
82ea56abe2 [client] pqkem: gate controller re-offer to kick the KEM exactly once
When the controller receives the responder's (KEM-less) offer it replies with
its own KEM offer instead of answering, so the only transaction that brings the
tunnel up is the one that also carries the PSK. Guard that reply with
ShouldSendBootstrapOffer so it fires only when no exchange is in flight: without
it, every responder offer triggered another offer (an offer-per-offer runaway).
The whole behaviour is isolated to the KEM path (config.PQ != nil); non-PQ
connections answer as before.
2026-08-06 18:04:30 +02:00
riccardom
1c47243da8 [client] pqkem: make signalling bootstrap idempotent through awaitingRekey
The controller sends its KEM offer both on its own guard event and in reply
to the responder's offer. SignalOffer was idempotent only while awaiting the
answer; once the answer arrived (awaitingRekey) a repeat call started a fresh
exchange with a different PSK, desyncing the two peers (one on the old PSK,
one on the new) so WireGuard derived misaligned transport keys and dropped all
data. Treat awaitingRekey as in-flight too and return the same offer.
2026-08-06 18:04:07 +02:00
riccardom
c7e888180a Introduces a forced imparity on MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".

Otherwise imagine two offers in parallel driving two answers at the same time

   A                   B
   | <----B-OFFER----- |
   | -----A-OFFER----> |
   |                   |
   |                   |
   ---------------------------------
  |****** ICE + WG Handshake ****** |
   ---------------------------------
   |                   |
   | <----B-ANSWER---- |
   | -----A-ANSWER---> |

PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.

So we impair the two nodes and only the offer of one of the two (the controller/initiator)
is allowed to progress and drive the answer (and carry the KEM material).

If a responder initiates an offer, we redo the offer towards it. This is oK
since the ICEworker don't treat offer/answers differently.
2026-08-06 15:42:27 +02:00
riccardom
191c0b94c7 Revert "Introduces a forced WG handshake on initial MLKEM bootstrap."
This reverts commit b5a72eca65.
2026-08-06 15:15:43 +02:00
riccardom
b5a72eca65 Introduces a forced WG handshake on initial MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".

Otherwise imagine two offers in parallel driving two answers at the same time

   A                   B
   | <----B-OFFER----- |
   | -----A-OFFER----> |
   |                   |
   |                   |
   ---------------------------------
  |****** ICE + WG Handshake ****** |
   ---------------------------------
   |                   |
   | <----B-ANSWER---- |
   | -----A-ANSWER---> |

PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.

So we impair the two nodes and only the offer of one of the two (the controller/initiator)
carries the KEM material.

This means that if the responder OFFER/ANSWER comes first, when the controller/initiator's one
completes (and the genuine PSK is shared between A and B, we need to force a new WG handshake with
the proper keys.
2026-08-06 14:42:07 +02:00
riccardom
7699697231 Anticipates PSK before WG does handshake so it finds it to set it 2026-08-05 16:39:55 +02:00
riccardom
fe530cf25a Skip default port send in signal proto 2026-08-04 19:17:44 +02:00
riccardom
915266ffff Allow non strict mode 2026-08-04 19:17:44 +02:00
riccardom
6d0f6c4bb8 Adds PQ connection tests 2026-08-04 19:17:44 +02:00
riccardom
45865ec80f Remove obvious comments; leave only the why of things 2026-08-04 19:17:44 +02:00
riccardom
171d22e4e8 Be more explicit on names that is a fake key to ensure we don't communicate with others in strict mode 2026-08-04 19:17:44 +02:00
riccardom
975479d4b8 Adds test to validate compromised keys are not accepted 2026-08-04 19:17:44 +02:00
riccardom
4d812a1353 Prioritize Kem over RP 2026-08-04 19:17:44 +02:00
riccardom
47e22ad6b8 pqkem: concurrency tests (recovery + race)
- RecoversViaResignalAfterDataPathBreak: a data-path rotation that can no longer
  converge raises OnRekeyFailed, and re-bootstrapping over signalling resyncs both
  peers on a fresh PSK even while the data path stays broken.
- ConcurrentRekeysNoRace: hammers the single-lock state machine with concurrent
  rotation clocks from many goroutines (run with -race) and asserts no split-brain
  via a final deterministic bootstrap.
2026-08-04 19:17:44 +02:00
riccardom
52a9843f95 pqkem: recover from persistent rekey failure by re-bootstrapping over signal
OnRekeyFailed now re-runs the KEM bootstrap over Signal (conn.RequestReoffer ->
handshaker.SendOffer) instead of only logging: a fresh signalling offer starts a new
exchange that overwrites the stalled PSK on both sides, resyncing after a persistent
data-path desync. Chosen over a responder-side awaitingAck revert (which fights the
confirm-less ack timing) and a full tunnel teardown (heavier). The tunnel stays up on
the previous PSK meanwhile since Signal is independent of the broken data path.
2026-08-04 19:17:44 +02:00
riccardom
cb73d7e959 Discriminate initial from rekey failure 2026-08-04 19:17:44 +02:00
riccardom
75c7a3bdca pqkem: strict (fail-closed) mode + wire status Quantum resistance
Strict mode (NB_PQ_MLKEM_STRICT, default off) closes the initial PQ-vulnerable
window (NET-1408): when enabled, conn.presharedKey programs a per-conn random
sentinel PSK until the ML-KEM exchange derives the real one, so no session can form
on a non-PQ key (the real PSK is pushed via SetPresharedKey once it converges).
Default stays opportunistic.

Also surface PQ status: the peer 'Quantum resistance' flag (RosenpassEnabled) is now
true when an ML-KEM PSK has been derived for the peer, not only for Rosenpass.
2026-08-04 19:17:44 +02:00
riccardom
97d99c9187 pqkem: rotate PSK in kernel mode instead of skipping
The idle-gate reads LastActivities, which only tracks per-peer data in userspace;
in kernel mode it is empty, so the gate treated every kernel peer as idle and
disabled data-path rotation entirely. Detect the bind via IsUserspaceBind and, in
kernel mode, report zero activity age (always 'active') so rotation runs on every
rekey. Lazy back-to-idle is already limited in kernel; the eBPF WG-activity
detection will later supply a real signal that excludes handshake/pqkem traffic.
2026-08-04 19:17:44 +02:00
riccardom
3e0f1ec083 pqkem: derive PSK with HKDF-SHA256
Replace the raw SHA-256 concat combiner with HKDF-SHA256 (crypto/hkdf, Go 1.24):
IKM = ML-KEM_ss || X25519_ss (draft-ietf-tls-ecdhe-mlkem order), salt = the
domain-separation label, info = full transcript (offer || answer) || canonicalised
peer identities. Keeps the transcript + identity binding while using a proper KDF.
2026-08-04 19:17:44 +02:00
riccardom
3877c84266 Don't rotate PQ keys if data path is idle for ~90s (less than a WG handhshake time 2026-08-04 19:17:44 +02:00
riccardom
44231f576e Adds log tracepoints
- Add a trace slog level (NB_PQ_MLKEM_LOG_LEVEL=trace) and move the verbose
  per-exchange lifecycle logs (offer/answer/PSK/ack/rotation) to it, so debug
  stays quiet and troubleshooting is opt-in.
- Stop logging the raw preshared key; drop the temporary pqkem-dbg OnRemoteOffer/
  OnRemoteAnswer probes.
- Demote the per-handshake conn log to trace.
2026-08-04 19:17:44 +02:00
riccardom
d2aeb2cc1f Fixes second answer dropped (the one carrying the PQ KEM data)
Prevents dropping concurrent answer / offer carrying the PQ ML-KEM data
2026-08-04 19:17:44 +02:00
riccardom
f8d58f23e5 Renames SetRemotePort to SetRemoteAddr 2026-08-04 19:17:44 +02:00
riccardom
341fb04d82 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-08-04 19:17:44 +02:00
riccardom
65454f23dc 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-08-04 19:17:44 +02:00
riccardom
c937df5aea pqkem: apply derived PSK at WG peer-config time (pull) + keep push for rekey 2026-08-04 19:17:44 +02:00
riccardom
135d3f5e7f pqkem: carry KEM offer/answer over the signalling exchange 2026-08-04 19:17:44 +02:00
riccardom
d959adfb25 pqkem: dedicated slog logger via NB_PQ_MLKEM_LOG_LEVEL 2026-08-04 19:17:44 +02:00
riccardom
4874f5ae51 Homogeneous logs prefix 2026-08-04 19:17:44 +02:00
riccardom
622be61b11 Bit of renaming
peer -> peerAddrs
have types for remoteID and localID
t.Close log error
Manager SetTransport -> Start
2026-08-04 19:17:44 +02:00
riccardom
1b1ddebd4c Typo 2026-08-04 19:17:44 +02:00
riccardom
56ec125afc Race fix 2026-08-04 19:17:44 +02:00
riccardom
5f81c1e5b8 Makes Transport just a UDP socket.
Manager owns maps for remoteID <-> remote UDP addr
Engine talks to manager only
2026-08-04 19:17:44 +02:00
riccardom
d91b7f53a0 Adds transport 2026-08-04 19:17:44 +02:00
riccardom
610f87c4cb Communicate the port over the signal exchange 2026-08-04 19:17:44 +02:00
riccardom
69d986276a Ensure iface is up and with overlay ip assigned to get a valid UDP port 2026-08-04 19:17:44 +02:00
riccardom
a26aeeee47 Adds real callback setter for PSK on ready 2026-08-04 19:17:44 +02:00
riccardom
bdd1683fed Initializes PQ ML-KEM manager 2026-08-04 19:17:44 +02:00
riccardom
f669147a76 Adds no-op Transports and callbacks 2026-08-04 19:17:44 +02:00
riccardom
8dcbd4ed8e Added enabled env var 2026-08-04 19:17:44 +02:00
riccardom
273101bfcb Adds MLKEM Payload placeholder to client internals 2026-08-04 19:17:44 +02:00
riccardom
2187760567 Invert order of keys as per draft 2026-08-04 19:17:44 +02:00
riccardom
09315d754a Protocol update 2026-08-04 19:17:44 +02:00
riccardom
608234e947 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-08-04 19:17:44 +02:00
riccardom
475b3e9790 Leave signal offer/answer as a pull/push operation not as an actual transport 2026-08-04 19:17:44 +02:00
riccardom
baea17efb6 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-08-04 19:17:44 +02:00
riccardom
6cef0d8bee Epurate wg refs 2026-08-04 19:17:44 +02:00
riccardom
37ce7b4f6f 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-08-04 19:17:44 +02:00
riccardom
a20ad0158e [squash] isInitial and answered can be inferred without state variables 2026-08-04 19:17:44 +02:00
riccardom
cdcf58a14c Manages convergence 2026-08-04 19:17:44 +02:00
riccardom
c3667140ea Models reattempts 2026-08-04 19:17:44 +02:00
riccardom
ca356353b1 Reuse answer, don't calculate again 2026-08-04 19:17:44 +02:00
riccardom
3f15ec14c1 Adds driver to glue together manager and outside world 2026-08-04 19:17:44 +02:00
riccardom
4ad3517869 Defines event callbacks 2026-08-04 19:17:44 +02:00
riccardom
f67332f062 Admits possible errors on Encode 2026-08-04 19:17:44 +02:00
riccardom
87c610b44b 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-08-04 19:17:44 +02:00
riccardom
a9946d7ffe Pure mechanics of manager 2026-08-04 19:17:44 +02:00
riccardom
942e3a1924 Messages definition 2026-08-04 19:17:44 +02:00
riccardom
fd08e8aaf7 ML-KEM encapsulate/decapsulate module 2026-08-04 19:17:44 +02:00
Misha Bragin
2afa69b622 [management] prevent dangling group refs in agent-network ACLs. (#7060)
Block deleting a group referenced as a source group by an agent network
   policy, and drop unresolvable groups from synthesised private-service
ACLs. A deleted group survived in agent_network_policies.source_groups
   and was carried into the injected in-memory policy, where network-map
   assembly resolved it to a nil group and panicked on every proxy peer
   sync.
2026-08-04 18:04:22 +02:00
Zoltan Papp
2a61eac047 [client] Fix Linux tray right-click opening the main window (#7039)
## Describe your changes

On SNI hosts that report icon clicks via Activate (KDE Plasma, Waybar),
Wails fired the left-click handler on every dbusmenu 'opened' event, so
a right click opened the tray menu and immediately raised the main
window, which stole focus and closed the menu.

	Host                           Left click              Right click
KDE Plasma, Waybar main window (Activate) menu (host-rendered)
	GNOME Shell + AppIndicator     menu only               menu only
	Minimal WMs via XEmbed host    main window (Activate)  XEmbed GTK popup

Point the wails/v3 replace at the netbirdio fork (v3.0.0-beta.3 plus the
fix): once the host has sent Activate/SecondaryActivate, a menu open no
longer fires the click handler, while AppIndicator-only hosts that
signal clicks solely via 'opened' keep the old behavior.


## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

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

## Chores

* Updated underlying application components to support compatibility and
ongoing maintenance.
* Clarified Linux tray interaction documentation, including
platform-specific left- and right-click behavior and menu activation.
* No user-facing features, workflow changes, or visual updates are
included.
* Existing Linux tray behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 17:36:04 +02:00
Zoltan Papp
f2d13b884a [client] Fix session expired relogin (#7055)
## Describe your changes

After the SSO session expires, the daemon tears the engine down
permanently
(management returns `PermissionDenied` → `runCancel()` → the retry loop
exits
for good). The "Session expired" dialog's Login button still drove the
extend-session flow, which requires a live engine: the user completed
the full
browser SSO + 2FA round trip only to get
`Failed to extend the session — engine is not initialised`, with no way
out
other than quitting and relaunching the client.

Reproduce:
1. Log in on a desktop client with session expiration enabled (e.g. 16h
TTL).
2. Let the session expire (e.g. leave the machine asleep overnight).
3. Wake it, click **Login** on the "Session expired" dialog, complete
SSO + 2FA.
4. The error dialog appears and every retry fails the same way.

Changes:
- The expired branch of the session-expiration dialog now emits
`trigger-login`, driving the full `Login → SSO → Up` sequence that
rebuilds
the client, instead of the extend flow (an expired session can no longer
be
  extended).
- `RequestExtendAuthSession` fails fast when the engine is already gone,
so the
  browser/2FA round trip is not wasted on a doomed extend.
- The expired tray row navigated the main window to `/#/login`, a route
that
does not exist and fell through to the main page without starting a
login;
  it now emits `trigger-login` as well.


## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

## 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**
- Improved session extension handling when the client engine is
unavailable by prompting users to log in again.
- Updated expired-session behavior to trigger the standard login flow,
providing a more consistent sign-in experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 16:02:31 +02:00
Zoltan Papp
564595d283 [client, android] Fix profile account path test on Windows (#7057)
## Describe your changes

The expected paths were hardcoded with Unix separators while
profileAccountPathFor builds the result with filepath.Join, so the
comparison failed on Windows. Derive the expectations with
filepath.FromSlash to keep the test platform-independent.


## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

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

* **Tests**
* Updated profile account path test expectations to use
platform-appropriate path separators, improving test reliability across
operating systems.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 16:02:09 +02:00
Zoltan Papp
78c1c2fc32 [client] Probe the daemon login with IsLoginRequired (#7052)
## Describe your changes

Probe the daemon login with IsLoginRequired 

The Login probe attemptLogin(ctx, "", "") on an unregistered peer ends
in registerPeer with no setup key and no JWT, which fails locally with
InvalidArgument before reaching Management. Since #6983 classified that
as StatusLoginFailed and returned early, every setup-key enrolment and
every expired-session SSO re-login aborted before using its credentials,
breaking all netbird-cloud e2e runs from commit e90be36cd.

IsLoginRequired asks the question the probe actually means - is the
peer's key alone still accepted - and reports Management's refusal as a
decision (needsLogin) instead of an error, the same pattern
foregroundLogin, Android and iOS already use.

## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

## 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**
  * Improved login handling when Management connectivity checks fail.
* Prevented unnecessary SSO prompts for already-authenticated sessions.
* Preserved setup-key login behavior while ensuring authentication
attempts proceed correctly.
* Login failures now return a clear failure status when authentication
state cannot be verified.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 14:27:47 +02:00
Maycon Santos
bc7a15ab71 [management] Align agent-network API contracts for API clients (#7026)
## Describe your changes

Work on the Terraform provider (terraform-provider-netbird #177–#183)
surfaced places where the agent-network API broke its own contracts or
deviated from the conventions the rest of the management API follows,
forcing client-side workarounds.

Settings reads now follow the settings-endpoint convention: GET always
answers with a JSON object. Before bootstrap it returns the defaults
with an empty cluster/subdomain/endpoint (previously 200 with a JSON
`null` body, while the spec said 404). The settings PUT can bootstrap
the account by carrying a `cluster` — previously the row could only come
into existence through the first provider create, and a settings-first
setup was impossible; a differing cluster on a bootstrapped account is
rejected instead of silently ignored. PUT remains full-state.

The provider PUT schema promised omit-preserves semantics for several
operator-editable fields that the handler never delivered (it builds the
row from the request, like every other update handler). The schema
wording now matches the shipped full-state behavior; only the api_key
(secret) and session keys stay preserved by the manager. Identity
headers are always present in provider responses so an explicitly
cleared value round-trips as an empty string.

The Go REST client gains the full agent-network surface (catalog,
providers, policies, guardrails, budget rules, settings), including a
shim translating the legacy 200+`null` settings body from older servers
into an `IsNotFound` error.

Note for reviewers: the dashboard special-cased the `null` settings
body; it needs a small follow-up for the new defaults response (in
progress).
2026-08-04 01:59:09 +02:00
Zoltan Papp
530021aec6 [client] Update wails to v3.0.0-beta.3 (#7038)
## Describe your changes

Update wails to v3.0.0-beta.3

## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

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

* **Chores**
* Updated the application framework dependency to a newer beta release
for improved compatibility and stability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 23:19:59 +02:00
Zoltan Papp
1bedb4e59d [client, android] Reuse the profile's account for Android SSO logins (#6988)
The Android binding never recorded which account a profile belongs to,
so every interactive login and every session extend went to the IdP with
no login_hint. With nothing to go on the IdP picks an account itself,
which on a session extend means re-authenticating an account the profile
is already signed in with.

Store the email the PKCE flow already parses out of the ID token, and
pass it back as the hint on later flows. An empty hint stays meaningful:
a fresh profile, or one that was logged out, deliberately leaves the
choice to the IdP, which is how a profile changes accounts. Logout
clears the stored email for that reason — while it is on disk it would
steer the next login straight back into the account just logged out of.

The email is keyed off the profile's config path rather than the active
profile: Auth.login runs in a goroutine, so the active profile can
change under a flow already in flight. It lands in
<profile>.account.json, not the <profile>.state.json desktop uses for
the same data — there the email and the engine's state manager sit in
different directories, but on Android both resolve under files/, and the
state manager rewrites the whole file from its own keys.

## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

* **New Features**
* Added account email and active-status details to Android profile
information.
* Improved SSO sign-in and session renewal by restoring the previously
used account as a login hint.
* Added Android-specific profile email persistence with automatic
cleanup on logout.

* **Bug Fixes**
* Profile email persistence failures now generate warnings without
blocking login or logout.
* Improved handling of missing or unreadable account data and repeated
logout cleanup.

* **Tests**
* Added coverage for account-file naming, email persistence, and logout
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 22:50:57 +02:00
camiloariza
7546e7751c [client, android] Reuse the persisted configuration when enrolling (#7022)
## Describe your changes

NewAuth builds a fresh in-memory configuration on every call, which
means a new WireGuard key each time. The peer registers under that key
and the key is written out, so any peer registered by an earlier call is
orphaned on the server — a client that enrols twice leaves two entries
and owns neither.

It also breaks the enrol-then-run sequence. `RunWithoutLogin` reloads
the configuration from disk through `UpdateOrCreateConfig`, so the
identity that registered is not necessarily the identity that runs, and
the management stream rejects it:

```
failed to login to Management Service: rpc error: code = PermissionDenied
desc = no peer auth method provided, please use a setup key or interactive SSO login
```

followed by a panic in `ConnectClient.run`.

### How it was found

Embedding the Android client in an application that enrols with a setup
key and then runs. Eight orphaned peers accumulated on a self-hosted
management server before the cause was clear, because every restart
registered a new one.

### The change

`NewAuth` passes `ConfigPath` and uses `UpdateOrCreateConfig`, so an
existing configuration is reused and one is only created when absent. A
caller wanting a fresh identity can delete the file — which is what
"forget this account" already does.

### Test

`TestNewAuth_ReusesPersistedIdentity` fails on the current code:

```
--- FAIL: TestNewAuth_ReusesPersistedIdentity (0.00s)
    login_test.go:33: private key changed between calls: a second enrolment would orphan the peer registered by the first
```

and passes with the fix. `TestNewAuth_CreatesConfigWhenAbsent` covers
the first-enrolment path being unchanged. Both run in `client/android`
on Linux.

Per CONTRIBUTING, opening directly as a bug fix rather than raising an
issue first.

## Issue ticket number and link


[NET-1465](https://linear.app/netbird/issue/NET-1465/agent-network-rest-api-settings-defaults-bootstrap-via-put-provider)

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why): the
API reference is generated from the OpenAPI spec, which this PR updates
in-repo.

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

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2026-08-03 18:42:15 +02:00
Zoltan Papp
075b319fb3 [client, android] Pull fresh TUN settings on Android rebuild (#6991)
## Describe your changes

Pull fresh TUN settings on Android rebuild instead of push

The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.

Requires the matching android-client change that switches recreateTUN to
the pull API.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

- **New Features**
  - Added access to current TUN route ranges and DNS search domains.
- TUN settings are returned in a mobile-friendly format for easier
integration.

- **Improvements**
  - Route changes are detected and synchronized more reliably.
- Current routing information now reflects active routes, including
supported fake-IP ranges.
- Simplified network initialization for more consistent startup
behavior.

- **API Changes**
- Removed the obsolete network-map retrieval method from the management
client interface.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 18:23:02 +02:00
Zoltan Papp
b82a42c855 [misc] Add android and ios tags to PR title check (#7037)
## Describe your changes
Extend CI tag list with Android and iOS


## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## 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)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

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

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

* **Chores**
* Updated pull request title validation to accept `android` and `ios`
tags.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 17:58:29 +02:00
Viktor Liu
e90be36cd5 [client] Don't ask for an SSO login when the login never reached management (#6983) 2026-08-03 16:28:51 +02:00
Riccardo Manfrin
d29bc23bb7 [client] launch macOS GUI as the logged-in user after install/update (#6962)
## Describe your changes

In unattended installs/updates there may be no logged-in user, so
there's no context to start the GUI (nor anyone to see it).

The bug is the GUI being started in the wrong user context / inheriting
the wrong `$HOME` (OS mechanics aside).

Today the GUI is started by a per-user LaunchAgent, i.e. on behalf of
the user who logs in — no login ⇒ no GUI.

The patch aligns to this: it launches the GUI on behalf of the logged-in
console user if one exists, otherwise it delegates the launch to the
per-user LaunchAgent at next login.

Additionally it logs when default UI settings are applied.

Note (small caveat): the LaunchAgent auto-starts the GUI at login only
once it's been registered — which happens on the first GUI launch in the
user's context. On an MDM/unattended fresh install done with no user
logged in (where the user has never run the GUI before), they may need
to start it manually once; it self-registers from then on.

## Issue ticket number and link

No public issue — reported internally (community report on Slack: macOS
advanced-view + onboarding reset on every update, esp. via MDM/Munki).
Buggy line on main:
dd2bdc0de3/release_files/darwin_pkg/postinstall (L33)

## 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 macOS installer / GUI-launch behavior. No public API, CLI, or
configuration change: the fix only changes the user context the desktop
GUI is launched in after a pkg install/update.

### 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/6962"><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=1787923973&installation_model_id=427504&pr_number=6962&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6962&signature=5226bbc7986fc60eb8b4ab77e98ac17a6534864763fc6407048bcbbb1550394b"><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 macOS installation and updater UI launching to occur only
when an active, valid GUI console session is detected.
- Prevented UI launches during unattended/system, root, or
login-window-related installs.
- Ensured the app is launched in the correct console-user context, and
skips cleanly when username/UID resolution fails.

- **Improvements**
- Added clearer informational logging when the UI preferences file is
not found and default preferences are used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 16:28:10 +02:00
Viktor Liu
ee1389d736 [client] Keep the UI running when the notification service fails to start (#6959) 2026-08-03 16:23:10 +02:00
Zoltan Papp
6f42636514 [client] Android - Serialize Android tunnel reconfiguration callbacks (#6990)
## Describe your changes

The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered: the
TUN rebuild handler applies them in arrival order and compares against
the last applied parameters, so a stale route set delivered last won as
the final TUN state. This is the same reordering hazard fixed for iOS in
#6454.

Wrap the Android network change listener into the shared tunnelnotifier
FIFO introduced in #6870, the same way RunOniOS does, and deliver both
notifiers synchronously into it. Enqueueing is non-blocking, a single
delivery goroutine preserves order, and calls into Java never overlap.

Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.

## 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/__
2026-08-03 15:03:15 +02:00
Zoltan Papp
28197e6504 [client] Android - Create the Android fake IP manager lazily on DNS flag enable (#6989)
The fake IP manager was only created at route manager construction, from the DNS feature flag fetched by the initial GetNetworkMap call. When the flag flipped to true mid-session, UpdateRoutes set useNewDNSRoute but never created the manager, so domain routes added after the flip got a DNS interceptor with a nil fake IP manager.

internalDnatFw only checked for a firewall and GOOS, so the interceptor took the DNAT path and called GetFakeIP/AllocateFakeIP on the nil *fakeip.Manager. These methods lock m.mu first, which is a nil pointer dereference: the first DNS answer for such a route panicked and crashed the VPN service. The fake IP blocks (240.0.0.0/8 and its v6 pair) also never reached the TUN, since only the constructor registered them.

Create the manager and its TUN routes from UpdateRoutes when the flag turns on, notify so the fake IP blocks get into the TUN without a client route change, and treat a nil manager as no internal DNAT.

This is groundwork for removing the initial GetNetworkMap fetch, after which every startup goes through the flag-off-to-on transition.
2026-08-03 14:30:41 +02:00
Zoltan Papp
2f721ec0d5 [client] Keep the account email backing the SSO login hint correct (#6986)
## Describe your changes

Three fixes to how the desktop GUI keeps the account email that backs
the SSO `login_hint`. Each is independent and reviewable on its own.

**1. Store the email after a GUI SSO login**

The daemon returns the authenticated user's email from `WaitSSOLogin`
but cannot persist it: it runs as root while the per-profile state file
is user-owned. The CLI's `handleSSOLogin` writes it after its own
`WaitSSOLogin`; the GUI path read the value and dropped it.

The profile was therefore left with no email, so `Profiles.List` showed
no account for it, and later logins and session extends went out with no
`login_hint` — leaving the IdP to pick an account instead of reusing the
one the profile belongs to. Mirror the CLI and store it, next to the
`Logout` path that already clears the same file for the same reason.

**2. File the email against the profile the login ran for**

`SetActiveProfileState` resolves the target itself, so it writes to
whichever profile is active when it is called. A GUI SSO login spans
seconds of user interaction in the browser, and the tray stays clickable
throughout: switching profiles in that window left the email filed under
the profile that happened to be active when the flow returned. The wrong
profile then advertised an account it does not own, and offered it as
the `login_hint` next time.

Adds `SetProfileState(id, state)`, the write-side counterpart of the
existing `GetProfileState(id)`, and keeps `SetActiveProfileState` as a
wrapper for callers with no particular profile in mind. `Login` now
reports the profile it resolved so the frontend can hand it back with
the SSO wait, which closes the window.

**3. Delete the email when a profile is removed**

Removing a profile left its state file behind: the daemon deletes what
it owns, but the email file is user-owned and out of reach for a root
daemon — the same split that already puts the `Logout` cleanup on the UI
side.

Beyond the stray file, legacy profiles are keyed by name rather than by
a generated ID, so recreating a profile under a removed one's name
inherited its email — shown as the account in the profile list and sent
as the `login_hint` on the next login.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

- **New Features**
- SSO login details can now be saved to the specific profile selected
during sign-in.
  - Profile state can be managed independently for different profiles.

- **Bug Fixes**
  - Removing a profile now also cleans up its associated saved state.
- Cleanup issues no longer prevent successful profile removal and are
handled gracefully.
  - Existing active-profile behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 14:21:40 +02:00
Pascal Fischer
f9b412228e [management] fix handling of empty network map during decode and encode (#6987) 2026-08-03 13:20:53 +02:00
Maycon Santos
2bfd9fcffe [management] Resolve agent network permissions per submodule (#7030)
## Describe your changes

Agent Network gates providers, policies, guardrails, budgets, usage,
access logs, and settings behind the single `agent_network` permission
module, so access is all-or-nothing: a future delegated role cannot be
scoped to a subset of the area (for example usage-only visibility).

This introduces dotted submodules (`agent_network.providers`,
`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`)
and resolves grants with a cascade: exact module first, then its parent,
then the role's `AutoAllowNew` default. The agent network manager now
validates each operation against its matching submodule. `usage`
(aggregated counters, overview) is deliberately separate from `logs`
(request-level entries, which can contain captured prompts).

No role definitions change. No built-in role carries an explicit
`agent_network` entry, so every role resolves the submodules exactly as
it resolved the parent module before — pinned by a test that compares
each built-in role's answer on every submodule against its answer on
`agent_network`. Role additions that use these submodules come
separately.
2026-08-03 12:45:17 +02:00
Brad Ison
7639655883 [management] Generic gRPC extension seam for external modules (#6894)
## Describe your changes

This adds an extension point to the management server for registering
additional gRPC services. We already have a generic integrations system
and dependency injection for server components. This closes the gap on
being able to also extend the gRPC API cleanly.

## Issue ticket number and link

N/A

## Stack

<!-- branch-stack -->

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

No docs needed. This is strictly a small internal plumbing enhancement /
refactor.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6894"><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=1787582002&installation_model_id=427504&pr_number=6894&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6894&signature=3288061677db243031830964fec8f0f34c82f7fc63a39298cd0b4e3490551060"><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

* **New Features**
* Added a gRPC extension mechanism to contribute additional services and
automatically chain extra unary and stream interceptors.
  * Extension shutdown hooks now run as part of server stop.
* Added exported proxy token generation via `GenerateProxyToken()` for
external integrations.
* **Tests**
* Added coverage for extension interceptor/service wiring, extension
shutdown execution, and proxy token generation validation (including
hash consistency and prefix).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 12:26:38 +02:00
Zoltan Papp
6044663788 [client] Declare GTK4/WebKitGTK runtime deps for the Linux UI packages (#6893)
The 0.75.0 UI is built against GTK 4.14 (Ubuntu 24.04 runner) and Wails
v3 calls gdk_monitor_get_scale (GTK 4.14+) unconditionally, but the
deb/rpm packages only depended on netbird. On distros shipping an older
GTK4 (Ubuntu 22.04, RHEL 9, openSUSE Leap 15.6) the package installed
fine and then died at startup with a symbol lookup error (#6890).

Declare the real runtime dependencies so package managers reject the
install up front instead:

- deb: libgtk-4-1 (>= 4.14) and libwebkitgtk-6.0-4
- rpm: rich (boolean) dependencies that accept both the Fedora/RHEL and
the SUSE package names, with the 4.14 floor: (gtk4 >= 4.14 or libgtk-4-1
>= 4.14) (webkitgtk6.0 or libwebkitgtk-6_0-4)

Rich deps are supported by dnf and zypper (RPM 4.13+); the pinned
goreleaser v2.16.0 -> nfpm v2.46.3 -> rpmpack v0.7.1 chain passes the
parenthesized form through verbatim. On RHEL/Alma/Rocky 10 the
webkitgtk6.0 package comes from EPEL, which becomes an install
prerequisite for the UI.

The wails3 packaging config (client/ui/build/linux/nfpm/nfpm.yaml, local
dev packaging only) is kept in sync.

## 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/6893"><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=1787575510&installation_model_id=427504&pr_number=6893&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6893&signature=93b4fc344fe4f4e6b781887386bfbae1a309007205c7b2ab061b58d89e29b9a8"><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 Linux package installation compatibility by requiring
supported GTK 4.14 and WebKit components.
* Updated Debian and RPM packages to recognize equivalent
platform-specific library names.
* Refined Linux package metadata to support installation across a wider
range of distributions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 10:21:14 +02:00
101 changed files with 5892 additions and 563 deletions

View File

@@ -16,6 +16,8 @@ jobs:
const allowedTags = [
'management',
'client',
'android',
'ios',
'signal',
'proxy',
'relay',

View File

@@ -93,7 +93,9 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
@@ -114,7 +116,9 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
rpm:
signature:

View File

@@ -57,6 +57,12 @@ type DnsReadyListener interface {
dns.ReadyListener
}
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
type TunSettings struct {
Routes string
SearchDomains string
}
func init() {
formatter.SetLogcatFormatter(log.StandardLogger())
}
@@ -76,6 +82,8 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string
// Identifies the running profile for the SSO login hint; see profile_state.go.
cfgPath string
stateChangeMu sync.Mutex
stateChangeSubID string
@@ -96,11 +104,12 @@ type Client struct {
extendCancel context.CancelFunc
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
c.config = cfg
c.cacheDir = cacheDir
c.cfgPath = cfgPath
c.connectClient = cc
}
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
return c.config, c.cacheDir, c.connectClient
}
// authSnapshot returns the config together with the path it was loaded from, in
// one lock: the path identifies the profile whose account email backs the login
// hint, so reading it separately could pair one profile's config with another's
// hint when a profile switch lands in between.
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
return c.config, c.cfgPath, c.connectClient
}
func (c *Client) getConnectClient() *internal.ConnectClient {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
auth := NewAuthWithConfig(ctx, cfg)
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
err = auth.login(urlOpener, isAndroidTV)
if err != nil {
return err
@@ -170,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
c.setState(cfg, cacheDir, cfgFile, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
// only once the fresh connect client is installed: until then Status()
@@ -211,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
return e.RenewTun(fd)
}
func (c *Client) GetTunSettings() (*TunSettings, error) {
cc := c.getConnectClient()
if cc == nil {
return nil, fmt.Errorf("engine not running")
}
e := cc.Engine()
if e == nil {
return nil, fmt.Errorf("engine not initialized")
}
routes, searchDomains := e.TunSettings()
return &TunSettings{
Routes: strings.Join(routes, ";"),
SearchDomains: strings.Join(searchDomains, ";"),
}, nil
}
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/system"
@@ -36,12 +38,20 @@ type Auth struct {
}
// NewAuth instantiate Auth struct and validate the management URL
//
// The configuration at cfgPath is reused when one is already there, and only created when it is
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
// every call: the peer registers under that key, the key is written out, and any peer registered by
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
// the persisted config, because the identity it registered is not the one it runs with — the
// management stream rejects it with "no peer auth method provided".
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
if err != nil {
return nil, err
}
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
}, nil
}
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
// file the config was loaded from; it identifies the profile whose account email
// backs the login_hint.
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
return &Auth{
ctx: ctx,
config: config,
ctx: ctx,
config: config,
cfgPath: cfgPath,
}
}
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
}
jwtToken := ""
email := ""
if needsLogin {
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
jwtToken = tokenInfo.GetTokenToUse()
email = tokenInfo.Email
}
err, _ = authClient.Login(a.ctx, "", jwtToken)
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
return fmt.Errorf("login failed: %v", err)
}
// Stored after Login, not before: a rejected token must not leave a hint
// pointing at an account that cannot be used.
if email != "" && a.cfgPath != "" {
if err := writeProfileEmail(a.cfgPath, email); err != nil {
log.Warnf("failed to store profile account email: %v", err)
}
}
go urlOpener.OnLoginSuccess()
return nil
}
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
// but absent from the OAuthFlow interface, hence the assertion below — the same
// way internal/auth wires it in authenticateWithPKCEFlow.
type loginHintSetter interface {
SetLoginHint(hint string)
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
// leaves the choice to the IdP, which is how accounts get switched.
if a.cfgPath != "" {
if hint := readProfileEmail(a.cfgPath); hint != "" {
if setter, ok := oAuthFlow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
}
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)

View File

@@ -0,0 +1,51 @@
package android
import (
"path/filepath"
"testing"
)
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
//
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
// time. The peer registers under that key and the key is written out, so a peer registered by an
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
// the identity that registered is not the identity that runs, and the management stream rejects it
// with "no peer auth method provided, please use a setup key or interactive SSO login".
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
first, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("first NewAuth: %v", err)
}
if first.config.PrivateKey == "" {
t.Fatal("first NewAuth produced no private key")
}
second, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("second NewAuth: %v", err)
}
if second.config.PrivateKey != first.config.PrivateKey {
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
}
}
// A missing configuration is still created, so a first enrolment works unchanged.
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
if auth.config == nil || auth.config.PrivateKey == "" {
t.Fatal("NewAuth did not create a usable configuration")
}
if auth.cfgPath != cfgPath {
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
}
}

View File

@@ -13,18 +13,17 @@ import (
)
const (
// Android-specific config filename (different from desktop default.json)
defaultConfigFilename = "netbird.cfg"
// Subdirectory for non-default profiles (must match Java Preferences.java)
profilesSubdir = "profiles"
// Android uses a single user context per app (non-empty username required by ServiceManager)
androidUsername = "android"
)
// Profile represents a profile for gomobile
type Profile struct {
ID string
Name string
ID string
Name string
// Email is the account this profile last logged in with, "" if it never
// completed an SSO login or was logged out. See profile_state.go.
Email string
IsActive bool
}
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
profiles = append(profiles, &Profile{
ID: p.ID.String(),
Name: p.Name,
Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
}
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
if err != nil {
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
}
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
return &Profile{
ID: prof.ID.String(),
Name: prof.Name,
Email: pm.profileEmail(prof.ID.String()),
IsActive: true,
}, nil
}
// profileEmail returns the account email recorded for a profile. Display-only, so
// an unresolvable path degrades to "" rather than an error.
func (pm *ProfileManager) profileEmail(id string) string {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return ""
}
return readProfileEmail(configPath)
}
// SwitchProfile switches to a different profile
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
return fmt.Errorf("failed to save config: %w", err)
}
// Not fatal: a stale hint costs an account switch, not the logout itself.
if err := removeProfileEmail(configPath); err != nil {
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
}
log.Infof("logged out from profile: %s", id)
return nil
}

View File

@@ -0,0 +1,108 @@
package android
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/util"
)
const (
// Android-specific config filename (different from desktop default.json)
defaultConfigFilename = "netbird.cfg"
// Subdirectory for non-default profiles (must match Java Preferences.java)
profilesSubdir = "profiles"
// profileAccountSuffix names the file holding the profile's account email.
// Deliberately not ".state.json", which desktop uses for the same data:
// there the email and the engine's state manager live in different
// directories, but on Android both resolve under files/, so sharing the name
// would have the two overwrite each other — the state manager rewrites the
// whole file from its own keys (see statemanager.Manager.PersistState), and
// this package's writer does the same in reverse.
profileAccountSuffix = ".account.json"
)
// profileAccountPathFor derives the account file path from a profile's config
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
//
// Deriving from the config path rather than resolving the active profile keeps
// the write on the profile the login actually ran for: Auth.login runs in a
// goroutine, so the active profile can change under a flow already in flight.
func profileAccountPathFor(configPath string) (string, error) {
if configPath == "" {
return "", fmt.Errorf("empty config path")
}
base := filepath.Base(configPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
if stem == "" || stem == "." {
return "", fmt.Errorf("config path %q has no filename stem", configPath)
}
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
}
// readProfileEmail returns the account email stored for the profile whose config
// lives at configPath. A missing or unreadable file yields "", which leaves the
// account choice to the IdP.
func readProfileEmail(configPath string) string {
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
log.Debugf("no profile account path for login hint: %v", err)
return ""
}
var state profilemanager.ProfileState
if _, err := util.ReadJson(accountPath, &state); err != nil {
if !os.IsNotExist(err) {
log.Debugf("failed to read profile account for login hint: %v", err)
}
return ""
}
return state.Email
}
// writeProfileEmail records the account email for the profile whose config lives
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
// email is ignored rather than blanking what is already stored.
func writeProfileEmail(configPath string, email string) error {
if email == "" {
return nil
}
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
return fmt.Errorf("resolve profile account path: %w", err)
}
state := profilemanager.ProfileState{Email: email}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
return fmt.Errorf("write profile account: %w", err)
}
return nil
}
// removeProfileEmail drops the stored account email. Called on logout: while the
// email is on disk it goes out as a login_hint, which would steer the next login
// straight back into the account just logged out of. Mirrors the desktop UI's
// RemoveProfileState call.
func removeProfileEmail(configPath string) error {
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
return fmt.Errorf("resolve profile account path: %w", err)
}
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove profile account: %w", err)
}
return nil
}

View File

@@ -0,0 +1,161 @@
package android
import (
"os"
"path/filepath"
"testing"
)
func TestProfileAccountPathFor(t *testing.T) {
tests := []struct {
name string
configPath string
want string
wantErr bool
}{
{
name: "default profile",
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
},
{
name: "id profile",
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
},
{
name: "legacy name-keyed profile is handled the same way",
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
},
{
name: "empty path is rejected",
configPath: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := profileAccountPathFor(tt.configPath)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got path %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
root := "/data/data/io.netbird.client/files"
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
if err != nil {
t.Fatalf("default profile: %v", err)
}
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
if err != nil {
t.Fatalf("id profile: %v", err)
}
if defaultAccount == idAccount {
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
}
}
// The account file must never land on the engine state file: on Android both
// resolve under files/, and the state manager rewrites the whole file from its
// own keys, so sharing a path would have the two overwrite each other. The
// expected names here mirror ProfileManager.GetStateFilePath.
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
root := "/data/data/io.netbird.client/files"
cases := []struct {
configPath string
engineState string
}{
{
configPath: filepath.Join(root, defaultConfigFilename),
engineState: filepath.Join(root, "state.json"),
},
{
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
},
}
for _, c := range cases {
account, err := profileAccountPathFor(c.configPath)
if err != nil {
t.Fatalf("%s: %v", c.configPath, err)
}
if account == c.engineState {
t.Errorf("account file collides with the engine state file: %q", account)
}
}
}
func TestWriteThenReadProfileEmail(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
if err := ensureDirFor(t, configPath); err != nil {
t.Fatalf("prepare dir: %v", err)
}
if got := readProfileEmail(configPath); got != "" {
t.Errorf("expected no email before a login, got %q", got)
}
const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err)
}
if got := readProfileEmail(configPath); got != email {
t.Errorf("got %q, want %q", got, email)
}
if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("remove: %v", err)
}
if got := readProfileEmail(configPath); got != "" {
t.Errorf("expected no email after logout, got %q", got)
}
// Logout may run on a never-logged-in profile, so a second remove must pass.
if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("second remove should be a no-op: %v", err)
}
}
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
if err := ensureDirFor(t, configPath); err != nil {
t.Fatalf("prepare dir: %v", err)
}
const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err)
}
if err := writeProfileEmail(configPath, ""); err != nil {
t.Fatalf("write empty: %v", err)
}
if got := readProfileEmail(configPath); got != email {
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
}
}
func ensureDirFor(t *testing.T, path string) error {
t.Helper()
return os.MkdirAll(filepath.Dir(path), 0o700)
}

View File

@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
}
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
cfg, _, cc := c.stateSnapshot()
cfg, cfgPath, cc := c.authSnapshot()
if cfg == nil || cc == nil {
return fmt.Errorf("engine is not running")
}
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
}
defer authClient.Close()
a := &Auth{ctx: ctx, config: cfg}
// Passing the config path makes the flow pick up the login_hint: an extend
// renews the session of the account already signed in, so it must not stop to
// offer a choice.
a := NewAuthWithConfig(ctx, cfg, cfgPath)
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)

View File

@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
stateFilePath string,
cacheDir string,
) error {
notifier := tunnelnotifier.New(networkChangeListener, nil)
defer notifier.Close()
// in case of non Android os these variables will be nil
mobileDependency := MobileDependency{
TunAdapter: tunAdapter,
IFaceDiscover: iFaceDiscover,
NetworkChangeListener: networkChangeListener,
NetworkChangeListener: notifier,
HostDNSAddresses: dnsAddresses,
DnsReadyListener: dnsReadyListener,
StateFilePath: stateFilePath,

View File

@@ -51,7 +51,5 @@ func (n *notifier) notify() {
return
}
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged("")
}(n.listener)
n.listener.OnNetworkChanged("")
}

View File

@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
ds.hostsDNSHolder.set(hostsDnsList)
ds.permanent = true
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
ds.searchDomainNotifier.setListener(listener)
setServerDns(ds)
return ds
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
}
func (s *DefaultServer) SearchDomains() []string {
s.mux.Lock()
defer s.mux.Unlock()
return s.searchDomains()
}
func (s *DefaultServer) searchDomains() []string {
var searchDomains []string
for _, dConf := range s.currentConfig.Domains {
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
}()
if s.searchDomainNotifier != nil {
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
}
s.updateNSGroupStates(update.NameServerGroups)

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"
@@ -197,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
@@ -555,7 +560,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
publicKey := e.config.WgPrivateKey.PublicKey()
e.flowManager = netflow.NewManager(e.wgInterface, publicKey[:], e.statusRecorder)
if e.config.RosenpassEnabled {
// Rosenpass and ML-KEM are mutually exclusive. ML-KEM (NB_ENABLE_PQ_MLKEM) takes precedence
if e.config.RosenpassEnabled && pqkem.Enabled() {
log.Warnf("rosenpass and ML-KEM post-quantum are mutually exclusive; ML-KEM is enabled, so rosenpass is disabled")
}
if e.config.RosenpassEnabled && !pqkem.Enabled() {
log.Infof("rosenpass is enabled")
if e.config.RosenpassPermissive {
log.Infof("running rosenpass in permissive mode")
@@ -572,12 +581,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
e.stateManager.Start()
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
if err != nil {
return fmt.Errorf("read initial settings: %w", err)
}
dnsServer, err := e.newDnsServer(dnsConfig)
dnsServer, err := e.newDnsServer()
if err != nil {
return fmt.Errorf("create dns server: %w", err)
}
@@ -595,10 +599,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
WGInterface: e.wgInterface,
StatusRecorder: e.statusRecorder,
RelayManager: e.relayManager,
InitialRoutes: initialRoutes,
StateManager: e.stateManager,
DNSServer: dnsServer,
DNSFeatureFlag: dnsFeatureFlag,
PeerStore: e.peerStore,
DisableClientRoutes: e.config.DisableClientRoutes,
DisableServerRoutes: e.config.DisableServerRoutes,
@@ -651,6 +653,30 @@ 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 {
cbHandler := pqCallbackHandler{
wg: e.wgInterface,
// On a persistent rekey failure, re-bootstrap the KEM over Signal: a
// fresh signalling offer starts a new exchange that overwrites the
// stalled PSK on both sides, recovering from a data-path desync.
reoffer: func(remoteKey string) {
if conn, ok := e.peerStore.PeerConn(remoteKey); ok {
conn.RequestReoffer()
}
},
}
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, 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)
@@ -914,6 +940,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)
@@ -1900,6 +1930,10 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
},
ICEConfig: e.createICEConfig(),
}
if e.pqkemManager != nil {
config.PQ = pqHandshaker{mgr: e.pqkemManager}
config.PQStrict = pqkem.Strict()
}
serviceDependencies := peer.ServiceDependencies{
StatusRecorder: e.statusRecorder,
@@ -2083,6 +2117,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 {
@@ -2102,42 +2140,6 @@ func (e *Engine) close() {
}
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
if runtime.GOOS != "android" {
// nolint:nilnil
return nil, nil, false, nil
}
info := system.GetInfo(e.ctx)
info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
&e.config.ServerSSHAllowed,
e.config.DisableClientRoutes,
e.config.DisableServerRoutes,
e.config.DisableDNS,
e.config.DisableFirewall,
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
)
netMap, err := e.mgmClient.GetNetworkMap(info)
if err != nil {
return nil, nil, false, err
}
routes := toRoutes(netMap.GetRoutes())
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
dnsFeatureFlag := toDNSFeatureFlag(netMap)
return routes, &dnsCfg, dnsFeatureFlag, nil
}
func (e *Engine) newWgIface() (*iface.WGIface, error) {
transportNet, err := e.newStdNet()
if err != nil {
@@ -2172,7 +2174,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
func (e *Engine) wgInterfaceCreate() (err error) {
switch runtime.GOOS {
case "android":
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
case "ios":
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
if e.config.WgAddr.HasIPv6() {
@@ -2185,7 +2187,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
return err
}
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
func (e *Engine) newDnsServer() (dns.Server, error) {
// due to tests where we are using a mocked version of the DNS server
if e.dnsServer != nil {
return e.dnsServer, nil
@@ -2197,7 +2199,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
e.ctx,
e.wgInterface,
e.mobileDep.HostDNSAddresses,
*dnsConfig,
nbdns.Config{},
e.mobileDep.NetworkChangeListener,
e.statusRecorder,
e.config.DisableDNS,
@@ -2895,6 +2897,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

@@ -0,0 +1,20 @@
package internal
func (e *Engine) TunSettings() ([]string, []string) {
e.syncMsgMux.Lock()
routeManager := e.routeManager
dnsServer := e.dnsServer
e.syncMsgMux.Unlock()
var routes []string
if routeManager != nil {
routes = routeManager.CurrentRouteRange()
}
var searchDomains []string
if dnsServer != nil {
searchDomains = dnsServer.SearchDomains()
}
return routes, searchDomains
}

View File

@@ -3,6 +3,7 @@ package peer
import (
"context"
"fmt"
"math"
"net"
"net/netip"
"runtime"
@@ -26,6 +27,7 @@ import (
"github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
@@ -74,6 +76,39 @@ 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)
// ShouldSendBootstrapOffer reports whether, as the controller, we should reply to a
// received responder offer with our own KEM offer (true only when no exchange is
// already in flight — so we kick the KEM once and ignore further offers).
ShouldSendBootstrapOffer(remoteKey string) bool
// 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 announced pq UDP port (port 0 means the peer omitted
// it and is on the default 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. sinceActivity is how long
// ago the peer last exchanged real user data, so the rotation can be skipped for
// idle tunnels.
OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration)
// 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 +126,12 @@ type ConnConfig struct {
RosenpassConfig RosenpassConfig
// PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled.
PQ PQHandshaker
// PQStrict fails closed: block peer traffic until the ML-KEM PSK is established,
// instead of letting the tunnel come up classically and upgrading to PQ later.
PQStrict bool
// ICEConfig ICE protocol configuration
ICEConfig icemaker.Config
}
@@ -149,6 +190,11 @@ type Conn struct {
// pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
// transport is up.
pendingFirstPacket []byte
// pqStrictSentinelKey is a per-conn random sentinel PSK used in PQ strict mode to
// fail closed: it is programmed until the real ML-KEM PSK is derived, so no session
// can form on a non-PQ key. Per-conn random so two strict peers never match by chance.
pqStrictSentinelKey *wgtypes.Key
}
// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
@@ -206,6 +252,14 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
metricsRecorder: services.MetricsRecorder,
}
if config.PQ != nil && config.PQStrict {
if k, err := wgtypes.GenerateKey(); err != nil {
connLog.Errorf("pqkem: failed to generate strict-mode sentinel key, strict fail-closed disabled for this peer: %v", err)
} else {
conn.pqStrictSentinelKey = &k
}
}
return conn, nil
}
@@ -670,6 +724,22 @@ func (conn *Conn) onGuardEvent() {
}
}
// RequestReoffer sends a fresh signalling offer for the peer, re-running the
// post-quantum bootstrap over Signal. Used to recover from a persistent data-path
// rekey failure: a new exchange overwrites the stalled PSK on both sides. No-op if the
// connection is not open yet.
func (conn *Conn) RequestReoffer() {
conn.mu.Lock()
h := conn.handshaker
conn.mu.Unlock()
if h == nil {
return
}
if err := h.SendOffer(); err != nil {
conn.Log.Debugf("pqkem: recovery re-offer failed: %v", err)
}
}
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -681,6 +751,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:
@@ -723,7 +797,7 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by
ConnStatus: conn.evalStatus(),
Relayed: conn.isRelayed(),
RelayServerAddress: relayServerAddr,
RosenpassEnabled: isRosenpassEnabled(rosenpassPubKey),
RosenpassEnabled: conn.quantumResistant(rosenpassPubKey),
}
err := conn.statusRecorder.UpdatePeerRelayedState(peerState)
@@ -742,7 +816,7 @@ func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time)
RemoteIceCandidateType: iceConnInfo.RemoteIceCandidateType,
LocalIceCandidateEndpoint: iceConnInfo.LocalIceCandidateEndpoint,
RemoteIceCandidateEndpoint: iceConnInfo.RemoteIceCandidateEndpoint,
RosenpassEnabled: isRosenpassEnabled(iceConnInfo.RosenpassPubKey),
RosenpassEnabled: conn.quantumResistant(iceConnInfo.RosenpassPubKey),
}
err := conn.statusRecorder.UpdatePeerICEState(peerState)
@@ -946,6 +1020,35 @@ func (conn *Conn) onWGCheckSuccess() {
conn.mu.Lock()
conn.wgTimeouts = 0
conn.mu.Unlock()
// A fresh WireGuard handshake clocks the post-quantum PSK rotation. Pass how long
// ago the peer last exchanged real user data (keepalives excluded) so the pqkem
// manager can skip rotation on idle tunnels — rotating then would push data-path
// traffic that keeps the lazy connection artificially active.
if conn.config.PQ != nil {
conn.config.PQ.OnDataPathRekeyed(conn.config.Key, conn.dataActivityAge())
}
}
// dataActivityAge returns how long ago the peer last exchanged real user data
// (WireGuard keepalives excluded), per the same LastActivities signal the
// lazy-connection inactivity monitor uses. It reports a very large duration when no
// activity has ever been recorded, so the peer is treated as idle.
//
// In kernel mode there is no per-peer data-activity signal (LastActivities is
// userspace-only), so we cannot tell active from idle. We report zero — always
// "active" — so PSK rotation is not disabled in kernel mode. Lazy back-to-idle is
// already limited there; the eBPF WG-activity detection (future) will supply a real
// signal that excludes handshake/pqkem traffic.
func (conn *Conn) dataActivityAge() time.Duration {
if !conn.config.WgConfig.WgInterface.IsUserspaceBind() {
return 0
}
last, ok := conn.config.WgConfig.WgInterface.LastActivities()[conn.config.WgConfig.RemoteKey]
if !ok {
return time.Duration(math.MaxInt64)
}
return monotime.Since(last)
}
// recordConnectionMetrics records connection stage timestamps as metrics
@@ -987,6 +1090,23 @@ 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.PQStrict && conn.pqStrictSentinelKey != nil {
// Fail closed: program a non-matching sentinel so no session forms on a
// non-PQ key until the ML-KEM exchange derives the real PSK (pushed via
// SetPresharedKey once it converges). "pending" — turns into a "stuck"
// warning from the manager if the exchange keeps failing (see raiseFailure).
conn.Log.Debugf("pqkem: strict mode — no PQ PSK yet, blocking peer traffic until the ML-KEM exchange converges")
return conn.pqStrictSentinelKey
}
}
if conn.config.RosenpassConfig.PubKey == nil {
return conn.config.WgConfig.PreSharedKey
}
@@ -1026,6 +1146,21 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool {
return remoteRosenpassPubKey != nil
}
// quantumResistant reports whether the peer's tunnel is post-quantum protected, for
// the status "Quantum resistance" field: either Rosenpass (the remote advertised a
// Rosenpass key) or the ML-KEM exchange (a PQ PSK has been derived for this peer).
func (conn *Conn) quantumResistant(remoteRosenpassPubKey []byte) bool {
if isRosenpassEnabled(remoteRosenpassPubKey) {
return true
}
if conn.config.PQ != nil {
if _, ok := conn.config.PQ.PSK(conn.config.Key); ok {
return true
}
}
return false
}
func evalConnStatus(in connStatusInputs) guard.ConnStatus {
// "Relay up and needed" — the peer uses relay and the transport is connected.
relayUsedAndUp := in.peerUsesRelay && in.relayConnected

View File

@@ -0,0 +1,86 @@
package peer
import (
"net/netip"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// fakePQ is a minimal PQHandshaker: only PSK is exercised by presharedKey, the rest
// are no-op stubs to satisfy the interface.
type fakePQ struct {
psk wgtypes.Key
ok bool
}
func (f fakePQ) OfferPayload(string) ([]byte, int) { return nil, 0 }
func (f fakePQ) ShouldSendBootstrapOffer(string) bool { return false }
func (f fakePQ) AnswerPayload(string, []byte) ([]byte, int) { return nil, 0 }
func (f fakePQ) OnAnswer(string, []byte) {}
func (f fakePQ) PSK(string) (wgtypes.Key, bool) { return f.psk, f.ok }
func (f fakePQ) SetRemoteAddr(string, netip.AddrPort) {}
func (f fakePQ) OnDataPathRekeyed(string, time.Duration) {}
func (f fakePQ) OnDataPathDown(string) {}
// TestConn_presharedKey_PQ covers the post-quantum branch of presharedKey across the
// three states that matter: a derived PSK is programmed, and — before one exists —
// strict mode blocks with a sentinel while non-strict falls open to the ordinary key.
func TestConn_presharedKey_PQ(t *testing.T) {
derivedPSK, err := wgtypes.GenerateKey()
require.NoError(t, err)
nbPSK, err := wgtypes.GenerateKey()
require.NoError(t, err)
newConn := func() *Conn {
return &Conn{
Log: log.WithField("peer", "pq-test"),
config: ConnConfig{
Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
WgConfig: WgConfig{PreSharedKey: &nbPSK},
RosenpassConfig: RosenpassConfig{},
},
}
}
t.Run("derived PSK is programmed", func(t *testing.T) {
for _, strict := range []bool{false, true} {
c := newConn()
c.config.PQ = fakePQ{psk: derivedPSK, ok: true}
c.config.PQStrict = strict
if strict {
sentinel, _ := wgtypes.GenerateKey()
c.pqStrictSentinelKey = &sentinel
}
got := c.presharedKey(nil)
require.NotNil(t, got)
require.Equal(t, derivedPSK, *got, "the derived PQ PSK must win (strict=%v)", strict)
}
})
t.Run("non-strict falls open to the ordinary key before a PSK exists", func(t *testing.T) {
c := newConn()
c.config.PQ = fakePQ{ok: false}
c.config.PQStrict = false
got := c.presharedKey(nil)
require.NotNil(t, got, "non-strict must not block")
require.Equal(t, nbPSK, *got, "non-strict falls through to the NetBird PSK, not a sentinel")
})
t.Run("strict blocks with the per-conn sentinel before a PSK exists", func(t *testing.T) {
sentinel, err := wgtypes.GenerateKey()
require.NoError(t, err)
c := newConn()
c.config.PQ = fakePQ{ok: false}
c.config.PQStrict = true
c.pqStrictSentinelKey = &sentinel
got := c.presharedKey(nil)
require.NotNil(t, got)
require.Equal(t, sentinel, *got, "strict must return the blocking sentinel")
require.NotEqual(t, nbPSK, *got, "the sentinel must not be the ordinary key")
})
}

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
@@ -81,14 +91,20 @@ type Handshaker struct {
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
h := &Handshaker{
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
remoteOffersCh: make(chan OfferAnswer),
remoteAnswerCh: make(chan OfferAnswer),
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
// Buffered by 1: the single Listen goroutine can be busy handling an offer
// (sendAnswer does a blocking signal send) exactly when the matching answer
// arrives on the other channel. Unbuffered, that answer would hit the
// non-blocking send's default and be dropped — fatal for the post-quantum
// exchange, which needs the answer to converge. A 1-slot cushion lets it wait
// until Listen loops back, without ever blocking the signal receiver.
remoteOffersCh: make(chan OfferAnswer, 1),
remoteAnswerCh: make(chan OfferAnswer, 1),
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
@@ -120,6 +136,42 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
// Post-quantum: the KEM material rides only the controller's offer, so the two
// peers derive a single shared PSK (a bidirectional KEM would yield two
// different PSKs and WireGuard would pick misaligned ones). If we are the
// controller and receive the responder's (KEM-less) offer, we must NOT let
// that KEM-less transaction drive the connection: it would bring WireGuard up
// on a pre-PQ key before the KEM completes (the race). Instead we reply with
// OUR offer, which carries the KEM, so the only transaction that establishes
// the tunnel is the one that also derives the PSK. This also guarantees a
// responder-initiated wake still triggers a KEM offer (no stuck responder).
// The re-offer reuses our stable ICE session id, so the peer dedups repeats.
if h.config.PQ != nil && isController(h.config) {
// Reply with our KEM offer exactly once, to kick the exchange. If one is
// already in flight, ignore this offer — re-sending on every responder
// offer would be an offer-per-offer runaway.
if h.config.PQ.ShouldSendBootstrapOffer(h.config.Key) {
h.log.Debugf("pqkem: controller received a responder offer, replying with our KEM offer instead of an answer")
if err := h.sendOffer(); err != nil {
h.log.Errorf("failed to send KEM offer in response to peer offer: %s", err)
}
} else {
h.log.Debugf("pqkem: controller received a responder offer but a KEM exchange is already in flight, ignoring")
}
continue
}
// Derive+store the KEM PSK (inside sendAnswer's AnswerPayload) BEFORE bringing
// up the connection: the relay/ICE workers configure the WG endpoint, which
// pulls the PSK for the first handshake. Notifying them first would race the
// KEM exchange and hand the first handshake a not-yet-derived key.
if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -127,11 +179,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
if h.iceListener != nil && h.RemoteICESupported() {
h.iceListener(&remoteOfferAnswer)
}
if err := h.sendAnswer(); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err)
continue
}
case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
@@ -142,6 +189,15 @@ func (h *Handshaker) Listen(ctx context.Context) {
h.updateRemoteICEState(&remoteOfferAnswer)
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
// Feed the KEM answer (derive+store PSK) BEFORE bringing up the connection so
// the WG endpoint config pulls the real PSK for the first handshake instead of
// racing ahead of the KEM exchange.
if h.config.PQ != nil {
h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload)
}
if h.relayListener != nil {
h.relayListener.Notify(&remoteOfferAnswer)
}
@@ -156,6 +212,18 @@ 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
}
// remotePort may be 0 (the peer omitted it, meaning the default port); the adapter
// resolves 0 to DefaultPort.
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 +263,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

@@ -10,6 +10,7 @@ import (
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/iface/wgproxy"
"github.com/netbirdio/netbird/monotime"
)
type WGIface interface {
@@ -19,4 +20,11 @@ type WGIface interface {
GetProxy() wgproxy.Proxy
Address() wgaddr.Address
RemoveEndpointAddress(key string) error
// LastActivities returns the last real-data activity time per peer (WireGuard
// keepalives excluded), used to gate post-quantum PSK rotation on active tunnels.
LastActivities() map[string]monotime.Time
// IsUserspaceBind reports whether WireGuard runs in userspace. Only there does
// LastActivities track per-peer data activity; in kernel mode it is unavailable,
// so PSK rotation cannot be gated on activity.
IsUserspaceBind() bool
}

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

@@ -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,65 @@
package pqkem
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestManager_NonCapablePeerNotOffered: a peer known not to run the KEM (it advertised
// no PQ port over signalling) is never offered an exchange, and no failure is raised —
// this is what stops the reoffer storm against non-PQ peers (e.g. Rosenpass peers).
func TestManager_NonCapablePeerNotOffered(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil) // initiator vs "aaaa"
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
d.MarkNonCapable("aaaa")
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.Nil(t, offer, "a non-capable peer must not be offered a KEM exchange")
require.Empty(t, wg.failed, "a non-capable peer must not raise a rekey failure")
}
// TestManager_MarkNonCapableCancelsInFlight: if we start an exchange with a peer whose
// capability is not yet known and then learn it does not run the KEM, the in-flight
// exchange is cancelled and no further offer is produced (no timeout -> no failure).
func TestManager_MarkNonCapableCancelsInFlight(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil)
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
// Capability unknown -> the bootstrap offer goes out optimistically.
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
// Now we learn the peer is non-PQ: the exchange must be dropped.
d.MarkNonCapable("aaaa")
next, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.Nil(t, next, "after learning non-capability the peer is no longer offered")
require.Empty(t, wg.failed, "cancelling an in-flight exchange must not raise a failure")
}
// TestManager_EstablishedPeerNotDowngraded: a stray zero-port observation must not tear
// down a peer we already have a working PQ session with.
func TestManager_EstablishedPeerNotDowngraded(t *testing.T) {
dA, dB, _, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
require.NotEqual(t, PSK{}, wgB.psk("aaaa"), "established a PSK")
dB.MarkNonCapable("aaaa") // stray zero after establishment
// The peer keeps its derived PSK (MarkNonCapable is a no-op once established).
psk, ok := dB.PSK("aaaa")
require.True(t, ok, "an established peer must keep its PSK despite a stray zero")
require.NotEqual(t, PSK{}, psk)
}

View File

@@ -0,0 +1,77 @@
package pqkem
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestConcurrency_RecoversViaResignalAfterDataPathBreak exercises the A-light recovery:
// a data-path rotation can no longer converge (OnRekeyFailed), and re-bootstrapping over
// signalling resyncs both peers on a fresh PSK — even while the data path stays broken,
// since the signal channel is independent of it.
func TestConcurrency_RecoversViaResignalAfterDataPathBreak(t *testing.T) {
dA, dB, wgA, wgB, lbB := pair(t)
defer dA.Stop()
defer dB.Stop()
// Tighten B's timings and make a single rotation miss raise OnRekeyFailed. Set
// before any exchange loop spawns (the loop reads these fields).
dB.retryInterval = 5 * time.Millisecond
dB.maxRetries = 2
dB.maxRekeyFailures = 1
bootstrap(t, dA, dB)
dA.OnDataPathRekeyed("bbbb", 0)
dB.OnDataPathRekeyed("aaaa", 0)
psk1 := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, psk1)
require.Equal(t, psk1, wgA.psk("bbbb"), "converged on the same PSK after bootstrap+rotation")
// Data path breaks: the rotation can no longer converge -> OnRekeyFailed.
lbB.drop.Store(true)
_, err := dB.startExchange("aaaa", false, ExchangeID{})
require.NoError(t, err)
require.Eventually(t, func() bool { return failedCount(wgB) >= 1 }, time.Second, 5*time.Millisecond)
// Recovery: re-bootstrap over signalling with the data path STILL broken. It must
// still converge (signal is independent of the data path) on a fresh PSK.
bootstrap(t, dA, dB)
psk2 := wgB.psk("aaaa")
require.NotEqual(t, psk1, psk2, "recovery derived a fresh PSK")
require.Equal(t, psk2, wgA.psk("bbbb"), "both sides resync after recovery")
}
// TestConcurrency_ConcurrentRekeysNoRace hammers both managers with concurrent rotation
// clocks from many goroutines. Its primary job (with -race) is to prove the single-lock
// state machine has no data races or deadlocks under contention; a final deterministic
// bootstrap then asserts there is no split-brain (both sides on the same PSK).
func TestConcurrency_ConcurrentRekeysNoRace(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
dB.OnDataPathRekeyed("aaaa", 0) // initiator chains a rotation
dA.OnDataPathRekeyed("bbbb", 0) // responder side is a no-op, still stresses the lock
}
}()
}
wg.Wait()
// The storm may leave an exchange mid-flight (concurrent cancellation). Force a
// clean convergence over signalling, then assert no split-brain.
bootstrap(t, dA, dB)
a, b := wgA.psk("bbbb"), wgB.psk("aaaa")
require.NotEqual(t, PSK{}, b)
require.Equal(t, a, b, "both sides converge on the same PSK, no split-brain")
}

View File

@@ -0,0 +1,273 @@
package pqkem
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
)
// idHex renders an exchange ID for logs.
func idHex(id ExchangeID) string { return hex.EncodeToString(id[:]) }
// pskFingerprint is a short, non-secret digest of a derived PSK: identical on both
// peers iff they derived the same key. Logged instead of the raw PSK so debug logs
// never carry the actual WireGuard preshared key.
func pskFingerprint(psk PSK) string {
sum := sha256.Sum256(psk[:])
return hex.EncodeToString(sum[:8])
}
// 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)
via := "data-path"
if viaSignal {
via = "signal"
}
m.trace("pqkem: offer sent", "peer", remoteID, "exchange", idHex(id), "acks", idHex(ackID), "via", via)
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) {
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
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
}
m.trace("pqkem: duplicate offer, resending cached answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
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()
m.trace("pqkem: exchange superseded during respond, dropping answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
return nil, nil
}
ex.state = stateAwaitingAck
ex.lastSent = raw
ex.pendingPSK = psk
m.psks[remoteID] = psk
m.capable[remoteID] = true // a real KEM offer proves the peer runs the exchange
m.mu.Unlock()
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
// Commit optimistically so our data path can rekey to the new PSK.
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
return nil, err
}
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
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 {
haveID := "none"
if ex != nil {
haveID = idHex(ex.id)
}
m.mu.Unlock()
m.trace("pqkem: unexpected answer dropped (inconsistency)", "peer", remoteID, "answer_for", idHex(a.ExchangeID), "have_exchange", haveID)
return nil
}
ex.state = stateAwaitingRekey
init := ex.initiator
ex.initiator = nil
m.mu.Unlock()
m.trace("pqkem: answer received", "peer", remoteID, "exchange", idHex(a.ExchangeID))
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.capable[remoteID] = true // a real KEM answer proves the peer runs the exchange
m.mu.Unlock()
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
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()
m.trace("pqkem: ack for unknown/mismatched exchange, ignored (inconsistency)", "peer", remoteID, "acks", idHex(ackID))
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()
m.trace("pqkem: previous exchange confirmed by ack", "peer", remoteID, "exchange", idHex(ackID))
}
// 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)
initial := !m.established[remoteID]
fail := m.registerFailureLocked(remoteID)
m.mu.Unlock()
m.raiseFailure(remoteID, fail, initial)
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
}
// raiseFailure reports a convergence failure. initial distinguishes a never-established
// peer (bootstrap failed → no PQ PSK at all; in strict mode the peer stays blocked =
// "stuck") from a rekey failure (a previous PSK is still in force and traffic continues).
func (m *Manager) raiseFailure(remoteID RemoteID, fail, initial bool) {
if !fail {
m.logger.Warn("pqkem: rekey attempt timed out, will retry next cycle", "peer", remoteID)
return
}
if initial {
m.logger.Warn("pqkem: initial exchange failed — no PQ PSK established for peer (strict mode keeps the peer blocked until it converges)", "peer", remoteID)
} else {
m.logger.Warn("pqkem: rekey failed after retries — staying on the previous PSK", "peer", remoteID)
}
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", 0)
dB.OnDataPathRekeyed("aaaa", 0)
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,138 @@
package pqkem
import (
"context"
"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
}
// EnvStrict enables strict (fail-closed) mode: block peer traffic until the ML-KEM
// PSK has been established, instead of the default opportunistic behaviour that lets
// the tunnel come up classically and upgrades to PQ once the exchange converges.
const EnvStrict = "NB_PQ_MLKEM_STRICT"
// Strict reports whether strict (fail-closed) mode is enabled via the environment.
// An empty or unrecognized value is treated as disabled (opportunistic).
func Strict() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict))) {
case "on":
return true
case "", "off":
return false
}
enabled, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(EnvStrict)))
if err != nil {
log.Warnf("failed to parse %s value %q: %v", EnvStrict, os.Getenv(EnvStrict), err)
return false
}
return enabled
}
// EnvLogLevel overrides the ML-KEM manager's slog level (trace/debug/info/warn/error).
// Defaults to info. The verbose per-exchange lifecycle logs are emitted at trace.
const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"
// LevelTrace is a custom slog level below Debug for the verbose per-exchange lifecycle
// logs, so they stay off unless NB_PQ_MLKEM_LOG_LEVEL=trace (and the daemon log level
// is trace, since the records are forwarded to logrus).
const LevelTrace = slog.LevelDebug - 4
// NewLogger builds the slog logger for the ML-KEM manager. It forwards records to
// logrus so PQ logs land in the same sink as the rest of the daemon (console +
// client.log) rather than stdout. Verbosity is gated by EnvLogLevel.
func NewLogger() *slog.Logger {
return slog.New(slogToLogrus{})
}
func logLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvLogLevel))) {
case "trace":
return LevelTrace
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// slogToLogrus is a slog.Handler that forwards records to logrus, so the ML-KEM
// manager's logs go wherever the daemon's logrus is configured (console + client.log)
// instead of stdout. Verbosity is gated by EnvLogLevel via logLevel().
type slogToLogrus struct {
fields log.Fields
}
func (h slogToLogrus) Enabled(_ context.Context, level slog.Level) bool {
return level >= logLevel()
}
func (h slogToLogrus) Handle(_ context.Context, r slog.Record) error {
fields := make(log.Fields, len(h.fields)+r.NumAttrs())
for k, v := range h.fields {
fields[k] = v
}
r.Attrs(func(a slog.Attr) bool {
fields[a.Key] = a.Value.Any()
return true
})
entry := log.WithFields(fields)
switch {
case r.Level >= slog.LevelError:
entry.Error(r.Message)
case r.Level >= slog.LevelWarn:
entry.Warn(r.Message)
case r.Level >= slog.LevelInfo:
entry.Info(r.Message)
case r.Level >= slog.LevelDebug:
entry.Debug(r.Message)
default:
entry.Trace(r.Message)
}
return nil
}
func (h slogToLogrus) WithAttrs(attrs []slog.Attr) slog.Handler {
fields := make(log.Fields, len(h.fields)+len(attrs))
for k, v := range h.fields {
fields[k] = v
}
for _, a := range attrs {
fields[a.Key] = a.Value.Any()
}
return slogToLogrus{fields: fields}
}
func (h slogToLogrus) WithGroup(_ string) slog.Handler { return h }

View File

@@ -0,0 +1,178 @@
// 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 as the KDF input. The PSK is derived with
// HKDF-SHA256 over that hybrid secret, salted with a domain-separation label and
// bound (via the HKDF info) to the full transcript and the canonicalised peer
// identities.
package pqkem
import (
"crypto/ecdh"
"crypto/hkdf"
"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)
}
// 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.
psk, err = derivePSK(ssMLKEM, ssX, offer, answer, b)
if err != nil {
return nil, PSK{}, err
}
return answer, psk, nil
}
// derivePSK runs HKDF-SHA256 over the hybrid shared secret (ML-KEM_ss ‖ X25519_ss,
// per draft-ietf-tls-ecdhe-mlkem), salted with the domain-separation label, and binds
// the result — via the HKDF info — to the full transcript (offer ‖ answer) and the
// canonicalised peer identities, so the PSK cannot be transplanted to another peer
// pair or a different exchange.
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) (PSK, error) {
lo, hi := canonicalPair(b.LocalID, b.RemoteID)
ikm := make([]byte, 0, len(ssMLKEM)+len(ssX))
ikm = append(ikm, ssMLKEM...)
ikm = append(ikm, ssX...)
info := make([]byte, 0, len(offer)+len(answer)+len(lo)+len(hi))
info = append(info, offer...)
info = append(info, answer...)
info = append(info, lo...)
info = append(info, hi...)
var psk PSK
key, err := hkdf.Key(sha256.New, ikm, []byte(pskLabel), string(info), len(psk))
if err != nil {
return PSK{}, fmt.Errorf("hkdf derive psk: %w", err)
}
copy(psk[:], key)
return psk, nil
}
func canonicalPair(a, b []byte) (lo, hi []byte) {
if string(a) <= string(b) {
return a, b
}
return b, a
}

View File

@@ -0,0 +1,105 @@
package pqkem
import (
"crypto/mlkem"
"testing"
"github.com/stretchr/testify/require"
)
// TestExchange_TamperedCiphertextFailsClosed verifies the core fail-closed
// property: mutating the ML-KEM ciphertext in the answer does not error (ML-KEM
// uses implicit rejection — Decapsulate always returns a value) but yields a
// different shared secret, so the initiator derives a PSK that does NOT match the
// responder's. A mismatched PSK means WireGuard passes no bytes: tamper => no data.
func TestExchange_TamperedCiphertextFailsClosed(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tampered := append([]byte(nil), answer...)
tampered[0] ^= 0xff // flip a bit in the ML-KEM ciphertext
pskA, err := init.Finish(tampered, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err, "implicit rejection: decapsulate still succeeds")
require.NotEqual(t, pskB, pskA, "tampered ciphertext must not yield the responder's PSK")
}
// TestExchange_TamperedX25519ShareDiverges flips a byte in the answer's X25519
// share: the classical half of the hybrid secret changes, so the derived PSK
// diverges from the responder's (fail-closed on the ECDH half too).
func TestExchange_TamperedX25519ShareDiverges(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
tampered := append([]byte(nil), answer...)
tampered[mlkem.CiphertextSize768] ^= 0x01 // first byte of the X25519 public key
pskA, err := init.Finish(tampered, Binding{LocalID: wgA, RemoteID: wgB})
// Either the point is rejected (error) or the ECDH differs (different PSK);
// in both cases the honest PSK is never reproduced.
if err == nil {
require.NotEqual(t, pskB, pskA, "tampered X25519 share must not yield the responder's PSK")
}
}
// TestExchange_AllZeroX25519Rejected feeds an all-zero X25519 share (a low-order
// point) in the answer. The stdlib ECDH must reject it, so Finish errors rather
// than deriving a PSK from a degenerate secret.
func TestExchange_AllZeroX25519Rejected(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
bad := append([]byte(nil), answer...)
for i := mlkem.CiphertextSize768; i < len(bad); i++ {
bad[i] = 0
}
_, err = init.Finish(bad, Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "all-zero X25519 share (low-order point) must be rejected")
}
// TestExchange_SizeBoundaries locks the exact-length framing checks: one byte
// short or long on either message is rejected, not silently truncated/padded.
func TestExchange_SizeBoundaries(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
offer := init.Offer()
_, _, err = Respond(offer[:OfferSize-1], Binding{})
require.Error(t, err, "offer one byte short")
_, _, err = Respond(append(append([]byte(nil), offer...), 0), Binding{})
require.Error(t, err, "offer one byte long")
answer, _, err := Respond(offer, Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
_, err = init.Finish(answer[:AnswerSize-1], Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "answer one byte short")
_, err = init.Finish(append(append([]byte(nil), answer...), 0), Binding{LocalID: wgA, RemoteID: wgB})
require.Error(t, err, "answer one byte long")
}
// TestExchange_BindingIsSymmetric confirms the canonicalisation: the two peers
// pass their identities in opposite (Local, Remote) order yet derive the same PSK,
// so identity binding does not depend on who is initiator vs responder.
func TestExchange_BindingIsSymmetric(t *testing.T) {
init, err := NewInitiator()
require.NoError(t, err)
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
require.NoError(t, err)
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
require.NoError(t, err)
require.Equal(t, pskB, pskA, "swapped Local/Remote order must canonicalise to the same PSK")
}

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,460 @@
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
// rotationActivityWindow gates rotation on recent real-data activity: a rekey
// clocks a rotation only if the peer exchanged user data within this window. It
// must stay shorter than the data path's rekey interval (WireGuard
// REKEY_AFTER_TIME ~120s) so the rotation's own traffic — which itself renews the
// activity signal — ages out before the next rekey, letting an idle tunnel stop
// rotating instead of self-sustaining.
rotationActivityWindow = 90 * time.Second
)
// 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)
capable map[RemoteID]bool // peer runs the KEM (advertised a PQ port); false = known non-capable
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),
capable: make(map[RemoteID]bool),
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.
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
}
// trace logs at LevelTrace, the verbose per-exchange lifecycle level gated by
// NB_PQ_MLKEM_LOG_LEVEL=trace.
func (m *Manager) trace(msg string, args ...any) {
m.logger.Log(context.Background(), LevelTrace, msg, args...)
}
// AddPeer registers where a peer's data-path messages are sent and received: its
// overlay endpoint (IP:port). This is pure routing and says nothing about capability —
// PQ capability is decided solely from the peer's KEM payload (see processOffer /
// processAnswer / MarkNonCapable), never from an endpoint or port.
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()
}
// MarkNonCapable records that a peer does not run the KEM: it answered our offer with
// no KEM material over signalling (the capability signal is the peer's payload, not its
// optional data-path port). Any in-flight exchange is cancelled and further offers are
// suppressed (see SignalOffer), so a non-PQ peer never drives the rekey-recovery storm.
// An already-established peer is left untouched — a stray empty answer must not tear
// down a working PQ session.
func (m *Manager) MarkNonCapable(remoteID RemoteID) {
m.mu.Lock()
defer m.mu.Unlock()
if m.established[remoteID] {
return
}
if prev, ok := m.capable[remoteID]; ok && !prev {
return // already known non-capable, nothing to do
}
m.capable[remoteID] = false
if ex := m.exchanges[remoteID]; ex != nil {
if ex.cancel != nil {
ex.cancel()
}
delete(m.exchanges, remoteID)
}
m.trace("pqkem: peer advertises no PQ service — treating as non-capable, no KEM attempted", "peer", remoteID)
}
// 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)
delete(m.capable, 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.
//
// A signal re-negotiation always re-bootstraps (fresh exchange): the remote may have
// restarted and lost its PSK, so reusing a locally frozen one would desync. The derived
// PSK still survives idle in the manager (dropped only on account-level peer removal),
// so a pure lazy wake with no re-negotiation reuses it via the conn's WG-config pull.
func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
if !m.IsInitiator(remoteID) {
return nil, nil
}
m.mu.Lock()
if capable, ok := m.capable[remoteID]; ok && !capable {
m.mu.Unlock()
return nil, nil // peer does not run the KEM; do not offer (avoids a failure/reoffer loop)
}
// Idempotent while a signalling bootstrap is in flight OR already derived a PSK but
// not yet chained a rotation (awaitingRekey): return the SAME offer instead of
// starting a new exchange. This matters when the controller both offers on its own
// guard AND re-offers in response to the responder's offer — without this, the
// second call would start a fresh exchange (a different PSK) and desync the peers.
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal &&
(ex.state == stateAwaitingAnswer || ex.state == stateAwaitingRekey) {
last := ex.lastSent
m.mu.Unlock()
return last, nil
}
m.mu.Unlock()
// bootstrap offer acknowledges nothing (zero AckID).
return m.startExchange(remoteID, true, ExchangeID{})
}
// ShouldSendBootstrapOffer reports whether we should emit a fresh KEM offer to kick a
// bootstrap for this peer. True only if we are the initiator, the peer is not known
// non-capable, and no exchange is already in flight. The host uses this when it (as the
// controller) receives the responder's offer: it replies with a KEM offer exactly once
// to start the exchange, and ignores further responder offers while one is in flight,
// avoiding an offer-per-offer runaway.
func (m *Manager) ShouldSendBootstrapOffer(remoteID RemoteID) bool {
if !m.IsInitiator(remoteID) {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
if capable, ok := m.capable[remoteID]; ok && !capable {
return false
}
return m.exchanges[remoteID] == nil
}
// 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.trace("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 clocks the next chained PSK rotation on a fresh data-path rekey
// (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 the key works). sinceActivity is how long ago the peer last exchanged real
// user data; past rotationActivityWindow the tunnel is treated as idle and rotation is
// skipped — an idle tunnel has nothing to protect, and rotating would emit data-path
// traffic that keeps the peer artificially active (see conn.onWGCheckSuccess).
func (m *Manager) OnDataPathRekeyed(remoteID RemoteID, sinceActivity time.Duration) {
if sinceActivity >= rotationActivityWindow {
m.trace("pqkem: peer idle, skipping data-path rotation", "peer", remoteID, "since_activity", sinceActivity)
return
}
m.mu.Lock()
ex := m.exchanges[remoteID]
chain := ex != nil && ex.state == stateAwaitingRekey
var ackID ExchangeID
if chain {
ackID = ex.id
}
m.mu.Unlock()
m.trace("pqkem: data-path rekey signal", "peer", remoteID, "chaining", chain)
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)
return
}
m.trace("pqkem: chain offer sent over data path", "peer", remoteID)
}
// 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,193 @@
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", 0)
dB.OnDataPathRekeyed("aaaa", 0)
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_RotationSkippedWhenIdle(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk1 := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, psk1)
// Idle: the peer's last real-data activity is older than the window, so a rekey
// must NOT clock a rotation.
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow)
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow)
require.Equal(t, psk1, wgB.psk("aaaa"), "idle peer must not rotate the PSK")
require.Equal(t, psk1, wgA.psk("bbbb"), "idle peer must not rotate the PSK")
// Active: activity within the window clocks the rotation as usual.
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow-1)
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow-1)
psk2 := wgB.psk("aaaa")
require.NotEqual(t, psk1, psk2, "recent activity must clock a rotation")
require.Equal(t, psk2, wgA.psk("bbbb"), "both sides converge on the rotated 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,150 @@
package internal
import (
"net/netip"
"time"
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
// reoffer re-bootstraps the KEM over Signal for a peer (a fresh signalling offer)
// to recover from a persistent data-path rekey failure. Nil disables recovery.
reoffer func(remoteKey string)
}
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
// if the peer is not present, mirroring Rosenpass).
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.Tracef("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 and re-bootstraps the KEM over
// Signal to recover: a fresh signalling offer starts a new exchange that overwrites the
// stalled PSK on both sides, resyncing after a persistent data-path desync. The tunnel
// stays up on the previous PSK meanwhile (the Signal channel is independent of the
// broken data path).
func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error {
log.Warnf("pqkem: post-quantum rekey failed for peer %s, re-bootstrapping over signal", remoteID)
if h.reoffer != nil {
h.reoffer(string(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
}
// announcedPort is the PQ data-path port to advertise to peers. It is omitted (0) when
// the manager is on DefaultPort, since peers assume the default when no port is sent;
// only a non-default (collision-forced) port is announced explicitly.
func (p pqHandshaker) announcedPort() int {
if port := p.mgr.LocalPort(); port != DefaultPort {
return port
}
return 0
}
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.announcedPort()
}
func (p pqHandshaker) ShouldSendBootstrapOffer(remoteKey string) bool {
return p.mgr.ShouldSendBootstrapOffer(pqkem.RemoteID(remoteKey))
}
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) {
if len(recvOffer) == 0 {
// Capability signal (responder side): the KEM offer flows initiator->responder,
// so if we are the responder for this peer (it is the KEM initiator by role) an
// empty offer means it does not run the KEM. If we are the initiator, an empty
// offer is normal — the peer is the responder and puts its material in the
// answer — so we must not flag it.
if !p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
return nil, p.announcedPort()
}
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.announcedPort()
}
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
if len(recvAnswer) == 0 {
// Capability signal (initiator side): the KEM answer flows responder->initiator,
// so an empty answer to our offer means the peer does not run the KEM — mark it
// non-capable to stop offering (no failure/reoffer storm). Only meaningful when
// we are the initiator: as the responder we also receive an (empty) answer to
// our own non-KEM offer from a perfectly capable peer, which must not be flagged.
if p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
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 learned from signalling. A
// zero port means the peer omitted it (it is on DefaultPort), so we resolve it here —
// DefaultPort lives in this package, not in peer. 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.Addr().IsValid() {
return
}
port := addr.Port()
if port == 0 {
port = DefaultPort
}
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), netip.AddrPortFrom(addr.Addr(), port))
}
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
// sinceActivity is how long ago the peer last exchanged real user data; the manager
// skips rotation for idle tunnels.
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration) {
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey), sinceActivity)
}
// 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,37 @@
package internal
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
type pqNoopHandler struct{}
func (pqNoopHandler) OnNewPSKReady(pqkem.RemoteID, pqkem.PSK) error { return nil }
func (pqNoopHandler) OnRekeyFailed(pqkem.RemoteID) error { return nil }
// TestPQAdapter_CapabilityRoleAware locks the role-aware capability signal: the KEM
// payload only flows initiator-offer -> responder-answer, so an empty message in the
// other direction comes from a perfectly capable peer and must NOT flag it. Only the
// message that should carry material (the answer we receive as initiator) marks a peer
// non-capable when empty.
func TestPQAdapter_CapabilityRoleAware(t *testing.T) {
// localID "zzzz" > "aaaa" => this manager is the KEM initiator for peer "aaaa".
mgr := pqkem.NewManager("zzzz", pqNoopHandler{}, nil)
defer mgr.Stop()
h := pqHandshaker{mgr: mgr}
// An empty OFFER from our peer is normal here: as the initiator's responder it puts
// its material in the answer, not the offer. It must not disable our offering.
h.AnswerPayload("aaaa", nil)
payload, _ := h.OfferPayload("aaaa")
require.NotNil(t, payload, "an empty offer from a responder-role peer must not mark it non-capable")
// An empty ANSWER to our offer means the peer does not run the KEM -> stop offering.
h.OnAnswer("aaaa", nil)
payload2, _ := h.OfferPayload("aaaa")
require.Nil(t, payload2, "an empty answer to our offer marks the peer non-capable, so we stop offering")
}

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

@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
return &state, nil
}
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
// SetProfileState writes the state file of the profile identified by id. Prefer
// it over SetActiveProfileState whenever the caller knows which profile the data
// belongs to: an SSO login spans seconds of user interaction, and the active
// profile can change during it, which would file the account email under
// whichever profile happened to be active when the flow returned.
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
if id == "" {
return fmt.Errorf("empty profile ID")
}
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
}
// SetActiveProfileState writes the state file of whichever profile is active at
// call time. Use SetProfileState when the target profile is known.
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
activeProf, err := pm.GetActiveProfile()
if err != nil {
if errors.Is(err, ErrNoActiveProfile) {
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
id := activeProf.ID
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid active profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
return pm.SetProfileState(activeProf.ID, state)
}
// RemoveProfileState deletes the per-profile state file (which holds the

View File

@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
// internalDnatFw checks if the firewall supports internal DNAT
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
if d.firewall == nil || runtime.GOOS != "android" {
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
return nil, false
}
fw, ok := d.firewall.(internalDNATer)

View File

@@ -8,14 +8,13 @@ import (
"net/netip"
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
@@ -62,7 +61,7 @@ type Manager interface {
GetActiveClientRoutes() route.HAMap
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
SetRouteChangeListener(listener listener.NetworkChangeListener)
InitialRouteRange() []string
CurrentRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
@@ -76,10 +75,8 @@ type ManagerConfig struct {
WGInterface iface.WGIface
StatusRecorder *peer.Status
RelayManager *relayClient.Manager
InitialRoutes []*route.Route
StateManager *statemanager.Manager
DNSServer dns.Server
DNSFeatureFlag bool
PeerStore *peerstore.Store
DisableClientRoutes bool
DisableServerRoutes bool
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
dm.setupRefCounters(useNoop)
// don't proceed with client routes if it is disabled
if config.DisableClientRoutes {
return dm
}
if runtime.GOOS == "android" {
dm.setupAndroidRoutes(config)
}
return dm
}
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
cr := m.initialClientRoutes(config.InitialRoutes)
routesForComparison := slices.Clone(cr)
if config.DNSFeatureFlag {
m.fakeIPManager = fakeip.NewManager()
v4ID := uuid.NewString()
fakeIPRoute := &route.Route{
ID: route.ID(v4ID),
Network: m.fakeIPManager.GetFakeIPBlock(),
NetID: route.NetID(v4ID),
Peer: m.pubKey,
NetworkType: route.IPv4Network,
}
v6ID := uuid.NewString()
fakeIPv6Route := &route.Route{
ID: route.ID(v6ID),
Network: m.fakeIPManager.GetFakeIPv6Block(),
NetID: route.NetID(v6ID),
Peer: m.pubKey,
NetworkType: route.IPv6Network,
}
cr = append(cr, fakeIPRoute, fakeIPv6Route)
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
}
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
func (m *DefaultManager) enableFakeIPRoutes() {
m.fakeIPManager = fakeip.NewManager()
m.notifier.NotifyRouteChange()
}
func (m *DefaultManager) setupRefCounters(useNoop bool) {
@@ -464,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes(
var merr *multierror.Error
if !m.disableClientRoutes {
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
m.enableFakeIPRoutes()
}
// Update route selector based on management server's isSelected status
m.updateRouteSelectorFromManagement(clientRoutes)
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
m.notifier.SetListener(listener)
}
// InitialRouteRange return the list of initial routes. It used by mobile systems
func (m *DefaultManager) InitialRouteRange() []string {
return m.notifier.GetInitialRouteRanges()
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
func (m *DefaultManager) CurrentRouteRange() []string {
m.mux.Lock()
defer m.mux.Unlock()
if m.disableClientRoutes {
return nil
}
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
}
sort.Strings(nets)
return nets
}
// GetRouteSelector returns the route selector
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
return newServerRoutesMap, newClientRoutesIDMap
}
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
_, crMap := m.ClassifyRoutes(initialRoutes)
rs := make([]*route.Route, 0, len(crMap))
for _, routes := range crMap {
rs = append(rs, routes...)
}
return rs
}
func isRouteSupported(route *route.Route) bool {
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
return true

View File

@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
return nil
}
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
func (m *MockManager) InitialRouteRange() []string {
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
func (m *MockManager) CurrentRouteRange() []string {
return nil
}

View File

@@ -6,7 +6,6 @@ import (
"net/netip"
"slices"
"sort"
"strings"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -14,12 +13,15 @@ import (
)
type Notifier struct {
initialRoutes []*route.Route
currentRoutes []*route.Route
fakeIPRoutes []*route.Route
mu sync.Mutex
listener listener.NetworkChangeListener
listenerMux sync.Mutex
// currentRoutes is the last announced route set. It exists only to
// suppress noise: without it every network map sync would trigger the
// Java side, even when the routes did not change. The actual TUN route
// state is owned by the route manager and pulled from there.
currentRoutes []*route.Route
listener listener.NetworkChangeListener
}
func NewNotifier() *Notifier {
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
n.mu.Lock()
defer n.mu.Unlock()
n.listener = listener
}
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
n.initialRoutes = filterStatic(initialRoutes)
n.currentRoutes = filterStatic(routesForComparison)
}
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
n.fakeIPRoutes = routes
func (n *Notifier) NotifyRouteChange() {
n.mu.Lock()
defer n.mu.Unlock()
n.notifyLocked()
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
}
}
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
n.mu.Lock()
defer n.mu.Unlock()
if !hasRouteDiff(n.currentRoutes, newRoutes) {
return
}
n.currentRoutes = newRoutes
n.notify()
n.notifyLocked()
}
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
// Not used on Android
}
func (n *Notifier) notify() {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
func (n *Notifier) notifyLocked() {
if n.listener == nil {
return
}
allRoutes := slices.Clone(n.currentRoutes)
allRoutes = append(allRoutes, n.fakeIPRoutes...)
routeStrings := n.routesToStrings(allRoutes)
sort.Strings(routeStrings)
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged(strings.Join(routeStrings, ","))
}(n.listener)
n.listener.OnNetworkChanged("")
}
func filterStatic(routes []*route.Route) []*route.Route {
out := make([]*route.Route, 0, len(routes))
for _, r := range routes {
if !r.IsDynamic() {
out = append(out, r)
}
}
return out
func (n *Notifier) Close() {
// unused
}
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
return nets
}
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
slices.SortFunc(a, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
slices.SortFunc(b, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
return x.NetString() == y.NetString()
})
}
func (n *Notifier) GetInitialRouteRanges() []string {
initialStrings := n.routesToStrings(n.initialRoutes)
sort.Strings(initialStrings)
return initialStrings
}
func (n *Notifier) Close() {
// unused
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := routesToStrings(a)
bs := routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}

View File

@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listener = listener
}
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// iOS doesn't care about initial routes
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on iOS
}

View File

@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on non-mobile platforms
}
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
// Not used on non-mobile platforms
}
func (n *Notifier) GetInitialRouteRanges() []string {
return []string{}
}
func (n *Notifier) Close() {
// unused
}

View File

@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)
// Get the current console user
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
output, err := cmd.Output()
username, err := consoleUser()
if err != nil {
return fmt.Errorf("failed to get console user: %w", err)
return err
}
username := strings.TrimSpace(string(output))
if username == "" || username == "root" {
return fmt.Errorf("no active user session found")
}
log.Infof("starting UI for user: %s", username)
// Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("failed to lookup user %s: %w", username, err)
return fmt.Errorf("lookup user %s: %w", username, err)
}
// Start the UI process as the console user using launchctl
// This ensures the app runs in the user's context with proper GUI access
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
// Set the user's home directory for proper macOS app behavior
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
if err := launchCmd.Start(); err != nil {
return fmt.Errorf("failed to start UI process: %w", err)
}
// Release the process so it can run independently
if err := launchCmd.Process.Release(); err != nil {
log.Warnf("failed to release UI process: %v", err)
if err := launchCmd.Run(); err != nil {
return fmt.Errorf("run UI launch: %w", err)
}
log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
func consoleUser() (string, error) {
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return "", fmt.Errorf("get console user: %w", err)
}
username := strings.TrimSpace(string(output))
switch username {
case "", "root", "loginwindow", "_mbsetupuser":
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
}
return username, nil
}
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
log.Infof("installing pkg file: %s", path)

View File

@@ -0,0 +1,110 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// A login that never reached Management is not a decision about the peer's
// credentials, so it must come back as a retryable error rather than an SSO
// prompt: the user cannot finish a browser login while Management is down, and
// the CLI's own backoff resolves the outage on its own once the daemon reports
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
// because Management happened to be restarting when the daemon dialed it.
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.isLoginRequiredFn = func(context.Context) (bool, error) {
attempts++
return false, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
require.Nil(t, resp, "a failed login must not answer with a login response")
require.Equal(t, 1, attempts)
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusLoginFailed, status,
"a peer that could not reach Management is not waiting on a login")
}
// The counterpart: Management refusing the peer's credentials is a decision, and
// the SSO flow still has to start for it. The profile carries an unusable
// private key so the flow setup fails immediately instead of dialing, which is
// enough to show the branch was entered — the refusal itself is never what comes
// back out.
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
breakProfilePrivateKey(t, cfgPath)
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, stateErr)
require.Equal(t, internal.StatusLoginFailed, status,
"the SSO flow setup was never reached with the broken key")
}
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
var keysTried []string
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
keysTried = append(keysTried, setupKey)
return "", nil
}
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
require.NoError(t, err, "the probe's outcome leaked out as the login result")
require.NotNil(t, resp)
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusIdle, status)
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
t.Helper()
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
cfg["PrivateKey"] = "not-a-key"
patched, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
}

View File

@@ -135,6 +135,13 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// loginAttemptFn stands in for the Management login round trip. Tests set
// it to drive the login outcomes that need a server on the other end;
// production leaves it nil, and every login goes through loginAttempt.
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
isLoginRequiredFn func(ctx context.Context) (bool, error)
}
type oauthAuthFlow struct {
@@ -370,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool {
}
}
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
// attemptLogin runs a login round trip against Management, or the stand-in a
// test installed in place of it.
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
if s.loginAttemptFn != nil {
return s.loginAttemptFn(ctx, setupKey, jwtToken)
}
return s.loginAttempt(ctx, setupKey, jwtToken)
}
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
if s.isLoginRequiredFn != nil {
return s.isLoginRequiredFn(ctx)
}
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
log.Errorf("failed to create auth client: %v", err)
return false, err
}
defer authClient.Close()
return authClient.IsLoginRequired(ctx)
}
// loginAttempt attempts to login using the provided information. It returns
// StatusNeedsLogin when Management refused the peer's credentials and
// StatusLoginFailed for every other failure, so callers can tell an
// authentication decision apart from a login that never got made.
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
@@ -623,7 +657,19 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
// A probe that errors leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
// something that is not theirs to solve, and a browser login cannot succeed
// while Management is unreachable anyway. Only Management refusing the
// peer's key is a decision, and IsLoginRequired reports that as
// needsLogin=true rather than an error.
needsLogin, err := s.isLoginRequired(ctx)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if !needsLogin {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
@@ -684,7 +730,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
// which returns NeedsLogin and parks on the browser leg.
state.Set(internal.StatusConnecting)
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -839,7 +885,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -1769,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
if connectClient == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
}
if connectClient.Engine() == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
}
hint := ""
if msg.Hint != nil {

View File

@@ -26,17 +26,17 @@ contents:
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- xdg-utils
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
rpm:
depends:
- gtk4
- webkitgtk6.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- xdg-utils
# Arch Linux packages

View File

@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
}
async function runSsoLogin(
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
result: {
verificationUri: string;
verificationUriComplete: string;
userCode: string;
profileId: string;
},
state: SsoState,
signal?: AbortSignal,
): Promise<void> {
@@ -56,7 +61,7 @@ async function runSsoLogin(
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "" },
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
{ profileName: "", username: "" },
);

View File

@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
}
}, [busy, t]);
const authenticate = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
await Events.Emit(EVENT_TRIGGER_LOGIN);
await WindowManager.CloseSessionExpiration();
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("connect.error.loginTitle"),
Message: formatErrorMessage(e),
});
}
}, [busy, t]);
const logout = useCallback(async () => {
if (busy) return;
setBusy(true);
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={stay}
onClick={expired ? authenticate : stay}
disabled={busy}
>
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}

View File

@@ -14,7 +14,6 @@ import (
"github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
@@ -63,7 +62,7 @@ type registeredServices struct {
profiles *services.Profiles
update *services.Update
daemonFeed *services.DaemonFeed
notifier *notifications.NotificationService
notifier *Notifier
compat *services.Compat
profileSwitcher *services.ProfileSwitcher
bundle *i18n.Bundle
@@ -102,7 +101,7 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := notifications.New()
notifier := newNotifier()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
@@ -210,7 +209,7 @@ func main() {
// requestNotificationAuthorization prompts for macOS notification permission.
// The request blocks until the user responds (up to 3 minutes), so callers run
// it in a goroutine. No-op on Linux/Windows.
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
func requestNotificationAuthorization(notifier *Notifier) {
authorized, err := notifier.CheckNotificationAuthorization()
if err != nil {
logrus.Debugf("check notification authorization: %v", err)

101
client/ui/notifier.go Normal file
View File

@@ -0,0 +1,101 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"errors"
"sync/atomic"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
var errNotificationsUnavailable = errors.New("notifications unavailable")
// Notifier wraps the Wails notification service so an unavailable backend
// disables notifications instead of aborting the app. Startup fails for
// environment reasons (a bare unbundled binary on macOS has no bundle
// identifier, a headless Linux session has no D-Bus session bus), and Wails
// treats a service startup error as fatal. After a failed startup every call
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
// identifier raises an Objective-C exception that recover() cannot catch.
type Notifier struct {
inner *notifications.NotificationService
available atomic.Bool
}
func newNotifier() *Notifier {
return &Notifier{inner: notifications.New()}
}
// ServiceName implements the Wails service-name hook for startup logs.
func (n *Notifier) ServiceName() string {
return n.inner.ServiceName()
}
// ServiceStartup starts the platform notifier, downgrading failure to a
// warning so the app keeps running without notifications.
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := n.inner.ServiceStartup(ctx, options); err != nil {
log.Warnf("notifications disabled: %v", err)
return nil
}
n.available.Store(true)
return nil
}
func (n *Notifier) ServiceShutdown() error {
if !n.available.Load() {
return nil
}
return n.inner.ServiceShutdown()
}
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.CheckNotificationAuthorization()
}
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.RequestNotificationAuthorization()
}
// SendNotification delivers a notification, silently dropping it when the
// backend never started (notifications are best-effort everywhere).
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotification(options)
}
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotificationWithActions(options)
}
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
if !n.available.Load() {
return nil
}
return n.inner.RegisterNotificationCategory(category)
}
// OnNotificationResponse registers the response callback. Pure Go state, so
// it is safe (and simply inert) when the backend never started.
//
//wails:ignore
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
n.inner.OnNotificationResponse(callback)
}

View File

@@ -246,6 +246,7 @@ func (s *Store) ExistedAtLoad() bool {
func (s *Store) load() error {
if _, err := os.Stat(s.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Infof("no ui preferences file at %s; using defaults", s.path)
return nil
}
return fmt.Errorf("stat preferences: %w", err)

View File

@@ -33,12 +33,21 @@ type LoginResult struct {
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
// ProfileID is the ID of the profile this login ran against, or "" when the
// caller named the profile itself and no ID was resolved. Pass it back in
// WaitSSOParams so the account email lands on this profile even if the
// active one changes during SSO.
ProfileID string `json:"profileId"`
}
// WaitSSOParams are the inputs to waitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
// ProfileID is the profile the login was started for, used to file the
// account email against it rather than against whichever profile is active
// when the flow returns. Optional: empty falls back to the active profile.
ProfileID string `json:"profileId"`
}
// UpParams selects the profile to bring up.
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
// Fall back to the daemon's active profile and the current OS user.
profileName := p.ProfileName
username := p.Username
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
// a handle — a display name or an ID prefix resolve too — and the state file
// is named after the ID, so passing a handle on would name the wrong file.
profileID := ""
if profileName == "" {
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
// Address the active profile by ID (the daemon resolves it as a
// handle); names can collide, the ID cannot.
profileName = active.GetId()
profileID = profileName
if username == "" {
username = active.GetUsername()
}
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
UserCode: resp.GetUserCode(),
VerificationURI: resp.GetVerificationURI(),
VerificationURIComplete: resp.GetVerificationURIComplete(),
ProfileID: profileID,
}, nil
}
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
return "", s.classifyDaemonError(err)
}
log.Infof("SSO login completed, daemon reported success")
// Persist the account email the same way the CLI does after its own
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
// root and the per-profile state file is user-owned (see Logout below).
// Without this the profile has no email, so Profiles.List shows no account
// and later logins and session extends go out without a login_hint —
// leaving the IdP to guess which account was meant.
if email := resp.GetEmail(); email != "" {
state := &profilemanager.ProfileState{Email: email}
pm := profilemanager.NewProfileManager()
// Against the profile the login was started for: SSO spans seconds of
// user interaction, and a profile switch in that window would otherwise
// file the email under the wrong profile.
if p.ProfileID != "" {
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
} else {
err = pm.SetActiveProfileState(state)
}
if err != nil {
// Non-fatal: the login itself succeeded.
log.Warnf("failed to store account email: %v", err)
}
}
return resp.GetEmail(), nil
}

View File

@@ -6,6 +6,8 @@ import (
"context"
"os/user"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
if err != nil {
return err
}
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
return err
if err != nil {
return err
}
// The daemon deletes what it owns but runs as root, so it leaves the
// user-owned state file holding the account email behind (same split as
// Connection.Logout). Legacy profiles are keyed by name rather than by a
// generated ID, so a recreated profile of the same name would inherit the
// deleted one's email and offer it as the login_hint.
//
// Keyed on the ID the daemon resolved, not on the request handle: that may
// have been a display name or an ID prefix, which would name a different
// file (or none).
if id := resp.GetId(); id != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
// Non-fatal: the profile itself is gone.
log.Warnf("failed to remove profile state for %s: %v", id, err)
}
}
return nil
}
// Rename changes a profile's display name. The on-disk ID is unaffected, so

View File

@@ -44,7 +44,7 @@ type TrayServices struct {
Profiles *services.Profiles
Networks *services.Networks
DaemonFeed *services.DaemonFeed
Notifier *notifications.NotificationService
Notifier *Notifier
Update *services.Update
ProfileSwitcher *services.ProfileSwitcher
WindowManager *services.WindowManager

View File

@@ -4,17 +4,26 @@ package main
// bindTrayClick wires the tray icon's left-click handler on Linux.
//
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
// fires the registered clickHandler:
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
// call itself (xembed_host_linux.go), so it routes through the same hook.
// Registering OnClick here therefore covers both paths with one handler — no
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
// main window; right-click still opens the menu via Wails' default
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
// Expected behaviour per tray host:
//
// Host Left click Right click
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
// GNOME Shell + AppIndicator menu only menu only
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
//
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
//
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
// on ANY click and only reports the menu opening via dbusmenu
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
// both buttons raised the main window on top of the menu, and on KDE/Waybar
// a right click raised it over the freshly opened menu. The netbirdio/wails
// fork (go.mod replace) drops that heuristic: a menu open never fires
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
// entry; left-click-opens-window is not achievable there anyway, since the
// host always opens the menu itself.
//
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's

View File

@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
ready, err := compat.DaemonReady(context.Background())
if err != nil {
log.Debugf("daemon compatibility probe: %v", err)

View File

@@ -27,11 +27,10 @@ const (
finalWarningCountdownSeconds = 120
)
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
@@ -308,11 +307,7 @@ 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()
}
t.app.Event.Emit(services.EventTriggerLogin)
return
}
if t.svc.WindowManager == nil {

View File

@@ -21,7 +21,7 @@ type trayUpdater struct {
app *application.App
window *application.WebviewWindow
update *services.Update
notifier *notifications.NotificationService
notifier *Notifier
loc *Localizer
onIconChange func()
// onMenuChange drives a full tray relayout: the update row lives in the
@@ -36,7 +36,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,

View File

@@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) {
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
// A cluster different from the pinned one must be rejected; echoing the
// pinned one back is valid.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("attacker.cluster.invalid"),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
})
requireClientError(t, err)
// Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(before.Cluster),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,

View File

@@ -0,0 +1,114 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper()
fresh, err := harness.StartCombined(ctx)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
if _, err := fresh.Bootstrap(ctx); err != nil {
return nil, err
}
return fresh, nil
}
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// account that has never been bootstrapped: the GET reads as the defaults
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
// pins it immutably. The shared srv cannot provide that starting state (any
// provider-creating test bootstraps it, and test order is deliberately not
// relied on), so this boots a dedicated combined server — the image is
// already built and cached by TestMain's StartCombined, so the extra cost is
// one container start.
func TestSettingsBootstrapViaPut(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
// Before agent-network bootstrap the settings read as the defaults, not
// as an error and not as a null body.
before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT without a cluster has nothing to pin the account to.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
})
requireClientError(t, err)
// A PUT carrying a cluster bootstraps the account and applies the
// mutable fields from the same request. Every toggle is set away from
// its bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: false,
EnablePromptCollection: true,
RedactPii: true,
})
require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
// The row is persisted: an independent read agrees on every field.
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after bootstrap must succeed")
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, later updates may omit the cluster entirely.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
})
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The cluster is immutable: a different value is rejected rather than
// silently ignored, and the rejected update must not disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: false,
})
requireClientError(t, err)
final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
}

4
go.mod
View File

@@ -114,7 +114,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.117
github.com/wailsapp/wails/v3 v3.0.0-beta.3
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
@@ -339,3 +339,5 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4

4
go.sum
View File

@@ -490,6 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -660,8 +662,6 @@ 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.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

@@ -17,6 +17,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
@@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, nil, nil)
// Swallow activity events so the mutation paths (create/update/delete)
// are exercisable through the HTTP layer.
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
accounts.EXPECT().
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, accounts, nil)
h := &handler{manager: manager}
router := mux.NewRouter()
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
h.addPolicyEndpoints(router)
h.addConsumptionEndpoints(router)
h.addBudgetRuleEndpoints(router)

View File

@@ -1,7 +1,9 @@
package handlers
import (
"encoding/json"
"math"
nethttp "net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
// with the other PUT endpoints: the request replaces the provider's mutable
// state, so optional fields absent from the JSON land as their zero values.
// The two exceptions are server-side: the api_key (a secret — omitted means
// "not rotated") and the session keypair, both preserved by the manager. The
// identity headers stay on the wire as explicit empty strings so a cleared
// value round-trips.
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
create := `{
"provider_id": "openai_api",
"name": "openai",
"upstream_url": "https://api.openai.com",
"api_key": "sk-test",
"enabled": true,
"metadata_disabled": true,
"skip_tls_verification": true,
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
"identity_header_user_id": "x-bf-dim-netbird_user_id",
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
}`
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
var created api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
// Minimal update: only the required fields, no api_key. Everything
// optional must land as its zero value.
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
var updated api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
assert.True(t, updated.Enabled, "sent field must apply")
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
assert.Empty(t, updated.Models, "omitted models must be cleared")
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
"cleared identity header must round-trip as an explicit empty string")
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/gorilla/mux"
@@ -11,19 +10,20 @@ import (
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// addSettingsEndpoints registers the Agent Network settings routes. The
// settings row is bootstrapped server-side on first provider create; GET reads
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
// immutable).
// settings row is bootstrapped server-side on first provider create or on the
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
// of the mutable collection toggles (cluster/subdomain stay immutable).
func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
}
// updateSettings applies the collection toggles to the account's settings row.
// updateSettings replaces the mutable settings fields on the account's row.
// A request carrying a cluster bootstraps the row when the account doesn't
// have one yet.
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -48,11 +48,9 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
}
// getSettings returns the account's agent-network settings. The settings
// row is bootstrapped on first provider create, so freshly-onboarded
// accounts have nothing to read. Rather than 404-ing in that case (which
// the dashboard would have to special-case), return a JSON null with 200
// so consumers can branch on the body alone.
// getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view.
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -62,11 +60,6 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
util.WriteJSONObject(r.Context(), w, nil)
return
}
util.WriteError(r.Context(), err, w)
return
}

View File

@@ -0,0 +1,137 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
// convention shared with the account and DNS settings endpoints: settings
// always read as a JSON object. Before bootstrap that object carries the
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
// signal) and no timestamps — never a 404 and never the legacy null body.
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code,
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
"the legacy 200+null shape must not come back")
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
assert.False(t, got.RedactPii, "defaults must show redaction off")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
}
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
// creates the row (cluster pinned, subdomain assigned) and applies the
// mutable fields from the same request.
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
// The row is now readable via GET.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
}
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
// without a cluster cannot conjure a settings row out of nothing — there is
// no cluster to pin — and surfaces as 404 like the GET.
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusNotFound, rec.Code,
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "cluster",
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
}
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
// with the other PUT endpoints: the request replaces every mutable field, so a
// toggle absent from the JSON lands as its zero value rather than being
// preserved. Cluster and subdomain survive untouched.
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
assert.False(t, got.RedactPii, "sent toggle must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays,
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
}
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
// assigned, a differing cluster is rejected as a validation error instead of
// being silently ignored, so callers never observe a value other than the one
// they sent. Echoing the assigned cluster back stays valid, which lets
// declarative clients send their full desired state idempotently.
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
}

View File

@@ -157,14 +157,14 @@ func NewManager(
}
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
@@ -175,9 +175,14 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
// been created yet; otherwise it is ignored (the cluster is pinned on
// Settings and every provider in the account routes through it).
func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil {
return nil, err
}
if strings.TrimSpace(bootstrapCluster) != "" {
if err := m.requireSettingsBootstrapPermission(ctx, provider.AccountID, userID); err != nil {
return nil, err
}
}
// An empty api_key would silently produce a synthesised service
// that 401s on every upstream request. Surface the misconfiguration
@@ -202,7 +207,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
if strings.TrimSpace(bootstrapCluster) != "" {
if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil {
if _, err := m.bootstrapSettingsIfNeeded(ctx, m.store, provider.AccountID, bootstrapCluster); err != nil {
// The provider create has already succeeded; logging the
// bootstrap miss matches the plan's PoC behaviour. The synth
// path treats a missing settings row as a no-op, and the next
@@ -218,7 +223,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
return nil, err
}
@@ -257,7 +262,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
return err
}
@@ -306,21 +311,21 @@ func pluralize(n int, singular, plural string) string {
}
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
}
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
return nil, err
}
@@ -346,7 +351,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
return nil, err
}
@@ -373,7 +378,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
return err
}
@@ -393,21 +398,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
}
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
}
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
return nil, err
}
@@ -429,7 +434,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
return nil, err
}
@@ -452,7 +457,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
return err
}
@@ -473,7 +478,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
// GetAllBudgetRules returns every account-level budget rule for the account.
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
@@ -481,7 +486,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
// GetBudgetRule returns a single account-level budget rule.
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
@@ -491,7 +496,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
// proxy config, so no reconcile is needed.
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
return nil, err
}
@@ -513,7 +518,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
// UpdateBudgetRule updates an existing account-level budget rule.
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
return nil, err
}
@@ -536,7 +541,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
// DeleteBudgetRule removes an account-level budget rule.
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
return err
}
@@ -554,40 +559,83 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
return nil
}
// UpdateSettings applies the mutable account-level settings — the collection
// toggles — onto the existing row. Cluster and Subdomain are immutable and are
// preserved from the persisted row regardless of the input. Because the
// collection toggles change the synthesised service config (prompt-capture
// gating, access-log emission), a reconcile is triggered so the proxy and peer
// network maps converge on the new state.
// UpdateSettings replaces the mutable account-level settings — the collection
// toggles and retention — on the account's row. When the account has no
// settings row yet, a non-empty settings.Cluster bootstraps one (same path as
// first provider create); without it the update fails with NotFound. On an
// existing row the cluster and subdomain are immutable: a differing
// settings.Cluster is rejected rather than silently ignored so callers never
// observe a value other than what they sent. Because the collection toggles
// change the synthesised service config (prompt-capture gating, access-log
// emission), a reconcile is triggered so the proxy and peer network maps
// converge on the new state.
func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) {
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
return nil, err
}
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
requestedCluster := strings.TrimSpace(settings.Cluster)
// The row lock from LockingStrengthUpdate only holds for the duration of
// the surrounding transaction, so the read, the cluster-immutability
// check, and the save must share one — otherwise concurrent PUTs could
// interleave between them.
var updated *types.Settings
err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error {
existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID)
switch {
case err == nil:
if requestedCluster != "" && requestedCluster != existing.Cluster {
return status.Errorf(status.InvalidArgument, "cluster is immutable once assigned (current: %s)", existing.Cluster)
}
case isNotFound(err):
if requestedCluster == "" {
return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; pass cluster to bootstrap them, or create a provider with bootstrap_cluster set")
}
// Bootstrapping pins the cluster and subdomain — a settings
// create on top of the update the caller already passed, matching
// the gate on the provider-create bootstrap path.
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil {
return err
}
existing, err = m.bootstrapSettingsIfNeeded(ctx, tx, settings.AccountID, requestedCluster)
if err != nil {
return err
}
default:
return fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil {
return fmt.Errorf("save agent network settings: %w", err)
}
updated = existing
return nil
})
if err != nil {
return nil, fmt.Errorf("get agent network settings: %w", err)
}
existing.EnableLogCollection = settings.EnableLogCollection
existing.EnablePromptCollection = settings.EnablePromptCollection
existing.RedactPii = settings.RedactPii
existing.AccessLogRetentionDays = settings.AccessLogRetentionDays
existing.UpdatedAt = time.Now().UTC()
if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
return nil, err
}
m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{
"log_collection": existing.EnableLogCollection,
"prompt_collection": existing.EnablePromptCollection,
"redact_pii": existing.RedactPii,
"log_collection": updated.EnableLogCollection,
"prompt_collection": updated.EnablePromptCollection,
"redact_pii": updated.RedactPii,
})
m.reconcile(ctx, settings.AccountID)
return existing, nil
return updated, nil
}
// isNotFound reports whether err is a status.NotFound error.
func isNotFound(err error) bool {
var sErr *status.Error
return errors.As(err, &sErr) && sErr.Type() == status.NotFound
}
// validateProviderRefs ensures every destination provider id refers to a
@@ -611,14 +659,38 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
return nil
}
// GetSettings returns the agent-network settings row for the account.
// Returns the underlying status.NotFound when no row has been
// bootstrapped yet (i.e. the account has no providers).
// GetSettings returns the agent-network settings row for the account. When no
// row has been bootstrapped yet, the defaults are returned (without
// persisting) with cluster and subdomain empty — settings always read as an
// object, like the account and DNS settings endpoints.
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
return settings, nil
case isNotFound(err):
return types.DefaultSettings(accountID), nil
default:
return nil, err
}
}
// requireSettingsBootstrapPermission gates the one-time settings bootstrap a
// first provider create performs. Pinning the account's cluster and subdomain
// is a settings write, so it needs the settings permission on top of the
// provider one. No-op once the settings row exists.
func (m *managerImpl) requireSettingsBootstrapPermission(ctx context.Context, accountID, userID string) error {
_, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return nil
}
if !isNotFound(err) {
return fmt.Errorf("get agent network settings: %w", err)
}
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
}
// bootstrapSettingsIfNeeded creates the per-account agent-network
@@ -626,8 +698,9 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
// hint the dashboard sends (auto-picked from the active cluster list);
// the subdomain is picked from the curated wordlist avoiding
// collisions on the same cluster. Idempotent: if a row already exists
// it is returned untouched and the hint is ignored.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
// it is returned untouched and the hint is ignored. st is the store to
// operate on — pass the transaction store when calling from within one.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, st store.Store, accountID, providerCluster string) (*types.Settings, error) {
if accountID == "" {
return nil, fmt.Errorf("bootstrap settings: account id is required")
}
@@ -635,16 +708,15 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
return nil, fmt.Errorf("bootstrap settings: provider cluster is required")
}
existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
existing, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
if err == nil {
return existing, nil
}
var sErr *status.Error
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
if !isNotFound(err) {
return nil, fmt.Errorf("get agent network settings: %w", err)
}
siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
siblings, err := st.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
@@ -663,18 +735,12 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
m.labelRngMu.Unlock()
now := time.Now().UTC()
settings := &types.Settings{
AccountID: accountID,
Cluster: providerCluster,
Subdomain: subdomain,
// Logs on by default; usage is collected regardless. Retention bounds
// how long full log rows are kept.
EnableLogCollection: true,
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
settings := types.DefaultSettings(accountID)
settings.Cluster = providerCluster
settings.Subdomain = subdomain
settings.CreatedAt = now
settings.UpdatedAt = now
if err := st.SaveAgentNetworkSettings(ctx, settings); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
}
return settings, nil
@@ -685,7 +751,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
// counter view; permission gate is the same Read role that gates
// every other agent-network surface.
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
return nil, err
}
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
@@ -694,7 +760,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -704,7 +770,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
@@ -713,7 +779,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -787,8 +853,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
}
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -877,8 +943,8 @@ func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.Accou
func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil }
func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) {
return nil, status.Errorf(status.NotFound, "agent network settings not found")
func (*mockManager) GetSettings(_ context.Context, accountID, _ string) (*types.Settings, error) {
return types.DefaultSettings(accountID), nil
}
func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) {

View File

@@ -0,0 +1,134 @@
package agentnetwork
import (
"context"
"runtime"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
// so tests can grant the provider permission while denying (or never
// expecting) the settings one.
type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("sqlite store not properly supported on Windows yet")
}
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
require.NoError(t, err, "test store setup must succeed")
t.Cleanup(cleanUp)
ctrl := gomock.NewController(t)
perms := permissions.NewMockManager(ctrl)
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
store: st,
perms: perms,
}
}
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
f.perms.EXPECT().
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
Return(allowed, context.Background(), nil)
}
func newBootstrapProvider(accountID string) *types.Provider {
p := types.NewProvider(accountID)
p.Name = "openai"
p.UpstreamURL = "https://api.openai.com"
p.APIKey = "sk-test"
p.Enabled = true
return p
}
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
// one-time settings bootstrap: creating the first provider with a
// bootstrap_cluster pins the account's cluster and subdomain, which is a
// settings write and must not ride on the providers permission alone.
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
ctx := context.Background()
t.Run("denied without settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.Error(t, err, "bootstrap without settings permission must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
})
t.Run("allowed with settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "bootstrap with both permissions must succeed")
require.NotNil(t, created)
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err, "bootstrap must create the settings row")
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
})
t.Run("existing settings need no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "existing",
}), "pre-existing settings row setup must succeed")
// Only the providers permission may be consulted: gomock fails the
// test on any unexpected settings-permission call.
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "create with existing settings must not require the settings permission")
})
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
require.NoError(t, err, "create without bootstrap must not require the settings permission")
})
}

View File

@@ -164,9 +164,7 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
p.MetadataDisabled = *req.MetadataDisabled
}
// Identity-header overrides for catalogs flagged Customizable.
// nil pointer = "field omitted on the wire" → leave the stored
// value untouched (per the openapi description). Empty string is
// an explicit clear that disables stamping for this dimension.
// Empty or omitted disables stamping for this dimension.
if req.IdentityHeaderUserId != nil {
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
}
@@ -192,16 +190,20 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
created := p.CreatedAt
updated := p.UpdatedAt
resp := &api.AgentNetworkProvider{
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
Id: p.ID,
ProviderId: p.ProviderID,
Name: p.Name,
UpstreamUrl: p.UpstreamURL,
Models: models,
// Always present on the wire so an explicitly cleared header
// round-trips as "" instead of vanishing from the response.
IdentityHeaderUserId: p.IdentityHeaderUserID,
IdentityHeaderGroups: p.IdentityHeaderGroups,
Enabled: p.Enabled,
SkipTlsVerification: p.SkipTLSVerification,
MetadataDisabled: p.MetadataDisabled,
CreatedAt: &created,
UpdatedAt: &updated,
}
if len(p.ExtraValues) > 0 {
out := make(map[string]string, len(p.ExtraValues))
@@ -210,14 +212,6 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
}
resp.ExtraValues = &out
}
if p.IdentityHeaderUserID != "" {
v := p.IdentityHeaderUserID
resp.IdentityHeaderUserId = &v
}
if p.IdentityHeaderGroups != "" {
v := p.IdentityHeaderGroups
resp.IdentityHeaderGroups = &v
}
return resp
}

View File

@@ -77,3 +77,41 @@ func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
}
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
// fields are always present in the API response — an explicitly cleared
// ("") header must round-trip as "" rather than vanish, so API consumers
// (e.g. the Terraform provider) never observe a value other than the one
// they wrote.
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
set := "x-bf-dim-netbird_user_id"
empty := ""
base := func() *api.AgentNetworkProviderRequest {
return &api.AgentNetworkProviderRequest{
ProviderId: "custom",
Name: "bifrost",
UpstreamUrl: "https://bifrost.internal",
}
}
p := NewProvider("acc-1")
resp := p.ToAPIResponse()
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
req := base()
req.IdentityHeaderUserId = &set
p.FromAPIRequest(req)
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
// Omitting the field preserves it.
p.FromAPIRequest(base())
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
// An explicit "" clears it AND stays visible on the wire.
req = base()
req.IdentityHeaderUserId = &empty
p.FromAPIRequest(req)
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
}

View File

@@ -1,6 +1,7 @@
package types
import (
"strings"
"time"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -42,18 +43,34 @@ type Settings struct {
// schema cohesive.
func (Settings) TableName() string { return "agent_network_settings" }
// DefaultSettings returns the settings an account observes before its row is
// bootstrapped: log collection on with the default retention, everything else
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
// and the freshly bootstrapped row agree.
func DefaultSettings(accountID string) *Settings {
return &Settings{
AccountID: accountID,
EnableLogCollection: true,
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
}
}
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`.
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
func (s *Settings) Endpoint() string {
if s.Cluster == "" || s.Subdomain == "" {
return ""
}
return s.Subdomain + "." + s.Cluster
}
// ToAPIResponse renders the settings as the API representation.
// ToAPIResponse renders the settings as the API representation. The
// timestamps are omitted while zero — a default (not yet bootstrapped) view
// has no persisted row to date.
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
created := s.CreatedAt
updated := s.UpdatedAt
retention := s.AccessLogRetentionDays
return &api.AgentNetworkSettings{
resp := &api.AgentNetworkSettings{
Cluster: s.Cluster,
Subdomain: s.Subdomain,
Endpoint: s.Endpoint(),
@@ -61,14 +78,27 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
EnablePromptCollection: s.EnablePromptCollection,
RedactPii: s.RedactPii,
AccessLogRetentionDays: &retention,
CreatedAt: &created,
UpdatedAt: &updated,
}
if !s.CreatedAt.IsZero() {
created := s.CreatedAt
resp.CreatedAt = &created
}
if !s.UpdatedAt.IsZero() {
updated := s.UpdatedAt
resp.UpdatedAt = &updated
}
return resp
}
// FromAPIRequest applies the mutable settings fields from the request. Cluster
// and Subdomain are immutable and intentionally not touched here.
// FromAPIRequest applies the request onto the receiver. The mutable
// collection fields are always replaced with the request values. Cluster
// participates only in bootstrap and the immutability check (see
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
// from a request.
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
if req.Cluster != nil {
s.Cluster = strings.TrimSpace(*req.Cluster)
}
s.EnableLogCollection = req.EnableLogCollection
s.EnablePromptCollection = req.EnablePromptCollection
s.RedactPii = req.RedactPii

View File

@@ -24,13 +24,13 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
@@ -184,6 +184,10 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
}
// Append interceptors contributed by registered gRPC extensions. These
// run after the built-in chain (ChainUnaryInterceptor is additive).
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
if s.Config.HttpConfig.LetsEncryptDomain != "" {
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
@@ -215,6 +219,9 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
log.Info("ProxyService registered on gRPC server")
// Register services contributed by external modules via the extension seam.
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
return gRPCAPIHandler
})
}

View File

@@ -0,0 +1,74 @@
package server
import (
"context"
"google.golang.org/grpc"
)
// GRPCExtension bundles an external module's contribution to the management
// gRPC server: the registration of one or more services onto the shared
// grpc.Server, any server-wide interceptors those services require, and an
// optional shutdown hook. It is a generic extension point with no knowledge of
// any specific service.
type GRPCExtension struct {
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
// after the built-in services are registered. It may register any number of
// services. May be nil.
Register func(grpc.ServiceRegistrar)
// UnaryInterceptors are appended to the server's unary interceptor chain,
// running after the built-in interceptors. May be empty.
UnaryInterceptors []grpc.UnaryServerInterceptor
// StreamInterceptors are appended to the server's stream interceptor chain,
// running after the built-in interceptors. May be empty.
StreamInterceptors []grpc.StreamServerInterceptor
// Shutdown, if non-nil, is called once during Stop() with the context
// governing server shutdown, which carries a deadline. The hook MUST
// return promptly and MUST abandon its work once that context is
// cancelled or expires: it runs before the rest of Stop()'s cleanup
// (store, event store, embedded IdP) and before Stop() itself checks the
// context's deadline, so a hook that ignores the context will delay all
// of that cleanup and prevent Stop() from returning on time. May be nil.
Shutdown func(ctx context.Context)
}
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
// is first built (i.e. before Start); registrations after that have no effect.
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
s.grpcExtensions = append(s.grpcExtensions, ext)
}
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
// extension interceptors after any interceptors already present in opts.
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
for _, ext := range exts {
if len(ext.UnaryInterceptors) > 0 {
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
}
if len(ext.StreamInterceptors) > 0 {
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
}
}
return opts
}
// registerExtensions registers each extension's services onto reg.
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Register != nil {
ext.Register(reg)
}
}
}
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
// passing ctx through so hooks can honor its deadline/cancellation.
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Shutdown != nil {
ext.Shutdown(ctx)
}
}
}

View File

@@ -0,0 +1,160 @@
package server
import (
"context"
"net"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/test/bufconn"
)
// Test that an extension's interceptors and service registration are actually
// wired onto a real in-process gRPC server via the helpers, and that shutdown
// hooks run. This validates the load-bearing assumption that
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
// addition to any base chain).
func TestGRPCExtensionAppliedToServer(t *testing.T) {
var unaryCalls atomic.Int32
var streamShutdownCalled atomic.Bool
ext := GRPCExtension{
Register: func(reg grpc.ServiceRegistrar) {
healthgrpc.RegisterHealthServer(reg, health.NewServer())
},
UnaryInterceptors: []grpc.UnaryServerInterceptor{
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
unaryCalls.Add(1)
return handler(ctx, req)
},
},
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
}
exts := []GRPCExtension{ext}
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
var baseUnaryCalls atomic.Int32
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
baseUnaryCalls.Add(1)
return handler(ctx, req)
}),
}
opts = appendExtensionInterceptors(opts, exts)
srv := grpc.NewServer(opts...)
registerExtensions(srv, exts)
lis := bufconn.Listen(1024 * 1024)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
if err != nil {
t.Fatalf("health check via extension-registered service failed: %v", err)
}
if baseUnaryCalls.Load() != 1 {
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
}
if unaryCalls.Load() != 1 {
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
}
runExtensionShutdownHooks(context.Background(), exts)
if !streamShutdownCalled.Load() {
t.Error("extension shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
// a non-nil context and that it is the very same context the caller passed
// in, so hooks can rely on values/deadlines placed on it by Stop().
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
type sentinelKey struct{}
want := "shutdown-ctx-sentinel"
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx == nil {
t.Fatal("hook received a nil context")
}
got, _ := hookCtx.Value(sentinelKey{}).(string)
if got != want {
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
// context must see ctx.Err() != nil and a closed Done() channel.
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx.Err() == nil {
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
}
select {
case <-hookCtx.Done():
default:
t.Error("hook context Done() channel is not closed for a cancelled context")
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
// with a nil Shutdown hook is skipped without panicking, and that hooks for
// other extensions still run.
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
var called atomic.Bool
exts := []GRPCExtension{
{Shutdown: nil},
{Shutdown: func(context.Context) { called.Store(true) }},
}
runExtensionShutdownHooks(context.Background(), exts)
if !called.Load() {
t.Error("shutdown hook for non-nil extension was not called")
}
}
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
s := &BaseServer{}
s.RegisterGRPCExtension(GRPCExtension{})
s.RegisterGRPCExtension(GRPCExtension{})
if len(s.grpcExtensions) != 2 {
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
}
}

View File

@@ -68,6 +68,11 @@ type BaseServer struct {
proxyAuthClose func()
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
// hooks registered by external modules via RegisterGRPCExtension. Populated
// during boot (single-threaded), consumed by GRPCServer() and Stop().
grpcExtensions []GRPCExtension
listener net.Listener
certManager *autocert.Manager
update *version.Update
@@ -257,6 +262,7 @@ func (s *BaseServer) Stop() error {
s.proxyAuthClose()
s.proxyAuthClose = nil
}
runExtensionShutdownHooks(ctx, s.grpcExtensions)
_ = s.Store().Close(ctx)
_ = s.EventStore().Close(ctx)
if s.update != nil {

View File

@@ -61,6 +61,8 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
return &proto.NetworkMapEnvelope{
Payload: &proto.NetworkMapEnvelope_Full{
Full: &proto.NetworkMapComponentsFull{
Serial: networkSerial(c.Network),
Network: toAccountNetwork(c.Network),
PeerConfig: in.PeerConfig,
// components.Peers always contains the target peer
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},

View File

@@ -758,6 +758,9 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
assert.Equal(t, "netbird.cloud", full.DnsDomain)
assert.Len(t, full.Peers, 1)
assert.Empty(t, full.Policies)
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
assert.Equal(t, "net-empty", full.Network.Identifier)
assert.Equal(t, uint64(9), full.Serial)
}
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
@@ -776,6 +779,12 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 9,
},
},
)
}

View File

@@ -102,11 +102,20 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap")
assert.False(t, before.EnablePromptCollection, "prompt collection defaults off")
// Attempt to flip toggles AND smuggle a different cluster/subdomain — the
// immutable fields must be ignored.
// A cluster different from the one pinned at bootstrap must be rejected
// outright — never silently swapped or ignored.
_, err = mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: "attacker.cluster",
EnableLogCollection: true,
})
require.Error(t, err, "UpdateSettings with a mismatched cluster must fail")
// Flipping the toggles works with the pinned cluster echoed back (and
// with it omitted); the subdomain is never taken from the request.
updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{
AccountID: accountID,
Cluster: "attacker.cluster",
Cluster: clusterAddr,
Subdomain: "evil",
EnableLogCollection: true,
EnablePromptCollection: true,

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
@@ -744,6 +745,10 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty
return &GroupLinkError{"network router", linkedRouter.ID}
}
if isLinked, linkedPolicy := isGroupLinkedToAgentNetworkPolicy(ctx, transaction, group.AccountID, group.ID); isLinked {
return &GroupLinkError{"agent network policy", linkedPolicy.Name}
}
return checkGroupLinkedToSettings(ctx, transaction, group)
}
@@ -875,6 +880,26 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
return false, nil
}
// isGroupLinkedToAgentNetworkPolicy checks if a group is used as a source group by any
// agent network policy in the account.
func isGroupLinkedToAgentNetworkPolicy(ctx context.Context, transaction store.Store, accountID string, groupID string) (bool, *agentNetworkTypes.Policy) {
policies, err := transaction.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
log.WithContext(ctx).Errorf("error retrieving agent network policies while checking group linkage: %v", err)
return false, nil
}
for _, policy := range policies {
if policy == nil {
continue
}
if slices.Contains(policy.SourceGroups, groupID) {
return true, policy
}
}
return false, nil
}
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
// It fetches each collection once and checks all groupIDs against them in memory.
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {

View File

@@ -18,6 +18,7 @@ import (
"golang.org/x/exp/maps"
nbdns "github.com/netbirdio/netbird/dns"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
@@ -125,6 +126,11 @@ func TestDefaultAccountManager_DeleteGroup(t *testing.T) {
"grp-for-integration",
"only service users with admin power can delete integration group",
},
{
"agent network policy",
"grp-for-agent-network-policy",
"agent network policy",
},
}
for _, testCase := range testCases {
@@ -218,6 +224,11 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) {
groupIDs: []string{"grp-for-integration"},
expectedReasons: []string{"only service users with admin power can delete integration group"},
},
{
name: "agent network policy",
groupIDs: []string{"grp-for-agent-network-policy"},
expectedReasons: []string{"agent network policy"},
},
{
name: "successfully delete multiple groups",
groupIDs: []string{"group-1", "group-2"},
@@ -406,6 +417,14 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
Peers: make([]string, 0),
}
groupForAgentNetworkPolicy := &types.Group{
ID: "grp-for-agent-network-policy",
AccountID: "account-id",
Name: "Group for agent network policies",
Issued: types.GroupIssuedAPI,
Peers: make([]string, 0),
}
routeResource := &route.Route{
ID: "example route",
Groups: []string{groupForRoute.ID},
@@ -461,6 +480,18 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForSetupKeys)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForUsers)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForIntegration)
_ = am.CreateGroup(context.Background(), accountID, groupAdminUserID, groupForAgentNetworkPolicy)
agentNetworkPolicy := &agentNetworkTypes.Policy{
ID: "example agent network policy",
AccountID: accountID,
Name: "Example agent network policy",
Enabled: true,
SourceGroups: []string{groupForAgentNetworkPolicy.ID},
}
if err := am.Store.SaveAgentNetworkPolicy(context.Background(), agentNetworkPolicy); err != nil {
return nil, nil, err
}
acc, err := am.Store.GetAccount(context.Background(), account.Id)
if err != nil {

View File

@@ -82,6 +82,9 @@ func (m *managerImpl) ValidateUserPermissions(
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
// ValidateRoleModuleAccess resolves an operation against the role's explicit
// grant for the module, then the grant for its parent module when the module
// is a dotted submodule, and finally the role's AutoAllowNew default.
func (m *managerImpl) ValidateRoleModuleAccess(
ctx context.Context,
accountID string,
@@ -89,7 +92,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
module modules.Module,
operation operations.Operation,
) bool {
if permissions, ok := role.Permissions[module]; ok {
if permissions, ok := lookupModulePermissions(role, module); ok {
if allowed, exists := permissions[operation]; exists {
return allowed
}
@@ -100,6 +103,21 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
// lookupModulePermissions returns the role's explicit permission set for the
// module, falling back to the parent module's set for dotted submodules. The
// second return reports whether any explicit set was found.
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
if permissions, ok := role.Permissions[module]; ok {
return permissions, true
}
if parent, hasParent := module.Parent(); hasParent {
if permissions, ok := role.Permissions[parent]; ok {
return permissions, true
}
}
return nil, false
}
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
return ctx, status.NewUserNotPartOfAccountError()
@@ -119,7 +137,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
permissions := roles.Permissions{}
for k := range modules.All {
if rolePermissions, ok := roleMap.Permissions[k]; ok {
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
permissions[k] = rolePermissions
continue
}

View File

@@ -0,0 +1,139 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
fullAccess := map[operations.Operation]bool{
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
}
readOnly := map[operations.Operation]bool{
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
denyAll := map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
t.Run("parent grant covers submodules", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
"parent full grant should allow create on a submodule")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"parent full grant should allow read on a submodule")
})
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"explicit submodule read should be allowed")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
"read-only submodule grant should not allow create")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
"submodule grant should not grant the parent module")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"submodule grant should not grant a sibling submodule")
})
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{
modules.AgentNetwork: fullAccess,
modules.AgentNetworkLogs: denyAll,
},
}
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"explicit submodule deny should override the parent grant")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"sibling submodules should still resolve through the parent grant")
})
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: readOnly,
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"auto-allow read should apply to submodules")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
"auto-allow should not grant unlisted operations")
})
}
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
// submodule split must not change: every built-in role resolves the new
// submodules exactly as it resolved the agent_network module before.
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
submodules := []modules.Module{
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
rolePermissions, ok := roles.RolesMap[role]
require.True(t, ok, "role %s must exist in RolesMap", role)
for _, sub := range submodules {
for _, op := range allOperations {
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
}
}
}
}
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
require.NoError(t, err, "auditor role must resolve")
usage, ok := permissions[modules.AgentNetworkUsage]
require.True(t, ok, "permissions map should contain the usage submodule")
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
require.NoError(t, err, "admin role must resolve")
providers, ok := adminPermissions[modules.AgentNetworkProviders]
require.True(t, ok, "permissions map should contain the providers submodule")
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
}

View File

@@ -1,5 +1,7 @@
package modules
import "strings"
type Module string
const (
@@ -20,6 +22,17 @@ const (
IdentityProviders Module = "identity_providers"
Services Module = "services"
AgentNetwork Module = "agent_network"
// Agent Network submodules. A role may grant one of these directly
// or grant the AgentNetwork parent, which covers all of them (see
// permissions.Manager cascade resolution).
AgentNetworkProviders Module = "agent_network.providers"
AgentNetworkPolicies Module = "agent_network.policies"
AgentNetworkGuardrails Module = "agent_network.guardrails"
AgentNetworkBudgets Module = "agent_network.budgets"
AgentNetworkUsage Module = "agent_network.usage"
AgentNetworkLogs Module = "agent_network.logs"
AgentNetworkSettings Module = "agent_network.settings"
)
var All = map[Module]struct{}{
@@ -40,4 +53,21 @@ var All = map[Module]struct{}{
IdentityProviders: {},
Services: {},
AgentNetwork: {},
AgentNetworkProviders: {},
AgentNetworkPolicies: {},
AgentNetworkGuardrails: {},
AgentNetworkBudgets: {},
AgentNetworkUsage: {},
AgentNetworkLogs: {},
AgentNetworkSettings: {},
}
// Parent returns the module owning a dotted submodule name and true, or the
// module itself and false when it has no parent.
func (m Module) Parent() (Module, bool) {
if i := strings.IndexByte(string(m), '.'); i > 0 {
return Module(string(m)[:i]), true
}
return m, false
}

View File

@@ -1707,14 +1707,34 @@ func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers
if len(proxyPeers) == 0 {
return
}
// A service's AccessGroups can name groups that no longer exist — persisted
// services and the agent-network synthesiser both carry the ids verbatim from
// their own state. An unresolvable source authorises nothing, so drop it here
// rather than let the network-map assembly resolve it to a nil group.
sources := a.existingGroupIDs(svc.AccessGroups)
if len(sources) == 0 {
return
}
for _, proxyPeer := range proxyPeers {
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer))
a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer, sources))
}
}
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy {
// existingGroupIDs returns the subset of groupIDs that resolve to a group in the account,
// preserving the input order.
func (a *Account) existingGroupIDs(groupIDs []string) []string {
out := make([]string, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := a.Groups[groupID]; ok {
out = append(out, groupID)
}
}
return out
}
func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer, accessGroups []string) *Policy {
policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
sources := append([]string(nil), svc.AccessGroups...)
sources := append([]string(nil), accessGroups...)
return &Policy{
ID: policyID,
Name: fmt.Sprintf("Private Access to %s", svc.Name),

View File

@@ -68,7 +68,7 @@ type ProxyAccessTokenGenerated struct {
// CreateNewProxyAccessToken generates a new proxy access token.
// Returns the token with hashed value stored and plain token for one-time display.
func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) {
hashedToken, plainToken, err := generateProxyToken()
hashedToken, plainToken, err := GenerateProxyToken()
if err != nil {
return nil, err
}
@@ -94,7 +94,10 @@ func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *
}, nil
}
func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
// GenerateProxyToken generates a new random proxy token, returning its SHA-256
// hash (for storage) and the one-time plaintext. Exported so external modules
// can mint tokens in the canonical proxy-token format.
func GenerateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
secret, err := b.Random(ProxyTokenSecretLength)
if err != nil {
return "", "", err

View File

@@ -1,6 +1,7 @@
package types
import (
"strings"
"testing"
"time"
@@ -123,6 +124,22 @@ func TestCreateNewProxyAccessToken(t *testing.T) {
})
}
func TestGenerateProxyToken(t *testing.T) {
hashed, plain, err := GenerateProxyToken()
if err != nil {
t.Fatal(err)
}
if err := plain.Validate(); err != nil {
t.Errorf("generated token failed Validate(): %v", err)
}
if plain.Hash() != hashed {
t.Error("returned hashed token does not match Hash(plain)")
}
if !strings.HasPrefix(string(plain), ProxyTokenPrefix) {
t.Errorf("token %q missing prefix %q", plain, ProxyTokenPrefix)
}
}
func TestProxyAccessToken_IsExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour)
future := time.Now().Add(1 * time.Hour)

View File

@@ -30,7 +30,23 @@ mkdir -p /usr/local/bin/
$AGENT service install || true
$AGENT service start || true
open $APP
console_user=$(stat -f%Su /dev/console 2>/dev/null)
case "$console_user" in
""|root|loginwindow|_mbsetupuser)
echo "No active GUI user session (console user: '${console_user:-none}'); skipping UI launch."
;;
*)
uid=$(id -u "$console_user" 2>/dev/null)
if [ -z "$uid" ]; then
echo "Could not resolve uid for console user '$console_user'; skipping UI launch."
else
echo "Launching NetBird UI as console user $console_user (uid $uid)."
if ! launchctl asuser "$uid" sudo -u "$console_user" -H open "$APP"; then
echo "Failed to launch NetBird UI; if autostart is enabled it will start at next login."
fi
fi
;;
esac
echo "Finished Netbird installation successfully"
exit 0 # all good

View File

@@ -22,7 +22,6 @@ type Client interface {
ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
GetServerURL() string
// IsHealthy returns the current connection status without blocking.
// Used by the engine to monitor connectivity in the background.

View File

@@ -436,49 +436,6 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
return nil
}
// GetNetworkMap return with the network map
func (c *GrpcClient) GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error) {
serverPubKey, err := c.getServerPublicKey()
if err != nil {
log.Debugf("failed getting Management Service public key: %s", err)
return nil, err
}
ctx, cancelStream := context.WithCancel(c.ctx)
defer cancelStream()
stream, err := c.connectToSyncStream(ctx, *serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
return nil, err
}
defer func() {
_ = stream.CloseSend()
}()
update, err := stream.Recv()
if err == io.EOF {
log.Debugf("Management stream has been closed by server: %s", err)
return nil, err
}
if err != nil {
log.Debugf("disconnected from Management Service sync stream: %v", err)
return nil, err
}
decryptedResp := &proto.SyncResponse{}
err = encryption.DecryptMessage(*serverPubKey, c.key, update.Body, decryptedResp)
if err != nil {
log.Errorf("failed decrypting update message from Management Service: %s", err)
return nil, err
}
if decryptedResp.GetNetworkMap() == nil {
return nil, fmt.Errorf("invalid msg, required network map")
}
return decryptedResp.GetNetworkMap(), nil
}
func (c *GrpcClient) connectToSyncStream(ctx context.Context, serverPubKey wgtypes.Key, sysInfo *system.Info) (proto.ManagementService_SyncClient, error) {
req := &proto.SyncRequest{Meta: infoToMetaData(sysInfo)}

View File

@@ -94,11 +94,6 @@ func (m *MockClient) HealthCheck() error {
return m.HealthCheckFunc()
}
// GetNetworkMap mock implementation of GetNetworkMap from Client interface.
func (m *MockClient) GetNetworkMap(_ *system.Info) (*proto.NetworkMap, error) {
return nil, nil
}
// GetServerURL mock implementation of GetServerURL from mgm.Client interface
func (m *MockClient) GetServerURL() string {
if m.GetServerURLFunc == nil {

View File

@@ -0,0 +1,381 @@
package rest
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// AgentNetworkAPI APIs for the Agent Network (AI/LLM gateway), do not use directly
// see more: https://docs.netbird.io/api/resources/agent-network
type AgentNetworkAPI struct {
c *Client
}
// ListCatalogProviders lists the catalog of supported upstream AI providers
// (openai_api, anthropic_api, bedrock_api, ...) with their default models and
// pricing, used to prefill provider create forms.
func (a *AgentNetworkAPI) ListCatalogProviders(ctx context.Context) ([]api.AgentNetworkCatalogProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/catalog/providers", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkCatalogProvider](resp)
return ret, err
}
// ListProviders lists all Agent Network providers
func (a *AgentNetworkAPI) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkProvider](resp)
return ret, err
}
// GetProvider gets Agent Network provider info
func (a *AgentNetworkAPI) GetProvider(ctx context.Context, providerID string) (*api.AgentNetworkProvider, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/providers/"+providerID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// CreateProvider creates a new Agent Network provider. Set
// request.BootstrapCluster on the account's first provider to bootstrap the
// per-account gateway endpoint (alternatively bootstrap via UpdateSettings
// with a cluster).
func (a *AgentNetworkAPI) CreateProvider(ctx context.Context, request api.PostApiAgentNetworkProvidersJSONRequestBody) (*api.AgentNetworkProvider, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/providers", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// UpdateProvider updates an Agent Network provider. The request replaces the
// provider's mutable state; only an omitted api_key keeps the stored key
// (secrets are never required to round-trip).
func (a *AgentNetworkAPI) UpdateProvider(ctx context.Context, providerID string, request api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody) (*api.AgentNetworkProvider, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/providers/"+providerID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkProvider](resp)
return &ret, err
}
// DeleteProvider deletes an Agent Network provider. Fails while any policy
// still references the provider — detach it first.
func (a *AgentNetworkAPI) DeleteProvider(ctx context.Context, providerID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/providers/"+providerID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListPolicies lists all Agent Network policies
func (a *AgentNetworkAPI) ListPolicies(ctx context.Context) ([]api.AgentNetworkPolicy, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkPolicy](resp)
return ret, err
}
// GetPolicy gets Agent Network policy info
func (a *AgentNetworkAPI) GetPolicy(ctx context.Context, policyID string) (*api.AgentNetworkPolicy, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/policies/"+policyID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// CreatePolicy creates a new Agent Network policy
func (a *AgentNetworkAPI) CreatePolicy(ctx context.Context, request api.PostApiAgentNetworkPoliciesJSONRequestBody) (*api.AgentNetworkPolicy, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/policies", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// UpdatePolicy updates an Agent Network policy
func (a *AgentNetworkAPI) UpdatePolicy(ctx context.Context, policyID string, request api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody) (*api.AgentNetworkPolicy, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/policies/"+policyID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkPolicy](resp)
return &ret, err
}
// DeletePolicy deletes an Agent Network policy
func (a *AgentNetworkAPI) DeletePolicy(ctx context.Context, policyID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/policies/"+policyID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListGuardrails lists all Agent Network guardrails
func (a *AgentNetworkAPI) ListGuardrails(ctx context.Context) ([]api.AgentNetworkGuardrail, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkGuardrail](resp)
return ret, err
}
// GetGuardrail gets Agent Network guardrail info
func (a *AgentNetworkAPI) GetGuardrail(ctx context.Context, guardrailID string) (*api.AgentNetworkGuardrail, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// CreateGuardrail creates a new Agent Network guardrail
func (a *AgentNetworkAPI) CreateGuardrail(ctx context.Context, request api.PostApiAgentNetworkGuardrailsJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/guardrails", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// UpdateGuardrail updates an Agent Network guardrail
func (a *AgentNetworkAPI) UpdateGuardrail(ctx context.Context, guardrailID string, request api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody) (*api.AgentNetworkGuardrail, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/guardrails/"+guardrailID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkGuardrail](resp)
return &ret, err
}
// DeleteGuardrail deletes an Agent Network guardrail
func (a *AgentNetworkAPI) DeleteGuardrail(ctx context.Context, guardrailID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/guardrails/"+guardrailID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// ListBudgetRules lists all account-level Agent Network budget rules
func (a *AgentNetworkAPI) ListBudgetRules(ctx context.Context) ([]api.AgentNetworkBudgetRule, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[[]api.AgentNetworkBudgetRule](resp)
return ret, err
}
// GetBudgetRule gets Agent Network budget rule info
func (a *AgentNetworkAPI) GetBudgetRule(ctx context.Context, ruleID string) (*api.AgentNetworkBudgetRule, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// CreateBudgetRule creates a new Agent Network budget rule
func (a *AgentNetworkAPI) CreateBudgetRule(ctx context.Context, request api.PostApiAgentNetworkBudgetRulesJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "POST", "/api/agent-network/budget-rules", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// UpdateBudgetRule updates an Agent Network budget rule
func (a *AgentNetworkAPI) UpdateBudgetRule(ctx context.Context, ruleID string, request api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody) (*api.AgentNetworkBudgetRule, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/budget-rules/"+ruleID, bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkBudgetRule](resp)
return &ret, err
}
// DeleteBudgetRule deletes an Agent Network budget rule
func (a *AgentNetworkAPI) DeleteBudgetRule(ctx context.Context, ruleID string) error {
resp, err := a.c.NewRequest(ctx, "DELETE", "/api/agent-network/budget-rules/"+ruleID, nil, nil)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
return nil
}
// GetSettings gets the account's Agent Network gateway settings (cluster,
// subdomain, endpoint, collection toggles). An account that has not been
// bootstrapped yet — via UpdateSettings with a cluster, or by creating the
// first provider with bootstrap_cluster set — reads as the defaults with an
// empty Cluster, Subdomain and Endpoint. Management servers prior to that
// contract answered 200 with a JSON null body instead; that legacy shape is
// translated to an APIError matchable via IsNotFound rather than fabricating
// defaults the server never stated.
func (a *AgentNetworkAPI) GetSettings(ctx context.Context) (*api.AgentNetworkSettings, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/agent-network/settings", nil, nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "agent network settings not found"}
}
var ret api.AgentNetworkSettings
if err := json.Unmarshal(body, &ret); err != nil {
return nil, err
}
return &ret, nil
}
// UpdateSettings updates the account's Agent Network settings; the request
// replaces every mutable field (collection toggles and retention). Setting
// request.Cluster bootstraps the settings row when the account does not have
// one yet; on a bootstrapped account it must match the assigned cluster (or
// be nil) and any other value is rejected — the cluster is immutable.
func (a *AgentNetworkAPI) UpdateSettings(ctx context.Context, request api.PutApiAgentNetworkSettingsJSONRequestBody) (*api.AgentNetworkSettings, error) {
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, err
}
resp, err := a.c.NewRequest(ctx, "PUT", "/api/agent-network/settings", bytes.NewReader(requestBytes), nil)
if err != nil {
return nil, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
ret, err := parseResponse[api.AgentNetworkSettings](resp)
return &ret, err
}

View File

@@ -0,0 +1,497 @@
//go:build integration
package rest_test
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
)
var (
testAgentNetworkProvider = api.AgentNetworkProvider{
Id: "ainp_test",
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
Models: []api.AgentNetworkProviderModel{},
Enabled: true,
}
testAgentNetworkPolicy = api.AgentNetworkPolicy{
Id: "ainpol_test",
Name: "Engineering → OpenAI",
Enabled: true,
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
}
testAgentNetworkGuardrail = api.AgentNetworkGuardrail{
Id: "aingr_test",
Name: "No secrets",
}
testAgentNetworkBudgetRule = api.AgentNetworkBudgetRule{
Id: "ainbud_test",
Name: "Org monthly ceiling",
Enabled: true,
}
testAgentNetworkSettings = api.AgentNetworkSettings{
Cluster: "eu.proxy.netbird.io",
Subdomain: "violet",
Endpoint: "violet.eu.proxy.netbird.io",
EnableLogCollection: true,
AccessLogRetentionDays: ptr(30),
}
)
func TestAgentNetwork_ListCatalogProviders_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/catalog/providers", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkCatalogProvider{{Id: "openai_api", Name: "OpenAI"}})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListCatalogProviders(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, "openai_api", ret[0].Id)
})
}
func TestAgentNetwork_ListProviders_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkProvider{testAgentNetworkProvider})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListProviders(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkProvider, ret[0])
})
}
func TestAgentNetwork_GetProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_GetProvider_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "not found", Code: 404})
w.WriteHeader(404)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.GetProvider(context.Background(), "ainp_test")
require.Error(t, err)
assert.True(t, rest.IsNotFound(err), "a 404 must be matchable via IsNotFound")
})
}
func TestAgentNetwork_CreateProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req api.PostApiAgentNetworkProvidersJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
assert.Equal(t, "OpenAI", req.Name)
require.NotNil(t, req.BootstrapCluster)
assert.Equal(t, "eu.proxy.netbird.io", *req.BootstrapCluster)
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateProvider(context.Background(), api.PostApiAgentNetworkProvidersJSONRequestBody{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: ptr("sk-test"),
BootstrapCluster: ptr("eu.proxy.netbird.io"),
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_UpdateProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
// Omitted optional fields must be absent from the wire (not
// zero-valued) so the server-side merge preserves them.
assert.NotContains(t, string(reqBytes), "api_key")
assert.NotContains(t, string(reqBytes), "models")
retBytes, _ := json.Marshal(testAgentNetworkProvider)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateProvider(context.Background(), "ainp_test", api.PutApiAgentNetworkProvidersProviderIdJSONRequestBody{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkProvider, *ret)
})
}
func TestAgentNetwork_DeleteProvider_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/providers/ainp_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteProvider(context.Background(), "ainp_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListPolicies_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkPolicy{testAgentNetworkPolicy})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListPolicies(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkPolicy, ret[0])
})
}
func TestAgentNetwork_GetPolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetPolicy(context.Background(), "ainpol_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_CreatePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreatePolicy(context.Background(), api.PostApiAgentNetworkPoliciesJSONRequestBody{
Name: "Engineering → OpenAI",
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_UpdatePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkPolicy)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdatePolicy(context.Background(), "ainpol_test", api.PutApiAgentNetworkPoliciesPolicyIdJSONRequestBody{
Name: "Engineering → OpenAI",
SourceGroups: []string{"grp-eng"},
DestinationProviderIds: []string{"ainp_test"},
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkPolicy, *ret)
})
}
func TestAgentNetwork_DeletePolicy_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/policies/ainpol_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeletePolicy(context.Background(), "ainpol_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListGuardrails_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkGuardrail{testAgentNetworkGuardrail})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListGuardrails(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkGuardrail, ret[0])
})
}
func TestAgentNetwork_GetGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetGuardrail(context.Background(), "aingr_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_CreateGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateGuardrail(context.Background(), api.PostApiAgentNetworkGuardrailsJSONRequestBody{
Name: "No secrets",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_UpdateGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkGuardrail)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateGuardrail(context.Background(), "aingr_test", api.PutApiAgentNetworkGuardrailsGuardrailIdJSONRequestBody{
Name: "No secrets",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkGuardrail, *ret)
})
}
func TestAgentNetwork_DeleteGuardrail_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/guardrails/aingr_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteGuardrail(context.Background(), "aingr_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_ListBudgetRules_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal([]api.AgentNetworkBudgetRule{testAgentNetworkBudgetRule})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.ListBudgetRules(context.Background())
require.NoError(t, err)
assert.Len(t, ret, 1)
assert.Equal(t, testAgentNetworkBudgetRule, ret[0])
})
}
func TestAgentNetwork_GetBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetBudgetRule(context.Background(), "ainbud_test")
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_CreateBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.CreateBudgetRule(context.Background(), api.PostApiAgentNetworkBudgetRulesJSONRequestBody{
Name: "Org monthly ceiling",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_UpdateBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
retBytes, _ := json.Marshal(testAgentNetworkBudgetRule)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateBudgetRule(context.Background(), "ainbud_test", api.PutApiAgentNetworkBudgetRulesRuleIdJSONRequestBody{
Name: "Org monthly ceiling",
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkBudgetRule, *ret)
})
}
func TestAgentNetwork_DeleteBudgetRule_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/budget-rules/ainbud_test", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "DELETE", r.Method)
_, err := w.Write([]byte("{}"))
require.NoError(t, err)
})
err := c.AgentNetwork.DeleteBudgetRule(context.Background(), "ainbud_test")
require.NoError(t, err)
})
}
func TestAgentNetwork_GetSettings_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.NoError(t, err)
assert.Equal(t, testAgentNetworkSettings, *ret)
})
}
// TestAgentNetwork_GetSettings_UnbootstrappedDefaults pins the settings-read
// contract: an unbootstrapped account answers 200 with the defaults and empty
// cluster/subdomain/endpoint, which the client passes through untouched.
func TestAgentNetwork_GetSettings_UnbootstrappedDefaults(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(api.AgentNetworkSettings{
EnableLogCollection: true,
AccessLogRetentionDays: ptr(30),
})
_, err := w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.NoError(t, err)
assert.Empty(t, ret.Endpoint, "empty endpoint is the not-bootstrapped signal")
assert.True(t, ret.EnableLogCollection, "defaults must pass through")
})
}
func TestAgentNetwork_GetSettings_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "no", Code: 403})
w.WriteHeader(403)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.GetSettings(context.Background())
require.Error(t, err)
assert.Equal(t, "no", err.Error())
})
}
// TestAgentNetwork_GetSettings_LegacyNullBody pins the compatibility shim for
// management servers that answered 200 with a JSON null body before the
// defaults contract: the client translates that shape into an IsNotFound
// error instead of returning a bogus zero-valued settings object or
// fabricating defaults the server never stated.
func TestAgentNetwork_GetSettings_LegacyNullBody(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("null"))
require.NoError(t, err)
})
ret, err := c.AgentNetwork.GetSettings(context.Background())
require.Error(t, err)
assert.Nil(t, ret)
assert.True(t, rest.IsNotFound(err), "the legacy 200+null shape must surface as IsNotFound")
})
}
func TestAgentNetwork_UpdateSettings_200(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "PUT", r.Method)
reqBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req api.PutApiAgentNetworkSettingsJSONRequestBody
require.NoError(t, json.Unmarshal(reqBytes, &req))
require.NotNil(t, req.Cluster, "bootstrap cluster must be on the wire")
assert.Equal(t, "eu.proxy.netbird.io", *req.Cluster)
assert.True(t, req.EnableLogCollection)
retBytes, _ := json.Marshal(testAgentNetworkSettings)
_, err = w.Write(retBytes)
require.NoError(t, err)
})
ret, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Cluster: ptr("eu.proxy.netbird.io"),
EnableLogCollection: true,
})
require.NoError(t, err)
assert.Equal(t, testAgentNetworkSettings, *ret)
})
}
func TestAgentNetwork_UpdateSettings_Err(t *testing.T) {
withMockClient(func(c *rest.Client, mux *http.ServeMux) {
mux.HandleFunc("/api/agent-network/settings", func(w http.ResponseWriter, r *http.Request) {
retBytes, _ := json.Marshal(util.ErrorResponse{Message: "cluster is immutable once assigned (current: eu.proxy.netbird.io)", Code: 422})
w.WriteHeader(422)
_, err := w.Write(retBytes)
require.NoError(t, err)
})
_, err := c.AgentNetwork.UpdateSettings(context.Background(), api.PutApiAgentNetworkSettingsJSONRequestBody{
Cluster: ptr("us.proxy.netbird.io"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "immutable")
})
}

View File

@@ -147,6 +147,10 @@ type Client struct {
// ReverseProxyTokens account-scoped proxy access tokens used to register
// self-hosted (bring-your-own-proxy) `netbird proxy` instances.
ReverseProxyTokens *ReverseProxyTokensAPI
// AgentNetwork NetBird Agent Network (AI/LLM gateway) APIs: catalog,
// providers, policies, guardrails, budget rules and account settings.
AgentNetwork *AgentNetworkAPI
}
// New initialize new Client instance using PAT token
@@ -209,6 +213,7 @@ func (c *Client) initialize() {
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
c.AgentNetwork = &AgentNetworkAPI{c}
}
// NewRequest creates and executes new management API request

View File

@@ -5149,12 +5149,12 @@ components:
identity_header_user_id:
type: string
description: |
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
example: "x-bf-dim-netbird_user_id"
identity_header_groups:
type: string
description: |
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
example: "x-bf-dim-netbird_groups"
enabled:
type: boolean
@@ -5186,6 +5186,8 @@ components:
- name
- upstream_url
- models
- identity_header_user_id
- identity_header_groups
- enabled
- skip_tls_verification
- metadata_disabled
@@ -5222,7 +5224,7 @@ components:
extra_values:
type: object
description: |
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
additionalProperties:
type: string
example:
@@ -5230,12 +5232,12 @@ components:
identity_header_user_id:
type: string
description: |
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
example: "x-bf-dim-netbird_user_id"
identity_header_groups:
type: string
description: |
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
example: "x-bf-dim-netbird_groups"
enabled:
type: boolean
@@ -5243,11 +5245,11 @@ components:
example: true
skip_tls_verification:
type: boolean
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
example: false
metadata_disabled:
type: boolean
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
example: false
required:
- provider_id
@@ -6191,19 +6193,19 @@ components:
- cache_cost_usd
AgentNetworkSettings:
type: object
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
description: Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
properties:
cluster:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
example: "eu.proxy.netbird.io"
subdomain:
type: string
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
description: Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
example: "violet"
endpoint:
type: string
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
description: Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
example: "violet.eu.proxy.netbird.io"
enable_log_collection:
type: boolean
@@ -6224,13 +6226,13 @@ components:
created_at:
type: string
format: date-time
description: Timestamp when the settings row was created.
description: Timestamp when the settings row was created. Absent until the account is bootstrapped.
readOnly: true
example: "2026-04-26T10:30:00Z"
updated_at:
type: string
format: date-time
description: Timestamp when the settings row was last updated.
description: Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
readOnly: true
example: "2026-04-26T10:30:00Z"
required:
@@ -6240,12 +6242,14 @@ components:
- enable_log_collection
- enable_prompt_collection
- redact_pii
- created_at
- updated_at
AgentNetworkSettingsRequest:
type: object
description: Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
description: Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
properties:
cluster:
type: string
description: Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
example: "eu.proxy.netbird.io"
enable_log_collection:
type: boolean
description: Whether per-request access-log entries are collected for this account's agent-network traffic.
@@ -13690,7 +13694,7 @@ paths:
/api/agent-network/settings:
get:
summary: Retrieve Agent Network settings
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Returns 404 when no provider has been created yet — settings are lazily bootstrapped on first provider create.
description: Returns the per-account Agent Network gateway settings (cluster, subdomain, endpoint). Before the account is bootstrapped — on first provider create (`bootstrap_cluster`) or via PUT with `cluster` — the response carries the default values with empty cluster, subdomain and endpoint.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13706,13 +13710,11 @@ paths:
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'500':
"$ref": "#/components/responses/internal_error"
put:
summary: Update Agent Network settings
description: Updates the mutable account-level Agent Network settings (collection toggles). Cluster and subdomain are immutable and ignored if sent. Returns 404 when settings have not been bootstrapped (no provider created yet).
description: Updates the account-level Agent Network settings; the request replaces every mutable field (collection toggles and retention). When the account has no settings row yet, providing `cluster` bootstraps it (assigning the subdomain that forms the agent endpoint); without `cluster` the request returns 404. Sending a `cluster` different from the assigned one is rejected (the cluster is immutable once assigned). The subdomain is always server-assigned and immutable.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13738,6 +13740,8 @@ paths:
"$ref": "#/components/responses/forbidden"
'404':
"$ref": "#/components/responses/not_found"
'422':
"$ref": "#/components/responses/validation_failed"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/budget-rules:

View File

@@ -2275,11 +2275,11 @@ type AgentNetworkProvider struct {
// Id Provider ID
Id string `json:"id"`
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
// IdentityHeaderGroups Wire header name the proxy stamps with the caller's NetBird groups as a comma-separated list (sorted) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Same per-catalog semantics as `identity_header_user_id`.
IdentityHeaderGroups string `json:"identity_header_groups"`
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Always present in responses; empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config).
IdentityHeaderUserId string `json:"identity_header_user_id"`
// MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it.
MetadataDisabled bool `json:"metadata_disabled"`
@@ -2335,16 +2335,16 @@ type AgentNetworkProviderRequest struct {
// Enabled Whether the provider is enabled. Defaults to true on create.
Enabled *bool `json:"enabled,omitempty"`
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). When present on a request, the whole map replaces the stored values. Empty strings drop the corresponding key.
// ExtraValues Operator-typed values for catalog-declared extra headers (see AgentNetworkProvider.extra_values). The request's map replaces the stored values; empty strings drop the corresponding key.
ExtraValues *map[string]string `json:"extra_values,omitempty"`
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same omit / empty semantics as `identity_header_user_id`.
// IdentityHeaderGroups Wire header name for the caller's groups CSV. See AgentNetworkProvider.identity_header_groups. Same semantics as `identity_header_user_id`.
IdentityHeaderGroups *string `json:"identity_header_groups,omitempty"`
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension).
// IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. Empty or omitted disables stamping for this dimension.
IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"`
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged.
// MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected).
MetadataDisabled *bool `json:"metadata_disabled,omitempty"`
// Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices.
@@ -2356,22 +2356,22 @@ type AgentNetworkProviderRequest struct {
// ProviderId Catalog identifier for the upstream AI provider (e.g. openai_api, anthropic_api, azure_openai_api, bedrock_api, vertex_ai_api, mistral_api, custom).
ProviderId string `json:"provider_id"`
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged.
// SkipTlsVerification Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false.
SkipTlsVerification *bool `json:"skip_tls_verification,omitempty"`
// UpstreamUrl Full upstream URL (with scheme) that NetBird forwards traffic to.
UpstreamUrl string `json:"upstream_url"`
}
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are auto-assigned on first provider create and immutable thereafter.
// AgentNetworkSettings Per-account Agent Network gateway settings. One row per account; cluster and subdomain are assigned at bootstrap and immutable thereafter. Before bootstrap the account reads as the default values with empty cluster, subdomain and endpoint.
type AgentNetworkSettings struct {
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely. Usage records are retained independently.
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint.
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. Empty until the account is bootstrapped.
Cluster string `json:"cluster"`
// CreatedAt Timestamp when the settings row was created.
// CreatedAt Timestamp when the settings row was created. Absent until the account is bootstrapped.
CreatedAt *time.Time `json:"created_at,omitempty"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
@@ -2380,24 +2380,27 @@ type AgentNetworkSettings struct {
// EnablePromptCollection Master switch for request/response prompt capture. Capture runs only when this is on AND a policy guardrail also enables it.
EnablePromptCollection bool `json:"enable_prompt_collection"`
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`.
// Endpoint Bare hostname agents call for this account, computed as `<subdomain>.<cluster>`. Empty until the account is bootstrapped.
Endpoint string `json:"endpoint"`
// RedactPii Whether captured prompts have PII redacted. Effective redaction is the OR of this and any policy guardrail's redact setting.
RedactPii bool `json:"redact_pii"`
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint.
// Subdomain Auto-generated DNS-safe label that prefixes the cluster to form the agent-network endpoint. Empty until the account is bootstrapped.
Subdomain string `json:"subdomain"`
// UpdatedAt Timestamp when the settings row was last updated.
// UpdatedAt Timestamp when the settings row was last updated. Absent until the account is bootstrapped.
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// AgentNetworkSettingsRequest Mutable account-level Agent Network settings. Cluster and subdomain are immutable and not accepted here.
// AgentNetworkSettingsRequest Account-level Agent Network settings update. The request replaces every mutable field. `cluster` additionally bootstraps the per-account settings row when the account does not have one yet; the subdomain is always server-assigned.
type AgentNetworkSettingsRequest struct {
// AccessLogRetentionDays Days to retain full access-log rows; older rows are swept. 0 or less means keep indefinitely.
AccessLogRetentionDays *int `json:"access_log_retention_days,omitempty"`
// Cluster Address of the NetBird proxy cluster fronting this account's agent-network endpoint. When the account has no settings row yet, providing it bootstraps the row (assigning the subdomain that forms the agent endpoint). The cluster is immutable once assigned — later updates must omit it or send the assigned value; any other value is rejected.
Cluster *string `json:"cluster,omitempty"`
// EnableLogCollection Whether per-request access-log entries are collected for this account's agent-network traffic.
EnableLogCollection bool `json:"enable_log_collection"`

View File

@@ -228,15 +228,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
return c, nil
}
// decodeAccountNetwork never returns nil — Calculate() dereferences
// c.Network unconditionally, and servers that predate the fix omit the field
// entirely from the empty-components envelope.
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
n := &types.Network{}
if an == nil {
return nil
}
n := &types.Network{
Identifier: an.Identifier,
Dns: an.Dns,
Serial: an.Serial,
return n
}
n.Identifier = an.Identifier
n.Dns = an.Dns
n.Serial = an.Serial
if an.NetCidr != "" {
if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
n.Net = *ipnet

View File

@@ -221,6 +221,66 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
"client-side Calculate must connect the same remote peers as the server")
}
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
// the server takes for a peer that is missing from the account or absent from
// the validated-peers map. The legacy server short-circuited before
// Calculate() and shipped a NetworkMap carrying only the account Network; the
// components path runs Calculate() on the client instead, so the envelope must
// carry Network or the client panics dereferencing a nil *types.Network.
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
localPeerKey := randomWgKey(t)
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
PeerID: "peer-A",
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
Peers: map[string]*types.ComponentPeer{
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
},
})
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components")
require.Equal(t, uint64(7), result.NetworkMap.Serial)
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
}
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
// they survive talking to a management server that predates the encoder fix.
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
c, localPeerKey := buildSmokeComponents(t)
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
envelope.GetFull().Network = nil
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "a missing AccountNetwork must not panic the client")
require.NotNil(t, result.Components.Network)
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
}
// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1
// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient
// to validate the encode → marshal → decode → Calculate pipeline produces

View File

@@ -52,6 +52,11 @@ type CredentialPayload struct {
Credential *Credential
RosenpassPubKey []byte
RosenpassAddr string
// MlkemPayload is the opaque post-quantum KEM handshake message riding this
// OFFER/ANSWER (see Body.mlkemPayload). Nil when not running the PQ exchange.
MlkemPayload []byte
// MlkemPort is the sender's ML-KEM PQ service UDP port (0 when not running).
MlkemPort int
RelaySrvAddress string
RelaySrvIP netip.Addr
SessionID []byte
@@ -89,6 +94,13 @@ func MarshalCredential(myKey wgtypes.Key, remoteKey string, p CredentialPayload)
if p.RelaySrvIP.IsValid() {
body.RelayServerIP = p.RelaySrvIP.Unmap().AsSlice()
}
if len(p.MlkemPayload) > 0 {
body.MlkemPayload = p.MlkemPayload
}
if p.MlkemPort > 0 {
port := uint32(p.MlkemPort)
body.MlkemPort = &port
}
return &proto.Message{
Key: myKey.PublicKey().String(),
RemoteKey: remoteKey,

View File

@@ -239,6 +239,16 @@ type Body struct {
// fallback dial target when DNS resolution of relayServerAddress fails.
// SNI/TLS verification still uses relayServerAddress.
RelayServerIP []byte `protobuf:"bytes,11,opt,name=relayServerIP,proto3,oneof" json:"relayServerIP,omitempty"`
// mlkemPayload carries a post-quantum X25519MLKEM768 handshake message that
// seeds the WireGuard PSK, riding this Body's OFFER/ANSWER: on an OFFER it is
// the KEM offer, on an ANSWER the KEM answer. It is opaque to signal — the
// pqkem library frames and parses it. Absent when the sender does not run the
// ML-KEM PQ exchange; unknown to older clients, which ignore it.
MlkemPayload []byte `protobuf:"bytes,12,opt,name=mlkemPayload,proto3,oneof" json:"mlkemPayload,omitempty"`
// mlkemPort is the UDP port of the sender's ML-KEM PQ service, bound on its
// WireGuard overlay IP. Peers send subsequent rekey messages there over the
// data path. Zero/absent when the ML-KEM PQ exchange is not running.
MlkemPort *uint32 `protobuf:"varint,13,opt,name=mlkemPort,proto3,oneof" json:"mlkemPort,omitempty"`
}
func (x *Body) Reset() {
@@ -343,6 +353,20 @@ func (x *Body) GetRelayServerIP() []byte {
return nil
}
func (x *Body) GetMlkemPayload() []byte {
if x != nil {
return x.MlkemPayload
}
return nil
}
func (x *Body) GetMlkemPort() uint32 {
if x != nil && x.MlkemPort != nil {
return *x.MlkemPort
}
return 0
}
// Mode indicates a connection mode
type Mode struct {
state protoimpl.MessageState
@@ -466,7 +490,7 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xbd, 0x05, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
@@ -494,39 +518,46 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x6d, 0x6c, 0x6b,
0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x48,
0x03, 0x52, 0x0c, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88,
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f, 0x72, 0x74, 0x18,
0x0d, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x04, 0x52, 0x09, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f,
0x72, 0x74, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a,
0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57,
0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54,
0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, 0x04, 0x12, 0x0b, 0x0a,
0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x48, 0x45,
0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65,
0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x42, 0x10,
0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50,
0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61,
0x64, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6d, 0x6c, 0x6b, 0x65, 0x6d, 0x50, 0x6f, 0x72, 0x74, 0x4a,
0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a,
0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52,
0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64,
0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65,
0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b,
0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45,
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12,
0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74,
0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61,
0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01,
0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (

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