* 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.
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.
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>
* [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] 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
* 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 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.
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.
* [client] Pass stored email as login hint from UI and keep it on logout
Follow the CLI pattern: the Wails UI now reads the account email from the
user-owned profile state file and passes it as the OIDC login_hint on login
and session extend, since the daemon-side fallback runs as root and cannot
see the user's state file. Logout no longer deletes the stored email, so a
later login preselects the account at the IdP; profile removal remains the
operation that deletes it.
* [client] Log ignored profile lookup errors in extend-session hint fallback
* [client] Skip IPv6 route tests when the default nexthop is unusable
ensureIPv6DefaultRoute treated a successful netlink RouteAdd as proof that
a usable IPv6 nexthop exists. Installing ::/0 via loopback can succeed while
the kernel still rejects that nexthop for a concrete prefix, which surfaced
on ubuntu22/20260810.260 runners as:
add route to table: netlink add route: invalid argument
Probe the resolved nexthop by installing and removing a discard-prefix route
through the same code path the tests use, and skip when it fails. EEXIST
means the nexthop already carries a route, so it counts as usable.
* [client] Probe the IPv6 nexthop through raw netlink
addRoute swallows EAFNOSUPPORT and EOPNOTSUPP via isOpErr, so a nil return
did not prove the probe route was installed. Call netlink directly so an
unsupported operation skips the test instead of passing as usable.
## Describe your changes
`recordConnectionMetrics` mapped only `conntype.Relay` to `relay` and
let a `default` branch
record everything else as `ice`. That silently included `ICETurn` — an
ICE connection through
a TURN server, which
[`conn.isRelayed`](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L788-L795)
itself counts as relayed — and `None`, the transient state set when the
relay drops
([conn.go:632](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L632))
or the peer state is reset
([conn.go:757](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L757)).
Both were reported as direct peer-to-peer, so the `ice` share overstated
direct connections on
every platform.
The mapping now lists every priority explicitly and emits `ice_p2p`,
`ice_turn`, `relay` or
`unknown`. The new values deliberately do not reuse `ice` to avoid
ambuguity.
## Issue ticket number and link
No public issue. Found while reviewing the first production sample of
client metrics: 38% of iOS
connection events were tagged `ice` on a platform that forces relay by
default, which traced back
to the `default` branch at
[client/internal/peer/conn.go#L963-L968](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L963-L968).
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
Internal metrics documentation only, in
`client/internal/metrics/infra/README.md`: the four
`connection_type` values with their derivation, and a note that pre-fix
`ice` samples are not
comparable with `ice_p2p`. No public API, CLI or configuration change,
so no netbirdio/docs PR.
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
N/A
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
## Summary by CodeRabbit
* **New Features**
* Connection metrics now distinguish direct peer-to-peer, TURN-assisted,
relay, and unknown connection types.
* Metrics include clearer connection and peer identification details.
* **Documentation**
* Updated connection timing metric values, traffic semantics, priority
behavior, and historical data guidance.
* **Bug Fixes**
* Unset or unrecognized connection priorities are no longer incorrectly
classified as peer-to-peer.
* Unknown-transport metrics are skipped to prevent misleading connection
data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
The Conn struct is reused across lazy-connection deactivate/activate.
Close
cancels the WireGuard watcher (via wgWatcherCancel, and ctxCancel also
tears
down its context) but left conn.wgWatcher pointing at the stopped
instance.
enableWgWatcherIfNeeded skips while conn.wgWatcher is non-nil, so the
next Open
never started a fresh watcher: once a lazy connection had idled and
woken, the
peer ran with no watcher at all — no WireGuard handshake-timeout
detection and
none of the escalation that depends on it.
Clear conn.wgWatcher and conn.wgWatcherCancel in Close so the next Open
re-arms
a fresh watcher.
## Issue ticket number and link
<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See
https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->
## Stack
<!-- branch-stack -->
### Checklist
- [X] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [X] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved connection cleanup by fully releasing WireGuard watcher
resources when a connection closes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
Pull fresh TUN settings on Android rebuild instead of push
The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.
Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.
Requires the matching android-client change that switches recreateTUN to
the pull API.
## Issue ticket number and link
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Added access to current TUN route ranges and DNS search domains.
- TUN settings are returned in a mobile-friendly format for easier
integration.
- **Improvements**
- Route changes are detected and synchronized more reliably.
- Current routing information now reflects active routes, including
supported fake-IP ranges.
- Simplified network initialization for more consistent startup
behavior.
- **API Changes**
- Removed the obsolete network-map retrieval method from the management
client interface.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
In unattended installs/updates there may be no logged-in user, so
there's no context to start the GUI (nor anyone to see it).
The bug is the GUI being started in the wrong user context / inheriting
the wrong `$HOME` (OS mechanics aside).
Today the GUI is started by a per-user LaunchAgent, i.e. on behalf of
the user who logs in — no login ⇒ no GUI.
The patch aligns to this: it launches the GUI on behalf of the logged-in
console user if one exists, otherwise it delegates the launch to the
per-user LaunchAgent at next login.
Additionally it logs when default UI settings are applied.
Note (small caveat): the LaunchAgent auto-starts the GUI at login only
once it's been registered — which happens on the first GUI launch in the
user's context. On an MDM/unattended fresh install done with no user
logged in (where the user has never run the GUI before), they may need
to start it manually once; it self-registers from then on.
## Issue ticket number and link
No public issue — reported internally (community report on Slack: macOS
advanced-view + onboarding reset on every update, esp. via MDM/Munki).
Buggy line on main:
dd2bdc0de3/release_files/darwin_pkg/postinstall (L33)
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
Internal macOS installer / GUI-launch behavior. No public API, CLI, or
configuration change: the fix only changes the user context the desktop
GUI is launched in after a pkg install/update.
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
N/A
<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6962"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787923973&installation_model_id=427504&pr_number=6962&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6962&signature=5226bbc7986fc60eb8b4ab77e98ac17a6534864763fc6407048bcbbb1550394b"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>
<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Bug Fixes**
- Improved macOS installation and updater UI launching to occur only
when an active, valid GUI console session is detected.
- Prevented UI launches during unattended/system, root, or
login-window-related installs.
- Ensured the app is launched in the correct console-user context, and
skips cleanly when username/UID resolution fails.
- **Improvements**
- Added clearer informational logging when the UI preferences file is
not found and default preferences are used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered: the
TUN rebuild handler applies them in arrival order and compares against
the last applied parameters, so a stale route set delivered last won as
the final TUN state. This is the same reordering hazard fixed for iOS in
#6454.
Wrap the Android network change listener into the shared tunnelnotifier
FIFO introduced in #6870, the same way RunOniOS does, and deliver both
notifiers synchronously into it. Enqueueing is non-blocking, a single
delivery goroutine preserves order, and calls into Java never overlap.
Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.
## Issue ticket number and link
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
The fake IP manager was only created at route manager construction, from the DNS feature flag fetched by the initial GetNetworkMap call. When the flag flipped to true mid-session, UpdateRoutes set useNewDNSRoute but never created the manager, so domain routes added after the flip got a DNS interceptor with a nil fake IP manager.
internalDnatFw only checked for a firewall and GOOS, so the interceptor took the DNAT path and called GetFakeIP/AllocateFakeIP on the nil *fakeip.Manager. These methods lock m.mu first, which is a nil pointer dereference: the first DNS answer for such a route panicked and crashed the VPN service. The fake IP blocks (240.0.0.0/8 and its v6 pair) also never reached the TUN, since only the constructor registered them.
Create the manager and its TUN routes from UpdateRoutes when the flag turns on, notify so the fake IP blocks get into the TUN without a client route change, and treat a nil manager as no internal DNAT.
This is groundwork for removing the initial GetNetworkMap fetch, after which every startup goes through the flag-off-to-on transition.
## Describe your changes
Three fixes to how the desktop GUI keeps the account email that backs
the SSO `login_hint`. Each is independent and reviewable on its own.
**1. Store the email after a GUI SSO login**
The daemon returns the authenticated user's email from `WaitSSOLogin`
but cannot persist it: it runs as root while the per-profile state file
is user-owned. The CLI's `handleSSOLogin` writes it after its own
`WaitSSOLogin`; the GUI path read the value and dropped it.
The profile was therefore left with no email, so `Profiles.List` showed
no account for it, and later logins and session extends went out with no
`login_hint` — leaving the IdP to pick an account instead of reusing the
one the profile belongs to. Mirror the CLI and store it, next to the
`Logout` path that already clears the same file for the same reason.
**2. File the email against the profile the login ran for**
`SetActiveProfileState` resolves the target itself, so it writes to
whichever profile is active when it is called. A GUI SSO login spans
seconds of user interaction in the browser, and the tray stays clickable
throughout: switching profiles in that window left the email filed under
the profile that happened to be active when the flow returned. The wrong
profile then advertised an account it does not own, and offered it as
the `login_hint` next time.
Adds `SetProfileState(id, state)`, the write-side counterpart of the
existing `GetProfileState(id)`, and keeps `SetActiveProfileState` as a
wrapper for callers with no particular profile in mind. `Login` now
reports the profile it resolved so the frontend can hand it back with
the SSO wait, which closes the window.
**3. Delete the email when a profile is removed**
Removing a profile left its state file behind: the daemon deletes what
it owns, but the email file is user-owned and out of reach for a root
daemon — the same split that already puts the `Logout` cleanup on the UI
side.
Beyond the stray file, legacy profiles are keyed by name rather than by
a generated ID, so recreating a profile under a removed one's name
inherited its email — shown as the account in the profile list and sent
as the `login_hint` on the next login.
## Issue ticket number and link
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- SSO login details can now be saved to the specific profile selected
during sign-in.
- Profile state can be managed independently for different profiles.
- **Bug Fixes**
- Removing a profile now also cleans up its associated saved state.
- Cleanup issues no longer prevent successful profile removal and are
handled gracefully.
- Existing active-profile behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Describe your changes
`getInterfaceIndex` in the iOS upstream DNS resolver dereferenced the
result of `net.InterfaceByName` before checking the error, so a missing
interface (e.g. during teardown or renaming) caused a nil-pointer panic
instead of a DNS client error.
The helper now returns a wrapped error before touching the interface;
the only caller, `GetClientPrivate`, already propagates the error.
The helper moved to an un-build-tagged file so it can be unit-tested on
host platforms while remaining available to the iOS build. Added a test
covering the missing-interface path. Verified with the new host test, an
iOS arm64 CGO compile, and `git diff --check`.
## Issue ticket number and link
N/A
## Stack
<!-- branch-stack -->
Standalone PR based on `main`.
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
Internal crash fix in the iOS DNS path; no user-facing behavior or
configuration changes.
### Docs PR URL (required if "docs added" is checked)
N/A
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved handling of network interface lookup failures with clearer
error messages that identify the affected interface.
* Added validation for network interface lookups, including reliable
error handling when an interface cannot be found.
* **Tests**
* Added coverage for both successful interface resolution and
missing-interface scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->