* 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
The file drop server listened on 41421, which falls inside the ephemeral
port range on Linux (32768-60999) and Windows (49152-65535), so any
outbound connection could take it after boot. The receiver then bound a
dynamic port and advertised it over signaling, and the sender waited for
that advertisement before retrying.
That coupled a data plane feature to signaling traffic: once a peer
connection is established there is no reason for another offer or answer
to go out, so a sender could wait out the grace period for an
advertisement that never came.
Move the port to 22042, next to the SSH (22022) and DNS forwarder (22054)
ports and clear of both ephemeral ranges, and keep the tunnel side fixed
the way SSH does. A receiver that cannot bind it falls back to a dynamic
port and redirects 22042 to it with an inbound DNAT rule, so senders
always dial the well known port and never negotiate. NB_FILEDROP_PORT
overrides the local bind only.
The DNAT runs ahead of the filter on every backend (nftables prerouting
at NAT dest priority, iptables nat/PREROUTING, and the userspace filter's
translate-then-redecode path), so the netstack service registry keeps
taking the bound port.
This drops the port registry, the retry that waited on it, and the
signaling plumbing that fed it.
Android cannot address the user's shared storage by path, so a received
file had to land in app-private storage and be copied out afterwards,
needing twice the space of the transfer.
Put the staging area behind a Sink interface the receiver writes every
payload through. The filesystem spool implements it unchanged and stays
the default; a platform that cannot be addressed by path implements the
gomobile-bound half instead and stages payloads wherever it can reach.
The writer reports its own total rather than returning a written count:
gomobile copies a []byte argument into a fresh Java array and carries no
count back out.
A failed delivery now drops the staged payloads. The filesystem spool
swept them up on its next pass, but a sink holding entries the engine
cannot address by path has no such fallback.
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.
The receiver reported progress once per file, after the whole body had
been staged: spool.Write drains its reader before returning, so a large
file sat at nothing until it jumped to done. It now stages through a
reader that reports as the bytes land, the same way the sender already
did, and both sides thin their reports to one per percent and one per
200ms so a fast transfer cannot flood the history or the UI.
Progress also becomes an event of its own. It was left out because a
report per byte would have been unusable; thinned, it costs a handful of
events a second and spares every UI a poll. The daemon's event bridge
ignores the new kind, so nothing is published where a notification would
be noise.
Withdrawing consent mid-transfer did nothing. Cancel routed through
Decide, which only moves an offer out of Pending, so an accepted offer
kept its decision, kept its spool, and kept being uploaded into: the
receiver's list went quiet while the sender ran to completion. Revoke
takes an offer back whatever it has already answered, the staging reader
gives up as soon as consent is gone, and the sender stops rather than
retrying a refusal three times over. A transfer withdrawn this way reads
as declined on both ends, which is what it is, rather than as a failure.
Android re-establishes the VpnService interface on every route change, which
replaces tun0 with a fresh device. The file drop listeners are bound to the
overlay address of the interface being swapped out: the IPv4 one dies with
accept4: invalid argument and never comes back, so a peer dialing the overlay
IPv4 address gets an RST. Restart the receiver once the new device is in place.
Files move directly between peers over the overlay, with no server in the
path. The receiver listens on the WireGuard address only, so the port is
unreachable from outside the tunnel, and every offer is matched to a known
peer before anything is read.
Consent is the default: an offer carries metadata alone, and no payload
moves until the receiver accepts. Policy is per profile and device-local —
off, ask, or auto-accept, with per-sender exceptions on top.
Policy and history live in the profile's preferences, so removing a profile
takes its file drop state with it. Transfers interrupted by a restart are
settled on load; nothing survives to finish them, and left alone they would
sit in the log as permanently pending.
The Android bindings pull payload bytes through a chunk-returning stream:
gomobile copies a []byte argument into a fresh Java array and never copies
it back, so a fill-my-buffer method would hand back the right length with
no data.
Introduce a namespaced preference store owned by the profile manager,
persisted next to the profile config as <id>.prefs.json and deleted with
the profile. Sections are opaque JSON, so the profile manager stays free
of any feature schema, and writes reuse the state file's atomic path.
Migrate the Android SSH known-hosts store and session list onto it. Both
previously lived outside the profile lifecycle: known hosts in a
per-profile file under filesDir, the session list in Java
SharedPreferences, each needing its own sweep against the live profile
list to avoid outliving the profile they belonged to. A profile ID that
got reused would have inherited the trusted keys of a deleted profile.
Both now share the "ssh" and "ssh-sessions" namespaces of the profile's
preferences, so deleting a profile takes them along and the Java-side
pruning is gone.
The known-hosts entries keep the OpenSSH line format, only the container
changed, and host key verification keeps rejecting a changed key
outright. SetKnownHostsPath becomes SetKnownHostsStore, taking the
config dir and profile ID instead of a file path.
Existing known-hosts files are not migrated: hosts trusted before this
change prompt for confirmation once more, which errs towards safety.
* [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.
GetOAuthFlow was the only flow factory without a hint parameter, which
forced its callers to apply the hint afterwards through a local setter
interface and a type assertion. Give it the same constructor-style hint
as NewOAuthFlow and set the hint on the concrete flows before they are
handed out as the interface, so a flow is always complete when built
and the caller-side ordering constraint disappears.
An empty hint is a valid value meaning the IdP chooses the account, so
the flows set it unconditionally.
Extract the shared RequestAuthInfo -> Open -> WaitToken sequence from the
login flow and the SSH JWT flow into runOAuthFlow. Open is now called
synchronously by both flows, matching iOS; openers must post their UI
work instead of blocking, which the app-side openers already do.
The SSH flow read its login hint via profilemanager.GetLoginHint, which
resolves desktop-layout files that the Android app never writes, so the
hint was always empty and the device-code flow could prompt for account
selection. Both flows now read the hint from the profile account file
via the config path, taken from authSnapshot so a concurrent profile
switch cannot pair one profile's config with another's hint.
The shared table used by the Android and wasm terminals was a strict
subset of the CLI one, leaving Ctrl+U, Ctrl+D, Ctrl+Z and friends
without explicit mappings. Export the full table from the ssh package
and derive both CLI variants from it; Windows adds its console-specific
modes to a copy so the shared map is never mutated.
Extract the dial-then-handshake sequence into nbssh.Handshake, which
applies the context deadline to the socket for the duration of the
handshake. Previously only the Android client did this; the CLI, wasm
and SSH proxy paths could block forever on a peer that accepts the TCP
connection and then goes silent, since ClientConfig.Timeout is not used
by NewClientConn.
Remove Engine.VerifySSHHostKey and keep GetPeerSSHKey as the only SSH
key API on the Engine. Verification now lives in the ssh package as a
PeerKeyLookup func type implementing HostKeyVerifier, shared by the
android and embed clients.
Align Android logout semantics with the desktop UI and CLI: logging out no
longer deletes the stored account email, so the next login passes it as the
OIDC login_hint and the IdP preselects the account. Removing the profile is
now the operation that deletes the email; previously RemoveProfile left the
account file behind, which the fixed-name default profile would have
inherited on recreation.
Extract the identical PTY session setup shared by the wasm and Android
terminal clients into ssh.StartPTYSession, and move the stored-key host
verification onto the engine so the embed client delegates and the
Android client passes the engine directly as HostKeyVerifier.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
English (en) is the source of truth for UI translation keys; the other
nine locales rely on runtime English fallback for any missing key, so a
gap never surfaces in CI. Add a dependency-free Node check that fails
when any locale declared in _index.json does not carry the exact same
key set as en (missing or orphaned keys), wired into a dedicated
UI Translations workflow that runs on locale changes.
Also close the one existing gap the check found: ja was missing
daemon.outdated.download ("Download Latest").
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NSIS installer deleted HKLM/HKCU CurrentVersion\Run values it never
writes, which matches common AV heuristics for unwanted Run-key
manipulation and is suspected to contribute to Windows Defender and
third-party antivirus false positives on the installer.
Drop all autostart registry deletions from both the install and
uninstall sections so the installer only touches keys it creates
itself. Cleanup of the legacy machine-wide entry written by old
installers is left to documentation.
Extends the approach of the closed PR #6735, which only removed the
per-user deletion on uninstall.