OnRekeyFailed now re-runs the KEM bootstrap over Signal (conn.RequestReoffer ->
handshaker.SendOffer) instead of only logging: a fresh signalling offer starts a new
exchange that overwrites the stalled PSK on both sides, resyncing after a persistent
data-path desync. Chosen over a responder-side awaitingAck revert (which fights the
confirm-less ack timing) and a full tunnel teardown (heavier). The tunnel stays up on
the previous PSK meanwhile since Signal is independent of the broken data path.
Strict mode (NB_PQ_MLKEM_STRICT, default off) closes the initial PQ-vulnerable
window (NET-1408): when enabled, conn.presharedKey programs a per-conn random
sentinel PSK until the ML-KEM exchange derives the real one, so no session can form
on a non-PQ key (the real PSK is pushed via SetPresharedKey once it converges).
Default stays opportunistic.
Also surface PQ status: the peer 'Quantum resistance' flag (RosenpassEnabled) is now
true when an ML-KEM PSK has been derived for the peer, not only for Rosenpass.
The idle-gate reads LastActivities, which only tracks per-peer data in userspace;
in kernel mode it is empty, so the gate treated every kernel peer as idle and
disabled data-path rotation entirely. Detect the bind via IsUserspaceBind and, in
kernel mode, report zero activity age (always 'active') so rotation runs on every
rekey. Lazy back-to-idle is already limited in kernel; the eBPF WG-activity
detection will later supply a real signal that excludes handshake/pqkem traffic.
Replace the raw SHA-256 concat combiner with HKDF-SHA256 (crypto/hkdf, Go 1.24):
IKM = ML-KEM_ss || X25519_ss (draft-ietf-tls-ecdhe-mlkem order), salt = the
domain-separation label, info = full transcript (offer || answer) || canonicalised
peer identities. Keeps the transcript + identity binding while using a proper KDF.
- Add a trace slog level (NB_PQ_MLKEM_LOG_LEVEL=trace) and move the verbose
per-exchange lifecycle logs (offer/answer/PSK/ack/rotation) to it, so debug
stays quiet and troubleshooting is opt-in.
- Stop logging the raw preshared key; drop the temporary pqkem-dbg OnRemoteOffer/
OnRemoteAnswer probes.
- Demote the per-handshake conn log to trace.
Source OnDataPathRekeyed from the WGWatcher's per-handshake callback
(onWGCheckSuccess), which fires only on a fresh handshake, and OnDataPathDown
from the handshake-timeout path. A fresh handshake clocks the next chained
KEM exchange pushed over the data-path UDP transport.
Learn the peer's data-path endpoint from the signalling offer/answer: its WG
overlay IP combined with the advertised pq UDP port (SetRemotePort -> AddPeer).
Registering here is safe before the tunnel is up because sends only ever fire
once it is (clocked by OnDataPathRekeyed). RemovePeer is wired at peer teardown
(engine.removePeer), not on transient disconnect.
We clock the next Offer initiation to the OnDataPathRekeyed, so we have 2 minutes
ahead of us to do our attempts and stuff before to give up.
On failure, we will know because we will not receive a new answer.. but more importantly
the wg handshake will fail :D
Define OnDataPathRekeyed event to transition from control plane path to data plane path over the WG tunnel.
Keep confirm ALWAYS on NEW established WG tunnel (posthandshake with rekeying). We keep an active method
irrelevant of the WG handshake (we might decide that the indirect wg handshake is sufficient in the future).
Optimistic commit on responder(when sending answer), while on initiator we set it on getting the answer
- Have just one manager => one lock
- Session state is needed in driver to => we have it available now.
- Isomorphically align to rosenpass components and functionality
File Role rosenpass equivalent
kem.go primitive pure X25519MLKEM768 crypto.go/handshake
message.go Offer/Answer/Confirm + Encode/Decode messages.go
manager.go Manager stateful, single lock server logic
callbacks.go WGCallbackHandler (seam output) Handler
Transport (interfaccia) seam trasporto pluggable Conn
* [client] Track the WireGuard device on the engine as a lock-free handle
Add an atomic handle on the wg device next to wgInterface, stored once the
interface is up and cleared when it is closed. Nothing reads it yet, so this
is a pure addition with no behavior change; it exists so the next commit can
reach the device without taking syncMsgMux.
* [client] Retune the WireGuard buffer pool without the engine lock
SetPerformance took syncMsgMux before reaching the device. That lock is held
by handleSync while it adds and removes peers, and peer removal is exactly
what blocks when a device's buffer pool is exhausted: Peer.Stop waits on a
keepalive timer callback that is itself parked in WaitPool.Get. Raising the
cap is the way out of that state, so the call must not queue behind the lock
the stall is holding.
Read the device through the atomic handle instead. Device.SetPreallocatedBuffersPerPool
takes the pool's own lock and broadcasts, so the waiters wake up.
* [proxy] Extract the buffer-cap apply loop out of the perf handler
Pure move: the loop over the registered clients becomes applyBufferCap, with
the same sequential behavior and the same return values. Split out so the next
commit can change how it iterates without the diff also carrying the move.
* [proxy] Bound the perf endpoint so one wedged client cannot hold it
The apply loop was sequential and unbounded. embed.Client.SetPerformance goes
through the client lock, which Start holds for the whole of a startup, so a
single account that is busy or wedged delayed the new buffer cap for every
other account on the node -- on the endpoint whose whole purpose is to
un-wedge a node.
Apply to all clients concurrently and give the whole call a 5s budget.
Accounts that do not answer in time are reported in "failed" instead of
blocking the response.
* [client] Drop the device handle before closing the interface
close() cleared the atomic handle only after wgInterface.Close() returned, so a
concurrent SetPerformance could still load it, retune a device that is being
torn down, and report the change as applied for an engine that has stopped.
Clear it first, so the window closes before the teardown begins.
Reported by cubic on PR #7452.
* [proxy] Put the per-client retune behind a field
Pure refactor: applyBufferCap calls h.setPerformance instead of the client
method directly, and NewHandler wires it to setClientPerformance. Same call,
same behavior; the seam is what lets the next two commits be tested without a
live embedded client.
* [proxy] Do not report a finished retune as timed out
When the deadline fires, select chooses at random among the ready cases, so a
result already sitting in the buffered channel could be skipped and its account
reported as timed out even though the cap had been applied. Drain what is
buffered before declaring the rest pending.
Reported by cubic on PR #7452.
* [proxy] Keep one retune per account in flight
The 5s budget bounds how long the endpoint waits, not the work: SetPerformance
goes through the embedded client's lock, and on a wedged account Stop holds that
lock forever, so every retry left one more goroutine parked there.
Route each account through a single worker. A request that finds one already
running takes its result if it has landed, and otherwise reports the account
under "in_flight" instead of starting a second attempt. One stuck account now
costs one goroutine, no matter how often the endpoint is called.
Reported by CodeRabbit and cubic on PR #7452.
* [proxy] Make the retune budget a var
Pure refactor: perfApplyTimeout becomes a var so a test can shorten it instead
of waiting five seconds. Same value, same behavior in production.
* [proxy] Extract the buffered-result drain
Pure refactor: the loop that empties the results channel when the deadline
fires becomes collectBuffered. Same behavior; split out so it can be tested
on its own, which the inline version could not be without racing the deadline.
* [proxy] Cover the retune single-flight and the deadline drain
TestApplyBufferCapSingleFlightPerAccount fails without the worker registry:
five calls against a client stuck in its own lock start five blocked workers
instead of one.
TestCollectBufferedCountsResultsReadyAtTheDeadline pins the drain helper's
contract - buffered results counted, errors recorded, only unanswered accounts
left pending. It drives collectBuffered directly: through applyBufferCap the
two select cases race by construction, so an end-to-end version of it would
pass on the unfixed code about half the time.
* [proxy] Keep the worker alongside each pending account
Pure refactor: the pending set becomes a map to the account's worker instead of
an empty struct. Same membership and same behavior; the next commit needs the
worker to resolve an account whose result has not reached the channel yet.
* [proxy] Publish a retune result before releasing its slot
The worker sent its result last, after taking perfMu to remove itself from the
registry. That lock is taken once per account by every caller walking the fleet,
so a worker that finished on time could queue behind an apply over thousands of
accounts and land after the deadline. Send first, deregister after.
Reported by cubic on PR #7452.
* [proxy] Read the worker, not the clock, for a finished retune
Publishing earlier only narrows the window: a client that answers just before
the deadline can still be reported as timed out. At the deadline the workers
themselves are authoritative - a closed done channel means the retune finished
and w.err carries its outcome, ordered by the close. Consult them instead of
declaring every pending account timed out, and keep the timeout label for the
ones actually still running.
Reported by cubic on PR #7452.
* [proxy] Cover the finished-worker resolution at the deadline
Fails on the previous behavior with "applied = 0, want 1": every pending
account was labelled a timeout, including the one whose retune had already
completed.
* [client] Support arbitrary UIDs in rootless image
* [client] Keep rootless executables root-owned
* [client] Harden arbitrary UID image validation
* [client] Preserve executable access in rootless image
Keep the binary and entrypoint executable when deployments override the runtime group. Retain root ownership so non-root users cannot modify either file.
* [client] Verify rootless state reuse with a stable UID
Persisted profiles remain scoped to the creating UID. Verify same-UID container recreation without broadening application permissions, and document the Kubernetes volume permission behavior observed on OpenShift. Remove unused synthetic-user home metadata.
* [client] Separate image changes from invoking user fix
Keep this PR limited to resolving unmapped non-root invoking users. Move container permissions and their smoke test to a dependent image branch so they can be reviewed separately.
* [client] Restore invoking process user test
Retain coverage for successful current-user lookup without sudo. Numeric-identity fallback tests do not cover this existing behavior.
* MDM Android mobile wiring
* Removes dead code
* Removes static vars
* Now we need to apply MDM in the GetConfig
* You now need to explicitly call these around
* Adds iOS wiring
* Resolve merge conflicts from main
- login.go: keep both new imports (mdm + nbnet + server)
- ios/NetBirdSDK/client.go: additive struct-field merge (mdmLoader + stateMu/connectClient/config)
- setconfig_mdm_test.go: adopt new withMDMPolicy(t, s, policy) signature; fix stray old-signature call in TestSetConfig_MDMAllow_ManagementURLPortNormalized
* Convey MDM overlay config to Debug Bundle output
Aligns to other clients OSes behavior
* Solved conflict in client.go
* Fixup helper withMDMPolicy -> configWithMDM
* Fixup after merge
* Resolve merge conflicts
* [client] Move MDM enforcement logic into a shared Go layer (#7319)
The mobile bridges only carried the policy fetcher, leaving every
enforcement decision to the native apps: the desktop derived its UI
restrictions in the Wails service layer, the daemon kept the conflict
machinery in the server package, and both mobile bridges duplicated the
JSON fetch adapter. Anything the native side had to reimplement was a
place for iOS and Android to drift apart.
Enforcement now lives in client/mdm and is consumed identically by all
three platforms:
- conflicts.go holds the value-aware conflict checks lifted out of the
daemon, so the same normalization (canonical URLs, PSK sentinel echo)
applies wherever a config change is validated.
- restrictions.go derives the UI enforcement snapshot from a policy and
renders it in the JSON shape the desktop frontend already consumes.
The service-layer types become aliases, keeping one source of truth.
- jsonloader.go replaces the adapter that was copy-pasted into both
bridges.
- changedetector.go moves change detection off the native side: the
caller forwards the OS notification and asks whether the managed
configuration actually changed, instead of diffing dictionaries
itself.
The mobile bridges gain the enforcement the daemon already had. The
Preferences getters resolve managed keys from the policy, so a naive UI
shows the enforced value; Commit rejects a staged change that diverges
from a managed key; NewAuth resolves the managed management URL before
persisting the config and overlays the policy on it, so a login can no
longer run against a URL the policy forbids. Android's profile
mutations fail closed when disableProfiles is set.
NewAuth takes the fetcher as a required argument rather than keeping a
policy-blind overload: the apps consume this code as a submodule, so a
compile error at the bump is the point. The mobile PSK getter is
replaced by a presence check — the key has no reason to cross the
bridge, and not returning it means the native side needs no redaction
sentinel of its own.
* [client] Resolve the main merge conflicts in the MDM integration
The merge commit was recorded with the conflict markers still in the
tree. Resolve them so the branch builds again:
- client/ios/NetBirdSDK: keep both the mdm and mobile imports, and keep
the mdmLoader/mdmDetector fields next to main's stateMu documentation.
- client/server/mdm.go: drop the conflict helpers main added locally,
they already live in the client/mdm package on this branch, and keep
the new checks main introduced (allowRemoteJobs, enableLocalMetrics,
localMetricsAddress) as calls into the package-level helpers.
- client/mdm/conflicts.go: add ConflictStringPtr, the presence-aware
string check main needs for the optional localMetricsAddress field.
- Port the two tests main added over the per-Server loader helper and the
configWithMDM helper, both of which replaced the package-level policy
injection this branch removed.
* [client] Reject explicit empty PSK when MDM enforces a pre-shared key
The SetConfig, Login and mobile Commit conflict checks collapsed the PSK
to a plain string, so an explicit empty value was indistinguishable from
an unset field and slipped past the MDM gate, clearing the persisted key.
Carry the optional field as a pointer through ConflictStringPtr, treating
only the redaction sentinel as a no-op echo. ConflictString had no other
callers and is removed.
* [client] Apply MDM overlay on the preloaded iOS config in Run
Run only overlaid the MDM policy when the config was loaded from file,
so the tvOS path fed by SetConfigFromJSON started with unmanaged
settings. Apply the overlay after the config source is selected, as the
other resolution sites already do.
* [client] Gate non-active profile logout behind the MDM profiles switch
The mobile ProfileManager let LogoutProfile clear credentials of any
profile even when disableProfiles was enforced. Follow the daemon's
validateProfileLogout semantics: logging out of the active profile is a
plain logout and stays allowed, logging out of any other profile is
profile management and is rejected under the policy.
* [client] Resolve the managed management URL through the MDM overlay on mobile
NewAuth on Android and iOS replaced the caller URL with the raw policy
value before persisting, so a malformed managed URL failed config
validation and blocked the login instead of being skipped with a warning
like the overlay does. Preferences.GetManagementURL likewise echoed the
raw policy string to the native UI even when the overlay had rejected it.
Follow the daemon: persist the caller URL, overlay the policy on the
resolved config, and report the overlaid ManagementURL as the effective
value.
* [client] Clean up MDM review leftovers
Drop the unused ChangeDetector.Current, point the stale LoadPolicy
comment references at Loader.Load, and move the profileEmail godoc back
above its function.
* [client] Check remote jobs and local metrics keys in the mobile MDM conflict gate
MDMConflicts skipped allowRemoteJobs, enableLocalMetrics and
localMetricsAddress even though the overlay applies all three and the
daemon gate already checks them, so a mobile Commit could persist values
diverging from the enforced policy. Align the list with the daemon.
* [client] Silence the deprecated PreSharedKey lint in the login conflict test
The legacy LoginRequest.PreSharedKey field is deliberately exercised by
the test, matching the nolint already carried by the production path.
* [client] Publish the mobile MDM loader and detector atomically
SetMDMPolicyFetcher wrote the loader and change detector as two plain
fields that Run, the OS-change callback and the restrictions getter read
from other threads without synchronization. Hold both behind a single
atomic pointer so a registration is published as one unit and readers
always observe a matching loader and detector pair; Preferences gets the
same treatment for its loader. Exported signatures are unchanged.
* [client] Report the MDM-overlaid remote jobs value from mobile Preferences
GetRemoteJobsAllowed returned the staged or persisted value even when
the policy manages allowRemoteJobs, so the native settings UI could show
a value the Commit gate would reject. Resolve it through the overlay like
GetManagementURL does.
* [client] Stop persisting the MDM-overlaid config after mobile logins
NewAuth already writes the config through UpdateOrCreateConfig before
the MDM policy is overlaid, and the login itself never mutates the
Config. The post-login WriteOutConfig calls therefore only rewrote the
same file with the enforced ManagementURL and PreSharedKey in it, so a
removed or changed policy kept acting through the persisted values.
* [client] Document that the MDM overlay on Config is not reversible
ApplyMDMPolicy promised that an empty Policy clears a prior overlay, but
applyMDMPolicy only resets the enforcement metadata and the runtime-only
upload URL; the enforced ManagementURL, PreSharedKey and flags stay. Every
lifecycle owner resolves the base Config again before applying, so state
that contract instead of the reversibility that was never implemented.
* [client] Re-resolve the tvOS preloaded config before every MDM overlay
The iOS Client kept the config parsed from SetConfigFromJSON and applied
the MDM overlay onto that same instance on every Run, IsLoginRequired
and DebugBundle, so a key removed from the policy stayed enforced. Store
the JSON instead and parse it per load through one loadConfig path.
Auth serialized the overlaid config from GetConfigJSON, which tvOS then
persisted to UserDefaults and fed back as the preload. Keep the resolved
config as the base, run the login on a JSON round-trip copy with the
overlay, and return the base from GetConfigJSON.
* [client] Serve the MDM-managed management URL without touching the config file on mobile
Preferences.GetManagementURL resolved a managed URL by reading and
overlaying the persisted config, so a corrupt file or the tvOS sandbox
turned an enforced URL into a read error. Return the canonical managed
value directly, the same string BuildRestrictions already hands to the
UI, and only fall back to the staged or persisted value when MDM does
not manage the key.
NewAuth validated the caller-supplied management URL before the overlay
ran, so a malformed or echoed value blocked or persisted under an MDM
policy that already dictates the URL. Ignore the caller value while the
key is managed; the login runs against the overlay either way.
* [client] Align the MDM loader docs with the fetcher precedence and make disableAdvancedView a tristate
NewLoader, PolicyFetcher and the darwin/windows loadPlatform docs claimed
the fetcher is unused on desktop, while every loader returns its values
when one is injected. That precedence is the seam the server tests rely
on across platforms, so the docs now describe it; production desktop
callers still pass nil and keep the registry / plist authoritative.
Fields.DisableAdvancedView collapsed "managed and false" into the same
JSON as "not managed", unlike AllowServerSSH and the daemon's optional
proto field. Carry it as a *bool so the UIs can tell the two apart; the
desktop reflect loop skips pointer fields already, and the mobile
decoders treat null as not managed.
* [client] Clean up MDM review nits
- ResolveConflicts treats a managed key whose ConflictCheck has no Check
as a conflict instead of dereferencing nil.
- Ticker.Run and ChangeDetector.Changed share policyChanged so the diff
semantics and the log line cannot drift apart.
- TestLoader_NilFetcherReturnsEmpty skips on windows/darwin, where a nil
fetcher reads the real registry / plist.
- The profilemanager test loader checks GetInt before GetBool so integer
keys survive the round trip, and the PSK tests use the exported
redaction sentinel.
* [client] Fix int policy values coercing to bool in the MDM test helper
withMDMPolicy rebuilt the policy map by trying GetString, then GetBool,
then GetInt. Policy.GetBool accepts native ints (non-zero means true), so
an int-valued key such as wireguardPort round-tripped through the helper as
the bool true and GetInt was never reached. Try GetInt before GetBool, as
the profilemanager helper already does; GetInt does not coerce bools, so
booleans still fall through to GetBool.
No test sets an int key today, so this was latent: the first test to
exercise the wireguardPort conflict gate would have seen ConflictInt64
report a conflict for every value, including a matching one.
---------
Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Android 16+ local network protection derives the blocked prefixes from the interface address prefix. A /16 address turns the whole overlay into a local network, so apps without ACCESS_LOCAL_NETWORK cannot reach any peer. Pass the address as /32 and /128 and add the overlay networks to the route list that the Android side turns into VPN routes, on both the initial create and the renew path.
* Gather fresh system info on every management sync stream connect
The engine collected the peer meta once at start and reused the same
Info for every Sync stream reconnect, so a mobile network switch that
redials management kept reporting the old local network addresses.
The peer network range posture check was then evaluated against stale
data until the client restarted.
Sync now takes a gatherer that runs at each stream connect. The
gatherer is cheap: GetInfo plus the cached posture check file results,
kept in the new system.InfoSource, which the engine refreshes whenever
the checks list changes. No process enumeration runs on the reconnect
path.
Also fix the management mock server calling itself instead of SyncFunc.
* Evaluate the login response posture checks before the first sync connect
The engine starts with the checks the login response carried, and the
first sync stream request used to send their evaluated file results.
After moving the gather into InfoSource, the stream opened with an empty
cache and the first sync response did not refill it, because its checks
equal the ones the engine already holds. Desktop peers therefore never
reported process or file posture results.
Seed the cache once before the first connect, where the old gather ran,
so a timed out evaluation still falls through to the address-only info.
* Harden the sync info source against nil callbacks and shared slices
A nil getInfo opens the stream without metadata, as a nil sysInfo did
before. The cached posture results are a copy, so the Info returned by
Refresh cannot alias the snapshot later Current calls report. The
exclusion test asserts the remaining address count so it cannot pass
vacuously on a single-address host.
* Retry a posture check refresh that timed out or failed to sync
The checks list was recorded before the gather ran, so once the gather
timed out or SyncMeta failed, the next sync response carrying the same
list matched the recorded one and nothing retried. The peer kept
reporting the previous posture results until the list changed again.
Record the checks only after the meta reached management, so a failed
cycle is repeated on the next sync response.
* Log the skipped posture refresh, let the mock Sync return errors and deflake the reconnect test
* Drop the nil guard around the sync info callback
* Send the refreshed info on the first sync connect instead of gathering it twice