NewAuth built a fresh in-memory config on every call via
CreateInMemoryConfig, which generates a new WireGuard private key when
none is set. The iOS Swift layer calls this on interactive re-login and
writes the resulting config back to the profile's netbird.cfg, so each
re-auth replaced the peer's persisted private key with a new one. A new
key means a new public key, so the management server registered a
brand-new peer on every re-authentication — named after the fallback
hostname.
Load the existing config with DirectUpdateOrCreateConfig when a config
file is already present so re-login reuses the peer's persisted private
key (and its identity). Only fall back to a fresh in-memory config for
the first-time login when no config file exists yet (or after logout,
which deletes the file). DirectUpdateOrCreateConfig uses non-atomic
writes so it also works inside the tvOS App Group sandbox. This matches
what Run() and LoginForMobile() already do.
## Describe your changes
## Issue ticket number and link
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for loading or creating persistent configuration when a
configuration file path is provided.
* Continued support for in-memory configuration for temporary or
first-time use.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
Removing a leftover from an initial implementation. We ended up
resolving it on the client with status checks on the DNS response
## Issue ticket number and link
## Stack
- \#6726 <!-- branch-stack -->
- \#6768 :point\_left:
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from <https://github.com/netbirdio/docs> here:
<https://github.com/netbirdio/docs/pull/>\_\_
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Added support for Bedrock-native request routing in agent network
scenarios.
- Added guardrail management capabilities for creating and removing
model allowlists.
- **Bug Fixes**
- Model allowlists now reject requests when the model is missing or
blank.
- Improved Rosenpass and WireGuard recovery after repeated handshake
failures.
- Improved relay connection handling so status and cleanup operations
remain responsive during stalled connections.
- Updated private service DNS zones to avoid unintended search-domain
behavior.
- **Tests**
- Expanded coverage for model allowlists, handshake recovery, relay
concurrency, and Bedrock routing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
The Agent Network policy Guardrail "Model Allowlist" was not enforced
for providers whose model travels in the URL/path rather than the JSON
body — most visibly AWS Bedrock (reported in netbirdio/netbird#6751),
and the same class applies to Google Vertex.
Root cause: the `llm_guardrail` allowlist check **failed open**.
`evaluateAllowlist` returned allow whenever the request model was absent
from the metadata bag (`middleware.go`, `if !modelPresent { return nil
}`). The model is stamped upstream by `llm_request_parser`; for
body-routed providers (OpenAI/Anthropic) it comes from the JSON body,
but for path-routed providers the model is recovered only when the
request matches a recognized path shape (Bedrock
`/model/{id}/{invoke|converse|...}`, Vertex
`/v1/projects/.../publishers/.../models/...`). Any shape the parser did
not recognize reached the guardrail with no model and was allowed
regardless of the allowlist.
Fix (provider-agnostic): **fail closed**. When an allowlist is
configured and the model cannot be determined (absent or empty), the
request is denied `403` with a distinct `llm_policy.model_unknown`
reason. This closes the bypass for Bedrock, Vertex, and any future
URL-routed provider in one place. When no allowlist is configured,
behavior is unchanged.
The model allowlist is enforced solely in the proxy `llm_guardrail`;
management's `CheckLLMPolicyLimits` handles only token/budget caps, so
no management change is required.
## Issue ticket number and link
<https://github.com/netbirdio/netbird/discussions/6751>
## Stack
- \#6726 <!-- branch-stack -->
- \#6764 :point\_left:
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
Bug fix that restores the documented allowlist behavior; no user-facing
surface changes.
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from <https://github.com/netbirdio/docs> here:
<https://github.com/netbirdio/docs/pull/>\_\_
## Tests
- `llm_guardrail`: absent/empty model under a configured allowlist now
denies (`model_unknown`); empty allowlist still allows a missing model
(fail-closed only applies when a list is set); existing
allow/deny/case-insensitive cases retained.
- `llm_request_parser`: new parser→guardrail integration test drives
real **Bedrock** (`/model/{id}/invoke`) and **Vertex**
(`/v1/projects/.../models/...`) URL shapes and asserts allowed→200,
disallowed→403 (`model_blocked`), and an unrecognized Bedrock action→403
(`model_unknown`, the #6751 regression guard).
Note: a full through-tunnel e2e for the allowlist is intentionally
deferred — the agent-network e2e (`WaitProxyPeer`) is currently red on
`main`/`0.74.x` for an unrelated lazy-connection reason; it will be
added once that harness gate is fixed.
* Improved residual state restoration during foreground startup and
foreground login, ensuring consistent recovery with stale states.
* Foreground flows now initialize advanced routing so stale routes
are bypassed during login.
* [client] Extract peerRoutesAddr helper in toExcludedLazyPeers
Refactor: pull the AllowedIPs match into a named
peerRoutesAddr helper and document why forward-target peers are excluded
from lazy connections. No behavior change; the existing address match is
preserved as-is.
* [client] Add failing test for lazy-conn forward-target exclusion
toExcludedLazyPeers compares AllowedIPs (CIDR) against the unmasked
TranslatedAddress, so forward-target peers are never excluded. This test
asserts the peer is excluded and fails on the current behavior; the fix
follows.
* [client] Fix lazy-conn exclusion for ingress forward peers
peerRoutesAddr compared AllowedIPs (CIDR, e.g. a peer's overlay IP as /32)
against the unmasked TranslatedAddress string, so the match never fired and
forward-target peers were never excluded from lazy connections. Use prefix
containment so a routed address matches the peer's AllowedIP
* [client] Reuse parsed AllowedIPs from peerStore in lazy exclusion
Instead of re-parsing the network map AllowedIPs strings, look up the
already-parsed []netip.Prefix from peerStore.AllowedIPs (the same typed
value the lazy manager itself consumes). A down/lazy peer still has its
conn in the store, so exclusion is unaffected by connection state. Extract
a pure prefixesContain helper and unit-test it.
* Stick new watcher creation to actual existence of af the conn
and its removal to the removal of such same conn.
Avoid debouncing and cross lock dead locking
* Discriminate not updated from timeout handshakes
* [Recheck watcher ctx cancellation under conn.mu in onWGDisconnected
onWGDisconnected only checked conn.ctx (the engine-scoped context), never
the watcher's own context. disableWgWatcherIfNeeded cancels the wgWatcherCtx,
not conn.ctx, so a disabled watcher's timeout callback did not see the
cancellation.
handshakeCheck runs lock-free, so between the ctx check in periodicHandshakeCheck
and acquiring conn.mu a fast disconnect/reconnect can slip in: the stale watcher
then acquires the lock and tears down the *new*, healthy connection based on the
old timeout, forcing the guard into an unnecessary reconnect (flap).
Recheck watcherCtx.Err() under conn.mu so a superseded watcher exits without
touching the connection that replaced it.
* Remove verbose comments
* Fixup merge conflict leftovers
* Fixup context brought by onWGDisconnected
Adds a settings constraint: enabling `agent_network_only` requires `dashboard_features.agent_network` to be `true` in the same account update. Without the Agent Network menu flag, a focused account that later turns the focused view off would lose access to the Agent Network menu entirely, so the two must be set together.
The check runs in `updateAccountRequestSettings` against the parsed request state: if the resulting settings have `agent_network_only == true` but `dashboard_features.agent_network` is not `true`, the update is rejected with `status.InvalidArgument` (HTTP 422) before anything is persisted.
The OpenAPI field descriptions for `agent_network_only` and `dashboard_features.agent_network` document the requirement. Only the descriptions changed — `required` and the schema `$ref` are untouched — and `types.gen.go` was regenerated from the spec (diff is the two comment lines).
* [client] Add autostart preference marker and MDM disableAutostart key
Adds the autostartInitialized marker to the Wails UI preferences store so
the one-time autostart default decision can persist per OS user, and a
UI-only disableAutostart MDM policy key that suppresses the default and
flows into GetConfigResponse.mDMManagedFields like disableAutoConnect.
* [client] Enable launch-on-login by default on fresh GUI installs
On the first interactive run the GUI persists the autostartInitialized
marker before any enable attempt, then enables autostart only when the
platform supports it, MDM policy does not disable it, the process was not
relaunched by an installer/updater (--post-update), and the installer's
fresh-install breadcrumb is present. Upgrading users have no breadcrumb,
so an update can never write login items, and a user's disable in
Settings is never overridden.
* [release] Write fresh-install breadcrumb from installers
Installers write a .fresh-install breadcrumb on fresh installs only and
delete stale breadcrumbs on upgrade; none of them writes login items or
registry Run keys. Windows NSIS detects upgrades via the uninstall
registry entry or an existing installed executable; the macOS pkg via the
previous pkgutil receipt; Linux deb/rpm via the standard postinstall
arguments. Post-update GUI relaunches (macOS open, Linux
ui-post-install.sh) pass --post-update so the first-run autostart default
cannot fire on updates.
* Revert installer breadcrumb changes
The real Windows installer does uninstall-then-install and deletes
$INSTDIR, so a breadcrumb written there cannot survive or discriminate
a fresh install from an upgrade. Restore the three installer files to
their main versions; no installer or updater writes an autostart entry.
* Detect fresh install from NetBird footprint instead of installer breadcrumb
Replace the installer-written breadcrumb discriminator with a GUI-side
check. netbirdFootprintExists inspects the daemon config/state files
(default.json, legacy config.json, state.json) under profilemanager's
default config dir; combined with whether the UI preferences file already
existed, this tells a genuinely fresh machine from an existing or
upgrading user. Only the signed GUI, via Wails, ever enables
launch-on-login, and a user's later manual disable is never overridden.
The preferences store now exposes ExistedAtLoad and the --post-update
flag is dropped.
* Update tests for footprint-based autostart default
Table tests for shouldEnableAutostartDefault now cover supported,
mdmDisabled, and priorInstall guards plus precedence; breadcrumb and
post-update cases are removed. Add a store test asserting ExistedAtLoad
is false with no file and true after persisting and reopening.
* [client] Bring the connection up in Go after SSO login
The post-login Up ran as a frontend promise continuation after WaitSSOLogin
resolved. During SSO the tray window is hidden and the webview is suspended
(macOS App Nap / hidden-window timer throttling), so that continuation didn't
run until the user woke the window (e.g. hovering the tray icon), leaving the
client not connected for a long time. Combine WaitSSOLogin and Up in a single
Go method so the daemon connects the moment SSO completes, independent of
webview state. The frontend no longer issues a separate Up on the SSO path.
* [client] unexport waitSSOLogin and move below exported methods
Introduce a nullable dashboard_features object on account settings, serialized
to a single JSON column so new dashboard sections can be added without schema
changes. Starts with agent_network (show the Agent Network menu for an account
without the deployment flag). Wires the API handler mapping, the pgx GetAccount
loader, and adds store round-trip and handler tests.
Users reported long delays between finishing browser authentication and
the client connecting. Logs could not attribute the time: the PKCE and
device flows were silent between issuing the auth URL and returning the
token, and nothing recorded when the GUI issued the Up request after
WaitSSOLogin completed.
Add log lines covering the full chain: PKCE callback arrival and token
exchange duration, device-flow polling and approval timing, GUI-side
brackets around WaitSSOLogin and Up, daemon-side Up arrival and
WaitSSOLogin return, and a frontend stall detector that reports when
webview timers were suspended (macOS App Nap / hidden-window
throttling), which delays the WaitSSOLogin-to-Up handoff.
SynthesizePrivateServiceZones created CustomZones for private services
without setting SearchDomainDisabled, causing the reverse proxy domain
to be injected as a search domain suffix on all connected peers.
This broke local hostname resolution: short names like 'myserver' were
expanded to 'myserver.app.example.com' (matching the reverse proxy
domain) before local DNS search domains were tried.
Fix: set SearchDomainDisabled: true so the zone is registered as a
match-only supplemental resolver, consistent with the NonAuthoritative
intent already expressed on the same zone.
* fix flaky test around event aggregation: control time.Now() from the test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
* actually use passed in func to generate time
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
---------
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.
Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
* routemanager: enforce a single selected exit node
Backport of the exit-node exclusivity reconcile from the 0.75.0 line
(upstream commit 966fbec11) onto v0.74.0. Exit nodes are mutually
exclusive, but the RouteSelector stores routes with default-on semantics,
so every available exit node reported as selected at once.
Reconcile exit-node selection on each network map: keep at most one
selected -- the user's persisted pick, else whatever management marks for
auto-apply (SkipAutoApply=false), else none. Never auto-activate an exit
node the map does not request.
Carries over only the manager/routeselector logic and its test; the
desktop-only client/server changes and the BumpNetworksRevision UI-push
feature from the original commit are intentionally excluded.
* routeselector: make exit-node reconciliation atomic
enforceSingleExitNode took the RouteSelector lock three separate times
(IsDeselectAll, then DeselectRoutes, then SelectRoutes), so a concurrent
DeselectAllRoutes could interleave and be silently undone: SelectRoutes on
its deselectAll branch clears the flag and re-selects the preferred exit
node, overriding the user's "all off".
Move the whole reconciliation into a single locked RouteSelector method
(SetExclusiveExitNode) that checks deselectAll inside the critical section,
so a deselect-all either fully precedes the reconcile (left untouched) or
fully follows it (honoured). No interleaving is possible.
* [iface] Drop redundant device dump in kernel configure()
wgctrl.ConfigureDevice already returns an error when the interface is
missing, so the preceding wg.Device() existence check is redundant. That
check dumps the entire device (all peers) on every configure() call,
making it O(peers) per call and turning bulk peer insertion into
O(peers^2): inserting N peers one by one re-parsed the whole growing peer
list N times. Removing it keeps each peer write constant-time regardless
of how many peers are already configured.
* [iface] Cache WireGuard stats to collapse per-peer device dumps
Each peer runs a WGWatcher that polls GetStats(), and every call dumps
the whole device, so with N peers the watchers perform O(N) full dumps
per poll cycle (O(N^2) work) while each keeps only its own peer's entry.
Wrap the kernel and userspace configurer GetStats() in a short-TTL cache
with singleflight: the staggered per-peer calls share a single device
dump per window and concurrent misses collapse into one dump. The kernel
and userspace WireGuard APIs have no per-peer stats query (a get always
returns the whole device), so a shared cached snapshot avoids the
repeated full dumps.
* Ignore .claude directory
Restore the pre-split native, sudo-based run for the Linux Client / Unit
job: build with the privileged tag and run under sudo, matching the darwin
job. Excludes the dockertest harness (client/testutil/privileged) so it does
not recurse into a container spawn. The Docker privileged job is kept as-is.
With improvements in userspace lazy connection handling, we should be able to enable it for new accounts with less impact on users.
These connections are cheaper and only target traffic that should go through the tunnels, leaving all other tunnels in an idle state.
* [client] Fix race between WG watcher initial handshake read and endpoint config
The watcher's initial handshake read ran in a separate goroutine with no
ordering guarantee relative to the WireGuard endpoint configuration, so it
would sometimes race with the peer being added to the interface. Split
enabling into a synchronous PrepareInitialHandshake, called before the
endpoint is configured, and an EnableWgWatcher that only runs the
monitoring loop, making the baseline read deterministic and keeping it
correct for reconnects where the peer's WireGuard entry survives.
* [client] Skip WG watcher disconnect callback when context is cancelled
A superseded or cancelled watcher whose handshake-check timer fires before
it observes ctx.Done() would still invoke onDisconnectedFn, tearing down a
now-healthy connection. Re-check ctx before firing the disconnect and
handshake-success callbacks and stand down silently if it was cancelled.
* [management,proxy] Add per-provider skip_tls_verification for agent-network
Let agent-network providers opt into skipping upstream TLS verification for
self-hosted / internal gateways behind a private or self-signed cert.
- provider: add SkipTLSVerification (persisted via AutoMigrate) with
request/response mapping (nil on update preserves, explicit false clears).
- openapi: skip_tls_verification on the provider request + response; types
regenerated.
- synthesizer: carry the flag into the llm_router route config so it reaches
the proxy.
- proxy: llm_router sets it on the UpstreamRewrite mutation, and the reverse
proxy applies roundtrip.WithSkipTLSVerify per selected route when forwarding
upstream (the router dials per provider, so a per-target flag alone wouldn't
cover it).
- tests: synthesizer route config carries the flag, router rewrite propagates
it, and the request/response round-trip incl. update semantics.
* [e2e] Validate per-provider skip_tls_verification end to end
Add a self-signed HTTPS upstream (nginx) to the harness and a test that
provisions two providers on that same upstream — one with
skip_tls_verification=true, one false — behind one proxy + client. The
skip=true provider's chat reaches the upstream (200); the skip=false
provider's fails the TLS handshake (5xx). Same upstream, opposite outcome,
which proves the flag is honoured per provider (a single target-level flag
could not, since all of an account's providers share one synthesised
target).
* [e2e] WaitProxyPeer: require >=1 connected peer, not exact 1/1
Each proxy container registers a fresh WireGuard key and its peer is not
removed on teardown, so proxy peers from earlier tests linger in the
account as disconnected. WaitProxyPeer matched the exact string
"1/1 Connected", which failed once a second proxy-using test ran in the
same package (status "1/2"). Parse the "Peers count: X/Y Connected" line
and wait for X>=1 instead: only the live proxy can be connected, and the
caller's subsequent chat is the real end-to-end assertion. Fixes the CI
failure of TestProviderSkipTLSVerification (runs after TestProvidersMatrix).
The suite builds combined/proxy/client from source and drives live
provider traffic, so running it per push/PR is too costly. Switch to a
nightly schedule (03:00 UTC) plus workflow_dispatch, and drop the
now-unneeded fork guard that only mattered for pull_request runs.
The HTTP auth middleware runs syncUserJWTGroups in the request context.
The dashboard SPA routinely aborts in-flight requests on re-render or
navigation, which cancels the request context mid-write and rolls back
the group-sync DB transaction. The error is logged but swallowed, so the
synced groups silently never persist (users.auto_groups stays empty)
while the failing log line repeats on every request.
Detach the sync from the request's cancellation with
context.WithoutCancel so the write can commit regardless of the client
connection; the store already bounds the transaction with its own
timeout. Add a regression test asserting the sync receives a
non-cancelled context even when the originating request is cancelled.