Commit Graph
50 Commits
Author SHA1 Message Date
f0a40e4395 [client, management] Harden the certificate posture client and keep challenge nonces fresh (#8052)
* implement certificate posture check

* log signal address

* add keychain and cert store support

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

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

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

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

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

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

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

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

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

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

* start TPM support

* split goreleaser to support pkcs11 and exclude on docker

* update goreleaser

* go mod tidy

* add tpm pin to netbird config

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

* add unsupported flag for mobile devices

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

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

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

* Stop retrying a PKCS#11 PIN the token rejected

* Log certificate posture details at debug level

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

* Skip certificate files whose key belongs to another certificate

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

* Never pass NULL to CFRelease and skip unreadable keychain identities

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Count the certificates field in the peer meta store test

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

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

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

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

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

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

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

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

* Add the certificate fields to the network map golden data

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

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

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

* Log what a successful certificate proof helper wrote to stderr

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

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

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

* End sudo option parsing before the macOS certificate helper binary

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

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

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

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

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

* Name NB_CERT_PKCS11_URI in the PIN-without-token error

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

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

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

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

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

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

* Clear the lint findings in certificate posture

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

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

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

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

* [management] Renew certificate challenge nonces on quiet accounts

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

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

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

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

(cherry picked from commit 7ad4a0df37)

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

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

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

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

(cherry picked from commit 0e38fcf409)

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

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

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

(cherry picked from commit dee68facfd)

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

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

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

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

(cherry picked from commit dc4d0e0274)

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

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

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

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

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

(cherry picked from commit 278f2f3807)

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

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

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

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

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

(cherry picked from commit 2d16dd7d7cf54762f2e64c5630ea092f32ef63ab)

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

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

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

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

(cherry picked from commit 59efe8d93e53bacdf57cb546f4ab2c19dc4eddab)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Share the proof collection single-flight across engine restarts

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

* Fix the pending checks comments

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

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

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

* Expect the challenge untrack in the session ownership test

* Drop an invalid certificate proof without discarding the valid ones

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

---------

Co-authored-by: pascal <pascal@netbird.io>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
2026-10-09 15:42:18 +02:00
Pascal Fischerandmlsmaycon 53a14551c8 [client, management] implement certificate posture check (#7535)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-10-09 14:57:00 +02:00
Pascal Fischer 06c4c20010 [management] Tear down only the peer session that owns the stream (#8057) 2026-10-08 16:17:23 +02:00
Zoltan Papp 515a01dd11 [client] Force interactive login when extending the auth session (#7216)
* [client] Force interactive login when extending the auth session

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

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

* [client] Reduce cognitive complexity of Server.Login

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

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

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

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

* [client] Respect DisablePromptLogin when extending the auth session

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SwitchProfile cancels and clears the pending session extend flow
unconditionally, the same way it clears the SSH JWT cache. New always
populates the field, but the hand-assembled test server did not, so
TestSwitchProfile_ClearsJWTCache panicked on a nil PendingFlow.
2026-10-08 15:40:39 +02:00
Pascal Fischer 2623feeb5b [management] remove ingress ports (#8062) 2026-10-06 16:35:31 +02:00
dmitri-netbird f422c41654 [management] extract peer update logic and wrap it in tests (#7338)
* extract peer update loop into a dedicated struct and wrap it in tests

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

* make linter happy

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

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-09-11 17:07:26 +02:00
Pascal Fischer 0bdfa4277e [management] blocking sync requests for user peers sharing the same wireguard key (#7427) 2026-09-04 13:10:51 +02:00
Maycon Santos ebc259e30b [management,client] Gate remote jobs behind an admin opt-in with MDM support (#7153)
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.
2026-09-01 17:53:41 +02:00
Pascal Fischer 611a9291cd [management] fix posture check flip evaluation for affected peers calc (#7347) 2026-08-28 15:39:48 +02:00
Pascal FischerandDmitri Dolguikh e06c17cf59 [management] network map from nmap data type (#6919)
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
Co-authored-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-27 11:28:05 +02:00
Bethuel Mmbaga e206f8827d [management] Suppress staticcheck warnings for deprecated proto fields (#7261) 2026-08-20 16:20:18 +02:00
Viktor Liu b3f9b82442 [management] Force routing-peer DNS resolution for reverse-proxy domain targets (#6872) 2026-07-28 14:45:57 +02:00
8435682ac8 [client, management] offload client config generation to the client (#6711)
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
Co-authored-by: crn4 <vladimir@netbird.io>
Co-authored-by: pascal <pascal@netbird.io>
2026-07-22 18:20:27 +02:00
Zoltan Papp 980598ed4a [management, client] Add management-controlled client metrics push (#5886)
* [management, client] Add management-controlled client metrics push

Allow enabling/disabling client metrics push from the dashboard via
account settings instead of requiring env vars on every client.

- Add MetricsConfig proto message to NetbirdConfig
- Add MetricsPushEnabled to account Settings (DB-persisted)
- Expose metrics_push_enabled in OpenAPI and dashboard API handler
- Populate MetricsConfig in sync and login responses
- Client dynamically starts/stops push based on management config
- NB_METRICS_PUSH_ENABLED env var overrides management when explicitly set
- Add activity events for metrics push enable/disable

* Remove log line

* [management] Fix peer update test for MetricsConfig in NetbirdConfig

Update TestUpdateAccountPeers assertions: NetbirdConfig is no longer
nil in peer update responses since it now carries MetricsConfig even
when STUN/TURN config is absent.

* Regenerate proto files with protoc v7.34.1

* [management] Read metrics push setting in Postgres account query

getAccountPgx omitted settings_metrics_push_enabled from its hand-written
SELECT and Scan, so the toggle was always read back as false on Postgres
and never reached clients.

* [client] Fix metrics push getting stuck off after engine restart

Engine restarts (backoff retries within the same login session) cancel
e.ctx, which the push goroutine's lifetime was tied to. The goroutine
died silently but ClientMetrics.push stayed non-nil since only an
explicit stop clears it, so the next UpdatePushFromMgm call saw a
"push already running" state and never restarted it.

Give the Engine its own metricsCtx sourced from ConnectClient.ctx,
which outlives engine restarts, so handleMetricsUpdate stops tying the
push to the wrong-scoped context. Additionally make ClientMetrics.push
an atomic.Pointer that the push goroutine clears via CompareAndSwap on
exit, so the tracked state can never drift from the goroutine's actual
lifetime regardless of which context a future caller passes in.

* [management] Regenerate OpenAPI types with oapi-codegen v2.7.1

types.gen.go was regenerated with a stale local v2.6.0 binary,
causing the CI git-diff check against generate.sh's pinned v2.7.1
to fail.
2026-07-01 14:50:25 +02:00
Pascal Fischer a400828b89 [management] move some logs to trace (#6541) 2026-06-25 15:16:54 +02:00
Pascal Fischer e2bb328a34 [management] less strict metaHash when blocking peers (#6531) 2026-06-25 15:02:43 +02:00
Pascal Fischer 2ebf26006a [management] empty file check in nmap on other posturechecks (#6511) 2026-06-22 19:54:38 +02:00
Pascal Fischer 211a26019a [management] validate meta change against posture checks (#6510) 2026-06-22 19:42:04 +02:00
Pascal Fischer e4397d4d46 [management] remove nmap calc from login (#6449) 2026-06-17 16:37:24 +02:00
Zoltan Papp 174dc24867 [management] Add SSO session extend flow (management) (#6197)
* add SSO session extend flow (management)

Adds the management-server half of the SSO session-extension feature:

- New ExtendAuthSession gRPC RPC that refreshes a peer's session expiry
  using a fresh JWT, validated through the same pipeline as Login but
  without tearing down the tunnel or redoing the NetworkMap sync.
- Per-peer SessionExpiresAt timestamp on every LoginResponse and
  SyncResponse so connected clients learn the deadline on the existing
  long-lived stream, and admin-side changes (toggling expiration,
  changing the expiration window) reach every peer within seconds.
- SessionExpiresAt(...) helper on Peer that derives the absolute UTC
  deadline from LastLogin + the account-level PeerLoginExpiration
  setting, returning zero when the peer is not SSO-tracked or expiration
  is disabled.

The matching client-side consumer of these fields lands separately.

* encode SessionExpiresAt as 3-state on the wire

Previously the `sessionExpiresAt` field on LoginResponse, SyncResponse
and ExtendAuthSessionResponse was 2-state: a valid timestamp meant
"new deadline", and nil meant "clear". That conflated two distinct
meanings — "no info in this snapshot" vs "expiry is explicitly off /
peer is not SSO-tracked" — so a Sync push that legitimately couldn't
compute the deadline (settings lookup failed) would silently clear the
client's anchor and lose the warning window.

Three states now, encoded on the same field number (no .proto schema
churn — only comments and the server-side encoder change):

  - nil pointer (field absent) → "no info"; client preserves anchor
  - &Timestamp{} (seconds=0, nanos=0) → explicit "disabled / not SSO"
    sentinel; client clears
  - valid timestamp → new absolute UTC deadline

A new encodeSessionExpiresAt helper centralises the zero/non-zero
encoding and is shared by the Sync, Login and ExtendAuthSession
builders. The Sync builder still emits nil when settings are missing.
Login and ExtendAuthSession always carry an authoritative value.

The matching client-side decoder lands on feature/session-extend.

* add UserExtendedPeerSession activity event

ExtendAuthSession previously reused UserLoggedInPeer for its audit
record, which conflated two distinct user actions: a full interactive
SSO login (tunnel re-established, network map resync) versus an
in-place deadline refresh (tunnel untouched). Auditors reading the log
couldn't tell which one happened, and downstream dashboards/alerts on
"login" volume were polluted by routine extends.

Adds a dedicated UserExtendedPeerSession Activity (code 125,
"user.peer.session.extend") and switches ExtendPeerSession over to it.
The peer-extend audit trail is now distinguishable from interactive
logins.

* make ExtendAuthSession JWT-retry backoff cancellable

Skip the retry log and 200ms wait on the final attempt, and replace the
uncancellable time.Sleep with a select on time.After/ctx.Done so an
upstream cancellation aborts the wait instead of running it to
completion.
2026-05-28 19:14:14 +02:00
Pascal Fischer 1f9a829f2c [management] update log levels (#6266) 2026-05-27 11:43:49 +02:00
Maycon Santos 347c5bf317 Avoid context cancellation in cancelPeerRoutines (#6175)
When closing go routines and handling peer disconnect, we should avoid canceling the flow due to parent gRPC context cancellation.

This change triggers disconnection handling with a context that is not bound to the parent gRPC cancellation.
2026-05-16 16:29:01 +02:00
Viktor Liu 205ebcfda2 [management, client] Add IPv6 overlay support (#5631) 2026-05-07 11:33:37 +02:00
Bethuel Mmbaga df197d5001 [management] Prevent JWT reuse during peer login (#6002) 2026-04-29 15:04:27 +03:00
Bethuel Mmbaga 9d1a37c644 [management,client] Revert gRPC client secret removal (#5781)
* This reverts commit e5914e4e8b

Signed-off-by: bcmmbaga <bethuelmbaga12@gmail.com>

* Deprecate client secret in proto

Signed-off-by: bcmmbaga <bethuelmbaga12@gmail.com>

* Fix lint

Signed-off-by: bcmmbaga <bethuelmbaga12@gmail.com>

---------

Signed-off-by: bcmmbaga <bethuelmbaga12@gmail.com>
2026-04-02 18:21:00 +02:00
Bethuel Mmbaga e5914e4e8b [management,client] Remove client secret from gRPC auth flow (#5751)
Remove client secret from gRPC auth flow. The secret was originally included to support providers like Google Workspace that don't offer a proper PKCE flow, but this is no longer necessary with the embedded IdP. Deployments using such providers should migrate to the embedded IdP instead.
2026-03-31 18:50:49 +03:00
Pascal Fischer 11eb725ac8 [management] only count login request duration for successful logins (#5545) 2026-03-09 14:56:46 +01:00
Pascal Fischerandmlsmaycon d7c8e37ff4 [management] Store connected proxies in DB (#5472)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-03-03 18:39:46 +01:00
Maycon Santos 327142837c [management] Refactor expose feature: move business logic from gRPC to manager (#5435)
Consolidate all expose business logic (validation, permission checks, TTL tracking, reaping) into the manager layer, making the gRPC layer a pure transport adapter that only handles proto conversion and authentication.

- Add ExposeServiceRequest/ExposeServiceResponse domain types with validation in the reverseproxy package
- Move expose tracker (TTL tracking, reaping, per-peer limits) from gRPC server into manager/expose_tracker.go
- Internalize tracking in CreateServiceFromPeer, RenewServiceFromPeer, and new StopServiceFromPeer so callers don't manage tracker state
- Untrack ephemeral services in DeleteService/DeleteAllServices to keep tracker in sync when services are deleted via API
- Simplify gRPC expose handlers to parse, auth, convert, delegate
- Remove tracker methods from Manager interface (internal detail)
2026-02-24 15:09:30 +01:00
Maycon Santos 63c83aa8d2 [client,management] Feature/client service expose (#5411)
CLI: new expose command to publish a local port with flags for PIN, password, user groups, custom domain, name prefix and protocol (HTTP default).
Management/API: create/renew/stop expose sessions (streamed status), automatic naming/domain, TTL renewals, background expiration, new management RPCs and client methods.
UI/API: account settings now include peer_expose_enabled and peer_expose_groups; new activity codes for peer expose events.
2026-02-24 10:02:16 +01:00
Vlad 4aff4a6424 [management] fix utc difference on last seen status for a peer (#5348) 2026-02-17 13:29:32 +01:00
Zoltan Papp 3be16d19a0 [management] Feature/grpc debounce msgtype (#5239)
* Add gRPC update debouncing mechanism

Implements backpressure handling for peer network map updates to
efficiently handle rapid changes. First update is sent immediately,
subsequent rapid updates are coalesced, ensuring only the latest
update is sent after a 1-second quiet period.

* Enhance unit test to verify peer count synchronization with debouncing and timeout handling

* Debounce based on type

* Refactor test to validate timer restart after pending update dispatch

* Simplify timer reset for Go 1.23+ automatic channel draining

Remove manual channel drain in resetTimer() since Go 1.23+ automatically
drains the timer channel when Stop() returns false, making the
select-case pattern unnecessary.
2026-02-06 19:47:38 +01:00
Vlad af8f730bda [management] check stream start time for connecting peer (#5267) 2026-02-06 18:00:43 +01:00
Vlad d488f58311 [management] fix set disconnected status for connected peer (#5247) 2026-02-04 11:44:46 +01:00
Vlad 8931293343 [management] run cancelPeerRoutinesWithoutLock in sync (#5234) 2026-02-01 15:44:27 +01:00
Vlad 7b830d8f72 disable sync lim (#5233) 2026-02-01 14:37:00 +01:00
Vlad cead3f38ee [management] fix ephemeral peers being not removed (#5203) 2026-01-28 18:24:12 +01:00
Zoltan Papp 44ab454a13 [management] Fix peer deletion error handling (#5188)
When a deleted peer tries to reconnect, GetUserIDByPeerKey was returning
Internal error instead of NotFound, causing clients to retry indefinitely
instead of recognizing the unrecoverable PermissionDenied error.

This fix:
1. Updates GetUserIDByPeerKey to properly return NotFound when peer doesn't exist
2. Updates Sync handler to convert NotFound to PermissionDenied with message
   'peer is not registered', matching the behavior of GetAccountIDForPeerKey

Fixes the regression introduced in v0.61.1 where deleted peers would see:
- Before: 'rpc error: code = Internal desc = failed handling request' (retry loop)
- After: 'rpc error: code = PermissionDenied desc = peer is not registered' (exits)
2026-01-26 23:15:34 +01:00
Zoltan Papp 58daa674ef [Management/Client] Trigger debug bundle runs from API/Dashboard (#4592) (#4832)
This PR adds the ability to trigger debug bundle generation remotely from the Management API/Dashboard.
2026-01-19 11:22:16 +01:00
Misha Bragin e586c20e36 [management, infrastructure, idp] Simplified IdP Management - Embedded IdP (#5008)
Embed Dex as a built-in IdP to simplify self-hosting setup.
Adds an embedded OIDC Identity Provider (Dex) with local user management and optional external IdP connectors (Google/GitHub/OIDC/SAML), plus device-auth flow for CLI login. Introduces instance onboarding/setup endpoints (including owner creation), field-level encryption for sensitive user data, a streamlined self-hosting provisioning script, and expanded APIs + test coverage for IdP management.

more at https://github.com/netbirdio/netbird/pull/5008#issuecomment-3718987393
2026-01-07 14:52:32 +01:00
Pascal Fischer 7ac65bf1ad [management] Fix/delete groups without lock (#5012) 2025-12-31 11:53:20 +01:00
Pascal Fischer 1d2c7776fd [management] apply login filter only for setup key peers (#4943) 2025-12-30 10:46:00 +01:00
Zoltan Papp 67f7b2404e [client, management] Feature/ssh fine grained access (#4969)
Add fine-grained SSH access control with authorized users/groups
2025-12-29 12:50:41 +01:00
Pascal Fischer 90e3b8009f [management] Fix sync metrics (#4939) 2025-12-11 20:11:12 +01:00
Pascal Fischer 94d34dc0c5 [management] monitoring updates (#4937) 2025-12-11 18:29:15 +01:00
Pascal Fischer 44851e06fb [management] cleanup logs (#4933) 2025-12-10 19:26:51 +01:00
Pascal Fischer 7193bd2da7 [management] Refactor network map controller (#4789) 2025-12-02 12:34:28 +01:00
Pascal Fischer 3351b38434 [management] pass config to controller (#4807) 2025-11-19 11:52:18 +01:00
Viktor Liu d71a82769c [client,management] Rewrite the SSH feature (#4015) 2025-11-17 17:10:41 +01:00
Pascal Fischer cc97cffff1 [management] move network map logic into new design (#4774) 2025-11-13 12:09:46 +01:00