Remote jobs (debug bundle requests from management) are gated behind
Config.RemoteJobsAllowed, which defaults to false and could only be
enabled through the CLI flag or an MDM policy. The mobile SDKs had no
way to set it, so the mobile clients always refused the job.
Add GetRemoteJobsAllowed/SetRemoteJobsAllowed to both mobile
Preferences types, following the existing ServerSSHAllowed accessors,
so the apps can offer a settings toggle for it.
* Unify peer and route ACL filtering with multi-source peer rules
* Remove partial userspace firewall mode and open foreign chains via a table-less allower
* Snapshot iptables rule maps before persisting state
* Scope userspace firewall wildcard source rules per address family
* Install nftables peer filter and mangle rules in a single transaction
* Share the iptables jump rule spec between install and cleanup
* Fix legacy ACL source wildcard and keep rollback tracking on delete failure
* Fix CI: recognize multi-value port set lookups in tests and correct PeerIP lint suppression
* Fall back to per-prefix filter rules when ipset is unavailable
* Annotate legacy PeerIP usages in ACL tests and fix import formatting
* Keep firewall rule bookkeeping in step with the kernel on replace and teardown
* Release the routing reference when the route manager shuts down
* Keep set references and rule tracking consistent when a routing rule fails
* [client] Bind the cached SSH JWT to the local caller that obtained it
Record the identity that obtained the token and return it only to that
same identity, comparing the account alone: the group set and the
elevation flag describe what a token may do rather than who it belongs
to, and the same user may call once elevated and once not.
A control channel that carries no caller identity gets a miss on read
and stores nothing on write, matching how the other ipcauth consumers
fail closed.
Clear the entry when the session it speaks for ends: logout, down and
profile switch.
* [client] Cover the profile-switch path of the SSH JWT cache
The cache being correct buys nothing if a handler around it forgets to
clear it, and SwitchProfile had no test at all.
Point the profile globals at a temp dir holding a single default profile,
which is the one ActiveProfileState.FilePath resolves without consulting
the current OS user, and call SwitchProfile with no request so neither
the switch itself nor the profile-list event is involved.
* [client] Report the SSH JWT cache in the no-identity startup warning
daemonServerOptions already warns once, at startup, about what a control
channel with no caller identity gives up. Name the SSH JWT cache there
too, on both the TCP and the no-peer-identity-primitive paths.
The per-request logs in cachedJWT and WaitJWTToken drop to Debug: the
condition is expected and handled on such a channel, the caller simply
re-authenticates, and repeating it on every SSH authentication buried the
one message that is actionable.
* [client] Stop the local-metrics manager leaking out of the profile test
localmetrics.NewManager runs a goroutine until its context is done, and
the test handed it context.Background(), so the manager outlived the test
and stayed in the test binary for every case that followed.
* [client] Keep the cached SSH JWT across a down/up cycle
Clearing the cache in cleanupConnection also caught Down, which ends the
connection and not the session: the peer stays enrolled, `up` reconnects
without going back to the IdP, and the token still belongs to the same
NetBird identity. With a long cache TTL that cost the owner a fresh
device-code flow for nothing, since the owner binding is what keeps the
token away from other local accounts.
Clear it on the two paths where the session really ends and the next one
may belong to a different NetBird user: profile logout when the profile
is the active one, and active-profile logout. SwitchProfile already
cleared it on its own.
* [client] Resolve the merge conflict in the profile-logout cleanup
main extracted the inline profile-logout cleanup into
cleanupAfterProfileLogout, which this branch had edited in place to clear
the SSH JWT cache. Take main's helper and move the clear inside it.
The helper returns early when the profile that was deregistered is not
the active one, so the cache is still only cleared when the session that
owns the token actually ends.
* [client] Do not cache an SSH JWT obtained under a session that ended
WaitJWTToken polls the IdP with s.mutex released, and that wait can run
for as long as the user takes in the browser. A logout or a profile
switch in the meantime clears the cache, but the poll then completed and
stored its token anyway, so the entry the next session read belonged to
the previous one.
Give the cache a generation that clear advances. WaitJWTToken takes the generation
before the wait and hands it back to store, which keeps the token only
while the generation still matches.
The two mutexes are distinct, so this was never a data race and the race
detector could not have found it: the window is between two separately
locked sections.
* [client] Make the profile-switch test switch a profile
SwitchProfile with a nil request skips switchProfileIfNeeded, so the test
only covered the no-op path and would have passed with profile-transition
invalidation broken. Create a second profile and name it in the request,
then assert the active profile actually moved before checking the cache.
Also correct the comment on the Down test: the logout handlers do call
cleanupConnection. What changed is that clearing the cache is no longer
one of the things cleanupConnection does.
* [client] Take the SSH JWT cache generation when the flow is created
WaitJWTToken read the generation after validating the device code, but
the flow it belongs to is created earlier, in RequestJWTAuth, and
SwitchProfile does not reset s.oauthAuthFlow. A profile switch between
the two therefore advanced the generation before it was ever read: the
guard compared the new session against itself and let the token through,
which is the case it exists to stop.
Record the generation on the flow when RequestJWTAuth creates it, and
read it from there. The whole span from the request to the IdP answering
now counts as one session for the cache.
* [client] Correct two test comments the clear-on-Down change invalidated
Moving the clear out of cleanupConnection left two comments describing
the old behaviour: newTestServer said cleanupConnection clears the cache,
and the comment above TestJWTCache_ClearDropsTheEntry listed Down among
the callers of clear. Neither is true any more.
* [client] Read the SSH JWT cache generation before the IdP round trip
RequestJWTAuth read the generation where it stored the flow, which is
after RequestAuthInfo has talked to the IdP. A logout or a profile switch
during that call advanced the generation first, so the flow recorded the
new session's value and the later store was accepted: the window moved
rather than closed.
Read it with the config, under the same s.mutex section. SwitchProfile
holds that mutex across its own clear(), so the config and the generation
cannot be torn apart by a switch.
* [client] Clarify that metrics ingest X-Peer-ID is not a credential
The ingest endpoint is intentionally unauthenticated: it accepts telemetry
from peers of both cloud and self-hosted deployments, and for a self-hosted
peer there is no shared trust anchor to authenticate against. The X-Peer-ID
header is a correlation tag whose format check exists to bound InfluxDB tag
cardinality.
Both the function name (validateAuth) and the 401 response implied an
authentication control that was never there, which invites the reading that
the check can be bypassed. Rename it to validatePeerIDFormat and return 400,
matching the other input validation failures in the same handler. Document
the intent in the godoc and the infra README.
No behavioural change for clients: push.go classifies responses by 2xx range
rather than by status code, so 400 and 401 are handled identically.
* [client] Reject metrics ingest bodies whose peer_id tag disagrees with the header
validateTag checked tag names against the per-measurement allowlist but only
bounded the value length, so the peer_id tag was free-form text up to 64 bytes
and could differ from the X-Peer-ID header the request was accepted with.
Tie the two together: the tag value must equal the header value. Since the
header is already checked to be 16 hex characters, this transitively constrains
the tag to the same shape. Every client sends the same value in both places
(metrics.go feeds agentInfo.peerID to both push.SetPeerID and the body tags),
so well-behaved clients are unaffected.
The mismatch is rejected rather than silently overwritten: rewriting the value
would re-serialize caller-controlled text back into line protocol and would
hide misbehaving senders instead of surfacing them. Rejection also matches the
other input validation failures in the same handler, which all return 400.
This narrows the value space of the peer_id tag but does not by itself bound
InfluxDB series cardinality: a sender that puts the same arbitrary 16 hex
characters in both the header and the body still passes. Limiting that needs a
per-source rate limit in front of the service.
* [client] Document what the metrics ingest peer_id check does and does not bound
The README described X-Peer-ID as the correlation tag, but grouping is done by
the peer_id tag in the submitted line protocol: that is what is forwarded to
InfluxDB, while the header only serves as the value each tag is checked against.
It also claimed the format check bounds tag cardinality. It bounds the value
space of the tag, not the number of distinct series, so state that explicitly
and point out that series cardinality has to be limited outside this service.
* [client] Set timeouts on the metrics ingest HTTP server
The server ran on http.ListenAndServe with no timeouts, silenced with a
nolint:gosec for G114. Without ReadHeaderTimeout a client can hold a connection
open by sending headers slowly, and without ReadTimeout or IdleTimeout
connections accumulate on an endpoint that takes unauthenticated requests.
Construct an http.Server with explicit limits instead, which also drops the
nolint. Handler stays nil so the existing DefaultServeMux registrations are
unaffected.
WriteTimeout is deliberately larger than the 10s upstream client timeout: the
response is only written after the forward to InfluxDB completes, so a tighter
value would cut off the server's own valid response.
* Revert "[client] Document what the metrics ingest peer_id check does and does not bound"
This reverts commit 837a5d8dda.
* Revert "[client] Reject metrics ingest bodies whose peer_id tag disagrees with the header"
This reverts commit 91d4f6128.
Tying the body peer_id tag to the X-Peer-ID header assumed the two always agree,
but a profile switch breaks that. UpdateAgentInfo swaps agentInfo.peerID and
calls push.SetPeerID with the new value while leaving the sample buffer alone,
and the peer_id is baked into each buffered line at record time, so samples from
the previous profile ship under the new header.
The consequences compound: validateLineProtocol rejects the whole batch on the
first bad line, so fresh samples are dropped along with the stale ones, and
push.go only resets the buffer after a successful push, so the batch is retried
and fails again. Metrics from that client stay stuck until the old samples age
out of the buffer, up to maxSampleAge (5 days).
Deciding whether the previous profile's unsent samples may be discarded, or
whether the push has to be partitioned per identity, is a product call, so
restore the previous behaviour for now. The header keeps its format check;
the body peer_id tag goes back to being bounded only by maxTagValueLength.
* [client] Stop claiming the metrics ingest peer ID format check bounds tag cardinality
The X-Peer-ID header is never forwarded to InfluxDB; the stored peer_id
tag comes from the request body and is constrained only by the tag
allowlist and the maximum tag value length. Align the README and the
validatePeerIDFormat godoc with the actual behavior after the
header/body match check was reverted.
* [client] Add tests for the ICEBind open and close races
Running many embedded clients in one process intermittently wedges interface
creation. A goroutine dump taken from 50 clients shows ten of them parked for
seven minutes in Device.IpcSet, in closeBindLocked waiting on
device.net.stopping.Wait, holding device.net while every other device
goroutine queues behind it on Device.Up.
Open writes s.closed and Close reads it with no synchronisation, and Close
also closes s.closedChan without the mutex that Open swaps it under. Two
Closes can both pass the check and close the same channel, and a Close racing
an Open can mark the bind closed while a live channel and live receive
functions remain, after which every later Close takes its early return and
runs neither close(closedChan) nor StdNetBind.Close. The receive functions
never stop, so stopping.Wait never returns.
These tests do not fix that. The first pins the contract closeBindLocked
depends on and passes today. The other two fail under -race, reporting the
races at the three sites above, and pass again once closed and closedChan are
guarded consistently.
* [client] Release parked receivers so reopening a bind cannot stall
receiveRelayed held closedChanMu for the whole of its blocking select, so a
parked receiver kept the read lock indefinitely and Open could never take the
write lock it needs to install a fresh closedChan. wireguard-go reaches Open
from Device.IpcSet and Device.Up with device.net held, so the stall took the
device lock with it: interface creation never finished, every other device
goroutine queued behind Device.Up, and Engine.Start never returned.
Callers now copy the channel under a short read lock and select on the copy.
Copying alone would stand a new trap in the same place, because an Open that
follows an Open leaves the previous generation parked on a channel no later
Close can reach, so Open now closes the outgoing channel before swapping it.
closed and closedChan are also updated together under that mutex. Read and
written apart, Close could see a stale closed and skip both close(closedChan)
and StdNetBind.Close, leaving every receive function running and wedging
closeBindLocked on device.net.stopping.Wait, or two Close calls could pass the
check together and close the same channel twice.
TestICEBindOpenDoesNotBlockOnParkedReceiver fails without this change, without
needing the race detector. The other three cover the surrounding contract and
report the state races under -race.
* [client] Make the bind lifecycle transition atomic and tighten its tests
Review caught that the previous commit moved the torn transition rather than
removing it. Open published the new generation before calling StdNetBind.Open,
so an Open rejected because the bind was already open had already signalled the
outgoing generation, and a Close arriving in that window could mark the bind
closed while the same call went on to install live sockets. Every later Close
then returned early and never shut them down.
Open now calls StdNetBind.Open first, so a failure leaves the current
generation untouched, and both Open and Close hold the lock across the whole
transition. Ordering is safe: StdNetBind.Open reaches muUDPMux through
createReceiverFn, and no path takes muUDPMux before closedChanMu.
The tests were also weaker than they read. The stress test claimed to cover a
stale channel but only ever raced two Closes, and the concurrency test left
overlap to goroutine start order. Both now gate their goroutines on a common
start, the stress test races an Open against the Closes, and both assert the
surviving generation channel is actually closed. Waiting on receive functions
to be entered replaces part of the sleep in the reopen probe, and teardown
bounds its Close so a regression fails the assertion instead of hanging.
Two of the four now fail without the fix and no race detector, the stress test
by reproducing close of a closed channel at the Close early return.
* [client] Fail the reopen probe when its teardown does not complete
closeBounded swallowed its timeout and the cleanup discarded what
receiversStopped returned, so the bounds added in the previous commit only
stopped teardown hanging. A wedged Close or a parked receiver would have left
the test green with a leaked goroutine, which is the failure this test exists
to catch.
closeBounded now reports whether Close returned, and cleanup fails the test on
either bound.
This introduces a disabled-by-default allow-remote-jobs setting that
controls whether the management server may run jobs (such as debug
bundles) on a peer. The flag propagates end to end: through client
configuration, the daemon SetConfig and Login requests, authentication,
and system info, up to management, where it is stored on the peer and
exposed on the peers API as remote_jobs_allowed. The client refuses any
management-requested job unless the peer has opted in. Because enabling
remote jobs crosses the user-to-root boundary, turning it on requires
privilege, mirroring the SSH-server gate. Administrators can enforce the
setting through MDM policy on both macOS and Windows, and MDM can also
override the debug-bundle upload URL. The change ships policy
documentation and generated profile templates, and adds configuration,
conflict, and enforcement tests covering the opt-in, privilege, and MDM
paths.
* [client] Reuse the profile's account for iOS SSO logins
Android reads the profile's stored account and passes it as the OIDC
login_hint, and records it again after a successful login. iOS did neither: it
called GetOAuthFlow with an empty hint, so a re-login was resolved by whatever
session the browser's cookie jar held rather than by the account the profile
belongs to. With a non-ephemeral browser session that is the wrong account as
soon as more than one is signed in.
Mirror client/android/login.go: hint from mobile.ReadProfileEmail before the
flow, mobile.WriteProfileEmail after Login succeeds. Storing after Login and
not before keeps a rejected token from leaving a hint that points at an
account which cannot be used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* [client] Persist the account email on tvOS and on the device flow
Two paths left a profile with no account bound, so every later login went out
without a login_hint — the case this change exists to remove.
WriteProfileEmail went through util.WriteJsonWithRestrictedPermission, which
writes a temp file and renames it over the target. The tvOS App Group sandbox
blocks exactly that, which is why the config sitting next to this file is
written with DirectWriteOutConfig. On tvOS the email write therefore failed and
was dropped with a warning. Use DirectWriteJson: the file is rewritten whole
from a single key, so the only thing atomicity buys here is surviving a crash
mid-write, and a torn file reads back as "no email" and is replaced by the next
login.
The device authorization flow never populated TokenInfo.Email, unlike the PKCE
flow, so a client driven through it — Android TV and tvOS — bound no account at
all. Parse the ID token there too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* [client] Report a failed close from DirectWriteJson
The deferred close assigned its error to err, but the return value was not
named, so the assignment went nowhere: a close that failed was logged and the
function still returned nil. The write is only durable once the file closes
cleanly, so every caller — the management config, the profile configs and the
profile account email — could be told the data landed when it had not.
Name the return so the assignment does what its shape always intended, and
report the failure once. When the body succeeded the close error is returned and
the caller logs it. When the body already failed, that error is the one that
explains the failure and is what the caller gets, which leaves the deferred log
as the only place the close failure can surface — at debug, per the logging
rules for close errors on writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* [client] Resolve profiles for the sudo invoking user instead of root
The SSH server flags force `netbird up` through sudo, but the CLI resolved
every per-user path with the process user. As root that reads root's own
(empty) local state, so a `sudo netbird up` silently switched the daemon from
the user's profile to the default one — cancelling any login already waiting
in the browser — and then ran an SSO login for the default profile's config.
Whichever account that login returned, the default profile's peer belongs to
someone else, so every attempt ended in "peer is already registered by a
different User or a Setup Key", with nothing telling the user why.
Resolve the acting user through SUDO_USER when running as root: the active
profile, the profile config paths and the stored account email now come from
the invoking user's directories. Privilege decisions are untouched — they stay
on the kernel credentials of the daemon connection, which an environment
variable can never influence; a forged SUDO_USER only selects a profile root
could select anyway.
The invoking user's directories are strictly read-only under sudo. Anything
root wrote there would be root-owned and break the user's own runs, so instead
of chowning files back, the local writes are skipped: the active-profile
bookkeeping and the account-email state simply do not update from a sudo run
(the daemon records the switch on its side; a skipped email write costs at
most one extra account prompt later).
Plain root — no sudo context — has no user to act for, so the ambiguity is
refused instead of guessed at: when the daemon's active profile differs from
what root resolves and no --profile was given, up fails with a message naming
both profiles, instead of silently switching the daemon and failing later with
the ownership error.
* [client] Act on the daemon-resolved profile and fail closed in the root guard
Under sudo the local active-profile mirror is not updated, so up/login
re-reading it after a profile switch acted on the previous profile; use
the daemon-resolved ID directly instead. The plain-root guard now runs
after the readiness wait, denies on lookup errors and empty responses,
and matches the owning username as well; an unowned profile (fresh
install) and a daemon predating the RPC stay allowed. Write-skip
decisions key off the sudo environment alone so a transient user lookup
failure cannot turn a run into writing root-owned files into the user's
directory, and RemoveProfileState honors the read-only rule too.
* [client] Return a wrapped error instead of double-reporting the dial failure
* [client] Read the profile from the daemon when the local mirror is not authoritative
Under sudo without --profile, `up` took the active profile from the invoking
user's local active_profile.txt mirror and drove the daemon to it. But that
mirror is never written under sudo (the SwitchProfile write is a no-op), so it
goes stale after any --profile run and silently switches the daemon back to the
mirror's default. The plain-root guard was meant to refuse exactly this
ambiguity but only ran for plain root, never for the sudo case the fix targets.
When there is no --profile and the mirror is not authoritative (sudo or plain
root), take the profile the daemon already holds for the invoking user instead
of the stale mirror: stay on the user's current profile when the daemon owns it
(or it is unowned, as on a fresh install), and refuse with a --profile hint when
the daemon is on another user's profile. A daemon predating the RPC keeps the
mirror-derived profile.
Reproduce (before this change):
1. As a non-root user misha, with the daemon installed and running:
sudo netbird up --profile work
misha connects on the `work` profile.
2. Because the local mirror write is skipped under sudo,
~misha/.config/netbird/active_profile.txt still says `default` (or is still
absent, which also resolves to `default`).
3. Run a bare:
sudo netbird up
The CLI reads `default` from the frozen mirror and sends ProfileName=default;
the daemon silently switches away from `work` and brings the tunnel up on
`default` — a different account/peer than the one last chosen, with no
warning. After this change step 3 stays on `work`.
* [client] Return a sentinel error instead of nil-nil for the missing daemon RPC
* [client] Load the extend-session hint from the resolved profile
* [client] Fail closed instead of reading root's config when the sudo user lookup fails
* [client] Fail closed in InvokingUser when the sudo user lookup fails
A previous change made baseConfigDir fail closed when SUDO_USER cannot be
resolved, but InvokingUser still fell through to user.Current(). Those two
guards disagreed: the active-profile mirror and the email state refused to
read root's directory, while every profile-path caller happily resolved as
root.
The consequence of a transient NSS failure under sudo was that
Profile.FilePath resolved through getConfigDirForUser("root"), creating
/var/lib/netbird/root and reading the profile JSON from there, and the CLI
sent Username "root" to the daemon in SetConfig and ListProfiles, so the
daemon resolved the same phantom namespace. The invoking user was silently
moved onto a root-owned profile instead of being told the lookup failed.
Fail closed at the single source of the fallback. getConfigDirForUser is
left alone on purpose: it is a pure path helper that also serves
daemon-supplied usernames, and under sudo with a successful lookup it must
still create the invoking user's own profile directory.
The error already says what went wrong: an invalid base64 payload
reports the offending byte offset, and a wrong key size reports the
length. Passing the key itself adds nothing an operator can act on, and
the line is emitted at Error level, so it reaches every log sink and
every debug bundle.
* [client] Allow logging out of the active profile when profiles are disabled
A profile-addressed logout was refused outright when the profiles feature is
disabled: handleProfileLogout ran validateProfileOperation, which returned
Unavailable ("profiles are disabled, you cannot use this feature without
profiles enabled") before looking at which profile was targeted.
The desktop UI always addresses logout by profile — both the profile menu and
the session-expiration dialog send the active profile's ID — so a client with
profiles disabled could not log out at all; only a plain `netbird logout`,
which takes the profile-less path, still worked. Logging out of the profile the
daemon is already running is a deregistration, not profile management, and with
profiles disabled there is a single profile anyway, so every profile-addressed
logout is by definition an active-profile logout.
Replace validateProfileOperation with validateProfileLogout, which skips the
profiles-disabled check when the target is the active profile and keeps gating
logout of any other profile. This mirrors switchProfileIfNeeded, which already
gates only the branch that actually manages profiles. The dropped
allowActiveProfile parameter was always true, leaving canRemoveProfile
unreachable, so both are removed.
* [client] Compare the username and propagate state errors on profile logout
Review follow-ups on the logout gate:
Propagate the GetActiveProfileState failure instead of discarding it. A failed
lookup made the target look non-active, so a caller with profiles disabled got
"profiles are disabled" in place of the real error.
Compare the username along with the ID when deciding whether the target is the
active profile, matching switchProfileIfNeeded. Legacy profile IDs are display
names, so two users can hold the same ID in their own profile directories, and
an ID-only match let one user's logout pass the gate against the other user's
active profile. The default profile is shared and carries no username, so it
keeps matching on the ID alone.
Re-read the active profile before the connection teardown rather than reusing
the pre-flight snapshot. Login switches profiles under guardedConfigMu, which
the logout path does not hold, so a login that landed while the deregistration
was in flight would otherwise lose its fresh connection to a stale flag.
* [client] Address review on the profile logout gate
Pass the username down to logoutFromProfile and reuse the running config only
when the target is the active profile for that username. On an ID-only match a
legacy profile ID shared between two users made the connected-client path
deregister the active peer while its connection stayed up, which the gate fix
alone did not cover.
Split the setup-key-less branch of Login into beginSSOLogin, with the
reuse-the-pending-flow decision in pendingOAuthFlowResponse. Login's cognitive
complexity drops from 37 to 21 (gocognit), clearing the SonarQube report on
this file with no behaviour change.
Point the test fixture at an https URL, since the profiles a gated logout must
not touch only need to be unreachable, not plaintext.
This extends the management-requested remote debug-bundle job with two
new, optional parameters. anonymize_level selects how aggressively the
bundle is scrubbed: "default" keeps internal (private) IP ranges
readable, while "strict" also anonymizes private, CGNAT and link-local
addresses; the value is trimmed and lowercased, and an unknown level is
rejected at creation. upload_url lets an operator point the peer at a
specific upload service instead of the default one; it must be a
well-formed https URL with a host, and an empty value falls back to the
default upload server. Both fields flow through the job workload API and
are surfaced in the create-debug-job modal on the dashboard. Validation
is shared so the client executor and the management boundary agree on
what a valid upload URL is, preventing drift between the two checks.
* [client] Keep the route selection when every requested ID is unavailable
A non-append SelectRoutes() wipes the current selection before applying
the requested one, but it validated the requested IDs only afterwards,
while already mutating. A request naming no available route at all left
every route deselected and returned an error - so a typo in a route ID
silently dropped the user's exit node, and the routes stayed applied
while the selector claimed nothing was selected.
Validate first and bail out before touching any state when nothing in
the request is available. A request with at least one available route
keeps applying the valid part and reporting the rest, and an empty
request still deselects everything, since that is the caller asking for
exactly that rather than a failed lookup.
* [client] Trim the new comments to the contributing guide's length budget
CONTRIBUTING.md caps comments at 90 characters per line and roughly 250
per comment. The three comments added by this PR were over both limits.
The test comments also restated their own test names, so they lose that
half and keep only the why.
* [client] Apply the route selection even when some IDs are unknown
SelectRoutes and DeselectRoutes returned the error before TriggerSelection,
so a request mixing valid and unknown network IDs changed the selector but
never reached the routing table. The valid routes read as selected while
`ip route` showed nothing.
Trigger the selection first and return the error afterwards. The inner
selectRoutes already applied the valid part of a partial request, only the
outer layer dropped it.
* [client] Publish the network selection event on a partial failure
Returning early on error was correct while an error meant nothing had
happened. A partial failure now changes the selection and the routing
table, so returning first left the change with no trace in the event log
or the UI, even though the new state had already been broadcast.
* [client] Cover the append and deselect-all paths of the selection guard
The append path was never destructive and behaves the same with or without
the early return, so that case is characterization rather than a regression
test. The deselect-all case is a real guard: the early return also skips
resetting deselectAll, so a typo no longer drops the "nothing selected,
including future networks" policy.
* [client] Pin that a fully invalid selection disturbs nothing
The selection is now applied on every request, including one where no ID is
known and the selector is left untouched. Nothing may be torn down or
reinstalled on that path.
* Revert "[client] Publish the network selection event on a partial failure"
This reverts commit 26219592.
The event would lie on the opposite path: when no requested ID is available
the selector is left untouched, so an unconditional publish reports a change
that never happened. Telling that case from a partial failure needs the
manager to report whether anything was applied, which is a new signal in its
API and does not belong in a PR about the selector guard. Follow-up instead.
---------
Co-authored-by: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
* Bump go version to 1.26 and go-quic to v0.62.0
* Replace deprecated ecdsa public key assembly and add tests for jwt
* Update goversioninfo
* Pin go toolchain to 1.26.7
* [client] Drop agentConnecting whenever ICE session state clears
Closing a WorkerICE raced a blocked dial goroutine: Close released the
agent while connect() was still inside Dial, and the goroutine's own
cleanup skipped its flag reset because w.agent no longer matched. With
agentConnecting stuck on true, evalConnStatus read the peer as
connected, the reconnection guard stopped sending offers and
same-session offers were dropped, so the peer could not recover without
a restart. An aborted recreate in OnNewOffer reaches the same wedged
state without any race.
Route every teardown path through one abandonNegotiation helper so the
agent and flag fields always clear together; Close now also cleans up
residual state left by an aborted recreate.
* [client] Drive the ICE teardown race test through the real dial goroutine
The regression test simulated the stale goroutine by calling closeAgent
directly, so it pinned the symptom rather than the mechanism. Rework it
to start a real negotiation, tear it down mid-flight and let the actual
goroutine run its own cleanup: with no remote responder the dial can
only fail once Close cancels it, so the interleaving stays deterministic
without sleeps or injection points.
Assert the full idle state that abandonNegotiation owns (agent nil,
connecting false, remote session ID empty) instead of only InProgress,
and make the stale-cleanup ownership test verify that the newer session
survives field by field.
* [client] Assert live remote session ID after stale ICE cleanup
The stale-cleanup test compared a snapshot captured before closeAgent
ran, so clearing the field during cleanup would have gone unnoticed.
Read the field under the mutex after the cleanup instead.
* [client] Give the ICE race tests a no-op signal client
The candidate callback fires from a real gather and dereferences the
signaler, so a nil one crashes the test package intermittently when
gather wins the race against Close. Build the worker with a stub
signal.Client instead.
* [client] Read the ICE dial cancel func from an argument in connect
The error paths read w.agentDialerCancel without holding muxAgent while
OnNewOffer rewrites the field for a newer negotiation, a data race the
new teardown test trips under -race. Reading a stale value also let an
old goroutine cancel another session's dial. Capture the cancel func at
goroutine spawn, like the dial context already is.
* [client] Guard the ICE dial success path against stale negotiations
The stale-cleanup guard in closeAgent only protected teardown. Its
success-path counterpart was missing: an older negotiation could complete
agentDial after a newer one replaced w.agent, then clear the newer
session's agentConnecting, record lastSuccess and publish its dead
connection via onICEConnectionIsReady.
Verify ownership under muxAgent twice: right after the dial returns, so a
stale goroutine drops its connection before touching a closed agent, and
again at the state-commit point, atomic with the agentConnecting and
lastSuccess writes, so a replacement arriving in the meantime cannot get
its state clobbered. Both paths close the stale connection and return
without modifying worker state. A regression test holds session A's dial
open until session B is installed, then releases it; the stale connection
must be discarded and B's agent, connecting flag and remote session ID
must survive.
* [client] Fix ICE teardown test leak and document the stale delivery window
A code review of the stale-negotiation guard found a leftover resource
leak in TestWorkerICE_StaleCloseAgentKeepsCurrentSession: session B is
never closed, so its ICE sockets and blocked dial goroutine live as long
as the test process. Register t.Cleanup(w.Close).
The delivery race flagged after the success-path guard is pre-existing
and self-correcting - the newer negotiation overwrites the transient
endpoint - so document it in the existing todo instead of locking the
callback, which would invert lock order against Conn.Close. Adjust the
teardown test comment to match the now-synchronous Close flag clearing.
* [client] Close the session-expiration dialog only on an actual session renewal
The dialog auto-closed on any Connected status snapshot, but the daemon
emits Connected periodically regardless of session state, so the warning
popup disappeared on the next snapshot (~30s) with no chance to
re-authenticate. Close only when the snapshot's session deadline jumps
past the one the dialog was opened for, meaning the session was renewed
from another surface (tray action, CLI, main window).
* [client] Compare session renewals against the exact deadline in the expiration dialog
The dialog reconstructed its reference deadline from the relative seconds
URL parameter, which carries up to a second of truncation and mount
latency, forcing a renewal-detection margin wide enough to miss a renewal
made shortly after the previous login. Pass the absolute deadline (unix
ms) from both tray call sites - the extend flow's cached deadline and the
final warning's event metadata - so any forward jump in the snapshot
deadline closes the dialog; the seconds-derived fallback with a small
tolerance remains for an unknown deadline.
* [client] Derive the expiration dialog countdown from the deadline
The per-second decrement assumed the interval fires once a second, but
the webview's timers get suspended for tens of seconds under App Nap /
hidden-window throttling, leaving the displayed countdown behind the
wall clock by the suspended time. Recompute the remaining time from the
absolute deadline on every tick so the first tick after a suspension
shows the correct value.
* [client] Tolerate the warning deadline's second precision in the renewal check
The final-warning metadata formats the deadline as RFC3339 truncated to
whole seconds while the status snapshot keeps millisecond precision, so
an unchanged deadline could appear up to 999 ms newer than the exact URL
value and close the dialog on the first snapshot. Allow a sub-second
tolerance on the exact path; any real renewal jumps by at least seconds.
Which applications the tunnel carries belongs with the rest of a
profile's preferences rather than on the Android side, so the choice
follows the profile the user is on.
Adds a split tunnel store beside the SSH session store, over a
"split-tunnel" namespace holding the mode and both selections. The two
selections are kept apart because the platform applies an allow list or
a deny list and never both, and so that switching mode does not throw
away the picks made in the other one.
Only the store itself needs the android build tag; the rest stays
untagged so it is covered by the package's host tests.
* fix(mobile): stop the client synchronously so a restart cannot inherit a cancelled context
Original finding
----------------
A user reported that leaving home and switching from wifi to cellular killed
all Internet traffic until NetBird was turned off. A debug bundle captured the
failure (iOS, CLI 0.75.0, self-hosted management, generated 2026-08-18 01:17;
the incident is at 2026-08-17 22:37:38-51 UTC).
The bundle shows the whole sequence:
22:37:38.255 management sync stream drops (keepalive ACK timeout)
22:37:43.670 Swift: "Network type changed: wifi -> cellular" -> schedules a
restart with a 1s debounce
22:37:44.737 Go: "ensuring wg interface is removed, Netbird engine context
cancelled" - engineCtx dies, every peer gets context canceled
22:37:49.910 iface.go:238 "failed to remove WireGuard interface utun6:
timeout when waiting for interface utun6 to be removed"
-> the teardown stretches out for ~5s
22:37:50.710 Swift: "restartClient: starting client", needsLogin=false
(so this is NOT a login expiry)
22:37:51.013 Go: connect.go:476 "exiting client retry loop due to
unrecoverable error: context canceled" - the OLD run dies here
22:37:51.333 Go: grpc.go:135 "failed creating connection to Management
Service: context canceled" - the NEW start, 2ms after the old
run finally exited
22:37:51.334 Swift: "restartClient: start failed" -> widget disconnected
then nothing for 15 minutes
The tunnel stayed installed with no engine behind it, so every packet was
black-holed. status.txt, generated ~14 hours later, still reads Management:
Disconnected / Signal: Disconnected / Peers count: 0/0 - the client never
recovered on its own.
Root cause
----------
Client.Stop() cancelled a shared ctxCancel field and returned immediately,
without waiting for the run loop to exit. The Swift stop{} completion handler
therefore fired while the Go teardown was still running (stretched out by the
utun6 removal timeout), and the start that followed landed on a context that
the outgoing run was about to cancel.
Two further paths wrote the same shared field. IsLoginRequired() and
LoginForMobile() each overwrote c.ctxCancel, so any call to them during a live
session discarded the running engine's cancel function. restartClient() calls
needsLoginCached() on exactly this path.
Changes
-------
- Stop() now drives the stored ConnectClient: ConnectClient.Stop() cancels the
run context and blocks on runExited, so the caller's completion handler only
fires once the run loop has really finished. The ctxCancel path stays as a
fallback for when no ConnectClient exists yet (e.g. during LoginForMobile).
- Run() owns its cancel in a local variable, so a concurrent call that
overwrites the shared field can no longer cancel this run's context through
the deferred cleanup.
- IsLoginRequired() and LoginForMobile() use local cancels and leave the shared
field alone. LoginForMobile's cancel moves into the deferred cleanup of the
goroutine that outlives the call, so the OAuth token wait is not cut short.
- The Android SDK gets the same treatment. The structural defect is identical
there, but the trigger is absent: Android has no automatic engine restart on
a network type change, and no interface-removal timeout to stretch the
teardown. This part is preventive, not a fix for an observed failure.
* fix(mobile): do not let a superseded startup publish its client
Review found a window the previous commit left open. Run stored its cancel
function and only published the ConnectClient later, after loading config and
constructing the client. A Stop landing inside that window found no
ConnectClient, cancelled the run and returned immediately. A new Run could then
publish its own client, and the cancelled older run — still executing — would
overwrite it with a client that was already being torn down. The next Stop
stopped that stale client and left the live one running with nothing tracking
it.
Runs now carry a generation. Run claims one before doing any work and publishes
its client only while the generation is still current; a superseded run returns
without touching the shared state. Stop bumps the generation, so any startup
still in flight is invalidated, then cancels it and waits for the run to exit
before returning (20s cap so a wedged teardown cannot block the caller
forever).
setState is gone: publishState replaces it at both call sites on each platform.
* fix(ios): add a non-waiting Stop for callers on a deadline
Stop now waits for the run loop to exit, which is what a restart needs but
wrong for stopTunnel: iOS gives NEPacketTunnelProvider only a few seconds
there before it kills the extension, and the wait can run to its 20s cap.
Waiting past the deadline earns a SIGKILL, so the next start inherits a dirty
state instead of the orderly shutdown the wait was meant to buy.
StopWithoutWait tears the client down and returns. ConnectClient.Stop blocks on
runExited with no cap of its own, so the non-waiting path runs it detached
rather than only skipping the runDone wait.
Android keeps a single blocking Stop: it has no equivalent deadline.
* fix(mobile): guard the run lifecycle with a single lock
Stop and beginRun each touched the same lifecycle state across two locks in
sequence: take stateMu, release it, then take ctxCancelLock. A run starting in
that gap installed its own cancel before Stop reached it, so Stop cancelled the
fresh run and left its own target running — the same class of defect this branch
exists to fix, this time in the locking rather than the state.
ctxCancel moves into the stateMu group, and both sides take their snapshot in
one critical section. ctxCancelLock then guarded nothing and is gone.
* fix(mobile): drop the run-generation machinery for a serialized lifecycle
The platform callers (Swift/Kotlin) always stop before starting and coalesce
restarts, so the generation counter guarded against call patterns that cannot
occur. Replace it with a single-run contract:
- startRun refuses a second Run while the previous one has not exited
- finishRun clears the published state on every exit path, including errors
- Stop cancels and waits for the run loop with a bounded timeout; it no
longer calls ConnectClient.Stop, whose wait is unbounded
- concurrent Stops wait on the same exit channel instead of returning early
- a superseded startup no longer reports a clean nil exit
* revert(android): drop the run lifecycle changes
Android does not have the defect this PR fixes. On ux/ios-style-redesign the
EngineRestarter is gone: network changes are handled as events instead of an
engine restart, so nothing stops the client and starts it again.
The remaining stop() callers are all final teardowns on the main thread with a
framework deadline - the stop-engine broadcast receiver, onDestroy, onRevoke and
the binder's stopEngine. A Stop that waits for the run loop would risk an ANR
there for a race that cannot occur, so the fix stays iOS-only.
* fix(ios): make loginComplete race-free
The OAuth goroutine spawned by LoginForMobile sets loginComplete after the
call has returned to Swift, while the Swift side polls IsLoginComplete and
later calls ClearLoginComplete from its own thread. The plain bool made all
three unsynchronized: the store may never become visible to the poller, and
a Clear racing the store can be lost, leaving a stale true that makes the
next login look already complete.
Switch the field to atomic.Bool. It is a standalone flag rather than part of
the run lifecycle that stateMu guards, and it has to stay readable while the
login goroutine is still in flight.
* [client] Add catch-all NRPT rule when NetBird is the primary DNS resolver
* Remove obvious comments
* Install the catch-all rule where the adapter's DNS is set
addDNSSetupForAll makes us the peer's main DNS forwarder, and the catch-all NRPT
rule is the other half of that same job: without it the adapter's NameServer only
adds one more resolver to the set Windows queries in parallel. Having the two in
one place says that, where a separate block at the end of applyDNSConfig read as
an afterthought.
The block could not simply move up: removeDNSMatchPolicies deletes the catch-all
key too, so installing the rule before it ran would have had the rule deleted
moments later. The cleanup now runs first, which is what it was always for - it
clears what the previous apply installed before this one installs anything - and
keeps being unconditional, so a leftover rule from an earlier run cannot survive
into a config that no longer wants it.
* Name the escape hatch after the behaviour it restores
NB_DISABLE_DNS_CATCHALL_NRPT described the mechanism it switches off. What an
operator reaching for it wants is the behaviour they had before, so name it that:
NB_USE_LEGACY_DNS_RESOLUTION, matching NB_USE_LEGACY_ROUTING, the only other
legacy switch in the client.
Not NB_WIN_LEGACY_FULL_TUNNEL_DNS_RESOLVE, as first suggested: the rule follows a
primary nameserver group, not a full tunnel, and putting FULL_TUNNEL in a public
variable name would carry that confusion for as long as the variable lives. No
OS prefix either, since nothing else in the client has one and this switch is
inert anywhere but Windows by construction.
Behaviour and default are unchanged: the catch-all rule is on unless the variable
says otherwise.
* Exempt .local from the catch-all rule
RFC 6762 reserves .local for multicast DNS and says unicast resolvers must not
answer for it. The catch-all rule hands it to us anyway, we forward it to
whatever upstream the primary nameserver group points at, and the answer comes
back NXDOMAIN for hosts that do exist - printers, NAS boxes, anything
announcing itself on the link. Confirmed on a Win11Pro VM: laptop.local resolves
with the client down and returns "Nome DNS inesistente" with it up, and the
client log shows the query arriving on the catch-all handler and being forwarded
to 1.1.1.1.
An NRPT rule that names a namespace and lists no servers is an exemption: the
DNS client resolves those names as it would with no rule at all. What that looks
like in the registry is not what it sounds like. Writing no server value and
clearing ConfigOptions produces a rule Windows treats as a no-op - it never
appears in Get-DnsClientNrptPolicy -Effective and the catch-all keeps the query.
The value has to be present and empty, with ConfigOptions still 0x8: the flag
says the server list is the meaningful part of the rule, and an empty list then
means "no server, resolve normally". Verified both encodings on the VM.
Installed together with the catch-all, since without one nothing captures .local
in the first place, and removed with it.
Exclusivity is unaffected elsewhere, and a more specific rule still wins - a
match domain under .local keeps resolving through NetBird, which is what a
legacy Active Directory domain named corp.local needs. Verified separately that
a match domain does take precedence over the catch-all: declaring fritz.box
against the local router restored laptop.fritz.box while the catch-all was in
force.
* Treat the root namespace as a match domain, not a special case
The catch-all had a function, a registry key and a call site of its own, which
made it look like a different mechanism. It is not: "." is an NRPT namespace like
any other, it just happens to match every name. So it goes into the match domain
list, and addDNSMatchPolicy writes it along with the rest — batching, GPO
variant, volatile keys and cleanup all come for free.
The .local exemption stays a rule of its own, and not for symmetry: it is the one
rule with a different server list, an empty one. Putting it in the same Name
value would give it our resolver and exempt nothing.
Windows expands a rule's Name value into one effective namespace each, so a rule
carrying {.example.com, .} still shows both as separate rows in
Get-DnsClientNrptPolicy -Effective. Nothing is lost for diagnosis by dropping the
dedicated key.
Suggested by Vik in review.
* Do not report a failed NRPT cleanup as success
removeRegistryKeyFromDNSPolicyConfig returned nil for every OpenKey error, so a
permission or registry failure was indistinguishable from a key that was never
there. Cleanup then reported success while the rule stayed in force — which is
how a rule outlives the interface it points at and keeps sending every query to
an address that no longer answers.
Distinguish the two, the way listNRPTRuleKeys already does for the policy store
root: a missing key is nothing to do, anything else reaches the caller.
restoreHostDNS now propagates that error instead of logging it. applyDNSConfig
keeps logging on purpose: there we are about to write fresh rules over whatever
survived, while restore is the path where a rule left behind is the whole
problem.
Also addresses review nits on the tests: doc comments on the two added cases,
reported Close and DeleteKey errors so a failed cleanup cannot contaminate the
next registry test, and a context message on the exemption's namespace assertion.
* Run lazy connection manager for rosenpass peers
* Treat forward-target peers as normal lazy connections
* Run Rosenpass in permissive mode on the embedded proxy
* [client] Add iOS NetBirdSDK profile manager binding
Mirror the Android profile manager in the iOS gomobile binding so the
core's ID-based profilemanager.ServiceManager owns profile state on iOS
too, instead of a parallel Swift reimplementation.
Adds client/ios/NetBirdSDK/profile_manager.go (//go:build ios): an
ID-based ProfileManager wrapping ServiceManager with iOS-specific path
handling (default profile at the container-root netbird.cfg, others as
profiles/<id>.json) and a gomobile-friendly API: List/Add/Switch/Rename/
Logout/Remove plus active config/state path accessors. The default
profile keeps the reserved "default" id and is never assigned a hex id.
* fix(ios): preserve profile name when saving config during auth
NewAuth built a fresh in-memory config from only the management URL, so
the SSO/setup-key save (DirectWriteOutConfig) overwrote the profile config
file the profile manager had just written, wiping the display name to ""
and forcing the UI to fall back to the profile ID. Load the existing config
when present and override only the management URL, keeping the name and keys.
* [client] Extract the mobile profile manager into client/mobile
The Android and iOS gomobile bindings carried two near-identical copies of
the profile manager. Move the shared implementation into a new client/mobile
package and reduce both bindings to thin adapters that only translate to
gomobile-friendly types (gomobile binds per package, so the Profile /
ProfileArray wrappers have to stay platform-side).
Also bring the account-email layer over to the shared package: an SSO login
records the account under <stem>.account.json so the next login can pass it
as an OIDC login_hint. Logout keeps it, profile removal drops it. The suffix
deliberately differs from .state.json, which the engine's state manager owns
in the same directory on mobile.
Adds profilemanager.Prefs (namespaced per-profile preference store) and its
cleanup in ServiceManager.RemoveProfile, exposed through the shared manager
as ProfilePrefs.
* Support per-peer lazy connection state and default proxy peers to lazy
* Classify forward targets from incoming config in lazy exclusion
* Set IsUserspaceBind mock so lazy manager starts in engine test
* Skip lazy exclude reconciliation when the set is unchanged
* Keep cached lazy flag when a sync carries no peer config
Losing the last network only flipped the availability state: the dead management, signal and relay sockets stayed silently connected until their own timeouts, so the client kept reporting Connected with no network at all.
Introduce client/netevents with a Manager that ties the availability state, the connection sweeper and the status recorder together, and move the netstate and netsweep packages under it (netsweep renamed to sweep). SetNetworkAvailable(false) now also sweeps the registered connections so their owners redial and the listener reaches the NoNetwork state.
The Android and iOS bindings own a Manager instance and inject it through the constructors; consumers hold the concrete *Manager whose nil zero value reports always-online and never sweeps, with interfaces kept only as parameter contracts. The relay guard settle wait moved into the Manager as WaitSettled, removing the netevents import from the relay package.
The main and Settings windows were created at startup and kept alive hidden on close, so an idle tray held two webview processes for surfaces the user may never open. Both are now built on first show and destroyed on close, which takes the idle footprint on macOS from ~160 MB to ~74 MB.
The WindowManager owns creation: it rebuilds the main window on the next show and hands out live pointers, since a stored one goes stale. Every show is deferred until the frontend reports it has rendered, so a freshly created window is never on screen empty, with a timeout so a frontend that never reports cannot strand a window hidden.
The updater runs as LocalSystem and started netbird-ui via
CreateProcessAsUser with a nil environment, so the UI inherited the
SYSTEM environment (USERPROFILE, APPDATA pointing at systemprofile)
while running under the user's token. The WebView2-based UI exits
immediately in that state, so the UI never came back after an update.
Build the environment from the user's token with CreateEnvironmentBlock
and pass it to CreateProcessAsUser.
Android never reported its local network interfaces, so PeerNetworkRange posture checks could not be evaluated: NetworkAddresses always arrived empty.
net.Interfaces() is unusable on Android 11+ (SELinux blocks netlink), so the addresses are parsed from the interface description the host app already provides via stdnet.ExternalIFaceDiscover. The MAC filter is skipped, mirroring #5906
for iOS, since Android does not expose MACs either and nothing reads Mac server side.
Stop the UI before a silent Windows update and suppress the installer reboot
On silent MSI updates msiexec could reboot the machine on its own. The running UI holds a lock on its own exe, and since msiexec runs as LocalSystem it cannot close the interactive user's UI via Restart Manager, so the MSI scheduled the
file replacement for the next reboot and marked the install restart-required.
Terminate netbird-ui.exe before launching the installer and wait until its image file is released; the existing deferred restart brings it back after the install on every exit path
Run msiexec with /norestart REBOOT=ReallySuppress so it never reboots on its own
Treat exit codes 3010/1641 as success with a warning instead of a failure
---------
Co-authored-by: Viktor Liu <viktor@netbird.io>
* [client] Clear stale installer result before starting update
The installer result file could survive a previous update attempt (e.g.
when the updater wrote it after the restarted daemon already ran its
startup check). A new install attempt left the old file in place, so the
GUI progress window's first GetInstallerResult poll read the outdated
result: a stale success made the GUI quit mid-install, which cancelled
the TriggerUpdate context and aborted the artifact verification; a stale
error surfaced a bogus failure dialog for a succeeding update.
Remove any leftover result file at the start of RunInstallation, before
the download begins, so result watchers only see the current attempt's
outcome.
* [client] Align stale-result warning with log message style
On network changes the client restarted the whole engine. That is heavy-handed and slow: it tears down working state to recover from a transition the engine could handle itself. This replaces the restart with proper network event handling.
Suspend the retry loops while no network is available. Instead of burning through backoff intervals against an unreachable network, the reconnection loops park until the OS reports a usable network again.
Reconnect immediately on a network switch. When the OS hands us a new network, connections bound to the old one are swept and re-dialed right away, rather than waiting for a timeout to notice they are dead.
Adds an SSHClient gomobile binding so the Android app can run an SSH session over the tunnel with a PTY, exposed through a listener interface for the in-app terminal.
Server type is auto-detected from the SSH banner, which selects the auth path: JWT device-code flow, NetBird key, or a regular server (NetBird key first, then password). Host keys are verified against the peer registry for NetBird servers and trust-on-first-use for regular ones.