Commit Graph
3476 Commits
Author SHA1 Message Date
Pascal Fischer 8c70f9ec71 [management] fix peer ip allocation test (#8173) v0.81.0-rc.1 2026-10-12 16:13:03 +02:00
Nicolas Frati e4ddd9a3fc [management] Add a release-wired UBI image variant (#8167)
* [management] Add a release-wired UBI image variant

OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy, combined server and rootless
client already ship -ubi variants; this adds the same for management,
published as <version>-ubi and ubi-latest for amd64 and arm64.

The default --port of 80 cannot bind without root, so the image defaults
to 8080. For Let's Encrypt, public 443 forwards to --port and
--letsencrypt-listen-address is empty (#7706), so the main listener
answers the TLS-ALPN challenges.

* [management] Certify the management UBI image

Adds management to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_MANAGEMENT repository
variable.
2026-10-12 15:44:02 +02:00
Nicolas Frati 763da1b985 [relay] Add a release-wired UBI image variant (#7457)
* [relay] Add a release-wired UBI image variant

* [relay] Align the UBI image with the shared UBI pattern

Main now ships UBI variants of the client, combined server and proxy that
share one license collector and one image layout, and the Red Hat
certification workflow expects every certified image to carry both amd64
and arm64. The relay variant predated that, kept its own copy of the
license script, built amd64 only, and ran as UID 65532 on the privileged
default port :443, so it would not start under OpenShift or rootless Podman.

Use release_files/collect-licenses.sh and build amd64 and arm64 like the
other UBI images. Run as 1000:0 with a group-0 writable /var/lib/netbird
for Let's Encrypt data, and default the listener to :8443 so an arbitrary
non-root UID can bind it; the relay's Let's Encrypt flow uses TLS-ALPN on
that same listener, so no separate challenge port is needed.

* [infrastructure] Certify the relay UBI image on release

Releases certify the UBI images through redhat-certify.yml, which only
lists the client, proxy and combined server, so a released relay UBI
image would never reach the Red Hat catalog.

Add relay to the component list and the manual dispatch options. Its
Partner Connect ID comes from REDHAT_CERT_ID_RELAY, which must exist
before this merges: the resolve step fails the whole run when any
component's variable is missing.
2026-10-12 15:09:45 +02:00
Nicolas Frati f0182dd645 [signal] Add a release-wired UBI image variant (#7458)
* [signal] Add an explicit non-root UBI image variant

Keep the existing signal image unchanged while making a separate UBI image available for local certification-readiness checks. Collect the linked Go dependency license terms as portable build inputs, and use SIGINT for the existing graceful stop handler.

* [signal] Wire the UBI variant into GoReleaser

Build the additive UBI signal variant through the existing release pipeline instead of relying on local Docker commands. Stage its licenses in the per-image build context and restrict the variant to validated AMD64 without changing existing image tags.

* [signal] Align the UBI image with the other UBI variants

Main now ships UBI images for the client, combined server and proxy with
a shared license collector and a common shape, so the signal variant
should look the same to reviewers and to Red Hat certification. Signal
also listens on port 80 by default, which an arbitrary non-root UID
cannot bind on OpenShift or Podman.

Use release_files/collect-licenses.sh instead of a signal-only copy,
build for amd64 and arm64 like the other UBI entries, and run as
1000:0 with a group-writable /var/lib/netbird that also holds Let's
Encrypt data. Default NB_PORT to the legacy gRPC port 10000 so the
image starts unprivileged and serves a single listener.

* [infrastructure] Certify the signal UBI image on release

The signal UBI image is published with every release, but only images
listed in the certification workflow reach the Red Hat catalog, so it
would otherwise need a manual run each time.

Add signal to the dispatch options and the component list, using the
same -ubi tag suffix as .goreleaser.yaml. The run reads its Partner
Connect ID from REDHAT_CERT_ID_SIGNAL.

* [signal] Answer Let's Encrypt challenges on the UBI image's port

The challenge listener defaults to :443, which a non-root UID cannot
bind on OpenShift or Podman, so Let's Encrypt could not work in the
UBI image. Now that the listener address is configurable, default it
to empty so the TLS listener on port 10000 answers the challenges
itself when public port 443 is forwarded to it.
2026-10-12 15:09:45 +02:00
Janek Härtter a6e154a9b3 [misc] Extend the copyright year to 2026 (#8169)
The Windows installer shows LICENSE on its license page, where the
2022 copyright line reads as outdated. Extend the range to 2022-2026
in LICENSE, its REUSE copy, and the installer's LegalCopyright, keeping
2022 as the year of first publication.
2026-10-12 15:02:52 +02:00
Bruno Mercier Costa ef2292d822 [client] Fix Windows UI exiting on first launch (#8138)
* [client] Fix Windows UI exiting on first launch

Bump the Wails fork to pick up netbirdio/wails#6, which stops
SetBackgroundColour from dereferencing a WebView2 controller that does
not exist yet. On first launch the theme service re-tints the Welcome
window while it is still being created, and losing that race made the
UI exit with no window.

* [client] Point the Wails fork at the merged netbirdio/wails#6

Replace the temporary pin on emrcbrn/wails with the merge commit of
netbirdio/wails#6 on the fork's integration branch.
2026-10-12 14:27:13 +02:00
Pascal Fischer f6cfa775f8 [management] setting to return error on name collision during AddPeer (#8154) 2026-10-12 14:01:37 +02:00
Maycon Santos 66a28fd666 [client] Add nblink, an unprivileged forwarder into the network (#7955)
Reaching an internal service meant installing the agent, which needs root
for a TUN device. nblink runs the embedded client in netstack mode and
forwards local HTTP listeners to upstreams inside the tunnel, so it works on
managed machines and in rootless containers. Upstream names resolve
in-process.

Forwards bind loopback unless --allow-public-bind is given. The listener
refuses rebound and cross-site browser requests and bounds stalled request
bodies. It ships as release archives and a distroless image that runs under
an arbitrary UID.

An embedded client given no StatePath now keeps its state in memory rather
than falling back to the installed agent's state file, which every embedded
client in a proxy process also shared. The state manager gets an explicit
in-memory store for it.
2026-10-12 13:54:15 +02:00
Nicolas Frati fe7841721a [management] Create HttpConfig before applying TLS flag overrides (#8168)
With the embedded IdP, management.json can leave out the HttpConfig
section, and ApplyEmbeddedIdPConfig creates it later. Command-line
overrides run before that, so --letsencrypt-domain, or --cert-file with
--cert-key, dereferenced a nil HttpConfig and management crashed at
startup.

Create the section only when one of those flags writes to it, so a
config without TLS flags keeps HttpConfig unset.
2026-10-12 13:44:30 +02:00
Maycon Santos 57722d3235 [proxy] Exercise target access through container E2E (#8117)
Verify access decisions through real management and proxy containers, including upstream delivery and denial. Cover inherited auth, bypass/block, precedence, credential stripping, updates, restrictions, deletion, and private-service rejection.

Validation: container E2E and scoped lint passed locally. The workflow builds both components from the checkout.
2026-10-12 13:23:18 +02:00
Nicolas Frati 88f1a307e3 [signal] Stop with an error when a server fails (#8105)
Signal can report a successful start and then stop with exit code 0, or
continue without a listener, when a listener fails. Supervisors and
operators do not see the failure.

Stop at startup when the Let's Encrypt challenge listener cannot bind.
Keep a failure that occurs before the run loop waits, and return a
non-zero exit code after a server failure. A listener that is closed
during shutdown is not a failure.
2026-10-12 13:16:08 +02:00
Pascal Fischer 7006113897 [management] update reserved IP exclusion when generating peer IP (#8144) 2026-10-12 13:14:19 +02:00
Maycon Santos cd22a9d4f1 [management] Configure per-target HTTP access actions (#8116)
Let HTTP targets inherit service authentication, bypass it, or block access. Validate and persist the action, expose it through the API, and deliver it to the proxy. Private services reject bypass.

Validation: service/manager, store, and management-to-proxy integration race tests passed; scoped lint passed.
2026-10-12 12:56:17 +02:00
Maycon Santos 1161616c02 [proxy] Enforce per-target HTTP access actions (#8115)
Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware.

Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
2026-10-12 12:31:57 +02:00
Maycon Santos a7a2575bad [proxy] Add middleware snapshot revisions (#8114)
Version middleware snapshots so callers can reject stale policies. Existing Rebuild callers remain supported.

Validation: middleware race tests and scoped lint passed.
2026-10-12 12:09:09 +02:00
Zoltan Papp 5c9c64e3e0 [client] Reuse one wgctrl client in the kernel configurer (#7912)
* [client] Reuse one wgctrl client in the kernel configurer

The kernel configurer opened a new wgctrl client for every request,
paying a socket open, a generic netlink family lookup and a close on
each peer update. With 10000 peers a single update dropped from about
37us to 10us per call once the client is kept open.

The configurer now opens the client lazily on the first request and
keeps it. A failed request drops the client so the next one opens a
fresh socket: a reply cut short mid-dump leaves stale messages in the
netlink buffer, and reusing that socket would fail every later request
on a sequence mismatch. The failed request is not retried.

Close releases the client and rejects later requests instead of
reopening a socket nobody would close. The kernel device never closed
its configurer, so it now keeps it and closes it on teardown.
2026-10-12 11:28:37 +02:00
Zoltan Papp 01185ba353 [client] Configure the userspace device through its typed API (#7937)
* [client] Configure the userspace device through its typed API

The userspace configurer drove wireguard-go through UAPI text: every
write was rendered to the configuration protocol and parsed back, and
every read, including the per-second statistics poll, dumped the whole
device as text and parsed it again. With 10000 peers a statistics read
cost about 16 ms, and clearing one endpoint needed a peer remove and a
re-add because the protocol cannot clear an endpoint or drop a single
prefix in place. The allowed IP mirror in the configurer existed only
to feed those rewrites.

The wireguard-go fork now exposes a typed peer API, so the configurer
calls the device directly: ConfigurePeer for creates and updates,
single-prefix add and remove on the routing table, ClearEndpoint, and
accessors for statistics. The mirror, the UAPI rendering and the
parsers are gone from the userspace path; the UAPI socket for wg show
stays.

Clearing an endpoint keeps the peer object, its counters and its
allowed IPs, but resets the session and switches the keepalive off
until the next endpoint arrives. The watcher expects a fresh handshake
after an endpoint reset, and an endpoint-less peer with a keepalive
would retry handshakes it cannot send; this is what the remove and
re-add provided before. Handshake times now carry nanosecond precision
like the kernel path.

Measured with 10000 peers against the UAPI implementation: clearing an
endpoint drops from 713 ms to 3 ms for all peers, a statistics read
from 15 ms to 0.9 ms, a full dump from 18 ms to 4 ms, and single
allowed IP changes are 7 to 20 times faster.

* [client] Keep the allowed IP mirror kernel-only

The userspace configurer no longer uses the allowed IP mirror, so its
ensure method and the preshared key config helper had no caller left
on Windows and macOS, where the kernel configurer is not compiled, and
the unused linter failed there.

The mirror and its tests now carry the kernel build tag, the prefix
normalizers that both paths share move to common.go, and the preshared
key helper moves next to its only caller.

* [client] Trim historical references from configurer comments

* [client] Keep the endpoint zone in the userspace configurer

The UAPI path wrote the endpoint as text, so a link-local IPv6 endpoint
reached the device with its zone. The typed path built the address from
the IP bytes only and dropped the zone, leaving the device with an
unroutable fe80:: address. The activity recorder keeps the zone-less
address it always had.

* [client] Run the prefix conversion tests on every platform

prefixesToIPNets lives in the untagged common.go and the userspace stats
path uses it on every platform, but its tests stayed in the kernel-tagged
allowedips_test.go, so darwin and windows CI stopped running them. Move
them next to the normalizePrefix tests. The round-trip test stays tagged
because ipNetsToPrefixes is kernel-only.

* [client] Pin wireguard-go to the merged typed peer API

The typed peer configuration API landed on master as #23, so the
replace no longer needs to point at the PR branch commit.
2026-10-12 10:29:26 +02:00
Pascal Fischer 67c2041fb5 [management] update proxy mappings on peer IP change (#8139) 2026-10-09 16:40:17 +02:00
Riccardo ManfrinandViktor Liu 008cf47512 [client, management] Recover from stuck proof collections and keep re-tracked accounts on the challenge refresher (#7890)
Co-authored-by: Viktor Liu <viktor@netbird.io>
2026-10-09 16:39:15 +02:00
f0a40e4395 [client, management] Harden the certificate posture client and keep challenge nonces fresh (#8052)
* implement certificate posture check

* log signal address

* add keychain and cert store support

* read the console user's keychain through a user session helper

A root daemon cannot reach a login keychain: securityd is per session and a
key ACL needs a session to prompt in, so dropping uid is not enough. The
daemon now answers certificate challenges from the System keychain itself,
where MDM installs device identities, and launches "netbird posture
cert-proof" into the console user's desktop session with launchctl asuser
for the login keychain. Only the signature and the chain cross back, never
the private key.

The console user comes from SCDynamicStoreCopyConsoleUser, bound with purego
like the keychain calls. The login window reports no user, root, or
"loginwindow", and all three are treated as no keychain to read, so a Mac at
the lock screen sends device proofs alone.

Adds info logging across the path: the keychain search list, per class query
status and item counts, the chain built per candidate, and the verification
error for every rejected candidate. A run that sends nothing now says why.

README.md documents the trust model, the console user limitation and how to
read the logs.

* read the signed-in user's certificate store on Windows

A service reads LocalMachine\MY, where AD and Intune enrol device
certificates. CurrentUser\MY lives in the signed-in user's registry hive
with keys protected against their profile, and a service that opens it does
not fail: "current user" resolves to HKU\S-1-5-18, so it silently reads the
service account's own empty store. The service therefore reads the machine
store itself and launches "netbird posture cert-proof" with the session
token for the rest, mirroring the macOS console user helper.

Windows lets a privileged service assume a user identity, so the token goes
straight into the child process and no external tooling is involved.
CREATE_NO_WINDOW keeps a console window from flashing on the desktop every
sync. In-process impersonation would also work but is per OS thread while
goroutines migrate, so the child process avoids that class of bug.

Session selection prefers the physical console and falls back to any active
session, so remote desktop and VDI hosts are covered. WTSQueryUserToken
needs SE_TCB_NAME, so a user-run client skips the helper and reads the
machine store alone.

SystemStore takes a store location, gaining NewUserStore alongside
NewSystemStore and the per candidate logging macOS already had. The request
building and proof merging move to helper_spawn.go, shared by both
platforms, and helperStore picks what the helper reads per platform.

* start TPM support

* split goreleaser to support pkcs11 and exclude on docker

* update goreleaser

* go mod tidy

* add tpm pin to netbird config

* split cert and key location and allow key lookup on tpm

* add unsupported flag for mobile devices

* Isolate the cert proof helper from the service environment and cap its output

* Read the PKCS#11 token PIN from NB_TPM_PIN instead of the profile config

* Bound certificate proof collection so a stuck token or keychain cannot hold the sync loop

* Stop retrying a PKCS#11 PIN the token rejected

* Log certificate posture details at debug level

* Sign only nonces and peer keys of the size management issues

* Skip certificate files whose key belongs to another certificate

* Bound PKCS#11 driver sizes, pin template values, and log out only a login the session owns

* Never pass NULL to CFRelease and skip unreadable keychain identities

* Keep the macOS keychain code out of iOS and the PKCS#11 driver out of Android

* Find a chain to each challenge's CAs through every intermediate the store holds

* Require a token label whenever a PKCS#11 PIN is set

* Read user certificates only from the session of the active profile's owner

* Collect certificate proofs again when the owner's session changes and report lost proofs

* Test the PKCS#11 build against SoftHSM in CI and warn once where the build has no driver

* Document where an inline PKCS#11 PIN is stored and how it is protected

* Refuse PKCS#11 URIs that this client cannot honour instead of widening the match

* Trust certificate and key files only when no other user can write or redirect them

* Explain a Windows certificate whose key only a legacy CryptoAPI provider holds

* Use platform absolute module paths in tests and add a real owner session test for Windows

* Match the Windows profile owner by name instead of resolving it through the domain controller

* Keep the certificate stores and TPM library out of the WebAssembly build

* [client] Read TSS2 key files on go-tpm, checked against the library it replaces

The TSS2 parser was the only reason this repository depended on a crypto suite
whose own build tooling it inherits. The replacement sits on go-tpm, which was
already a direct dependency and is in fact what that suite calls underneath, so
this removes a wrapper rather than porting onto a different library: the load,
the derived storage root key and the signing commands are the same calls.

Swapping a parser on the one path a customer actually runs is not something to
assert, so the two are held side by side for this commit. One test feeds the
replacement bytes the old library wrote and requires the same key type, empty
auth flag, parent handle, blobs and decoded public key; the other feeds both the
fixtures the tests are built on, so those are the shape the format calls for and
not merely the shape the new parser reads. The scaffolding goes away with the
dependency in the commit that follows.

The encoder behind the fixtures is written out separately from the parser under
test, so an encoder bug and a decoder bug cannot cancel each other out.

* [client] Drop go.step.sm/crypto and the repo-wide upgrades it imposed

The TSS2 parser was the only thing in the repository that used this module, and
it brought 302 modules into the graph to do it — 35 of them linters, along with
Google Cloud KMS and IAM, the AWS SDK and a terminal styling library. Those are
the module's own development dependencies, which minimal version selection turns
into floors in ours, and they are the whole reason gRPC, protobuf, the AWS SDK,
OpenTelemetry, logrus and five x/ packages had moved. Management, signal, relay
and proxy inherited every one of them for a feature none of them runs.

Removing the import is not enough, because tidy never downgrades: the raised
floors stay written in go.mod. Each one is pinned back to the version main had,
then tidy is left to raise again whatever something still genuinely needs. It
raised nothing: all 43 are back where they were, and go-tpm was already in the
graph at the same version, so the certificate feature now costs no new module at
all.

The differential tests go with it. They existed to check the swap against the
library while both were present, and there is nothing left to compare against.

* [client] Clear the lint findings only the macOS and Windows runners see

golangci-lint analyses one build at a time, so running it on Linux says nothing
about the two platforms CI also lints. Against those builds the feature's
packages reported eight findings, and the structural one is Config.dir: it is
dead on macOS and Windows because neither reads a directory at all, their
collectors take the configuration and discard it. Moving the method beside its
only callers makes that visible in the layout instead of in a linter, and leaves
the gap itself — no file or token store on those platforms — where it belongs,
as something to decide rather than something to silence.

An absent key file beside a certificate was reported as a nil signer with a nil
error, which the caller then had to recognise by its nilness. It is a sentinel
now, so the meaning is in the error rather than in the absence of one.

The rest follow the standard library: the elliptic coordinates and the private
scalar come from the encoding helpers rather than the deprecated big.Int fields,
and an error string loses its trailing colon.

Lint is clean on linux, darwin and windows; the hardware TPM path was exercised
separately against a real device and passes.

* Accept the TSS2 emptyAuth boolean OpenSSL writes and persistent parents on 32-bit builds

* Count the certificates field in the peer meta store test

* Check the store directory before listing it, refuse group-writable files, and reject a URI with two PIN sources

* Share a PKCS#11 login between sessions and send each PIN at most once at a time

* Collect certificate proofs again when the meta sync carrying them failed

* Use no Windows user store when a domainless owner matches accounts of several domains

* Use no user certificate store when the active profile's owner cannot be read

* Document the PIN sources on CertPKCS11URI and keep the README PIN example off the command line

* Test that the PKCS#11 URI stays out of the debug bundle and run the wrong-PIN test only on a disposable token

* Refuse a TPM PSS signature request for the maximum salt length

* Add the certificate fields to the network map golden data

* Retry posture checks whose meta sync timed out instead of dropping them

* Start no system info gathering while a timed-out one is still running

* Guard the applied posture checks across goroutines and keep refreshing proofs while a pending update times out

* Log what a successful certificate proof helper wrote to stderr

* Send recollected certificate proofs to management only when the proven chains changed

* Explain a macOS keychain key whose access list does not allow netbird

* Kill the whole macOS certificate helper process group when it times out

* End sudo option parsing before the macOS certificate helper binary

* Hold off system info gathering only while a timed-out one is still running

* Collect certificate proofs on the posture watcher instead of under the sync lock

* Read the certificate store directory and PKCS#11 URI from the daemon environment, not the profile config

* Install the RPM sysconfig file readable by root only and show the certificate posture variables

* Move the certificate posture README into the package doc and the docs site

* Name NB_CERT_PKCS11_URI in the PIN-without-token error

* Keep the file check results of the latest-started system info refresh

* Give the full import command for a keychain key netbird may not use, and correct the package doc

* Restrict the service environment file to root on every package install

* Search only the System keychain in the macOS daemon and only the login keychain in the user helper

* Let the certificate proof helper read the PKCS#11 token from the environment on Linux

* Ask a macOS user's keychain again only after an hour when it proved nothing

* Clear the lint findings in certificate posture

* Hold off the keychain helper only after a completed or timed-out run, independent of CA order

* Keep free functions out of the method lists of PKCS11Store, URI and Challenger

* Name the post-install permission helper in snake case and shorten the sysconfig certificate block

* Drop the certificate store directory from certproof.Config, which only NB_CERT_STORE_DIR sets

* [management] Renew certificate challenge nonces on quiet accounts

A certificate challenge nonce is accepted for its own window and the one before
it, and it only reaches a peer attached to a network map. An account where
nothing changes sends no map, so after a day the peer re-sends the nonce it
still holds, verification rejects its whole proof set, and the certificates
stored for it are dropped. It fails the certificate check and loses every policy
gated on it until some unrelated change happens to push a map. The outage
repairs itself in seconds, which is what makes it expensive: it is intermittent,
it only hits stable networks, and it is not reproducible on demand.

Push the account's peers an update often enough that the nonce they hold is
never close to expiring. Only accounts whose posture checks actually ask for a
certificate are tracked, so a deployment without the feature does no extra work.

The refresh runs from one goroutine over a map of accounts rather than a timer
per account: the period is hours, so one pass every few minutes costs nothing
next to it, and there is no timer to re-arm when an account that falls due
sooner appears. Each account's first run is offset by a hash of its ID, because
the challenge window is global and an instance restart would otherwise arm every
account in the same moment.

The push carries no administrative change, so it is counted as a refresh rather
than an update and stays out of the figures that track what was edited.

(cherry picked from commit 7ad4a0df37)

* [management] Make the certificate challenge window one knob to turn

Renewal was timed against the window in two different ways: the period derived
from it, the sweep interval did not. Shortening the window to watch a renewal in
an end-to-end run would have left the refresher still looking for due accounts
every quarter of an hour, so nothing would have been renewed in time and the
test would have reported the feature broken.

Derive the sweep from the period, within bounds that keep a very short window
from spinning and a normal one from checking less often than is useful, and
allow the window itself to be set through the environment so a run can take
seconds instead of half a day. A value that cannot be parsed or falls outside
the bounds keeps the default, because a window nobody intended is a security
property nobody chose, and an override is logged at warning level since it sets
how long a device keeps passing the check after its key is gone.

Every instance has to be given the same value: the window is part of the nonce,
so instances that disagree reject each other's.

(cherry picked from commit 0e38fcf409)

* [management] Pin the property that makes per-peer nonce state unnecessary

A nonce carries the window it was minted in, not the instant, and is accepted
for that window and the one before it. So a peer re-stamped at least once per
window can never be left holding one outside the accepted pair, whenever it was
last served and however much life its own nonce had left. That is the whole
reason management tracks nothing per peer, and it was resting on an argument
rather than a test.

The phases are part of the property, not decoration: accounts are deliberately
given a refresh phase of their own, so the guarantee has to hold off the window
boundary too. The negative case shows why that matters — a cadence of exactly
two windows lands inside the grace window when it is aligned to the boundary and
leaves a gap when it is not.

(cherry picked from commit dee68facfd)

* [management] Renew challenges only for the peers that answer one

The refresh pushed an update to every connected peer of the account, while only
the peers a certificate check applies to carry a nonce. On an account where a
handful of peers sit behind the check and the rest do not, everyone was woken
several times a day to be handed a map that changed nothing for them.

Push to the sources of the enabled policies whose posture checks include a
certificate check, which is exactly the set that is sent a challenge.

Resolving the set the other way round than the gRPC layer does is the risk here:
a peer the refresh forgets stops being renewed and falls out of its policies
silently, which is the failure this whole mechanism exists to prevent. So the
selection is held against processPeerPostureChecks, the per-peer rule that
decides who receives a challenge in the first place, by a test that asks both
the same question and requires the same answer.

(cherry picked from commit dc4d0e0274)

* [management] Derive certificate challenge nonces from the stored encryption key

The nonce secret came from the server's WireGuard key, which is generated afresh
in every process and never persisted. A nonce carries no state, so the only
thing that lets one instance verify what another issued is deriving the same
secret — and that premise, written in the comment above the challenger, was not
met: every instance had its own key.

A peer reconnecting after a restart therefore presented a nonce minted under the
previous secret, verification failed with a mismatch, its whole proof set was
rejected and the certificates stored for it were dropped until it signed again.
Reproduced three times on the lab, each one logging "nonce was not issued to
this peer", which only a changed secret produces. On a single instance it costs
seconds of lost policy access per restart; across instances it is not transient
at all, because every reconnect that lands elsewhere is rejected the same way.

Derive from the data store encryption key instead: it is generated once, written
back to the configuration and read by every instance, so it survives restarts
and is shared. Where none is configured the secret falls back to the WireGuard
key with a warning — degraded but still unpredictable, which is the property
that matters most: a peer able to guess it could mint the nonces of future
windows, sign them while its key is present and keep passing after it is gone.

The challenger is now built once and passed to the two places that need it,
rather than re-derived per message.

(cherry picked from commit 278f2f3807)

* [management] Register an account for renewal where its nonce is issued

Renewal was armed when a peer connected or when a posture check was saved, both
of which ask the store whether the account has a certificate check. That misses
the case it most needs to catch: the check is created through one instance while
the peers are connected to another, so the instance serving them never learns it
has anything to renew and their nonce expires. It also charged a query to every
peer connect in every account, including the ones that will never use the
feature, which a fleet reconnecting after a restart pays all at once.

Register where the nonce is actually stamped instead. A nonce is verified from a
shared secret and so travels between instances, but the renewal that keeps it
fresh cannot: only the instance holding a peer's stream can push to it. Issuing
and renewing now line up by construction — an instance renews exactly the
accounts it has issued nonces for — and an instance that never issues one has
nothing to renew, so there is no case left to miss.

The registration is a map insert with no store access, which is what lets it sit
on a path taken by every login and every initial sync.

Reported by Viktor Liu, who also proposed registering at the point of issue.

(cherry picked from commit 2d16dd7d7cf54762f2e64c5630ea092f32ef63ab)

* [management] Register for renewal on pushed updates, not only on connect

Registering where the nonce is stamped only covered the login and the initial
sync, which both happen when a peer opens a stream. That left out the path the
mechanism exists for.

On the cloud the network map controller is wrapped so that an update publishes
to an event bus instead of pushing locally: an instance handling a REST change
broadcasts, and every instance holding a peer of that account pushes to its own.
Those pushes stamp a nonce through the update handler, and nothing there
registered, so an instance learned about an account only when one of its peers
happened to reconnect. For a quiet fleet that is the original bug: the check is
created, the peers are told about it, and nobody renews what they were told.

Registering on the pushed update closes it, and is the difference between
stamping and marking a peer connected — one happens on every push, the other
only when a stream opens. Reported by Viktor Liu; the broadcast that makes it
work was pointed out by Pascal Fischer.

(cherry picked from commit 59efe8d93e53bacdf57cb546f4ab2c19dc4eddab)

* [management] Let the challenge refresh loop stop with the manager that owns it

The loop was started on a context explicitly detached from the caller's, so
nothing could ever stop it. Production is unaffected either way, since
BuildManager is called with context.Background(), but a test that builds a
manager leaked a sweeping goroutine for the rest of the run, and a shutdown
path added later would have had no way to reach it.

Take the manager's context as the request buffer built on the line above
already does. The test pins the contract the loop offers, so a detached
context cannot come back inside Start either.

* [management] Bound one account's challenge refresh so it cannot starve the rest

Resolving which peers answer a challenge reads the store three times, and the
refresher sweeps accounts one after another on a single goroutine. A read that
never returns held the sweep for the life of the process, so every other
account on the instance stopped being renewed and its peers fell out of the
policies gated on the check: one account's bad luck became an outage for all
of them.

Give each refresh the sweep interval it is allowed to occupy, capped at 30s so
a 12-hour window does not grant minutes to a query that should take
milliseconds. A refresh that runs out of time keeps its account tracked, since
a deadline says nothing about whether that account still has a certificate
check.

* [management] Send challenge refreshes down the path the rest of management uses

The refresh dispatched through UpdateAffectedPeers, the one variant that takes
no reason, so it was missing from the update counters and coalesced with
nothing. An administrator editing a policy while the sweep ran made the
account's network map twice over, and UpdateOperationRefresh, added for
exactly this caller, was never referenced.

Buffer it with a posture_check/refresh reason instead. The periodic push is
now visible in the metrics as what it is, distinct from an edit, and the send
detaches from the sweep deadline on its own, so that deadline bounds the store
reads it was meant for.

* [management] Keep the certificate challenge comments to what the history does not say

Four of these ran to three and four times the comment budget, the longest at
992 characters. Most of the excess argued against designs that were never
written or explained a bug that no longer exists in the code, which is what
the commit that fixed it is for.

What is left is the part a reader cannot recover from the code: that the
nonce secret has to be persisted and unpredictable, that stamping and
renewing are decided together because only the serving instance can push, and
that the target rule is the inverse of processPeerPostureChecks.

* Keep the newest posture checks pending whatever made their meta sync fail

* Report no lost certificate when the engine stops during a proof collection

* Share the proof collection single-flight across engine restarts

* Close a PKCS#11 module that loads but cannot be used

* Fix the pending checks comments

* Renew certificate challenges only for the peers streamed to this instance

* Ignore a challenge stamp from an older sync stream of the same peer

* Kill the Windows certificate proof helper with its whole process tree

* Expect the challenge untrack in the session ownership test

* Drop an invalid certificate proof without discarding the valid ones

* Start a system info gathering beside one that has been stuck for ten timeouts

---------

Co-authored-by: pascal <pascal@netbird.io>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
2026-10-09 15:42:18 +02:00
Pascal Fischerandmlsmaycon 53a14551c8 [client, management] implement certificate posture check (#7535)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-10-09 14:57:00 +02:00
Nicolas Frati a5834fdaab [management,signal] Make the Let's Encrypt challenge listener address configurable (#7706)
* [management,signal] Make the Let's Encrypt challenge listener address configurable

With Let's Encrypt enabled and --port set to something other than 443,
signal and management also opened a separate challenge listener that was
hard-coded to :443. Non-root deployments, such as the UBI images, could
not start that listener.

Add --letsencrypt-listen-address to both. It defaults to :443, so current
behavior is unchanged. An empty value disables the separate listener for
setups that forward public port 443 to --port, where the main TLS
listener already answers TLS-ALPN-01 challenges.

Signal now fails on startup when the challenge listener cannot bind, and
exits non-zero when a server stops unexpectedly instead of exiting 0. A
failure reported before the run loop waited was previously dropped.
Management no longer opens a new :443 listener on shutdown just to close it.

* [management,signal] Keep the challenge listener change additive

Remove the Signal fail-fast changes from this PR. They change the
behavior that existing installations see after an upgrade, so they move
to a separate PR.

If the challenge listener cannot bind, Signal now logs the error and
continues. The main TLS listener still answers TLS-ALPN-01 challenges.
Management keeps its previous behavior and stops with an error.

The check for an empty address moves to the caller, so the function
does not return a nil listener with a nil error. Also add assertion
messages, guard a nil listener in a test cleanup, and add the flag to
the Signal README.
2026-10-09 13:37:25 +02:00
Zoltan Papp fe0e9042ad [client] Drop the pending login flow when a login switches the profile (#7882)
* [client] Force interactive login when extending the auth session

A session extend must be answered from the account the peer is registered
under. With a silent PKCE flow (DisablePromptLogin or max_age=0) the IdP
answers from whatever session it already holds, which need not be the
peer's account when several are signed in; the token then fails the
user match in ExtendAuthSession with no way to pick another account.

Mark the PKCE flow request as a session extend so the management server
can force prompt=login for it, overriding the configured silent flow.

* [client] Reduce cognitive complexity of Server.Login

Login sat at cognitive complexity 27, over the 25 the linter allows.

Extract the interactive SSO branch into startSSOLogin, and split the
nested in-flight-flow reuse check out of it into reuseOAuthFlow, which
flattens the original if/else into early returns: it returns the cached
auth info when the previous flow targets the same client and still has
more than 90s left, otherwise cancels the stale wait and returns nil so
the caller requests a fresh flow.

The helpers take the contextState through a small statusSetter
interface, since internal.contextState is unexported and re-deriving it
with CtxGetState inside the helper would resolve against callerCtx
rather than rootCtx.

No behavior change: same ordering of state transitions, same mutex scope
around the oauthAuthFlow write, same error paths. Login is now at 21.

* [client] Respect DisablePromptLogin when extending the auth session

Forcing prompt=login on a session extend overrode DisablePromptLogin, which
is set for IdPs that break on it: Authentik triggers a double authentication
and social logins fail outright. Overriding it there trades a recoverable
extend for a login that cannot complete at all.

Keep the LoginFlag override, which only replaces max_age=0 or none with
prompt=login so the IdP honours login_hint, and leave DisablePromptLogin as
configured. Those deployments keep the silent flow, and with several accounts
signed in an extend answered from the wrong one still fails the user match.

* [client] Guard the shared OAuth flow state with the server mutex

reuseOAuthFlow read flow, expiresAt, waitCancel and info without holding
s.mutex, while startSSOLogin and WaitSSOLogin write them under it. Reading the
fields one at a time could also answer with auth info from a flow that was
already replaced, or cancel a wait that no longer belongs to the flow just
judged stale. Take one snapshot under the lock and decide from it.

WaitSSOLogin read oauthAuthFlow.flow twice outside the lock; both now use a
value snapshotted in the critical section that already installs actCancel.

Its stale waitCancel was read and called in a separate section from the one
installing the new one, so two racing calls could read the same predecessor and
leave one wait uncancelled. Swap the two in a single critical section. Both
cancels run after unlocking: the displaced wait takes s.mutex as it unwinds.

* [client] Verify the SSO login came back for the hinted account

login_hint is a suggestion the IdP may ignore: with a silent flow configured
(DisablePromptLogin or max_age=0) and a live IdP session for another account,
the login completes with that account's token. On a registered peer the
management server rejects it as a user mismatch, but on a fresh profile the
peer silently registers under the wrong account and the profile is then bound
to it — every later login follows the stored hint straight back.

After the token exchange, compare the ID token's email against the hint the
flow was sent with. On a mismatch, do not log in to management with the token;
run one more round asking the IdP to re-decide the account (prompt=login, via
ForceAccountPrompt — DisablePromptLogin still wins there). If the prompted
round also comes back different, proceed with a warning: the address may
legitimately have changed, and refusing forever would lock the user out of the
profile while the management server still rejects a token that does not own
the peer. A token or profile with no email to compare is not judged.

The retry differs per platform because of who opens the browser:

- CLI (netbird login foreground) and Android run the whole flow in one
  process, so the mismatch retries automatically: the browser reopens with
  the account prompt within the same login attempt.
- On desktop the login is split between the daemon and the GUI: Login hands
  the authorize URL to the GUI, WaitSSOLogin blocks for the token, and only
  the GUI can open a browser. A new URL cannot be handed out from inside
  WaitSSOLogin (its response has no field for one, kept that way to avoid a
  proto change), so the daemon arms forceAccountPrompt, fails the round with
  "connect again to choose the account", and builds the next Login's flow
  with the prompt — the user's next connect is the retry.

The flag and the flow annotations live in daemon memory only; SwitchProfile
drops them so the previous profile's hint cannot judge the next profile's
token. The device code flow has no prompt parameter (RFC 8628), so a prompted
round there runs as-is and a repeated mismatch is let through with the
warning rather than looping.

* [client] Address review comments on PKCE session extend flow

Fail the PKCE authorization flow test on request error instead of
continuing into a nil dereference, and make the godoc comments on the
touched exported symbols identifier-leading full sentences.

* [client] Match accounts only on the email claim of the ID token

The name-claim fallback in the ID token parsing is kept for the login
hint and display, but account matching now only considers a value that
came from the email claim, so a token without one no longer produces a
false account mismatch.

* [client] Drop the pending session extend on a profile switch

The profile-switch cleanup dropped the pending login flow and the
account-prompt flag, but left extendAuthSessionFlow untouched. Its device
code was issued by the previous profile's IdP client, so a
WaitExtendAuthSession still parked on the browser leg would submit the
resulting token against the new profile's engine.

* [client] Judge the SSO account against the flow that produced the token

WaitSSOLogin snapshotted the flow on entry but re-read the info, hint and
accountPrompted from the live s.oauthAuthFlow afterwards, in separate
critical sections. WaitToken blocks for the whole browser leg, so a
concurrent Login or RequestJWTAuth could replace the flow meanwhile and
the mismatch check would compare this wait's token against another flow's
account: either arming the prompt spuriously or letting a wrong-account
token through against an unrelated profile's hint. Take all of it in the
entry snapshot.

* [client] Keep the forced account prompt from being lost to flow reuse

startSSOLogin consumed forceAccountPrompt and applied the prompt to the
freshly built flow, but reuseOAuthFlow could then answer from a cached
flow for the same client — one built without prompt=login, e.g. by
RequestJWTAuth. The user got the same silent authorization URL that
produced the mismatch, with the flag already spent, so no later round
asked either. Rule reuse out when the prompt is forced, while still
cancelling the predecessor's wait.

RequestJWTAuth also wrote the flow fields one by one, leaving the previous
login's hint and accountPrompted behind for WaitSSOLogin to judge a later
token against. Both sites now replace the whole record.

* [client] Consume the forced account prompt after the retry

forceAccountPrompt was never cleared, so a flow that outlived the retry it
was armed for kept sending prompt=login on every later authorization
request and re-authenticated the user each time. RequestAuthInfo now takes
the flag as it builds the request.

* [client] Cancel the caller context in the SSO login tests

WaitSSOLogin parks a goroutine on the caller's context for the whole
browser leg. The tests passed context.Background(), which never cancels,
so each left one goroutine behind for the lifetime of the test binary.

* [client] Cancel the wait displaced by an OAuth flow replacement

Replacing the shared record with a whole struct value dropped the previous
flow's waitCancel, so an SSO browser wait still parked on it lost its
cancel: nothing could preempt it, and it could go on to run attemptLogin
or mutate the record behind the new flow. Both replacement sites now take
the displaced cancel over in the same critical section, via a shared
replaceOAuthFlow, and invoke it after the unlock.

* [client] Guard OAuth flow mutations by the flow that owns the wait

* [client] Arm the account prompt only from the wait that owns the flow

* [client] Drop the pending login flow when a login switches the profile

SwitchProfile cancels the pending OAuth wait and clears the flow record,
the account-prompt flag and the pending session extend, because they
describe the previous profile's login. A Login or Up that carries a
ProfileName switches the profile through switchProfileIfNeeded without
that cleanup, so reuseOAuthFlow could hand the new profile the previous
profile's flow: the same IdP client ID, the previous account's
login_hint in the URL, and a record whose hint WaitSSOLogin would judge
the new profile's token against. That either fails the login with a
spurious account mismatch or lets a fresh peer register under the other
account.

switchProfileIfNeeded now reports whether it switched, and its callers
run the same cleanup on a switch. A login on the same profile keeps the
pending flow, so a second client can still join it.

* [client] Clear the JWT cache when a login switches the profile

The daemon keeps the user's JWT in a cache for SSH logins. When the user
switches to another profile, this token belongs to the old profile, so it
must not be used for the new one.

SwitchProfile already cleared the cache. A login or up request can also
switch the profile, but it did not clear the cache. After such a switch,
SSH could still use the old profile's token, and a sign-in still running
for the old profile could save its token into the new profile's cache.

Clear the cache in dropPendingAuthFlows, so every profile switch does it.

* [client] Bump the JWT cache generation with the config swap in Login

RequestJWTAuth snapshots s.config and the cache generation under one
s.mutex section and relies on the two flipping together. Login cleared
the cache right after the profile switch but replaced s.config only
later, after getConfig, so a JWT flow started in between carried the
previous profile's config with the new generation, and its token landed
in the cache the new profile then served.

The swap now bumps the generation under the same lock. The early drop
stays: it covers a login that fails after the switch, where a retry no
longer sees a profile change.
2026-10-09 12:56:41 +02:00
Riccardo ManfrinandZoltán Papp 3934e7c697 [client] Evaluate the session deadline against the wall clock (#7959)
* Skip session warnings that fire after their window

The warning timers run on the monotonic clock, which does not advance
while an Android device is suspended. A timer armed for T-10 or T-2 can
therefore fire long after the window it was armed for, delivering a
"session expires soon" notification once that window is already gone.

Gate both callbacks on the wall clock at fire time: the T-10 warning is
skipped once the final-warning window has been reached, and the final
warning is skipped once the deadline itself has passed. Both set their
edge guard before returning so a skipped warning cannot fire again for
the same deadline.

* Harden the late-warning guards

Clamp a non-positive final lead to zero in the T-10 guard so a disabled
final warning cannot move the cutoff past the deadline, matching how
armTimerLocked already treats it.

Strip the monotonic reading from both sides of the comparison so the
guard measures wall-clock time regardless of how the caller built the
deadline. The production deadline comes from a protobuf timestamp and
has no monotonic reading; this keeps the guard correct for callers that
derive one from time.Now.

* Log the deadline and lateness on skipped warnings

Include the deadline and how far past the cutoff the timer fired, so a
debug bundle shows how long the device was suspended.

* Inject the clock into the late-warning guard and cover it with tests

The guard read time.Now internally, so the skip paths were reachable
only through a deadline already in the past and the boundary depended
on real time. Extract the comparison into isLate and read the time
through a nowFn field, so tests can place a resume anywhere around the
deadline without sleeping.

* Send the final warning when the T-10 timer fires inside its window

A suspend between roughly eight and ten minutes long made the T-10
timer fire inside the final-warning window and the final timer fire
after the deadline, so both were skipped and a user who resumed with
time left got no warning at all. When the T-10 timer fires late but
before the deadline, send the final warning in its place and mark it
fired so the delayed final timer does not repeat it.

* Respect dismissal when promoting a late warning to the final one

fireFinal skips the final warning once the user dismissed the deadline,
but the promoted path did not, so a dismissed deadline could still get
a final warning. Check the dismissal first, and give each skip reason
its own log line so an already-fired final warning no longer logs a
negative lateness.

* Add a deadline-only mode to the session watcher

Android will schedule its own expiry warnings from the deadline, so
the engine must not arm the T-10 and T-2 timers there. NewDeadlineOnly
keeps the deadline validation, the recorder propagation and the
logging, and skips only the timers, so the status snapshot the app
reads stays correct and an out-of-range deadline is still rejected.

* Use the deadline-only watcher on Android and drop the warning callbacks

The warning timers run on the monotonic clock, which does not advance
while the device sleeps, so a warning armed for T-10 could fire long
after its window. The app now schedules the warnings itself with
WorkManager, anchored to the wall clock, from the deadline it reads
through SessionExpiresAtUnix on every OnStateChanged.

Wire the deadline-only watcher into the android build and remove the
event-driven path from the gomobile surface: OnSessionExpiring, the
event subscription behind it and DismissSessionWarning, which the app
never called.

* Describe the late-warning guard without naming Android

The guard stays for the desktop builds, where a timer can also stall
across a sleep. Android no longer arms the timers at all.

* [client] Evaluate the session deadline against the wall clock

The session deadline is an absolute instant published by management, but
the warnings for it were armed as relative timers. A relative timer runs
on the monotonic clock, which does not advance while a device is
suspended: it fires once that much awake time has passed, which can be
long after the deadline, and nothing re-evaluates when the device wakes
up. A device that suspends before the warning window and resumes with
minutes left is never told it can still extend. The same applies to a
device that boots before NTP has corrected its clock: the one-shot timer
has already fired by the time the correction lands.

Compare the tracked deadline against the wall clock on a ticker instead.
Every tick after a resume, or after a clock correction, sees the real
remaining time, so the warning is published whenever there is still time
to act on it and never once the deadline has passed. This supersedes the
late-callback guards: with no relative timer there is no late callback to
detect.

The warning windows keep their semantics. Each one publishes at most once
per deadline value, a dismissal still suppresses the final warning, and
inside the final window the interactive warning is skipped in favour of
the final one, since a device resuming there never saw it. Update
evaluates immediately so a deadline that already sits inside a window
does not wait for a tick, and Close stops the loop and waits for it.

Two trade-offs: a warning can land up to one tick (10s) after its lead,
which is noise against leads measured in minutes, and the loop runs from
the first tracked deadline until Close rather than being armed and
disarmed per deadline.

* [client] Poll whatever the clock reads when the deadline arrives

Update started the loop only for a deadline in the future, and read the
wall clock directly to decide. A device whose clock runs ahead before NTP
corrects it accepts the deadline as recent-past, starts nothing, and then
has no loop left to notice the correction: the next sync carries the same
deadline value, which Update treats as a no-op. The warning is lost for
exactly the case this mechanism exists to cover.

Start the loop for every accepted deadline. evaluate already ignores a
deadline that has genuinely passed, so the only cost is a ticker on an
expired session until the next deadline or Close.

Take the current time from nowFn there as well, so the sanity checks and
the evaluation agree on what time it is and a test can drive both.

* [client] Announce the deadline before any warning about it

Update released the lock before telling the recorder about the new
deadline, so a tick landing in that window could publish a warning for a
deadline consumers had not been told about yet, inverting the order the
function documents.

Gate publishing on the deadline the recorder has been told about: Update
records it after the recorder call, and an evaluation that finds the gate
shut leaves the warning for the next tick.

* [client] Describe the deadline-only mode in terms of the poll

The mode no longer skips arming timers, it skips the evaluation entirely,
and the doc comment said otherwise.

* [client] Publish warnings only from the evaluation loop

Update evaluated the new deadline on the caller's goroutine, so a warning
could still be on its way to the recorder after Close had returned: Close
waits for the evaluation loop, and that publish was not coming from it.

Hand the work to the loop instead. Update announces the deadline and
nudges a buffered wake channel, so a deadline that already sits inside a
warning window is still warned about at once, without this goroutine ever
touching the recorder. The loop is now the only caller of evaluate, which
makes waiting for it in Close enough.

* [client] Make a concurrent Close wait for the loop as well

The first Close stopped the evaluation loop and waited for it, but a
second concurrent Close saw the closed flag and returned straight away,
telling its caller the teardown was done while a warning was still on its
way to the recorder.

Keep the completion channel on the receiver after the teardown starts, so
the call that loses the race waits on the same loop.

* [client] Correct the poll start condition in the doc comment

Update starts the loop for every accepted deadline, including one that
already reads as expired, not only for a future one.

* [client] Unblock the parked publish on every test exit path

The concurrent-Close test parks the evaluation loop inside a publish and
releases it at the end. A t.Fatal before that line runs Goexit and skips
the release, stranding the loop and both Close calls for the rest of the
package run.

Release through a sync.Once registered with t.Cleanup, and close the
watcher there too, so an early failure reports itself instead of hanging.

* [client] Release the parked publish before closing in the test cleanup

t.Cleanup runs in reverse registration order, so the watcher was closed
before the release ran: Close waits for the evaluation loop, the loop was
still parked in the publish, and a t.Fatal hung the cleanup instead of
reporting the failure.

Register one cleanup that releases first and closes after.

---------

Co-authored-by: Zoltán Papp <zoltan.pmail@gmail.com>
2026-10-09 11:44:08 +02:00
Riccardo Manfrin 3e85e40be2 [client] Cache the WireGuard interface check shared by ICE agents (#8001)
* [client] Take a WireGuard detector through the interface filter

The interface filter answers whether an interface is a WireGuard device by opening
a wgctrl client and asking for it, and it does that for every interface it is given.
Nothing about that call is tied to the caller, so it can be answered by a shared
object instead of being repeated, but the filter has no way to receive one.

InterfaceFilter and the constructors that build one now take a detector, and the ICE
config carries it so that every agent can be handed the same one. Nobody supplies a
detector yet: a nil one probes on every call, which is what the filter did before, so
this changes no behaviour.

* [client] Share one WireGuard detector across every ICE agent

Creating an ICE agent builds two interface filters, one for the agent and one for
the transport net it sits on, and each is asked about every host interface. For an
interface the disallow list does not settle, answering means opening a wgctrl client,
which builds a kernel and a userspace client and resolves the netlink family, and
then a round trip that usually just reports the device does not exist. An agent is
created per peer connection attempt, so on a large network that runs constantly:
on a routing peer with ~16000 peers it measured 2.40s of a 66.59s CPU profile, 3.6%,
split evenly between opening the client and the round trip.

The engine now owns a detector and passes it to every agent through the ICE config,
so the answer for an interface is reused instead of being asked again for each agent.
It is kept for a second, short enough that a WireGuard interface appearing is picked
up before ICE settles on candidates over it.

The callers that build one filter and keep it, the relay and the UDP mux, keep
passing nil and so keep probing, which costs them nothing at their rate.

* [client] Recheck the WireGuard cache inside the singleflight group

A caller that saw an expired entry could enter the singleflight group
after another caller had already refreshed the entry and left it, and
probe the interface a second time. Read the cache again inside the group
before probing.

This also makes the concurrent probe test independent of scheduling: a
late caller finds the fresh entry instead of starting a new probe.

* [client] Drop expired WireGuard detector entries

The detector lives as long as the engine and kept an entry for every
interface name it was ever asked about. On hosts that churn interfaces,
such as container veths, the map only grew. Remove expired entries when
a new answer is stored; the map holds a few dozen names at most, so the
sweep is cheap and runs at most once per interface per TTL.

* [client] Skip the disallow-list filter test on iOS

InterfaceFilter does not apply the disallow list on iOS, so the subtest
reaches the probe there and its no-probe assertion cannot hold.
2026-10-09 11:03:34 +02:00
Pascal Fischer 01d828d732 [management] Fall back to the IdP for the proxy session email (#8112) 2026-10-08 16:28:56 +02:00
Pascal Fischer 06c4c20010 [management] Tear down only the peer session that owns the stream (#8057) 2026-10-08 16:17:23 +02:00
Zoltan Papp 515a01dd11 [client] Force interactive login when extending the auth session (#7216)
* [client] Force interactive login when extending the auth session

A session extend must be answered from the account the peer is registered
under. With a silent PKCE flow (DisablePromptLogin or max_age=0) the IdP
answers from whatever session it already holds, which need not be the
peer's account when several are signed in; the token then fails the
user match in ExtendAuthSession with no way to pick another account.

Mark the PKCE flow request as a session extend so the management server
can force prompt=login for it, overriding the configured silent flow.

* [client] Reduce cognitive complexity of Server.Login

Login sat at cognitive complexity 27, over the 25 the linter allows.

Extract the interactive SSO branch into startSSOLogin, and split the
nested in-flight-flow reuse check out of it into reuseOAuthFlow, which
flattens the original if/else into early returns: it returns the cached
auth info when the previous flow targets the same client and still has
more than 90s left, otherwise cancels the stale wait and returns nil so
the caller requests a fresh flow.

The helpers take the contextState through a small statusSetter
interface, since internal.contextState is unexported and re-deriving it
with CtxGetState inside the helper would resolve against callerCtx
rather than rootCtx.

No behavior change: same ordering of state transitions, same mutex scope
around the oauthAuthFlow write, same error paths. Login is now at 21.

* [client] Respect DisablePromptLogin when extending the auth session

Forcing prompt=login on a session extend overrode DisablePromptLogin, which
is set for IdPs that break on it: Authentik triggers a double authentication
and social logins fail outright. Overriding it there trades a recoverable
extend for a login that cannot complete at all.

Keep the LoginFlag override, which only replaces max_age=0 or none with
prompt=login so the IdP honours login_hint, and leave DisablePromptLogin as
configured. Those deployments keep the silent flow, and with several accounts
signed in an extend answered from the wrong one still fails the user match.

* [client] Guard the shared OAuth flow state with the server mutex

reuseOAuthFlow read flow, expiresAt, waitCancel and info without holding
s.mutex, while startSSOLogin and WaitSSOLogin write them under it. Reading the
fields one at a time could also answer with auth info from a flow that was
already replaced, or cancel a wait that no longer belongs to the flow just
judged stale. Take one snapshot under the lock and decide from it.

WaitSSOLogin read oauthAuthFlow.flow twice outside the lock; both now use a
value snapshotted in the critical section that already installs actCancel.

Its stale waitCancel was read and called in a separate section from the one
installing the new one, so two racing calls could read the same predecessor and
leave one wait uncancelled. Swap the two in a single critical section. Both
cancels run after unlocking: the displaced wait takes s.mutex as it unwinds.

* [client] Verify the SSO login came back for the hinted account

login_hint is a suggestion the IdP may ignore: with a silent flow configured
(DisablePromptLogin or max_age=0) and a live IdP session for another account,
the login completes with that account's token. On a registered peer the
management server rejects it as a user mismatch, but on a fresh profile the
peer silently registers under the wrong account and the profile is then bound
to it — every later login follows the stored hint straight back.

After the token exchange, compare the ID token's email against the hint the
flow was sent with. On a mismatch, do not log in to management with the token;
run one more round asking the IdP to re-decide the account (prompt=login, via
ForceAccountPrompt — DisablePromptLogin still wins there). If the prompted
round also comes back different, proceed with a warning: the address may
legitimately have changed, and refusing forever would lock the user out of the
profile while the management server still rejects a token that does not own
the peer. A token or profile with no email to compare is not judged.

The retry differs per platform because of who opens the browser:

- CLI (netbird login foreground) and Android run the whole flow in one
  process, so the mismatch retries automatically: the browser reopens with
  the account prompt within the same login attempt.
- On desktop the login is split between the daemon and the GUI: Login hands
  the authorize URL to the GUI, WaitSSOLogin blocks for the token, and only
  the GUI can open a browser. A new URL cannot be handed out from inside
  WaitSSOLogin (its response has no field for one, kept that way to avoid a
  proto change), so the daemon arms forceAccountPrompt, fails the round with
  "connect again to choose the account", and builds the next Login's flow
  with the prompt — the user's next connect is the retry.

The flag and the flow annotations live in daemon memory only; SwitchProfile
drops them so the previous profile's hint cannot judge the next profile's
token. The device code flow has no prompt parameter (RFC 8628), so a prompted
round there runs as-is and a repeated mismatch is let through with the
warning rather than looping.

* [client] Address review comments on PKCE session extend flow

Fail the PKCE authorization flow test on request error instead of
continuing into a nil dereference, and make the godoc comments on the
touched exported symbols identifier-leading full sentences.

* [client] Match accounts only on the email claim of the ID token

The name-claim fallback in the ID token parsing is kept for the login
hint and display, but account matching now only considers a value that
came from the email claim, so a token without one no longer produces a
false account mismatch.

* [client] Drop the pending session extend on a profile switch

The profile-switch cleanup dropped the pending login flow and the
account-prompt flag, but left extendAuthSessionFlow untouched. Its device
code was issued by the previous profile's IdP client, so a
WaitExtendAuthSession still parked on the browser leg would submit the
resulting token against the new profile's engine.

* [client] Judge the SSO account against the flow that produced the token

WaitSSOLogin snapshotted the flow on entry but re-read the info, hint and
accountPrompted from the live s.oauthAuthFlow afterwards, in separate
critical sections. WaitToken blocks for the whole browser leg, so a
concurrent Login or RequestJWTAuth could replace the flow meanwhile and
the mismatch check would compare this wait's token against another flow's
account: either arming the prompt spuriously or letting a wrong-account
token through against an unrelated profile's hint. Take all of it in the
entry snapshot.

* [client] Keep the forced account prompt from being lost to flow reuse

startSSOLogin consumed forceAccountPrompt and applied the prompt to the
freshly built flow, but reuseOAuthFlow could then answer from a cached
flow for the same client — one built without prompt=login, e.g. by
RequestJWTAuth. The user got the same silent authorization URL that
produced the mismatch, with the flag already spent, so no later round
asked either. Rule reuse out when the prompt is forced, while still
cancelling the predecessor's wait.

RequestJWTAuth also wrote the flow fields one by one, leaving the previous
login's hint and accountPrompted behind for WaitSSOLogin to judge a later
token against. Both sites now replace the whole record.

* [client] Consume the forced account prompt after the retry

forceAccountPrompt was never cleared, so a flow that outlived the retry it
was armed for kept sending prompt=login on every later authorization
request and re-authenticated the user each time. RequestAuthInfo now takes
the flag as it builds the request.

* [client] Cancel the caller context in the SSO login tests

WaitSSOLogin parks a goroutine on the caller's context for the whole
browser leg. The tests passed context.Background(), which never cancels,
so each left one goroutine behind for the lifetime of the test binary.

* [client] Cancel the wait displaced by an OAuth flow replacement

Replacing the shared record with a whole struct value dropped the previous
flow's waitCancel, so an SSO browser wait still parked on it lost its
cancel: nothing could preempt it, and it could go on to run attemptLogin
or mutate the record behind the new flow. Both replacement sites now take
the displaced cancel over in the same critical section, via a shared
replaceOAuthFlow, and invoke it after the unlock.

* [client] Guard OAuth flow mutations by the flow that owns the wait

* [client] Arm the account prompt only from the wait that owns the flow

* [client] Adopt the three-value parseEmailFromIDToken in the device flow

The main merge brought in the device flow's email extraction from #7193,
which still used the two-value signature this branch replaced when account
matching was narrowed to the email claim. Git merged the files without a
textual conflict, so the branch stopped compiling.

Take the fromEmailClaim result and fill EmailClaim from it, the same way the
PKCE path does, so device-flow clients get the same account matching.

* [client] Populate the pending extend flow in the test server helper

SwitchProfile cancels and clears the pending session extend flow
unconditionally, the same way it clears the SSH JWT cache. New always
populates the field, but the hand-assembled test server did not, so
TestSwitchProfile_ClearsJWTCache panicked on a nil PendingFlow.
2026-10-08 15:40:39 +02:00
Maycon Santos 834b0f699c [misc] Exclude tools/mysql-migrate from docker tests (#8128) 2026-10-08 15:04:12 +02:00
Bethuel Mmbaga cc51a3e16a [misc] Skip mysql-migrate container tests on Windows (#8121) 2026-10-08 13:55:09 +03:00
Zoltan PappandClaude Opus 5 a8a67f5cf1 [client] Keep the updater silent until management decides the mode (#7455)
* [client] Keep the updater silent until management decides the mode

The update manager started in download-only mode and every engine stop
reset it there again, kicking the update loop with the cached latest
version. On a managed peer this published a "New version available"
notification on each disconnect, session expiry or logout, and once more
in the window between daemon start and the first network map. Users under
enforced updates then installed the release by hand.

Replace the boolean with a three-state mode. The manager starts undecided
and publishes nothing, from the fetcher or from NotifyUI, until the network
map picks download-only or managed. A new connection lifecycle resets to
undecided; disconnects leave the last decision untouched. A missing
AutoUpdateSettings now means download-only instead of a no-op, and on
platforms without an installer SetVersion falls back to download-only so
they keep notifying.

* [client] Address review findings on the updater mode split

Route every mode transition through one locked helper so the staged
managed version cannot outlive the decision that created it. Both
download-only transitions kept pendingVersion, letting Install put on
the old enforced release after management disabled updates or the
platform fell back to download-only.

The helper also bumps a generation counter. handleUpdate and NotifyUI
copy the mode under the mutex but publish and install after releasing
it, so a reset landing in that window could still emit an enforced
notification. Both now recheck the generation right before the side
effect and drop the stale work.

Reset on every connection attempt instead of once per run: the backoff
loop reconnects without re-entering run, and since disconnects no longer
force download-only, a fetch in the disconnected gap republished the
enforced notification before the new network map arrived.

A missing AutoUpdateSettings now logs that it defaults to download-only
rather than claiming auto-update is disabled, which contradicted the
notifications that mode still emits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [client] Reset the updater mode on engine stop

Removing SetDownloadOnly from the engine teardown also dropped the reset of
the forced-update flag. After netbird down the manager kept the last
management directive, so a release published while the client was stopped
could still trigger a forced install that management no longer asked for.
The same window existed between an engine teardown and the next reconnect
attempt, where ResetMode only ran at the start of the attempt.

Reset the mode in stopLocked instead, so the updater stays silent from the
moment the engine goes down until the first network map of the next
connection decides the mode again.

* [client] Name the force-reset test for the behavior it proves

The silence after ResetMode follows from the undecided mode alone, and the
closing SetVersion overwrites forceUpdate from its argument, so the test
cannot observe whether the flag itself was cleared. Name it for the stale
directive staying silent, which is what it asserts.

* [client] Fall back to download-only on a malformed expected version

* [client] Compare the full auto-update directive before skipping it

* [client] Set the mock latest version under its lock in the updater tests

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 12:46:15 +02:00
Zoltan Papp e392c5899e [relay] Fix race in Relay server shutdown (#7607)
* [relay] Bind listeners before serving to fix the shutdown race

A shutdown signal that arrives while the relay is still starting races
the listener goroutines. Server.Shutdown read the listener's server
field while Listen was writing it, which the race detector reported,
and when Shutdown won it saw a nil server, returned as if nothing was
running, and the listener then bound and served with nothing left to
stop it. Server.Listen never returned and the process hung on exit.

The listener lifecycle is now split into Bind and Serve. Server.Listen
binds every listener under its mutex before spawning the accept loops,
so the fields Shutdown reads are written before the goroutines exist.
A closed flag on the server makes a Listen that runs after Shutdown
return without binding. A bind failure on one listener shuts down the
ones already bound and surfaces the error at once instead of holding
it in a channel until the surviving listener exits.

* [relay] Mark the server closed before shutting down the relay

Shutdown set the closed flag only after the relay had finished closing
peers, so a Listen that started during that window could bind sockets
and start serving on a server that was already going down. The flag is
now set under listenerMux before the relay shutdown, and Accept closes
connections it receives once the relay is closed instead of leaving
them to the client's timeout.

The tests now cover the public Listen bind failure and a QUIC listener
shut down while blocked in Accept, and they pick ports that are free
for both TCP and UDP, reporting a bind error instead of a timeout.

* Fix tests

* [relay] Test the Listen and Shutdown race and the QUIC bind rollback

The existing tests order Listen and Shutdown deterministically, so the
race the fix targets was never exercised. A new test fires both from a
shared start channel across repeated rounds so either side can take the
lock first, and fails on the hang the old code produced.

The rollback test now drives Server.Listen with the real ws and quic
listeners and a UDP blocker, so the ws socket is bound and released when
the quic bind fails. Both tests pass a real TLS config because a nil one
only yields a quic listener in the devcert build.

* [relay] Bound the Shutdown wait in the concurrent Listen and Shutdown test

* [relay] Keep the default WS port for an empty listen address

net.Listen picks a random port for an empty address, while ListenAndServe
used :http or :https. Apply the same default in Bind so an empty address
keeps listening where it did before.

* [relay] Release the WS socket when Serve fails

ServeTLS can return before it takes ownership of the listener, for example
when no certificate is configured, leaving the socket opened in Bind bound
until Shutdown. Close it in Serve on any error other than a server close.

* [relay] Drain the relay outside the listener lock

Shutdown held listenerMux while the relay closed its peers gracefully, so
ListenerProtocols, and with it the healthcheck, blocked for the whole drain.
Mark the server closed and take the listeners under the lock, then drain and
stop them after releasing it. The closed flag still keeps Listen from
registering new listeners.
2026-10-08 11:36:37 +02:00
dmitri-netbird 24cb7b75c2 [management] switch to libopenapi for managing of openapi-based api (#8056)
* use libopenapi OpenAPI generator

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

* added a handler for v1alpha1/peers

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

* wire up request validator

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

* wired up spec-based validation

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

* use sync validation; set base url to v1alpha1

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

* moved stuff around, extracted runtime libopenapu deps into runtime_tooling

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

* testing /peers path params

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

* fixed tests

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

* added user schemas and paths

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

* use api validation in tests

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

* post-merge fixes

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

* deleted tmp command used to test validator integration

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

* require request body in post/put requests in order to force validation

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

* making linter happy

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

* switch to api/v1alpha1

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

* go mod tidy

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

* keep the original order of middleware: metrics, cors, then auth

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

* return 422 on validation errors

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

* cleanups

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

* more cleanups

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

* v1alpha1 spec cleanups

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

* no need for a double-pointer

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

* more linter fixes

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

* removed more double pointers

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

* use di to inject api_v0 and api_v1 http routers

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

* do not re-add middleware on repeated call to ApiHandler

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

* calling ApiRouter() now also calls ApiV1Router()

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

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-10-07 19:07:31 +02:00
Nicolas Frati 192d3d0344 [infrastructure] Find per-arch preflight results in the Red Hat certify check (#8111)
For a manifest list, preflight writes results.json only under
artifacts/<arch>/. The literal artifacts/results.json path is not a glob,
so nullglob kept it in the list and jq failed on the missing file even
though every check passed. A single globstar pattern matches both layouts.
2026-10-07 18:17:24 +02:00
Bethuel Mmbaga 6a38217113 [management] Add a MySQL to Postgres migration tool (#8095) 2026-10-07 18:39:25 +03:00
Riccardo Manfrin 5433e92183 [client] Stop one unreachable relay from churning the others (#8098)
* [client] Pass the home relay client into isForeignServer

isForeignServer read m.relayClient directly, so it was only safe while the
caller held relayClientMu, and that dependency was invisible at the call site.
Taking the client as a parameter makes the requirement explicit and lets a
caller narrow the window it holds the lock for.

Pure refactor, no behavior change: the single caller passes m.relayClient while
still holding the read lock.

* [client] Release the relay client lock before dialing a foreign server

OpenConn held relayClientMu for its whole body, dial included. Dialing a foreign
relay takes as long as that server's connect timeout, and onServerDisconnected
needs the same mutex in write mode; a pending writer also blocks later readers,
so a single unreachable relay stalled every relay operation on the node for the
duration of the dial.

On a routing peer this stalled the disconnect handling of an unrelated relay for
a full dial timeout. By the time the eviction ran, the track had already been
rebuilt, so it dropped a working client and the next OpenConn built a duplicate
connection to a server the peer was already connected to.

Read the home client under the lock, then release it and dial unlocked.

* [client] Keep a reconnected foreign relay client on a late disconnect notice

evictForeignRelay deleted the track by server address without checking which
client the entry held. The disconnect notice is delivered on its own goroutine
and carries only the address, so it can arrive after the track has been rebuilt:
the eviction then dropped a live client, and the next OpenConn built a second
connection to a server the peer was already connected to. The relay answers a
duplicate peer ID by closing the existing connection, which tears down every
relayed channel on it, including the peers whose active path it is.

Skip the eviction when the stored client is still connected.

* [client] Drop the comments added with the previous commits

Comment-only change, no behavior change.

* [client] Cover the foreign relay stall and the late disconnect notice

Two regression tests, both failing on the unfixed code:

TestOpenConn_DoesNotHoldRelayClientLockAcrossDial dials a listener that accepts
and never answers, then calls onServerDisconnected and fails if it does not
return. On the unfixed code the writer waits out the whole dial.

TestEvictForeignRelay_KeepsConnectedClient opens a real connection to a second
relay and then replays a disconnect notice for that address. On the unfixed code
the live client is dropped from the map.

Both reuse the existing stallingRelayListener and relay server helpers.

* [client] Keep a foreign relay track whose dial is still in progress

openConnVia publishes the track before dialing and fills relayClient only once
the dial finishes, so the readiness guard did not cover the dial-in-progress
window: a disconnect notice arriving there deleted a track whose client was
about to connect. That client then became unreachable through the map, so
cleanUpUnusedRelays could not close it either, and the next OpenConn dialed a
duplicate the relay answers by closing the first - the churn this branch is
meant to remove.

Skip the eviction while relayClient is still nil, the same signal
cleanUpUnusedRelays already uses to leave an in-flight dial alone.

Reported by cubic on PR #8098.
v0.81.0-canary.pr-8098.1
2026-10-07 15:17:15 +02:00
dmitri-netbird ae7ad5e0c8 [management] Return permission-denied error when setup key is expired or invalid (#8086)
* return permission-denied error when setup key is expired or invalid

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

* fix tests, don't return permission-denied on internal errors

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

* return not found error when key fetch failed

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

* normalise errors returned from each call-site of GetSetupKeyBySecret

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

* return PermissionDenied after isValid() check failure

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

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-10-07 10:21:24 +02:00
Viktor Liu e2a2d379c5 [infrastructure] Exempt Crowdin service PRs from title and docs checks (#7170) 2026-10-06 19:10:44 +02:00
Zoltan Papp 2b9c36dc49 [client] Close the UI before InstallValidate in the MSI (#7395)
* [client] Close the UI before InstallValidate in the MSI

The WiX CloseApplication action runs deferred, right before InstallFiles.
By then InstallValidate has already asked Restart Manager about the files
in use. When msiexec runs as LocalSystem (third-party deployment tools,
scheduled tasks) and netbird-ui.exe runs in the interactive user's
session, Restart Manager reports a session mismatch, so the installer
schedules the UI binary for replacement on the next reboot and returns
3010. Killing the UI afterwards is too late, the file stays on the old
version until a real reboot happens.

Replace the CloseApplication with an immediate WixQuietExec custom action
running taskkill /F /IM netbird-ui.exe, scheduled before InstallValidate.
The file is no longer held open when the in-use check runs, InstallFiles
overwrites it directly and no reboot is scheduled. Return is ignored
because taskkill exits non-zero when no UI is running. The action also
runs on uninstall so removal does not require a reboot either.

* Keep the deferred UI close as a fallback for the immediate taskkill

The immediate taskkill runs with the token of whoever launched msiexec. A
non-SYSTEM install cannot terminate a UI running in another user's session,
so keep the deferred CloseApplication, which runs as LocalSystem, to stop it
before InstallFiles. When taskkill already succeeded it finds no process.

* Document why the UI kill runs before InstallValidate
v0.81.0-canary.pr-7395.1
2026-10-06 17:33:54 +02:00
Edward d3d27dc08d [client] Only offer locales listed in _index.json (#8091) 2026-10-06 17:00:21 +02:00
Edward 51a9d32cfc [client] Warn on missing translations and check placeholders (#8090) 2026-10-06 16:59:26 +02:00
Pascal Fischer 2623feeb5b [management] remove ingress ports (#8062) 2026-10-06 16:35:31 +02:00
Zoltan Papp d56e6fc5f4 [client, android, ios] Coalesce peer list change notifications to the mobile listener (#7546)
* [client] Coalesce peer list change notifications to the mobile listener

Every peer state change spawned a goroutine to call the platform
listener. During a reconnect storm this pinned hundreds of OS threads
in JNI and let the UI call back into the engine from each of them.
Deliver peer list changes from a single goroutine per listener and
collapse pending changes into the latest count.

* [client] Cover a pending wake-up when the peer list deliverer is replaced

The replacement test waited for the old callback to finish before
swapping listeners, so it never exercised the stop check that runs
after a wake-up. Block the old callback, queue a peer list change and
swap while it is blocked, then assert the old listener never sees the
new count.

* [client] Signal peer list deliverer exit and wait for it in the test

The replacement test sampled the old listener after a fixed sleep, so a
late stale delivery could slip past it. Close a done channel when the
deliverer goroutine returns and let the test wait on it instead.

* [client] Drop the test-only peer list deliverer exit channel

The done channel was only read by the replacement test. Production code
cannot wait on it, since joining the deliverer would block on a mobile
callback. The tests now drive the deliverer loop directly and check that
setListener and removeListener close its stop channel.
2026-10-06 15:41:01 +02:00
Alberto Xosé Méndez Taboada c5aa55d2b9 [client] feat(i18n): Add Galician (gl) localization for desktop client (#7041)
* Add Galician (gl) localization for desktop client

* [client] Address review feedback and sync latest i18n keys for Galician

Fix terms per review feedback (usarase, creen, auditar, JWT cache, poscuántico, etc.) and translate newly added upstream i18n keys.

* [client] Fix translations, polish terminology, and sync latest keys for Galician

* [client] Fix i18n parity against branch source of truth and drop undeclared uk

* [ci] Skip Galician locales in codespell check

* [client] Add latest error keys for Galician and revert _index.json formatting

Add error.settings_locked and error.settings_managed_by_mdm to Galician locale for 100% key parity with en/common.json. Revert multi-line formatting in _index.json to keep a single-line entry for Galician.
2026-10-06 15:05:01 +02:00
Pascal Fischer a816acd97c [management] fix the concurrency handling within the update message tests (#8000) 2026-10-06 14:53:16 +02:00
Eduard GertandEdward f5707c3485 [client] Add RTL layout support to the desktop UI (#8076)
* [client] Add RTL layout support to the desktop UI

<html dir> follows the active language, Radix primitives get the
matching dir, physical spacing/positioning uses logical utilities, and
directional icons, animations, arrow-key navigation and tooltip sides
flip in RTL.

* [client] Address RTL review feedback

Force LTR with isolation for monospace values, let truncated names
take their direction from their content, and keep the profile name
field in the UI direction.

* [client] Keep translated monospace labels in their own direction

The forced-LTR rule for monospace values now skips elements with an
explicit dir, translated development labels use dir=auto, and
monospace values rendered through TruncatedText opt into LTR.

---------

Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com>
2026-10-06 12:08:11 +02:00
Riccardo Manfrin a8dff998ef [client] Gate settings updates on value, not on field presence (#7398)
* [client] Gate settings updates on value, not on field presence

The update-settings kill switch (--disable-update-settings /
NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids
changing settings, but it decided what a "change" was by looking at
whether a field was present in the request. The CLI fills the whole
config surface of SetConfigRequest and LoginRequest from its flags and
environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a
client configured by environment restates its own configuration on every
start and tripped the gate every time.

SetConfig only warned about that, but Login carries the same fields and
was gated the same way, and Login runs inside the CLI's backoff loop: the
daemon answered every attempt with codes.Unavailable, `netbird up` never
completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any
config env var (NB_MANAGEMENT_URL, for one) could not come up at all.

Both gates now compare values. Config.WouldChange is the dry-run half of
UpdateConfig: it runs the very same diff logic (Config.apply) against a
copy of the stored config, so the gate cannot drift from what an actual
update would do, nor go stale when a field is added. A request that
restates what the profile already holds changes nothing and is allowed; a
request that diverges is refused exactly as before, and a dry run that
cannot be evaluated fails closed. A profile with no config on disk yet is
judged against the config the daemon would create for it.

For Login, the compared input comes from loginOverridesInput, which
persistLoginOverrides also uses to perform the write, so the gate judges
precisely the two fields a login can persist (management URL, pre-shared
key) and no field it ignores.

Two adjacent defects surfaced while making the comparison exact:

- Config.apply compared URLs as raw strings, so the same endpoint spelled
  without its default port ("https://api.netbird.io" vs
  "https://api.netbird.io:443") counted as a new value and rewrote the
  config. It now compares the parsed forms.
- UpdateConfig did not collapse the redacted pre-shared key, unlike
  UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the
  mask replaced the stored key with asterisks.

The CLI warning for a refused SetConfig said the method was not available
in the daemon, which sent people looking for a version mismatch that was
not there; it now reports the refusal.

* [client] Do not write the profile config while only reading it to decide

The update-settings gate needs the stored config to decide whether a request
changes anything, so the previous commit moved that read ahead of the refusal.
The read is not side-effect free: profilemanager.GetConfig writes the config
back whenever apply() has to fill in a default the file was missing. A request
that the gate then refuses had therefore already rewritten the profile file.

PeekConfig is GetConfig without that write-back. The returned config is still
normalized in memory, which is what the decision needs; the file is left
exactly as it was found. Every caller of storedConfigAtPath feeds a gate that
can refuse, so they all peek.

Note for reviewers: the daemon still normalizes the file on startup and on
every real update, so nothing depends on a read performing that write.

* [client] Compare service URLs as endpoints, not as strings

Three places in one request path each had their own notion of "same
management URL": the config layer compared the parsed URLs as strings, the
privileged-change gate compared scheme + host + effective port, and the MDM
conflict check compared strings after filling in the default port. Only the
middle one was right.

A string comparison answers the wrong question. "https://api.netbird.io",
"https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint
written three ways, so a client restating its own management URL with a
trailing slash — a normal way to write it — was still read as a client asking
to be repointed, and the update-settings gate refused it. The MDM check had
the same flaw against the enforced value.

profilemanager.SameServiceURL is now the single comparison: same scheme, same
host case-insensitively as DNS names are, same effective port. The config
layer, the privileged-change gate and the MDM conflict check all defer to it,
so there is one answer to "did this URL change?" instead of three.

* [client] Stop the config dry run from generating throwaway keys

The dry run's baseline for a profile with no config file yet went through
createNewConfig, and apply() generates a WireGuard and an SSH key whenever it
finds those fields empty. The baseline is compared against and discarded, so
every evaluation minted a keypair it threw away — and logged "generated new
Wireguard key". The CLI retries Login in a backoff loop, so a first `netbird up`
on a fresh profile filled the daemon log with what reads like peer-key rotation.

The baseline now starts from the shared skeleton with placeholder keys, so
apply() has nothing to generate. No ConfigInput field maps to either key, so
the comparison is unaffected.

* [client] Cover the login the update-settings gate used to refuse

The gate's decision procedure was tested directly, but no test drove the Login
RPC that the refusal actually broke: the CLI retries Login in a backoff loop,
so a refused no-op login is what kept a client configured by environment from
ever coming up. The handler-level coverage stopped at the refusal case, which
passes on the pre-fix code too.

This test fails on the pre-fix daemon with "update settings are disabled" and
passes now. Past the gate the handler does real work the test does not stand
up, so it asserts only that the refusal did not happen.

* [client] Re-take the update-settings decision under the config lock

Login checks twice on purpose: the first check refuses the ordinary case
early, and authorizeAndPrepareLogin re-takes the authoritative one under
guardedConfigMu because the first is unsynchronized against a concurrent
privileged request. The update-settings decision is now equally
value-dependent — it compares the request against the stored config — but it
was taken only in the first, unlocked check.

So a login that was a no-op when it was checked could be written after a
concurrent writer had repointed the profile, which is exactly the window the
lock exists to close. The decision is now re-taken alongside the privilege one,
which also makes it the last read before persistLoginOverrides writes.

The test drives that interleaving through the existing afterLoginPreCheck seam
and fails without the re-check.

* [client] Drop an unreachable guard and fix two stale comments

- loginOverridesInput's nil-message guard cannot be reached: Login
  dereferences the message well before it, in storedLoginConfig.
- The docstring above afterLoginPreCheck described persistLoginOverrides,
  which lives further down the file and now carries its own.
- UpdateConfig's comment named DirectUpdateConfig; the function is
  DirectUpdateOrCreateConfig.

* [client] Make config reads pure and provision the identity explicitly

Reading a config wrote it back. profilemanager.readConfig persisted whatever
apply() had filled in, and ReadConfig created and wrote the file outright when
it was absent, so every reader was quietly a writer: a gate deciding whether to
refuse a request, a UI listing profiles, a mobile getter reading one preference.
The previous commit worked around that with a PeekConfig variant, which left
two read functions with opposite side effects and the antipattern still there
for everyone else.

Only one thing in a read genuinely had to be persisted: apply() generated the
WireGuard and SSH keys when it found them empty, and a generated key cannot be
recomputed — losing it means the peer comes back with a different identity and
registers again. Everything else apply() fills in is a deterministic default
that the next read recomputes anyway.

So identity provisioning is now its own step, Config.EnsureIdentity, and the
callers that provision write the result out themselves, in the open:

- Server.getConfig, the daemon's provisioning point;
- the CLI's foreground login, which is about to dial management;
- update() / directUpdate(), the config write paths — a stored profile can
  legitimately carry no identity, since a mobile logout clears the keys in
  place, and the next write is what has to mint a new one.

ReadConfig and GetConfig no longer write anything, PeekConfig is gone, and the
dry-run baseline no longer needs placeholder keys to keep apply() from minting
real ones.

One deliberate leftover: readConfig still calls util.EnforcePermission, which
chmods a config file whose permissions are too broad. It changes no content and
is idempotent, and dropping it would leave a legacy file world-readable until
its first write.

* [client] Name the two config readers for what they do

ReadConfig and GetConfig differed in one thing — what happens when the file is
absent — and neither name said which was which:

- ReadConfig      -> ReadOrGenerateConfig  (reads it, or generates one in memory)
- GetConfig       -> GetExistingConfig     (reads it, or fails)

Three comments went with them:

- GetConfig's said "return with Config and if it was created. Errors out if it
  does not exist", which described a bool it does not return and a creation it
  never performs.
- ReadConfig's explained that it does not write, which is what a reader is
  supposed to do anyway.
- Server.getConfig's said it "errors out if it does not exist", which it does
  not — it resolves a default config, and now provisions the identity too.

* [client] Do not panic on a config with no sync message version

apply() wrote the incoming sync message version through the stored pointer,
without checking it was there: a config that carries no version yet made it
dereference nil. Reachable from the update-settings dry run, which runs inside
a request handler — where failing closed is the worst acceptable outcome, and a
panic is not one.

The field is now reassigned like every other optional one, which also means
apply() no longer mutates anything the caller still holds through a pointer, so
the dry run's copy has one less field to detach.

Reported by cubic-dev-ai on PR #7398.

* [client] Compare the client certificate paths before reporting a change

apply() assigned the incoming mTLS certificate and key paths and set updated
unconditionally, without comparing them to what the config already held. It is
the same presence-instead-of-value mistake this branch set out to fix, one
layer down: a caller restating its own certificate paths was reported as
changing them, which trips the value-aware update-settings gate.

Reported by cubic-dev-ai on PR #7398.

* [client] Address the remaining bot findings on PR #7398

- Login logged the active-profile-state error and returned the same cause; the
  repo's guidelines call for one or the other, and the wrapped error is the one
  that carries context. (CodeRabbit)
- `netbird up` reported a codes.Unavailable SetConfig failure as "the daemon
  refused the settings update", but that code also covers a daemon that became
  unreachable. It now reports what the daemon said without asserting why.
  (cubic-dev-ai)
- TestLogin_ChangingTheManagementURLIsRefused asserted the error and nothing
  else, while "refused before it can touch daemon state" is the contract. It now
  checks the stored management URL, the in-progress login and the active profile,
  matching its SetConfig counterpart. (cubic-dev-ai)

* [client] Keep the peer identity out of a read that finds no file

ReadOrGenerateConfig resolves a default config when the profile has no file
yet, and createNewConfig was minting the WireGuard and SSH keys while doing so.
That defeated the provisioning pair it was meant to serve: the CLI's foreground
login calls EnsureIdentity to find out whether it has to persist the keys, got
generated == false because the read had already generated them, and so never
wrote them out. The login then dialed management with an identity that only
existed in memory, and the next login registered a second peer.

createNewConfig no longer provisions. createProvisionedConfig is the variant
that does, and the callers whose contract is "usable as it comes back" use it:
CreateInMemoryConfig, whose callers connect with the result, and the two
create-and-write branches. A read gets a config with no identity, so the
caller's own EnsureIdentity reports the work and triggers the write.

Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect.

* [client] Stop the gate test from dialing the real management server

TestLogin_RestatingTheStoredConfigPassesTheGate asserts that the gate lets a
no-op login through, and the handler then went on to do the login for real:
isLoginRequired builds an auth client when isLoginRequiredFn is unset, so the
test dialed the profile's management URL — api.netbird.io:443. It took 1.05s
locally and would hang on a runner with no egress, for a fact about the gate
that needs no network at all.

Stubbed like the login_outcome tests do. The test now runs in 0.00s.

Reported by cubic-dev-ai on PR #7398.

* [client] Keep the admin panel path part of its identity

The endpoint comparison introduced for the management URL was applied to the
admin URL too, and that one is opened in a browser rather than dialed over
gRPC: a panel served under /netbird is not the panel served at the root. So a
config whose admin URL differed only by path reported no change, and the new
path was never persisted — a custom panel URL could not be updated at all.

SameServiceURLIncludingPath adds what a URL carries past its endpoint (path,
query, fragment, userinfo) while still treating equivalent spellings as equal:
a missing path and "/" are the same root, and so is a trailing slash. The
management URL keeps the endpoint-only comparison, since only the endpoint is
ever dialed.

Ports are also normalized numerically now, so ":0443" and ":443" are one port.

Reported by cubic-dev-ai on PR #7398 (two findings).

* [client] Treat a profile with no identity as already deregistered

Two findings on the same consequence of pure reads: a profile can legitimately
carry no keys, because logging out clears them in place.

- sendLogoutRequestWithConfig went straight to wgtypes.ParseKey and failed with
  "incorrect key size: 0" on the second logout of the same profile. There is
  nothing to deregister for a peer that was never registered, so it returns
  cleanly. Before pure reads this case was hidden: the read minted a key and
  the daemon dialed management with one it had never seen.
- The mobile logout read the config with the generating reader right after
  checking the file exists. The two are not atomic, so a profile removed in
  between was resolved from the defaults and recreated by the write that
  follows. It uses the existing-file reader now.

Reported by cubic-dev-ai and CodeRabbit on PR #7398.

* [client] Fail `netbird up` when the daemon refuses the settings update

With the update-settings kill switch on, `netbird up --enable-rosenpass`
connected and said almost nothing: SetConfig refused the change, the CLI
downgraded that to a warning, and Login carries no rosenpass field to apply, so
the flag was silently dropped. The setting stayed disabled, which is the point
of the switch, but the caller was never told their request had been ignored.

The refusal now travels as codes.FailedPrecondition instead of
codes.Unavailable, and the CLI fails on it. Unavailable means "the daemon
cannot serve this call", which is why the CLI downgraded it and why
client/ui/services reads it as an unreachable daemon — both wrong for a daemon
that answered and refused. FailedPrecondition also matches what the MDM gate
already returns for a managed field, so both refusals are now one class of
error, and it is added to the login backoff's early-exit codes so a refused
login stops instead of retrying for 30s.

This does not put the container back in the deadlock: with the value-aware
gate, a client restating its own configuration is not refused at all, so
nothing reaches this path unless a real change was asked for.

* [client] Name the reader storedConfigAtPath actually calls

The purity note still said profilemanager.GetConfig, which the rename two
commits later turned into GetExistingConfig.

Reported by cubic-dev-ai on PR #7398.

* [client] Restore the gofmt alignment of the error constants

The comment added above errUpdateSettingsDisabled in the previous commit split
the const block's alignment group, so gofmt wants the two constants above it
re-aligned. CI runs gofmt, so this would have failed the lint job.

* [client] Let an unprivileged caller log out a profile with no identity

The empty-key check sat behind requirePrivilegeForDeregistration, so an
unprivileged logout of an identity-less profile was refused with
PermissionDenied instead of completing as the no-op it is. And it was refused
for most profiles, not a corner case: the gate arms whenever the SSH server is
enabled, and sshServerEnabled reads an absent ServerSSHAllowed as enabled, so
every legacy profile qualifies.

The check now runs first. What the gate protects against is handing this
machine's registered key to another management server; with no key there is
nothing to hand over and nothing to protect.

Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect.

* [client] Stop `netbird login` from retrying a refusal for 30 seconds

`netbird up` and `netbird login` both run Login through the backoff cycle, and
each carried its own copy of the list of codes that end it. Only up.go learned
about codes.FailedPrecondition, so a refused `netbird login` kept retrying and
then reported "login backoff cycle failed" instead of what the daemon said.

terminalLoginError is now that list, once, next to WithBackOff — the duplicated
copies are what let the two commands disagree in the first place.

Reported by cubic-dev-ai on PR #7398.

* [client] Answer terminalLoginError's nil case on its own terms

A successful Login reaches terminalLoginError with a nil error, and nothing
covered that. It happens to work on grpc v1.80.0 — gstatus.FromError(nil)
answers (nil, true), and Status.Code tolerates a nil receiver by returning
codes.OK, which is not in the terminal set — but that is a chain of internal
details to be relying on for the common path, and none of it was asserted.

Now the nil error is handled where it is obvious, and the table covers it.

Reported by CodeRabbit on PR #7398, which called it a panic; measured on
v1.80.0 it is not one. The gap was the untested reliance, not a crash.

* [client] Treat an unset optional field as its default when diffing a config

Seven Config fields mean "the effective default" when they hold no value:
the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every
consumer already reads a nil as that default, but apply() diffed them by
presence — `config.X == nil || *input.X != *config.X` — so an input restating
the default counted as a change.

That made the update-settings gate refuse `netbird up` outright. The CLI
sends every flag whose value came from an environment variable
(SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag
Changed), and the config a plain login writes leaves all seven unset, so a
container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default
the file held as null on every start and was answered with
FailedPrecondition.

apply() now resolves the seven up front, the way it already did for
ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on
its next write. With the values named, the comparisons below diff values
instead of presence, so their nil branches are gone.

The network monitor keeps its platform default — on for windows and darwin —
and naming it as false elsewhere is what createEngineConfig already read a
nil to be. getJWTCacheTTL reaches the same 0 through its own default, and
Android's GetEnableSSH* getters already answered nil with false.

* [client] Normalize the config before diffing it in WouldChange

apply() reports two different things through one bool: an input that changed
a value, and a field it had to fill in because the config carried none. The
update-settings gate reads that bool as "the caller asked for a change", so
any config still missing a default answered a request that asks for nothing
with a refusal.

Readers already hand out normalized configs — readConfig applies an empty
input for exactly this reason — which is why the gate got away with it. But a
handler that refuses a request must not depend on where its caller obtained
the config, and it must not start reading "this profile predates a field" as
"the caller asked for a change" the day someone adds one with a default.

WouldChange now runs the filling-in as a pass of its own and discards its
verdict, so the pass that answers the caller measures only what the input
did.

* [client] Stop the last config write that skipped normalization

Every path that creates or updates a profile config goes through apply(),
which resolves an optional field to its default — except RenameProfile,
which read the file with a bare json.Unmarshal, set the name, and wrote it
straight back. That copied whatever the file held, so a config written by a
client that stored these fields as null kept them null. It could not
introduce a null, only carry one forward, but renaming a profile is a poor
place to leave a half-resolved config behind. It now reads through
GetExistingConfig, which normalizes what it hands out.

The tests state the invariant the fix completes, over the *bool fields of
Config listed by reflection so a field added later is covered without
touching them: none may come out of apply() unset, and no write may store
one as null. An optional bool that can be nil, true or false forces every
reader to invent the meaning of nil, and makes a diff of the config compare
presence rather than value — which is exactly what refused `netbird up` for
a client restating its own defaults.

SyncMessageVersion stays a genuine three-state field and is not covered: it
is an *int whose absence means the client pins no version, and it travels to
management that way.

* [client] Refuse a serialized config that carries no peer identity

ConfigFromJSON still promised a "fully initialized" config after this PR
moved key generation out of apply() into EnsureIdentity, but identity stopped
being one of the defaults it applies. Its two callers both connect with what
they get back: the iOS SDK's Client.SetConfigFromJSON keeps it as the
preloaded config Run() uses on tvOS, and Auth.SetConfigFromJSON as the config
it authenticates with.

No caller feeds it a document without keys today — every stored document
comes from Auth.GetConfigJSON, whose config is provisioned by
DirectUpdateOrCreateConfig or CreateInMemoryConfig, and the tvOS app only
ever edits fields of a document it already has. This is a safety net for the
next caller, not a live bug.

Provisioning the identity here would be the wrong net. Neither caller can
hand a generated key back to the store the document came from — Client
exports no config at all — so the peer would connect under an identity
nothing persists and register anew on every launch, which is the failure the
EnsureIdentity split exists to prevent. A document with no identity means
nobody has logged in yet, and saying so is the only useful answer.

Both keys are required because both are dead ends when missing: an empty
WireGuard key fails the management login on its size, and an empty SSH key
fails ssh.GeneratePublicKey in ConnectClient before the engine starts.

* [client] Say that the null-on-disk fixture is synthesized, not written

The test comment described the null state in the present tense — "the config
a plain login writes leaves every one of them unset" — which was true before
this branch and is not any more: apply() now resolves those fields, so a
login writes them set. unsetOnDisk puts the null state back deliberately, to
stand in for a profile an older client wrote. Comments only.

* [client] Gather the optional-field defaults into one function

Resolving an unset optional field was spread over five places: the two
values newConfigSkeleton pre-sets, the block this branch added for the SSH
toggles, the network monitor's own if, the `else if` tails of
ServerSSHAllowed and RemoteJobsAllowed, and a trailing if for
DisableNotifications several hundred lines further down. Reading apply() left
no single answer to "what does this field default to, and who decides".

They now live in Config.resolveUnsetDefaults, which apply() calls before it
compares anything — the ordering being the point, since it is what lets
every comparison below diff values instead of presence. The comparisons for
ServerSSHAllowed, RemoteJobsAllowed and DisableNotifications lose their
`config.X == nil ||` clauses accordingly, as the other six already had.

newConfigSkeleton keeps its two, and that is the one asymmetry worth naming:
ServerSSHAllowed defaults to false for a new profile and to true for a
legacy one, and it only works because the skeleton runs first. The doc
comment says so, where before it was implied by the order of two distant
blocks.

Pure refactor. Verified as one: for the four fields whose branches moved,
plus two that did not and the JWT TTL, all 63 combinations of stored value
(nil/false/true) against input value (absent/false/true) produce byte-
identical resolved values and `updated` verdicts before and after.

* [client] Resolve the merge conflicts left in the tree

262ce8c3b landed with the conflict markers still in it, so client/server and
the iOS SDK did not compile. Four regions, resolved as follows.

client/server/mdm.go — main moved the MDM conflict-check machinery into the
mdm package (mdm.ResolveConflicts, mdm.ConflictBool, mdm.ConflictURL, ...).
This branch had edited the local copies, which are now dead: dropped, along
with the profilemanager import that only the local conflictURL needed.

client/server/server.go, Login gate — this branch's value-aware gate stays
(the point of the PR: refuse a real divergence, let a restatement through),
so main's presence-based `loginRequestHasConfigOverrides` block goes; that
helper no longer exists here anyway. Main's other change in the same lines
is real and kept: the MDM policy now comes from the daemon-owned
s.mdmLoader.Load() instead of the package-level loadMDMPolicy, which main
removed. The stale call right below the conflict was the reason the file
would not have compiled even with the markers gone.

client/server/server.go, getConfig — both sides add something and both are
needed. The identity is provisioned and persisted first, then the MDM
overlay is applied, so what reaches disk stays the profile's own config: the
overlay is runtime-only and re-derived on every load.

client/ios/NetBirdSDK/client.go — main reworked SetConfigFromJSON to store
the JSON and re-parse it on each load, which is the shape kept; the parse is
now only a validity check, and this branch's reason for it (a document with
no peer identity is refused, not just an unparseable one) moves into that
comment.

client/server/update_settings_gate_test.go — follows the sentinel constant
to its new home, mdm.PreSharedKeyRedactedSentinel.

* [client] Reuse util's service-URL comparison instead of a second copy

The endpoint-comparison rules this branch introduced now live in util (PR
#7472 moved them there so the MDM conflict check could stop comparing URLs
as strings). Keeping a copy here is what produced that bug in the first
place: two implementations of "is this the same endpoint?" drift, and the
one that drifts starts refusing a URL that addresses the very server it
already points at.

So SameServiceURL delegates the port normalization to util.ServiceURLPort
and drops the local one, and SameServiceURLIncludingPath — endpoint plus
path, for the admin panel URL, which is opened rather than dialed — is
util.SameServiceURL plus the query, fragment and userinfo it adds on top,
so the local path normalization goes too.

What stays here is the distinction util does not make: SameServiceURL is
endpoint-only, because a management URL is dialed and only its host and port
are, while util.SameServiceURL includes the path.

Pure refactor. Verified as one: all 198 pairs of a 14-spelling matrix
(default and zero-padded ports, host case, trailing slash, path, query,
fragment, userinfo, both schemes, nil operands) answer identically for both
functions before and after.

* [client] Give a newly added profile its identity (review item 1)

AddProfile writes the config it builds straight to disk, but built it with
createNewConfig, which stopped generating the peer's keys when identity
generation moved out of apply() into EnsureIdentity. The profile file landed
with an empty PrivateKey and SSHKey.

Nothing lost the keys permanently — the daemon's own getConfig provisions and
persists them on first use — but every reader that does not write got a
config that cannot connect in the meantime, which is exactly the set this
branch grew: the update-settings gate deciding whether to refuse a request,
and the mobile SDKs loading a stored profile.

createProvisionedConfig exists for callers that persist or connect, and this
is one; before the split, createNewConfig produced the keys here too.

* [client] Let a logged-out profile deserialize again (review item 2)

ConfigFromJSON refused a document with no WireGuard or SSH key. A config
legitimately has none between a logout and the next login: mobile
LogoutProfile clears both in place and writes the profile back, so the peer
re-registers on the next login instead of returning as itself.

So the refusal broke the mobile flows it was meant to protect. On iOS and
tvOS the stored JSON of a logged-out profile stopped loading through
Client.SetConfigFromJSON and Auth.SetConfigFromJSON, and copyConfig — which
round-trips a Config through JSON to take an in-memory copy before applying
the MDM overlay — failed on the same document. Where the old code silently
minted a key, this returned an error, which is worse for logout and profile
switching alike: neither is asking to connect.

The deserializer now stays out of the identity question in both directions:
it does not generate one (a read cannot hand back keys nothing will write
down) and does not refuse one that is absent. Whoever goes on to connect is
where an absent identity has to be answered — and it already is, by the
login path that provisions and persists.

ErrConfigWithoutIdentity goes with it; nothing else used it.

* [client] Fold the scheme case here too, like util does (review item 6)

profilemanager.SameServiceURL compared the scheme with ==, util.SameServiceURL
with EqualFold. No observable difference — net/url lowercases the scheme when
it parses, and both functions take parsed URLs — but two functions of the same
name with two different rules is a trap for whoever reads one and assumes the
other.

* [client] Classify the daemon's refusals in the GUI (review item 3)

FailedPrecondition reached the classifier unmatched, so a refusal showed as
"Operation failed". It is the code both of the daemon's deliberate refusals
carry: the update-settings kill switch, and a field an MDM policy manages.

Both are now named — settings_locked and settings_managed_by_mdm, matched on
the message the daemon composes — and FailedPrecondition itself falls back to
change_refused, so a refusal the daemon grows later still reads as a refusal
rather than a failure.

Only the English strings are added. Bundle.Translate falls back to the
default language for a missing key, so other locales show English until the
usual translation pass, rather than the bare "error.<code>" the classifier
would otherwise surface.

Note: the package needs GTK4/WebKit to build, which this machine has not, so
the test is type-checked (go vet, GOOS=windows) but was not executed locally;
CI's Linux job runs it.

* [client] Cover the mobile profile round trip: create, logout, reload

Both mobile regressions this branch's review turned up lived on the same
path, and neither was visible from the desktop client: a profile created
without an identity, and a logged-out profile that would no longer
deserialize. The desktop never meets the second one — it is mobile logout
that clears the peer's keys in place, so the next login registers a new peer
instead of bringing the old one back.

The test walks a profile through the round its user puts it through —
created, logged out, loaded again, switched away from and back — and loads it
at each step the way the SDKs do: read the stored config, serialize it, load
it back. That is Client.SetConfigFromJSON storing the document for tvOS,
Auth.SetConfigFromJSON authenticating with it, and copyConfig taking an
in-memory copy before the MDM overlay.

Verified to fail on each regression separately: restoring the bare
constructor in AddProfile fails it with "a new profile was written with no
identity", and restoring the identity check in ConfigFromJSON fails it at
"load the profile back".

client/mobile already had the coverage for the first one in
TestLogoutProfile_DisableProfiles — which arrived from main with the MDM
work, and which I had not been running.

* [client] Name only the refusals, not every FailedPrecondition

The classifier gained a blanket FailedPrecondition -> change_refused fallback
so a refusal would stop reading as "Operation failed". It reaches too far:
the daemon returns that code for two dozen states that are not settings
refusals — "not logged in", "client is not running", "another capture is
already running", "session can no longer be extended, log in again to
reconnect" — and errorClassifier is shared with the session and connection
services, not just the settings save.

So the user was told the service had refused their change while what they
actually had to do was log in again. The two refusals the daemon composes
stay named by their message; everything else goes back to the generic
message, which says nothing rather than something wrong.

Reported by cubic on the PR.

* [client] Say what each assertion was checking in the mobile test

AGENTS.md asks for a context message on comparison and boolean assertions,
and four of the ones added with this test had none, so a failure would have
read as a bare Empty/Equal with no hint of which step of the round trip broke.

Reported by cubic on the PR.

* [client] Translate the two new error strings into every locale

The GUI classifier gained error.settings_locked and
error.settings_managed_by_mdm, and only the English strings were added: the
bundle falls back to the default language for a missing key, so nothing would
have shown a bare "error.<code>" to a user.

CI disagrees, and it is right to: check-translations.mjs requires every
locale to carry the full English key set, so English-only fails the gate
rather than degrading quietly.

The ten locales now carry both strings. These are my translations, not a
localization pass — worth a second pass by whoever owns the language, in
particular for the phrasing of "an administrator has locked them".

The uk file also loses two lines of stray 8-space indentation, normalized by
rewriting the file; no key or value changed with it.

* [client] Persist the profile before overlaying MDM on it (review item)

`netbird login` read the config, applied the MDM policy on top, and only then
provisioned the identity and wrote the result out. On a profile with no
identity yet — a first login — that write persisted the enforced values into
the user's own config file: an MDM-managed management URL or pre-shared key
became indistinguishable from one the user set, and stayed behind once the
policy was withdrawn.

Provisioning and its write now come first, and the overlay is applied to the
in-memory config afterwards, where it belongs: it is re-derived on every load
and never meant to reach disk from here. Server.getConfig already orders the
two this way; the two paths now agree.

Reported by cubic on the PR.

* [client] Assert against the stored config, not a resolved default (review item)

The login-gate test read the profile back with ReadOrGenerateConfig, which
resolves a default config in memory when the file is missing — and that
default's management URL is the very value the assertion checks. An erased or
mislocated profile would have passed the test instead of failing it.

The file is written by the test itself, so GetExistingConfig is the right
reader: it errors when the file is gone.

Reported by cubic on the PR.

* [client] Keep the mTLS pair off the gate's dry run (review item)

WouldChange runs the real apply() against a throwaway copy, and apply() loads
the client mTLS certificate and key from disk whenever the config names them.
So every gated SetConfig and Login read the pair — twice per request, once for
the normalization pass and once for the verdict — including requests that were
about to be refused or that changed nothing, and logged an error per request
when the files were missing. The gate used to be presence-based and never
called apply(), so this was new work on a request path.

The loaded pair feeds the connection and never the comparison: nothing in
apply() reads it back, and it does not move the `updated` verdict. A config
built only to be compared against now says so, and apply() skips the load for
it.

Reported by cubic on the PR.

* Makes it explicit that RenameProfile does write on disk

* [client] Provision the peer identity under the config lock (review item)

Login took the authoritative update-settings and privilege decisions under
guardedConfigMu, then released it and called getConfig, which mints the peer's
identity and writes the config out. Between that read and that write, a
SetConfig holding the same lock could land a change and answer its caller —
and then be overwritten by the config the login had already read.

The window is narrow: getConfig only writes when the profile has no identity
or no file, so in practice a first login racing a settings change on the same
profile. It is also narrower than before this branch, where the write happened
inside the reader on every read that filled in a default.

Provisioning now runs where the decision it belongs to runs: at the end of
authorizeAndPrepareLogin, with the lock already held, next to
persistLoginOverrides, which writes there too. No lock is taken that was not
held before, so the documented guardedConfigMu-then-mutex order is untouched.

getConfig keeps its behaviour by calling the same extracted helper; on the
login path it now finds the identity already there and writes nothing. The
other callers are unchanged, and still provision outside any lock — a
concurrent SetConfig is not part of their flow.

Reported by cubic on the PR.

* [client] Declare the probe marker to the debug-bundle field check

TestAddConfig_AllFieldsCovered walks Config by reflection and fails until every
field is either rendered in the debug bundle or listed as excluded with a
reason. The probe marker added for the gate's dry run was neither, so the
client unit suite went red on every platform.

It is excluded: it marks a throwaway copy built to be compared against and
discarded, so it is never set on a config anyone runs with, and rendering it
would only ever print false.

* [client] Provision the peer identity on the iOS login path

Key generation used to happen inside apply(), so a config loaded from JSON with
no keys got them in memory on the way in, the login worked, and the app stored
the result. This branch moved generation into EnsureIdentity, and nothing in
the iOS SDK called it.

The consequence lands on the flow the mobile logout sets up: logout clears both
keys in place so the next login registers a new peer. The app then hands that
keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with
an empty WireGuard key, which fails on key size before the SSO flow starts —
the user cannot sign back in.

Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and
SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns
for the caller to persist, and writes it to disk itself when the profile has a
file — non-atomically, like NewAuth's own write, since the tvOS App Group
sandbox blocks temp-file-and-rename.

Not covered by a test: the package builds only under GOOS=ios, which the test
jobs do not run. Verified by building and vetting for GOOS=ios/arm64.

Reported by pappz in review.

* [client] Name the resolving reader for what it does, not what it makes

ReadOrGenerateConfig reads the profile config and falls back to the defaults in
memory when there is no file. "Generate" reads as "produces and stores", which
is the opposite of the property the rename it came from was meant to advertise:
the read is pure, writes nothing and mints no identity.

ReadConfigOrDefault says the same without the side effect, and pairs with
GetExistingConfig, which fails where this one falls back. Its doc comment now
states the absence of a write rather than only the fallback.

Pure rename; the two remaining mentions of the pre-branch name ReadConfig in
the tests go with it.

Reported by pappz in review.

* [client] Read an emptied NAT list as the absent one it matches

apply() compared NATExternalIPs with reflect.DeepEqual, which calls a nil
slice and an empty slice different. Both mean the same thing — no NAT
mappings — and the two meet on a perfectly ordinary start: a profile stores
the absent list as JSON null and reads it back nil, while `netbird up` sends
CleanNATExternalIPs, an empty list, whenever NB_EXTERNAL_IP_MAP is set to
nothing, which a deployment template does by default.

So the gate saw a change where nothing changed and refused the request with
FailedPrecondition. That is the same deadlock this branch exists to remove,
reached through another field: a container with the kill switch on could not
come up, and `netbird up` reported "the daemon refused the settings update".

The DNS label list next to it already used slices.Equal, which treats nil and
empty as the same list. The NAT list now does too, and the last use of
reflect in the package goes with it.

Reported by pappz in review.
2026-10-06 11:39:22 +02:00
Janek Härtter d622e03d40 [misc] Use ASCII hyphens in the LICENSE header (#8065)
The first line of LICENSE spelled BSD-3-Clause with non-breaking
hyphens (U+2011). The Windows NSIS installer shows LICENSE on its
license page and reads it in the system ANSI code page, so the UTF-8
bytes rendered as "BSD‑3‑Clause". Plain hyphens also match the
SPDX identifier and keep the file ASCII-only.
2026-10-06 10:32:23 +02:00
Nicolas Frati ab79aebd88 [misc] Share one license collection script across the UBI images (#8055)
* [self-hosted] Add a UBI image variant for the combined server

OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy and rootless client already ship
-ubi variants; this adds the same for netbird-server, published as
<version>-ubi and ubi-latest for amd64 and arm64.

* [self-hosted] Check the license output path before creating temp files

The existing-output exit ran before the cleanup trap was registered, so it
left the two mktemp files behind.

* [self-hosted] Certify the netbird-server UBI image

Adds netbird-server to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository
variable.

* [misc] Share one license collection script across the UBI images

The client, proxy and combined images each carried a near-identical copy of
collect-licenses.sh, and the signal and relay variants would add two more.
The copies differed only in the Go package, build tags, component license
and the proxy's web licenses, which are now options of one script in
release_files/. The client gains the staged write the others already had.
2026-10-06 10:25:57 +02:00
Nicolas Frati d6340ba0de [self-hosted] Add a UBI image variant for the combined server (#7953)
* [self-hosted] Add a UBI image variant for the combined server

OpenShift and other Red Hat environments expect UBI-based images that run
as an arbitrary non-root UID. The proxy and rootless client already ship
-ubi variants; this adds the same for netbird-server, published as
<version>-ubi and ubi-latest for amd64 and arm64.

* [self-hosted] Check the license output path before creating temp files

The existing-output exit ran before the cleanup trap was registered, so it
left the two mktemp files behind.

* [self-hosted] Certify the netbird-server UBI image

Adds netbird-server to the Red Hat certification components. Its Partner
Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository
variable.
2026-10-06 01:04:36 +02:00