mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-07 05:59:06 +02:00
* [client] Gate settings updates on value, not on field presence
The update-settings kill switch (--disable-update-settings /
NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids
changing settings, but it decided what a "change" was by looking at
whether a field was present in the request. The CLI fills the whole
config surface of SetConfigRequest and LoginRequest from its flags and
environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a
client configured by environment restates its own configuration on every
start and tripped the gate every time.
SetConfig only warned about that, but Login carries the same fields and
was gated the same way, and Login runs inside the CLI's backoff loop: the
daemon answered every attempt with codes.Unavailable, `netbird up` never
completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any
config env var (NB_MANAGEMENT_URL, for one) could not come up at all.
Both gates now compare values. Config.WouldChange is the dry-run half of
UpdateConfig: it runs the very same diff logic (Config.apply) against a
copy of the stored config, so the gate cannot drift from what an actual
update would do, nor go stale when a field is added. A request that
restates what the profile already holds changes nothing and is allowed; a
request that diverges is refused exactly as before, and a dry run that
cannot be evaluated fails closed. A profile with no config on disk yet is
judged against the config the daemon would create for it.
For Login, the compared input comes from loginOverridesInput, which
persistLoginOverrides also uses to perform the write, so the gate judges
precisely the two fields a login can persist (management URL, pre-shared
key) and no field it ignores.
Two adjacent defects surfaced while making the comparison exact:
- Config.apply compared URLs as raw strings, so the same endpoint spelled
without its default port ("https://api.netbird.io" vs
"https://api.netbird.io:443") counted as a new value and rewrote the
config. It now compares the parsed forms.
- UpdateConfig did not collapse the redacted pre-shared key, unlike
UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the
mask replaced the stored key with asterisks.
The CLI warning for a refused SetConfig said the method was not available
in the daemon, which sent people looking for a version mismatch that was
not there; it now reports the refusal.
* [client] Do not write the profile config while only reading it to decide
The update-settings gate needs the stored config to decide whether a request
changes anything, so the previous commit moved that read ahead of the refusal.
The read is not side-effect free: profilemanager.GetConfig writes the config
back whenever apply() has to fill in a default the file was missing. A request
that the gate then refuses had therefore already rewritten the profile file.
PeekConfig is GetConfig without that write-back. The returned config is still
normalized in memory, which is what the decision needs; the file is left
exactly as it was found. Every caller of storedConfigAtPath feeds a gate that
can refuse, so they all peek.
Note for reviewers: the daemon still normalizes the file on startup and on
every real update, so nothing depends on a read performing that write.
* [client] Compare service URLs as endpoints, not as strings
Three places in one request path each had their own notion of "same
management URL": the config layer compared the parsed URLs as strings, the
privileged-change gate compared scheme + host + effective port, and the MDM
conflict check compared strings after filling in the default port. Only the
middle one was right.
A string comparison answers the wrong question. "https://api.netbird.io",
"https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint
written three ways, so a client restating its own management URL with a
trailing slash — a normal way to write it — was still read as a client asking
to be repointed, and the update-settings gate refused it. The MDM check had
the same flaw against the enforced value.
profilemanager.SameServiceURL is now the single comparison: same scheme, same
host case-insensitively as DNS names are, same effective port. The config
layer, the privileged-change gate and the MDM conflict check all defer to it,
so there is one answer to "did this URL change?" instead of three.
* [client] Stop the config dry run from generating throwaway keys
The dry run's baseline for a profile with no config file yet went through
createNewConfig, and apply() generates a WireGuard and an SSH key whenever it
finds those fields empty. The baseline is compared against and discarded, so
every evaluation minted a keypair it threw away — and logged "generated new
Wireguard key". The CLI retries Login in a backoff loop, so a first `netbird up`
on a fresh profile filled the daemon log with what reads like peer-key rotation.
The baseline now starts from the shared skeleton with placeholder keys, so
apply() has nothing to generate. No ConfigInput field maps to either key, so
the comparison is unaffected.
* [client] Cover the login the update-settings gate used to refuse
The gate's decision procedure was tested directly, but no test drove the Login
RPC that the refusal actually broke: the CLI retries Login in a backoff loop,
so a refused no-op login is what kept a client configured by environment from
ever coming up. The handler-level coverage stopped at the refusal case, which
passes on the pre-fix code too.
This test fails on the pre-fix daemon with "update settings are disabled" and
passes now. Past the gate the handler does real work the test does not stand
up, so it asserts only that the refusal did not happen.
* [client] Re-take the update-settings decision under the config lock
Login checks twice on purpose: the first check refuses the ordinary case
early, and authorizeAndPrepareLogin re-takes the authoritative one under
guardedConfigMu because the first is unsynchronized against a concurrent
privileged request. The update-settings decision is now equally
value-dependent — it compares the request against the stored config — but it
was taken only in the first, unlocked check.
So a login that was a no-op when it was checked could be written after a
concurrent writer had repointed the profile, which is exactly the window the
lock exists to close. The decision is now re-taken alongside the privilege one,
which also makes it the last read before persistLoginOverrides writes.
The test drives that interleaving through the existing afterLoginPreCheck seam
and fails without the re-check.
* [client] Drop an unreachable guard and fix two stale comments
- loginOverridesInput's nil-message guard cannot be reached: Login
dereferences the message well before it, in storedLoginConfig.
- The docstring above afterLoginPreCheck described persistLoginOverrides,
which lives further down the file and now carries its own.
- UpdateConfig's comment named DirectUpdateConfig; the function is
DirectUpdateOrCreateConfig.
* [client] Make config reads pure and provision the identity explicitly
Reading a config wrote it back. profilemanager.readConfig persisted whatever
apply() had filled in, and ReadConfig created and wrote the file outright when
it was absent, so every reader was quietly a writer: a gate deciding whether to
refuse a request, a UI listing profiles, a mobile getter reading one preference.
The previous commit worked around that with a PeekConfig variant, which left
two read functions with opposite side effects and the antipattern still there
for everyone else.
Only one thing in a read genuinely had to be persisted: apply() generated the
WireGuard and SSH keys when it found them empty, and a generated key cannot be
recomputed — losing it means the peer comes back with a different identity and
registers again. Everything else apply() fills in is a deterministic default
that the next read recomputes anyway.
So identity provisioning is now its own step, Config.EnsureIdentity, and the
callers that provision write the result out themselves, in the open:
- Server.getConfig, the daemon's provisioning point;
- the CLI's foreground login, which is about to dial management;
- update() / directUpdate(), the config write paths — a stored profile can
legitimately carry no identity, since a mobile logout clears the keys in
place, and the next write is what has to mint a new one.
ReadConfig and GetConfig no longer write anything, PeekConfig is gone, and the
dry-run baseline no longer needs placeholder keys to keep apply() from minting
real ones.
One deliberate leftover: readConfig still calls util.EnforcePermission, which
chmods a config file whose permissions are too broad. It changes no content and
is idempotent, and dropping it would leave a legacy file world-readable until
its first write.
* [client] Name the two config readers for what they do
ReadConfig and GetConfig differed in one thing — what happens when the file is
absent — and neither name said which was which:
- ReadConfig -> ReadOrGenerateConfig (reads it, or generates one in memory)
- GetConfig -> GetExistingConfig (reads it, or fails)
Three comments went with them:
- GetConfig's said "return with Config and if it was created. Errors out if it
does not exist", which described a bool it does not return and a creation it
never performs.
- ReadConfig's explained that it does not write, which is what a reader is
supposed to do anyway.
- Server.getConfig's said it "errors out if it does not exist", which it does
not — it resolves a default config, and now provisions the identity too.
* [client] Do not panic on a config with no sync message version
apply() wrote the incoming sync message version through the stored pointer,
without checking it was there: a config that carries no version yet made it
dereference nil. Reachable from the update-settings dry run, which runs inside
a request handler — where failing closed is the worst acceptable outcome, and a
panic is not one.
The field is now reassigned like every other optional one, which also means
apply() no longer mutates anything the caller still holds through a pointer, so
the dry run's copy has one less field to detach.
Reported by cubic-dev-ai on PR #7398.
* [client] Compare the client certificate paths before reporting a change
apply() assigned the incoming mTLS certificate and key paths and set updated
unconditionally, without comparing them to what the config already held. It is
the same presence-instead-of-value mistake this branch set out to fix, one
layer down: a caller restating its own certificate paths was reported as
changing them, which trips the value-aware update-settings gate.
Reported by cubic-dev-ai on PR #7398.
* [client] Address the remaining bot findings on PR #7398
- Login logged the active-profile-state error and returned the same cause; the
repo's guidelines call for one or the other, and the wrapped error is the one
that carries context. (CodeRabbit)
- `netbird up` reported a codes.Unavailable SetConfig failure as "the daemon
refused the settings update", but that code also covers a daemon that became
unreachable. It now reports what the daemon said without asserting why.
(cubic-dev-ai)
- TestLogin_ChangingTheManagementURLIsRefused asserted the error and nothing
else, while "refused before it can touch daemon state" is the contract. It now
checks the stored management URL, the in-progress login and the active profile,
matching its SetConfig counterpart. (cubic-dev-ai)
* [client] Keep the peer identity out of a read that finds no file
ReadOrGenerateConfig resolves a default config when the profile has no file
yet, and createNewConfig was minting the WireGuard and SSH keys while doing so.
That defeated the provisioning pair it was meant to serve: the CLI's foreground
login calls EnsureIdentity to find out whether it has to persist the keys, got
generated == false because the read had already generated them, and so never
wrote them out. The login then dialed management with an identity that only
existed in memory, and the next login registered a second peer.
createNewConfig no longer provisions. createProvisionedConfig is the variant
that does, and the callers whose contract is "usable as it comes back" use it:
CreateInMemoryConfig, whose callers connect with the result, and the two
create-and-write branches. A read gets a config with no identity, so the
caller's own EnsureIdentity reports the work and triggers the write.
Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect.
* [client] Stop the gate test from dialing the real management server
TestLogin_RestatingTheStoredConfigPassesTheGate asserts that the gate lets a
no-op login through, and the handler then went on to do the login for real:
isLoginRequired builds an auth client when isLoginRequiredFn is unset, so the
test dialed the profile's management URL — api.netbird.io:443. It took 1.05s
locally and would hang on a runner with no egress, for a fact about the gate
that needs no network at all.
Stubbed like the login_outcome tests do. The test now runs in 0.00s.
Reported by cubic-dev-ai on PR #7398.
* [client] Keep the admin panel path part of its identity
The endpoint comparison introduced for the management URL was applied to the
admin URL too, and that one is opened in a browser rather than dialed over
gRPC: a panel served under /netbird is not the panel served at the root. So a
config whose admin URL differed only by path reported no change, and the new
path was never persisted — a custom panel URL could not be updated at all.
SameServiceURLIncludingPath adds what a URL carries past its endpoint (path,
query, fragment, userinfo) while still treating equivalent spellings as equal:
a missing path and "/" are the same root, and so is a trailing slash. The
management URL keeps the endpoint-only comparison, since only the endpoint is
ever dialed.
Ports are also normalized numerically now, so ":0443" and ":443" are one port.
Reported by cubic-dev-ai on PR #7398 (two findings).
* [client] Treat a profile with no identity as already deregistered
Two findings on the same consequence of pure reads: a profile can legitimately
carry no keys, because logging out clears them in place.
- sendLogoutRequestWithConfig went straight to wgtypes.ParseKey and failed with
"incorrect key size: 0" on the second logout of the same profile. There is
nothing to deregister for a peer that was never registered, so it returns
cleanly. Before pure reads this case was hidden: the read minted a key and
the daemon dialed management with one it had never seen.
- The mobile logout read the config with the generating reader right after
checking the file exists. The two are not atomic, so a profile removed in
between was resolved from the defaults and recreated by the write that
follows. It uses the existing-file reader now.
Reported by cubic-dev-ai and CodeRabbit on PR #7398.
* [client] Fail `netbird up` when the daemon refuses the settings update
With the update-settings kill switch on, `netbird up --enable-rosenpass`
connected and said almost nothing: SetConfig refused the change, the CLI
downgraded that to a warning, and Login carries no rosenpass field to apply, so
the flag was silently dropped. The setting stayed disabled, which is the point
of the switch, but the caller was never told their request had been ignored.
The refusal now travels as codes.FailedPrecondition instead of
codes.Unavailable, and the CLI fails on it. Unavailable means "the daemon
cannot serve this call", which is why the CLI downgraded it and why
client/ui/services reads it as an unreachable daemon — both wrong for a daemon
that answered and refused. FailedPrecondition also matches what the MDM gate
already returns for a managed field, so both refusals are now one class of
error, and it is added to the login backoff's early-exit codes so a refused
login stops instead of retrying for 30s.
This does not put the container back in the deadlock: with the value-aware
gate, a client restating its own configuration is not refused at all, so
nothing reaches this path unless a real change was asked for.
* [client] Name the reader storedConfigAtPath actually calls
The purity note still said profilemanager.GetConfig, which the rename two
commits later turned into GetExistingConfig.
Reported by cubic-dev-ai on PR #7398.
* [client] Restore the gofmt alignment of the error constants
The comment added above errUpdateSettingsDisabled in the previous commit split
the const block's alignment group, so gofmt wants the two constants above it
re-aligned. CI runs gofmt, so this would have failed the lint job.
* [client] Let an unprivileged caller log out a profile with no identity
The empty-key check sat behind requirePrivilegeForDeregistration, so an
unprivileged logout of an identity-less profile was refused with
PermissionDenied instead of completing as the no-op it is. And it was refused
for most profiles, not a corner case: the gate arms whenever the SSH server is
enabled, and sshServerEnabled reads an absent ServerSSHAllowed as enabled, so
every legacy profile qualifies.
The check now runs first. What the gate protects against is handing this
machine's registered key to another management server; with no key there is
nothing to hand over and nothing to protect.
Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect.
* [client] Stop `netbird login` from retrying a refusal for 30 seconds
`netbird up` and `netbird login` both run Login through the backoff cycle, and
each carried its own copy of the list of codes that end it. Only up.go learned
about codes.FailedPrecondition, so a refused `netbird login` kept retrying and
then reported "login backoff cycle failed" instead of what the daemon said.
terminalLoginError is now that list, once, next to WithBackOff — the duplicated
copies are what let the two commands disagree in the first place.
Reported by cubic-dev-ai on PR #7398.
* [client] Answer terminalLoginError's nil case on its own terms
A successful Login reaches terminalLoginError with a nil error, and nothing
covered that. It happens to work on grpc v1.80.0 — gstatus.FromError(nil)
answers (nil, true), and Status.Code tolerates a nil receiver by returning
codes.OK, which is not in the terminal set — but that is a chain of internal
details to be relying on for the common path, and none of it was asserted.
Now the nil error is handled where it is obvious, and the table covers it.
Reported by CodeRabbit on PR #7398, which called it a panic; measured on
v1.80.0 it is not one. The gap was the untested reliance, not a crash.
* [client] Treat an unset optional field as its default when diffing a config
Seven Config fields mean "the effective default" when they hold no value:
the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every
consumer already reads a nil as that default, but apply() diffed them by
presence — `config.X == nil || *input.X != *config.X` — so an input restating
the default counted as a change.
That made the update-settings gate refuse `netbird up` outright. The CLI
sends every flag whose value came from an environment variable
(SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag
Changed), and the config a plain login writes leaves all seven unset, so a
container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default
the file held as null on every start and was answered with
FailedPrecondition.
apply() now resolves the seven up front, the way it already did for
ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on
its next write. With the values named, the comparisons below diff values
instead of presence, so their nil branches are gone.
The network monitor keeps its platform default — on for windows and darwin —
and naming it as false elsewhere is what createEngineConfig already read a
nil to be. getJWTCacheTTL reaches the same 0 through its own default, and
Android's GetEnableSSH* getters already answered nil with false.
* [client] Normalize the config before diffing it in WouldChange
apply() reports two different things through one bool: an input that changed
a value, and a field it had to fill in because the config carried none. The
update-settings gate reads that bool as "the caller asked for a change", so
any config still missing a default answered a request that asks for nothing
with a refusal.
Readers already hand out normalized configs — readConfig applies an empty
input for exactly this reason — which is why the gate got away with it. But a
handler that refuses a request must not depend on where its caller obtained
the config, and it must not start reading "this profile predates a field" as
"the caller asked for a change" the day someone adds one with a default.
WouldChange now runs the filling-in as a pass of its own and discards its
verdict, so the pass that answers the caller measures only what the input
did.
* [client] Stop the last config write that skipped normalization
Every path that creates or updates a profile config goes through apply(),
which resolves an optional field to its default — except RenameProfile,
which read the file with a bare json.Unmarshal, set the name, and wrote it
straight back. That copied whatever the file held, so a config written by a
client that stored these fields as null kept them null. It could not
introduce a null, only carry one forward, but renaming a profile is a poor
place to leave a half-resolved config behind. It now reads through
GetExistingConfig, which normalizes what it hands out.
The tests state the invariant the fix completes, over the *bool fields of
Config listed by reflection so a field added later is covered without
touching them: none may come out of apply() unset, and no write may store
one as null. An optional bool that can be nil, true or false forces every
reader to invent the meaning of nil, and makes a diff of the config compare
presence rather than value — which is exactly what refused `netbird up` for
a client restating its own defaults.
SyncMessageVersion stays a genuine three-state field and is not covered: it
is an *int whose absence means the client pins no version, and it travels to
management that way.
* [client] Refuse a serialized config that carries no peer identity
ConfigFromJSON still promised a "fully initialized" config after this PR
moved key generation out of apply() into EnsureIdentity, but identity stopped
being one of the defaults it applies. Its two callers both connect with what
they get back: the iOS SDK's Client.SetConfigFromJSON keeps it as the
preloaded config Run() uses on tvOS, and Auth.SetConfigFromJSON as the config
it authenticates with.
No caller feeds it a document without keys today — every stored document
comes from Auth.GetConfigJSON, whose config is provisioned by
DirectUpdateOrCreateConfig or CreateInMemoryConfig, and the tvOS app only
ever edits fields of a document it already has. This is a safety net for the
next caller, not a live bug.
Provisioning the identity here would be the wrong net. Neither caller can
hand a generated key back to the store the document came from — Client
exports no config at all — so the peer would connect under an identity
nothing persists and register anew on every launch, which is the failure the
EnsureIdentity split exists to prevent. A document with no identity means
nobody has logged in yet, and saying so is the only useful answer.
Both keys are required because both are dead ends when missing: an empty
WireGuard key fails the management login on its size, and an empty SSH key
fails ssh.GeneratePublicKey in ConnectClient before the engine starts.
* [client] Say that the null-on-disk fixture is synthesized, not written
The test comment described the null state in the present tense — "the config
a plain login writes leaves every one of them unset" — which was true before
this branch and is not any more: apply() now resolves those fields, so a
login writes them set. unsetOnDisk puts the null state back deliberately, to
stand in for a profile an older client wrote. Comments only.
* [client] Gather the optional-field defaults into one function
Resolving an unset optional field was spread over five places: the two
values newConfigSkeleton pre-sets, the block this branch added for the SSH
toggles, the network monitor's own if, the `else if` tails of
ServerSSHAllowed and RemoteJobsAllowed, and a trailing if for
DisableNotifications several hundred lines further down. Reading apply() left
no single answer to "what does this field default to, and who decides".
They now live in Config.resolveUnsetDefaults, which apply() calls before it
compares anything — the ordering being the point, since it is what lets
every comparison below diff values instead of presence. The comparisons for
ServerSSHAllowed, RemoteJobsAllowed and DisableNotifications lose their
`config.X == nil ||` clauses accordingly, as the other six already had.
newConfigSkeleton keeps its two, and that is the one asymmetry worth naming:
ServerSSHAllowed defaults to false for a new profile and to true for a
legacy one, and it only works because the skeleton runs first. The doc
comment says so, where before it was implied by the order of two distant
blocks.
Pure refactor. Verified as one: for the four fields whose branches moved,
plus two that did not and the JWT TTL, all 63 combinations of stored value
(nil/false/true) against input value (absent/false/true) produce byte-
identical resolved values and `updated` verdicts before and after.
* [client] Resolve the merge conflicts left in the tree
262ce8c3b landed with the conflict markers still in it, so client/server and
the iOS SDK did not compile. Four regions, resolved as follows.
client/server/mdm.go — main moved the MDM conflict-check machinery into the
mdm package (mdm.ResolveConflicts, mdm.ConflictBool, mdm.ConflictURL, ...).
This branch had edited the local copies, which are now dead: dropped, along
with the profilemanager import that only the local conflictURL needed.
client/server/server.go, Login gate — this branch's value-aware gate stays
(the point of the PR: refuse a real divergence, let a restatement through),
so main's presence-based `loginRequestHasConfigOverrides` block goes; that
helper no longer exists here anyway. Main's other change in the same lines
is real and kept: the MDM policy now comes from the daemon-owned
s.mdmLoader.Load() instead of the package-level loadMDMPolicy, which main
removed. The stale call right below the conflict was the reason the file
would not have compiled even with the markers gone.
client/server/server.go, getConfig — both sides add something and both are
needed. The identity is provisioned and persisted first, then the MDM
overlay is applied, so what reaches disk stays the profile's own config: the
overlay is runtime-only and re-derived on every load.
client/ios/NetBirdSDK/client.go — main reworked SetConfigFromJSON to store
the JSON and re-parse it on each load, which is the shape kept; the parse is
now only a validity check, and this branch's reason for it (a document with
no peer identity is refused, not just an unparseable one) moves into that
comment.
client/server/update_settings_gate_test.go — follows the sentinel constant
to its new home, mdm.PreSharedKeyRedactedSentinel.
* [client] Reuse util's service-URL comparison instead of a second copy
The endpoint-comparison rules this branch introduced now live in util (PR
#7472 moved them there so the MDM conflict check could stop comparing URLs
as strings). Keeping a copy here is what produced that bug in the first
place: two implementations of "is this the same endpoint?" drift, and the
one that drifts starts refusing a URL that addresses the very server it
already points at.
So SameServiceURL delegates the port normalization to util.ServiceURLPort
and drops the local one, and SameServiceURLIncludingPath — endpoint plus
path, for the admin panel URL, which is opened rather than dialed — is
util.SameServiceURL plus the query, fragment and userinfo it adds on top,
so the local path normalization goes too.
What stays here is the distinction util does not make: SameServiceURL is
endpoint-only, because a management URL is dialed and only its host and port
are, while util.SameServiceURL includes the path.
Pure refactor. Verified as one: all 198 pairs of a 14-spelling matrix
(default and zero-padded ports, host case, trailing slash, path, query,
fragment, userinfo, both schemes, nil operands) answer identically for both
functions before and after.
* [client] Give a newly added profile its identity (review item 1)
AddProfile writes the config it builds straight to disk, but built it with
createNewConfig, which stopped generating the peer's keys when identity
generation moved out of apply() into EnsureIdentity. The profile file landed
with an empty PrivateKey and SSHKey.
Nothing lost the keys permanently — the daemon's own getConfig provisions and
persists them on first use — but every reader that does not write got a
config that cannot connect in the meantime, which is exactly the set this
branch grew: the update-settings gate deciding whether to refuse a request,
and the mobile SDKs loading a stored profile.
createProvisionedConfig exists for callers that persist or connect, and this
is one; before the split, createNewConfig produced the keys here too.
* [client] Let a logged-out profile deserialize again (review item 2)
ConfigFromJSON refused a document with no WireGuard or SSH key. A config
legitimately has none between a logout and the next login: mobile
LogoutProfile clears both in place and writes the profile back, so the peer
re-registers on the next login instead of returning as itself.
So the refusal broke the mobile flows it was meant to protect. On iOS and
tvOS the stored JSON of a logged-out profile stopped loading through
Client.SetConfigFromJSON and Auth.SetConfigFromJSON, and copyConfig — which
round-trips a Config through JSON to take an in-memory copy before applying
the MDM overlay — failed on the same document. Where the old code silently
minted a key, this returned an error, which is worse for logout and profile
switching alike: neither is asking to connect.
The deserializer now stays out of the identity question in both directions:
it does not generate one (a read cannot hand back keys nothing will write
down) and does not refuse one that is absent. Whoever goes on to connect is
where an absent identity has to be answered — and it already is, by the
login path that provisions and persists.
ErrConfigWithoutIdentity goes with it; nothing else used it.
* [client] Fold the scheme case here too, like util does (review item 6)
profilemanager.SameServiceURL compared the scheme with ==, util.SameServiceURL
with EqualFold. No observable difference — net/url lowercases the scheme when
it parses, and both functions take parsed URLs — but two functions of the same
name with two different rules is a trap for whoever reads one and assumes the
other.
* [client] Classify the daemon's refusals in the GUI (review item 3)
FailedPrecondition reached the classifier unmatched, so a refusal showed as
"Operation failed". It is the code both of the daemon's deliberate refusals
carry: the update-settings kill switch, and a field an MDM policy manages.
Both are now named — settings_locked and settings_managed_by_mdm, matched on
the message the daemon composes — and FailedPrecondition itself falls back to
change_refused, so a refusal the daemon grows later still reads as a refusal
rather than a failure.
Only the English strings are added. Bundle.Translate falls back to the
default language for a missing key, so other locales show English until the
usual translation pass, rather than the bare "error.<code>" the classifier
would otherwise surface.
Note: the package needs GTK4/WebKit to build, which this machine has not, so
the test is type-checked (go vet, GOOS=windows) but was not executed locally;
CI's Linux job runs it.
* [client] Cover the mobile profile round trip: create, logout, reload
Both mobile regressions this branch's review turned up lived on the same
path, and neither was visible from the desktop client: a profile created
without an identity, and a logged-out profile that would no longer
deserialize. The desktop never meets the second one — it is mobile logout
that clears the peer's keys in place, so the next login registers a new peer
instead of bringing the old one back.
The test walks a profile through the round its user puts it through —
created, logged out, loaded again, switched away from and back — and loads it
at each step the way the SDKs do: read the stored config, serialize it, load
it back. That is Client.SetConfigFromJSON storing the document for tvOS,
Auth.SetConfigFromJSON authenticating with it, and copyConfig taking an
in-memory copy before the MDM overlay.
Verified to fail on each regression separately: restoring the bare
constructor in AddProfile fails it with "a new profile was written with no
identity", and restoring the identity check in ConfigFromJSON fails it at
"load the profile back".
client/mobile already had the coverage for the first one in
TestLogoutProfile_DisableProfiles — which arrived from main with the MDM
work, and which I had not been running.
* [client] Name only the refusals, not every FailedPrecondition
The classifier gained a blanket FailedPrecondition -> change_refused fallback
so a refusal would stop reading as "Operation failed". It reaches too far:
the daemon returns that code for two dozen states that are not settings
refusals — "not logged in", "client is not running", "another capture is
already running", "session can no longer be extended, log in again to
reconnect" — and errorClassifier is shared with the session and connection
services, not just the settings save.
So the user was told the service had refused their change while what they
actually had to do was log in again. The two refusals the daemon composes
stay named by their message; everything else goes back to the generic
message, which says nothing rather than something wrong.
Reported by cubic on the PR.
* [client] Say what each assertion was checking in the mobile test
AGENTS.md asks for a context message on comparison and boolean assertions,
and four of the ones added with this test had none, so a failure would have
read as a bare Empty/Equal with no hint of which step of the round trip broke.
Reported by cubic on the PR.
* [client] Translate the two new error strings into every locale
The GUI classifier gained error.settings_locked and
error.settings_managed_by_mdm, and only the English strings were added: the
bundle falls back to the default language for a missing key, so nothing would
have shown a bare "error.<code>" to a user.
CI disagrees, and it is right to: check-translations.mjs requires every
locale to carry the full English key set, so English-only fails the gate
rather than degrading quietly.
The ten locales now carry both strings. These are my translations, not a
localization pass — worth a second pass by whoever owns the language, in
particular for the phrasing of "an administrator has locked them".
The uk file also loses two lines of stray 8-space indentation, normalized by
rewriting the file; no key or value changed with it.
* [client] Persist the profile before overlaying MDM on it (review item)
`netbird login` read the config, applied the MDM policy on top, and only then
provisioned the identity and wrote the result out. On a profile with no
identity yet — a first login — that write persisted the enforced values into
the user's own config file: an MDM-managed management URL or pre-shared key
became indistinguishable from one the user set, and stayed behind once the
policy was withdrawn.
Provisioning and its write now come first, and the overlay is applied to the
in-memory config afterwards, where it belongs: it is re-derived on every load
and never meant to reach disk from here. Server.getConfig already orders the
two this way; the two paths now agree.
Reported by cubic on the PR.
* [client] Assert against the stored config, not a resolved default (review item)
The login-gate test read the profile back with ReadOrGenerateConfig, which
resolves a default config in memory when the file is missing — and that
default's management URL is the very value the assertion checks. An erased or
mislocated profile would have passed the test instead of failing it.
The file is written by the test itself, so GetExistingConfig is the right
reader: it errors when the file is gone.
Reported by cubic on the PR.
* [client] Keep the mTLS pair off the gate's dry run (review item)
WouldChange runs the real apply() against a throwaway copy, and apply() loads
the client mTLS certificate and key from disk whenever the config names them.
So every gated SetConfig and Login read the pair — twice per request, once for
the normalization pass and once for the verdict — including requests that were
about to be refused or that changed nothing, and logged an error per request
when the files were missing. The gate used to be presence-based and never
called apply(), so this was new work on a request path.
The loaded pair feeds the connection and never the comparison: nothing in
apply() reads it back, and it does not move the `updated` verdict. A config
built only to be compared against now says so, and apply() skips the load for
it.
Reported by cubic on the PR.
* Makes it explicit that RenameProfile does write on disk
* [client] Provision the peer identity under the config lock (review item)
Login took the authoritative update-settings and privilege decisions under
guardedConfigMu, then released it and called getConfig, which mints the peer's
identity and writes the config out. Between that read and that write, a
SetConfig holding the same lock could land a change and answer its caller —
and then be overwritten by the config the login had already read.
The window is narrow: getConfig only writes when the profile has no identity
or no file, so in practice a first login racing a settings change on the same
profile. It is also narrower than before this branch, where the write happened
inside the reader on every read that filled in a default.
Provisioning now runs where the decision it belongs to runs: at the end of
authorizeAndPrepareLogin, with the lock already held, next to
persistLoginOverrides, which writes there too. No lock is taken that was not
held before, so the documented guardedConfigMu-then-mutex order is untouched.
getConfig keeps its behaviour by calling the same extracted helper; on the
login path it now finds the identity already there and writes nothing. The
other callers are unchanged, and still provision outside any lock — a
concurrent SetConfig is not part of their flow.
Reported by cubic on the PR.
* [client] Declare the probe marker to the debug-bundle field check
TestAddConfig_AllFieldsCovered walks Config by reflection and fails until every
field is either rendered in the debug bundle or listed as excluded with a
reason. The probe marker added for the gate's dry run was neither, so the
client unit suite went red on every platform.
It is excluded: it marks a throwaway copy built to be compared against and
discarded, so it is never set on a config anyone runs with, and rendering it
would only ever print false.
* [client] Provision the peer identity on the iOS login path
Key generation used to happen inside apply(), so a config loaded from JSON with
no keys got them in memory on the way in, the login worked, and the app stored
the result. This branch moved generation into EnsureIdentity, and nothing in
the iOS SDK called it.
The consequence lands on the flow the mobile logout sets up: logout clears both
keys in place so the next login registers a new peer. The app then hands that
keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with
an empty WireGuard key, which fails on key size before the SSO flow starts —
the user cannot sign back in.
Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and
SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns
for the caller to persist, and writes it to disk itself when the profile has a
file — non-atomically, like NewAuth's own write, since the tvOS App Group
sandbox blocks temp-file-and-rename.
Not covered by a test: the package builds only under GOOS=ios, which the test
jobs do not run. Verified by building and vetting for GOOS=ios/arm64.
Reported by pappz in review.
* [client] Name the resolving reader for what it does, not what it makes
ReadOrGenerateConfig reads the profile config and falls back to the defaults in
memory when there is no file. "Generate" reads as "produces and stores", which
is the opposite of the property the rename it came from was meant to advertise:
the read is pure, writes nothing and mints no identity.
ReadConfigOrDefault says the same without the side effect, and pairs with
GetExistingConfig, which fails where this one falls back. Its doc comment now
states the absence of a write rather than only the fallback.
Pure rename; the two remaining mentions of the pre-branch name ReadConfig in
the tests go with it.
Reported by pappz in review.
* [client] Read an emptied NAT list as the absent one it matches
apply() compared NATExternalIPs with reflect.DeepEqual, which calls a nil
slice and an empty slice different. Both mean the same thing — no NAT
mappings — and the two meet on a perfectly ordinary start: a profile stores
the absent list as JSON null and reads it back nil, while `netbird up` sends
CleanNATExternalIPs, an empty list, whenever NB_EXTERNAL_IP_MAP is set to
nothing, which a deployment template does by default.
So the gate saw a change where nothing changed and refused the request with
FailedPrecondition. That is the same deadlock this branch exists to remove,
reached through another field: a container with the kill switch on could not
come up, and `netbird up` reported "the daemon refused the settings update".
The DNS label list next to it already used slices.Equal, which treats nil and
empty as the same list. The NAT list now does too, and the last use of
reflect in the package goes with it.
Reported by pappz in review.
2851 lines
99 KiB
Go
2851 lines
99 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
gstatus "google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/auth"
|
|
"github.com/netbirdio/netbird/client/internal/expose"
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
|
"github.com/netbirdio/netbird/client/internal/updater"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/util"
|
|
"github.com/netbirdio/netbird/util/capture"
|
|
"github.com/netbirdio/netbird/version"
|
|
)
|
|
|
|
const (
|
|
probeThreshold = time.Second * 5
|
|
retryInitialIntervalVar = "NB_CONN_RETRY_INTERVAL_TIME"
|
|
maxRetryIntervalVar = "NB_CONN_MAX_RETRY_INTERVAL_TIME"
|
|
maxRetryTimeVar = "NB_CONN_MAX_RETRY_TIME_TIME"
|
|
retryMultiplierVar = "NB_CONN_RETRY_MULTIPLIER"
|
|
defaultInitialRetryTime = 30 * time.Minute
|
|
defaultMaxRetryInterval = 60 * time.Minute
|
|
defaultMaxRetryTime = 14 * 24 * time.Hour
|
|
defaultRetryMultiplier = 1.7
|
|
|
|
// JWT token cache TTL for the client daemon (disabled by default)
|
|
defaultJWTCacheTTL = 0
|
|
|
|
errRestoreResidualState = "failed to restore residual state: %v"
|
|
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
|
// errUpdateSettingsDisabled is returned with codes.FailedPrecondition, not
|
|
// codes.Unavailable: the daemon answered, and it refused. Unavailable means
|
|
// "the daemon cannot serve this", which is why the CLI downgrades it to a
|
|
// warning and the GUI reads it as an unreachable daemon — both wrong for a
|
|
// refusal the caller has to act on.
|
|
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
|
errNetworksDisabled = "network selection is disabled by the administrator"
|
|
)
|
|
|
|
var ErrServiceNotUp = errors.New("service is not up")
|
|
|
|
// Server for service control.
|
|
type Server struct {
|
|
rootCtx context.Context
|
|
actCancel context.CancelFunc
|
|
|
|
logFile string
|
|
|
|
// uiLogPath is the desktop UI's absolute log path, reported via
|
|
// RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
|
|
// can collect the GUI log even though the daemon runs as root and can't
|
|
// resolve the user's config dir. Last-writer-wins (one UI per socket).
|
|
// DebugBundle opens it on behalf of the bundle requester and refuses a file
|
|
// that caller does not own, so a local user cannot read another user's log
|
|
// or a root-only file through it.
|
|
uiLogPath string
|
|
|
|
oauthAuthFlow oauthAuthFlow
|
|
// extendAuthSessionFlow holds the pending PKCE flow created by
|
|
// RequestExtendAuthSession until WaitExtendAuthSession resolves it.
|
|
// Kept separate from oauthAuthFlow (which is reserved for the SSH
|
|
// JWT path) so a concurrent SSH auth doesn't clobber the session
|
|
// extend flow or vice versa.
|
|
extendAuthSessionFlow *auth.PendingFlow
|
|
|
|
// guardedConfigMu serializes a privilege check against the write it
|
|
// authorizes. Without it the two are separate steps over the same file, and a
|
|
// change that was allowed because the profile had the SSH server disabled
|
|
// could land after a concurrent privileged request enabled it.
|
|
guardedConfigMu sync.Mutex
|
|
|
|
mutex sync.Mutex
|
|
config *profilemanager.Config
|
|
proto.UnimplementedDaemonServiceServer
|
|
// clientRunning tracks "the daemon wants to be connected" — set true by
|
|
// Start / Up, cleared by Down / Logout. Persists across retry
|
|
// loops, signal disconnects, and ErrResetConnection cycles. NOT
|
|
// changed by connectWithRetryRuns goroutine exit — for that
|
|
// (goroutine-still-alive) check, see connectionGoroutineRunning() which
|
|
// derives from clientGiveUpChan close state. Protected by s.mutex.
|
|
clientRunning bool
|
|
clientRunningChan chan struct{}
|
|
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
|
|
|
|
connectClient *internal.ConnectClient
|
|
|
|
statusRecorder *peer.Status
|
|
sessionWatcher *internal.SessionWatcher
|
|
localMetrics *localmetrics.Manager
|
|
|
|
probeThrottle *probeThrottle
|
|
persistSyncResponse bool
|
|
isSessionActive atomic.Bool
|
|
|
|
cpuProfileBuf *bytes.Buffer
|
|
cpuProfiling bool
|
|
|
|
profileManager *profilemanager.ServiceManager
|
|
profilesDisabled bool
|
|
updateSettingsDisabled bool
|
|
captureEnabled bool
|
|
bundleCapture *bundleCapture
|
|
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
|
activeCapture *capture.Session
|
|
networksDisabled bool
|
|
|
|
sleepHandler *sleephandler.SleepHandler
|
|
|
|
// mdmTicker periodically re-reads the OS-native MDM policy and triggers
|
|
// an engine restart when the policy changes. Launched once by Start;
|
|
// stopped by the rootCtx cancellation.
|
|
mdmTicker *mdm.Ticker
|
|
|
|
// mdmLoader is the daemon-owned source of the active MDM policy.
|
|
// Constructed once during Server.Start (with a nil PolicyFetcher on
|
|
// desktop — the build-tagged Loader.loadPlatform reads the OS
|
|
// registry / plist directly) and injected into every consumer:
|
|
// mdmTicker for its periodic reload, the SetConfig / Login MDM
|
|
// gates for conflict detection, and every Config produced via
|
|
// getConfig() so its apply() picks up the same overlay.
|
|
mdmLoader *mdm.Loader
|
|
|
|
updateManager *updater.Manager
|
|
|
|
jwtCache *jwtCache
|
|
|
|
// loginAttemptFn stands in for the Management login round trip. Tests set
|
|
// it to drive the login outcomes that need a server on the other end;
|
|
// production leaves it nil, and every login goes through loginAttempt.
|
|
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
|
|
|
|
isLoginRequiredFn func(ctx context.Context) (bool, error)
|
|
}
|
|
|
|
type oauthAuthFlow struct {
|
|
expiresAt time.Time
|
|
flow auth.OAuthFlow
|
|
info auth.AuthFlowInfo
|
|
|
|
// cacheGeneration is the SSH JWT cache's generation as of the start of the
|
|
// request that created this flow. The flow outlives a profile switch, so
|
|
// reading the generation any later — when the IdP has answered, or when the
|
|
// token finally arrives — would read the new session's one and let the old
|
|
// session's token into the new session's cache.
|
|
cacheGeneration uint64
|
|
|
|
waitCancel context.CancelFunc
|
|
}
|
|
|
|
// New server instance constructor.
|
|
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, captureEnabled bool, networksDisabled bool) *Server {
|
|
s := &Server{
|
|
rootCtx: ctx,
|
|
logFile: logFile,
|
|
persistSyncResponse: true,
|
|
statusRecorder: peer.NewRecorder(""),
|
|
profileManager: profilemanager.NewServiceManager(configFile),
|
|
profilesDisabled: profilesDisabled,
|
|
updateSettingsDisabled: updateSettingsDisabled,
|
|
captureEnabled: captureEnabled,
|
|
networksDisabled: networksDisabled,
|
|
jwtCache: newJWTCache(),
|
|
extendAuthSessionFlow: auth.NewPendingFlow(),
|
|
probeThrottle: newProbeThrottle(probeThreshold),
|
|
}
|
|
agent := &serverAgent{s}
|
|
s.sleepHandler = sleephandler.New(agent)
|
|
s.startSleepDetector()
|
|
|
|
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
|
|
|
|
return s
|
|
}
|
|
|
|
// clientMetricsGatherer returns the Prometheus gatherer of the running
|
|
// engine's client metrics, or nil when no engine is running.
|
|
func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
|
|
s.mutex.Lock()
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
|
|
if connectClient == nil {
|
|
return nil
|
|
}
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
return engine.GetClientMetrics().PrometheusGatherer()
|
|
}
|
|
|
|
func (s *Server) Start() error {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if s.clientRunning {
|
|
return nil
|
|
}
|
|
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
// Every contextState.Set in the connect/login/server paths must push a
|
|
// SubscribeStatus snapshot, otherwise transitions that don't happen to
|
|
// be accompanied by a Mark{Management,Signal,...} call (e.g. plain
|
|
// StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed
|
|
// after OAuth init failure, StatusIdle in the Login defer) leave the
|
|
// UI stuck on the previous status until the next unrelated peer event.
|
|
// Binding the recorder here means new state.Set callsites don't have
|
|
// to opt in individually.
|
|
state.SetOnChange(s.statusRecorder.NotifyStateChange)
|
|
|
|
if err := handlePanicLog(); err != nil {
|
|
log.Warnf("failed to redirect stderr: %v", err)
|
|
}
|
|
|
|
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
|
log.Warnf(errRestoreResidualState, err)
|
|
}
|
|
|
|
if s.updateManager == nil {
|
|
stateMgr := statemanager.New(s.profileManager.GetStatePath())
|
|
s.updateManager = updater.NewManager(s.statusRecorder, stateMgr)
|
|
s.updateManager.CheckUpdateSuccess(s.rootCtx)
|
|
}
|
|
|
|
// MDM policy reload ticker: every minute the desktop daemon re-reads
|
|
// the OS-native managed-config store and, on diff vs the previous
|
|
// observation, cancels the active engine context so connectWithRetry-
|
|
// Runs re-resolves Config (re-running profilemanager.Config.apply which
|
|
// applies the freshly-read MDM policy as the last layer) and brings
|
|
// the engine back with the new values.
|
|
if s.mdmLoader == nil {
|
|
// Desktop builds pass a nil PolicyFetcher: the Loader's
|
|
// build-tagged loadPlatform reads the OS source directly
|
|
// (registry on Windows, plist on macOS, no-op elsewhere).
|
|
s.mdmLoader = mdm.NewLoader(nil)
|
|
}
|
|
if s.mdmTicker == nil {
|
|
s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader)
|
|
go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
|
|
}
|
|
|
|
// if current state contains any error, return it
|
|
// in all other cases we can continue execution only if status is idle and up command was
|
|
// not in the progress or already successfully established connection.
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if status != internal.StatusIdle {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(s.rootCtx)
|
|
s.actCancel = cancel
|
|
|
|
// copy old default config
|
|
_, err = s.profileManager.CopyDefaultProfileIfNotExists()
|
|
if err != nil && !errors.Is(err, profilemanager.ErrorOldDefaultConfigNotFound) {
|
|
return err
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
config, existingConfig, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile config: %v", err)
|
|
|
|
return err
|
|
}
|
|
s.config = config
|
|
|
|
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
|
|
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
|
|
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
|
|
|
if s.sessionWatcher == nil {
|
|
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
|
|
s.sessionWatcher.SetOnExpireListener(s.onSessionExpire)
|
|
}
|
|
|
|
if config.DisableAutoConnect {
|
|
state.Set(internal.StatusIdle)
|
|
return nil
|
|
}
|
|
|
|
if !existingConfig {
|
|
log.Warnf("not trying to connect when configuration was just created")
|
|
state.Set(internal.StatusNeedsLogin)
|
|
return nil
|
|
}
|
|
|
|
s.clientRunning = true
|
|
s.clientRunningChan = make(chan struct{})
|
|
s.clientGiveUpChan = make(chan struct{})
|
|
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
|
s.publishConfigChangedEvent(proto.MetadataSourceStartup)
|
|
return nil
|
|
}
|
|
|
|
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
|
|
// mechanism to keep the client connected even when the connection is lost.
|
|
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
|
|
//
|
|
// The goroutine's exit is signalled to the daemon via close(giveUpChan)
|
|
// — placed in the function-scope defer so every return path (panic,
|
|
// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
|
|
// it. Callers that need to observe "is the goroutine still alive?" use
|
|
// Server.connectionGoroutineRunning() which non-blockingly checks the close state
|
|
// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
|
|
// "intent" (clientRunning) is maintained by the RPC handlers, not by this
|
|
// goroutine.
|
|
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
|
|
// close(giveUpChan) MUST run on every exit path (DisableAutoConnect
|
|
// return, backoff.Retry return, panic) — Down() blocks for up to 5s
|
|
// waiting on this signal before flipping the state to Idle, and a
|
|
// missed close leaves Down() always hitting the timeout.
|
|
defer func() {
|
|
if giveUpChan != nil {
|
|
close(giveUpChan)
|
|
}
|
|
}()
|
|
|
|
if s.config.DisableAutoConnect {
|
|
if err := s.connect(ctx, s.config, s.statusRecorder, runningChan); err != nil {
|
|
log.Debugf("run client connection exited with error: %v", err)
|
|
}
|
|
log.Tracef("client connection exited")
|
|
return
|
|
}
|
|
|
|
backOff := getConnectWithBackoff(ctx)
|
|
go func() {
|
|
t := time.NewTicker(24 * time.Hour)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Stop()
|
|
return
|
|
case <-t.C:
|
|
mgmtState := statusRecorder.GetManagementState()
|
|
signalState := statusRecorder.GetSignalState()
|
|
if mgmtState.Connected && signalState.Connected {
|
|
log.Tracef("resetting status")
|
|
backOff.Reset()
|
|
} else {
|
|
log.Tracef("not resetting status: mgmt: %v, signal: %v", mgmtState.Connected, signalState.Connected)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
runOperation := func() error {
|
|
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
|
if err != nil {
|
|
// PermissionDenied means the daemon transitioned to NeedsLogin
|
|
// inside connect(). Without backoff.Permanent the outer retry
|
|
// re-enters connect(), which resets the state to Connecting and
|
|
// makes the tray flicker between NeedsLogin and Connecting until
|
|
// the user logs in. Stop retrying and let the state stick.
|
|
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
|
|
log.Debugf("run client connection exited with PermissionDenied, waiting for login")
|
|
return backoff.Permanent(err)
|
|
}
|
|
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
|
return err
|
|
}
|
|
|
|
log.Tracef("client connection exited gracefully, do not need to retry")
|
|
return nil
|
|
}
|
|
|
|
if err := backoff.Retry(runOperation, backOff); err != nil {
|
|
log.Errorf("operation failed: %v", err)
|
|
}
|
|
// giveUpChan is closed by the function-scope defer.
|
|
}
|
|
|
|
// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
|
|
// still running. Returns false when no goroutine has ever been started
|
|
// AND when the most recent one has already closed clientGiveUpChan on
|
|
// exit (whether due to ctx cancel, DisableAutoConnect single-shot
|
|
// completion, or backoff retry exhaustion).
|
|
//
|
|
// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
|
|
// is written by Start/Up under the same lock.
|
|
func (s *Server) connectionGoroutineRunning() bool {
|
|
if s.clientGiveUpChan == nil {
|
|
return false
|
|
}
|
|
select {
|
|
case <-s.clientGiveUpChan:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// attemptLogin runs a login round trip against Management, or the stand-in a
|
|
// test installed in place of it.
|
|
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
|
if s.loginAttemptFn != nil {
|
|
return s.loginAttemptFn(ctx, setupKey, jwtToken)
|
|
}
|
|
return s.loginAttempt(ctx, setupKey, jwtToken)
|
|
}
|
|
|
|
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
|
|
if s.isLoginRequiredFn != nil {
|
|
return s.isLoginRequiredFn(ctx)
|
|
}
|
|
|
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
|
if err != nil {
|
|
log.Errorf("failed to create auth client: %v", err)
|
|
return false, err
|
|
}
|
|
defer authClient.Close()
|
|
|
|
return authClient.IsLoginRequired(ctx)
|
|
}
|
|
|
|
// loginAttempt attempts to login using the provided information. It returns
|
|
// StatusNeedsLogin when Management refused the peer's credentials and
|
|
// StatusLoginFailed for every other failure, so callers can tell an
|
|
// authentication decision apart from a login that never got made.
|
|
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
|
|
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
|
|
if err != nil {
|
|
log.Errorf("failed to create auth client: %v", err)
|
|
return internal.StatusLoginFailed, err
|
|
}
|
|
defer authClient.Close()
|
|
|
|
var status internal.StatusType
|
|
err, isAuthError := authClient.Login(ctx, setupKey, jwtToken)
|
|
if err != nil {
|
|
if isAuthError {
|
|
log.Warnf("failed login: %v", err)
|
|
status = internal.StatusNeedsLogin
|
|
} else {
|
|
log.Errorf("failed login: %v", err)
|
|
status = internal.StatusLoginFailed
|
|
}
|
|
return status, err
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// Login uses setup key to prepare configuration for the daemon.
|
|
func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) {
|
|
// Privilege gate: refuse the parts of the request that would let a local
|
|
// user turn the root daemon into a root shell. Held across the write so the
|
|
// config cannot gain the SSH server between the decision and the update.
|
|
//
|
|
// Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding
|
|
// guardedConfigMu, so acquiring the two in the other order here would let a
|
|
// concurrent login deadlock the daemon.
|
|
s.guardedConfigMu.Lock()
|
|
defer s.guardedConfigMu.Unlock()
|
|
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
config, err := s.setConfigInputFromRequest(msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update-settings gate: refuse the request only when it would actually
|
|
// change a persisted setting. The CLI builds a SetConfigRequest
|
|
// unconditionally on every `netbird up` (setupSetConfigReq in
|
|
// cmd/up.go) and fills it from its flags and environment, so a service
|
|
// or container that restates the configuration it already runs with
|
|
// must pass the gate. Deciding this on field presence alone refused
|
|
// those callers, and — through the identical gate in Login — refused
|
|
// their login too, which left a client configured by environment
|
|
// (NB_MANAGEMENT_URL and friends) unable to come up at all.
|
|
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, config) {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
|
|
}
|
|
|
|
// MDM gate: refuse the whole request if any of its fields is enforced
|
|
// by the active MDM policy. The error carries an MDMManagedFields-
|
|
// Violation detail listing the offending key names. Non-conflicting
|
|
// fields in the same request are not applied either.
|
|
policy := s.mdmLoader.Load()
|
|
if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updatedConf, err := profilemanager.UpdateConfig(config)
|
|
if err != nil {
|
|
log.Errorf("failed to update profile config: %v", err)
|
|
return nil, fmt.Errorf("failed to update profile config: %w", err)
|
|
}
|
|
|
|
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
|
|
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
|
|
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
|
|
}
|
|
}
|
|
|
|
return &proto.SetConfigResponse{}, nil
|
|
}
|
|
|
|
// setConfigInputFromRequest translates a SetConfigRequest into the
|
|
// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
|
|
// Pure mapping with no business logic beyond presence-aware copying of
|
|
// optional fields and the "empty / clean" semantics for the two slice
|
|
// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
|
|
// keep the handler's cognitive complexity below the SonarCube
|
|
// threshold; the body is intentionally linear because each proto
|
|
// field is its own optional case. Returns the resolved ConfigInput
|
|
// and a non-nil error only when the active profile file path cannot
|
|
// be determined.
|
|
func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
|
|
var config profilemanager.ConfigInput
|
|
|
|
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
|
if err != nil {
|
|
log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
|
|
return config, err
|
|
}
|
|
profPath := resolved.Path
|
|
if profPath == "" {
|
|
profPath = profilemanager.DefaultConfigPath
|
|
}
|
|
config.ConfigPath = profPath
|
|
|
|
if msg.ManagementUrl != "" {
|
|
config.ManagementURL = msg.ManagementUrl
|
|
}
|
|
if msg.AdminURL != "" {
|
|
config.AdminURL = msg.AdminURL
|
|
}
|
|
if msg.InterfaceName != nil {
|
|
config.InterfaceName = msg.InterfaceName
|
|
}
|
|
if msg.WireguardPort != nil {
|
|
wgPort := int(*msg.WireguardPort)
|
|
config.WireguardPort = &wgPort
|
|
}
|
|
if msg.OptionalPreSharedKey != nil {
|
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
|
}
|
|
|
|
if msg.CleanDNSLabels {
|
|
config.DNSLabels = domain.List{}
|
|
} else if msg.DnsLabels != nil {
|
|
config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
|
|
}
|
|
|
|
if msg.CleanNATExternalIPs {
|
|
config.NATExternalIPs = make([]string, 0)
|
|
} else if msg.NatExternalIPs != nil {
|
|
config.NATExternalIPs = msg.NatExternalIPs
|
|
}
|
|
|
|
config.CustomDNSAddress = msg.CustomDNSAddress
|
|
if string(msg.CustomDNSAddress) == "empty" {
|
|
config.CustomDNSAddress = []byte{}
|
|
}
|
|
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
|
|
|
|
if msg.DnsRouteInterval != nil {
|
|
interval := msg.DnsRouteInterval.AsDuration()
|
|
config.DNSRouteInterval = &interval
|
|
}
|
|
|
|
config.RosenpassEnabled = msg.RosenpassEnabled
|
|
config.RosenpassPermissive = msg.RosenpassPermissive
|
|
config.LocalMetricsEnabled = msg.EnableLocalMetrics
|
|
config.LocalMetricsAddress = msg.LocalMetricsAddress
|
|
config.DisableAutoConnect = msg.DisableAutoConnect
|
|
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
|
config.RemoteJobsAllowed = msg.RemoteJobsAllowed
|
|
config.NetworkMonitor = msg.NetworkMonitor
|
|
config.DisableClientRoutes = msg.DisableClientRoutes
|
|
config.DisableServerRoutes = msg.DisableServerRoutes
|
|
config.DisableDNS = msg.DisableDns
|
|
config.DisableFirewall = msg.DisableFirewall
|
|
config.BlockLANAccess = msg.BlockLanAccess
|
|
config.DisableNotifications = msg.DisableNotifications
|
|
config.BlockInbound = msg.BlockInbound
|
|
config.DisableIPv6 = msg.DisableIpv6
|
|
config.EnableSSHRoot = msg.EnableSSHRoot
|
|
config.EnableSSHSFTP = msg.EnableSSHSFTP
|
|
config.EnableSSHLocalPortForwarding = msg.EnableSSHLocalPortForwarding
|
|
config.EnableSSHRemotePortForwarding = msg.EnableSSHRemotePortForwarding
|
|
if msg.DisableSSHAuth != nil {
|
|
config.DisableSSHAuth = msg.DisableSSHAuth
|
|
}
|
|
if msg.SshJWTCacheTTL != nil {
|
|
ttl := int(*msg.SshJWTCacheTTL)
|
|
config.SSHJWTCacheTTL = &ttl
|
|
}
|
|
if msg.Mtu != nil {
|
|
mtu := uint16(*msg.Mtu)
|
|
config.MTU = &mtu
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
// Login uses setup key to prepare configuration for the daemon.
|
|
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
// The stored config of the profile this request targets backs all three
|
|
// gates below. It is read before anything changes daemon state, so a
|
|
// refused login neither switches the profile nor cancels a login already
|
|
// in progress, and it is the profile the switch further down would
|
|
// activate.
|
|
stored, err := s.storedLoginConfig(activeProf, msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Config-override gates. LoginRequest carries the same surface as
|
|
// SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
|
|
// ...), so the same protections must apply. Without these the CLI
|
|
// command `netbird up --management-url=X` (which falls through to
|
|
// Login when SetConfig is rejected — see cmd/up.go) would silently
|
|
// bypass `--disable-update-settings` and any MDM policy.
|
|
//
|
|
// The update-settings gate is value-aware, as in SetConfig: it looks at
|
|
// what a login would actually persist (loginOverridesInput) and refuses
|
|
// only a real divergence from the stored config. A login that restates
|
|
// the values already on disk changes nothing, so it must go through —
|
|
// that is what keeps a re-login, or a container restart carrying
|
|
// NB_MANAGEMENT_URL, working with the kill switch on.
|
|
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
|
|
}
|
|
|
|
policy := s.mdmLoader.Load()
|
|
if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Privilege gate: same restrictions as SetConfig, since LoginRequest can carry
|
|
// the same fields.
|
|
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
status := state.CurrentStatus()
|
|
if status == internal.StatusConnected {
|
|
return &proto.LoginResponse{}, nil
|
|
}
|
|
|
|
defer func() {
|
|
status, err := state.Status()
|
|
if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) {
|
|
state.Set(internal.StatusIdle)
|
|
}
|
|
}()
|
|
|
|
ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf)
|
|
if err != nil {
|
|
// The RPC boundary is where this gets recorded: nothing logs handler
|
|
// errors for us, and a caller that retries would otherwise leave no
|
|
// trace in the daemon log. A refusal is skipped because the gate has
|
|
// already logged the decision, with the caller's identity.
|
|
if gstatus.Code(err) != codes.PermissionDenied {
|
|
log.Errorf("failed to prepare login: %v", err)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
|
|
|
s.mutex.Lock()
|
|
|
|
if msg.Hostname != "" {
|
|
// nolint
|
|
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, msg.Hostname)
|
|
}
|
|
|
|
s.mutex.Unlock()
|
|
|
|
config, _, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile config: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile config: %w", err)
|
|
}
|
|
s.mutex.Lock()
|
|
s.config = config
|
|
s.mutex.Unlock()
|
|
|
|
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
|
|
|
// A probe that errors leaves the login undecided: Management unreachable, a
|
|
// restart mid-request, an internal error. Those are returned for the caller
|
|
// to retry, because turning them into an SSO prompt asks the user to solve
|
|
// something that is not theirs to solve, and a browser login cannot succeed
|
|
// while Management is unreachable anyway. Only Management refusing the
|
|
// peer's key is a decision, and IsLoginRequired reports that as
|
|
// needsLogin=true rather than an error.
|
|
needsLogin, err := s.isLoginRequired(ctx)
|
|
if err != nil {
|
|
state.Set(internal.StatusLoginFailed)
|
|
return nil, err
|
|
}
|
|
if !needsLogin {
|
|
state.Set(internal.StatusIdle)
|
|
return &proto.LoginResponse{}, nil
|
|
}
|
|
|
|
if msg.SetupKey == "" {
|
|
return s.beginSSOLogin(ctx, config, msg)
|
|
}
|
|
|
|
// Setup-key path: we are about to dial Management with the key, so the
|
|
// Connecting paint is meaningful here — unlike the SSO branch above,
|
|
// which returns NeedsLogin and parks on the browser leg.
|
|
state.Set(internal.StatusConnecting)
|
|
|
|
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
|
|
state.Set(loginStatus)
|
|
return nil, err
|
|
}
|
|
|
|
return &proto.LoginResponse{}, nil
|
|
}
|
|
|
|
// beginSSOLogin starts the browser leg of a login that carries no setup key and
|
|
// returns the response that parks the caller on it.
|
|
func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
|
|
hint := ""
|
|
if msg.Hint != nil {
|
|
hint = *msg.Hint
|
|
}
|
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
|
|
if err != nil {
|
|
state.Set(internal.StatusLoginFailed)
|
|
return nil, err
|
|
}
|
|
|
|
if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil {
|
|
state.Set(internal.StatusNeedsLogin)
|
|
return resp, nil
|
|
}
|
|
|
|
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
|
if err != nil {
|
|
log.Errorf("getting a request OAuth flow failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow.flow = oAuthFlow
|
|
s.oauthAuthFlow.info = authInfo
|
|
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
|
|
s.mutex.Unlock()
|
|
|
|
state.Set(internal.StatusNeedsLogin)
|
|
|
|
return &proto.LoginResponse{
|
|
NeedsSSOLogin: true,
|
|
VerificationURI: authInfo.VerificationURI,
|
|
VerificationURIComplete: authInfo.VerificationURIComplete,
|
|
UserCode: authInfo.UserCode,
|
|
}, nil
|
|
}
|
|
|
|
// pendingOAuthFlowResponse returns the in-flight flow's response when it
|
|
// targets the same IdP client and has enough time left for the user to finish
|
|
// the browser leg, so a second login joins the pending flow instead of opening
|
|
// a competing one. A flow too close to expiry has its waiter cancelled and nil
|
|
// returned, leaving the caller to start a fresh flow.
|
|
func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse {
|
|
if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
|
|
return nil
|
|
}
|
|
|
|
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
|
|
log.Debugf("using previous oauth flow info")
|
|
return &proto.LoginResponse{
|
|
NeedsSSOLogin: true,
|
|
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
|
|
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
|
|
UserCode: s.oauthAuthFlow.info.UserCode,
|
|
}
|
|
}
|
|
|
|
log.Warnf("canceling previous waiting execution")
|
|
if s.oauthAuthFlow.waitCancel != nil {
|
|
s.oauthAuthFlow.waitCancel()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
|
|
// device/PKCE flow and blocks until the user finishes the browser leg.
|
|
//
|
|
// The daemon holds StatusNeedsLogin for the whole browser wait (set on
|
|
// entry): the login is not done until the token returns, so a client that
|
|
// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads
|
|
// "login required" and offers the affordance, instead of a Connecting that
|
|
// never resolves. The wait is also tied to the caller's context (see the
|
|
// goroutine below), so a client that goes away cancels the wait instead of
|
|
// orphaning it on rootCtx until the device-code window expires.
|
|
//
|
|
// State transitions on exit:
|
|
//
|
|
// ┌──────────────────────────────────────────┬──────────────────────────────────┐
|
|
// │ Outcome │ contextState │
|
|
// ├──────────────────────────────────────────┼──────────────────────────────────┤
|
|
// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │
|
|
// │ │ drives Connecting → Connected │
|
|
// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │
|
|
// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │
|
|
// │ UserCode mismatch │ StatusLoginFailed │
|
|
// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │
|
|
// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │
|
|
// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │
|
|
// │ switch / app quit / another │ internal aborts are │
|
|
// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │
|
|
// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │
|
|
// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │
|
|
// │ while waiting on the browser leg) │ re-enters the Login flow) │
|
|
// │ WaitToken: any other error │ StatusLoginFailed │
|
|
// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │
|
|
// │ failure, token validation rejection) │ surfaced verbatim to caller) │
|
|
// └──────────────────────────────────────────┴──────────────────────────────────┘
|
|
//
|
|
// The defer still applies a StatusIdle fallback for the early
|
|
// oauth-flow-not-initialized return (before the entry Set), so a half state
|
|
// doesn't leak when there is nothing to wait on.
|
|
func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) {
|
|
s.mutex.Lock()
|
|
if s.actCancel != nil {
|
|
s.actCancel()
|
|
}
|
|
ctx, cancel := context.WithCancel(s.rootCtx)
|
|
|
|
// Tie the in-flight browser wait to the caller. ctx stays rooted in
|
|
// rootCtx so CtxGetState resolves the daemon's contextState, but if the
|
|
// UI window or CLI that drove the login goes away mid-flow (restart,
|
|
// Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of
|
|
// orphaning it on rootCtx until the OAuth device-code window expires.
|
|
// The goroutine exits as soon as either context completes, so it can't
|
|
// outlive the RPC.
|
|
go func() {
|
|
select {
|
|
case <-callerCtx.Done():
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
md, ok := metadata.FromIncomingContext(callerCtx)
|
|
if ok {
|
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
|
}
|
|
|
|
if msg.Hostname != "" {
|
|
// nolint
|
|
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, msg.Hostname)
|
|
}
|
|
|
|
s.actCancel = cancel
|
|
s.mutex.Unlock()
|
|
|
|
if s.oauthAuthFlow.flow == nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "oauth flow is not initialized")
|
|
}
|
|
|
|
state := internal.CtxGetState(ctx)
|
|
defer func() {
|
|
s, err := state.Status()
|
|
if err != nil || (s != internal.StatusNeedsLogin && s != internal.StatusLoginFailed) {
|
|
state.Set(internal.StatusIdle)
|
|
}
|
|
}()
|
|
|
|
// Hold NeedsLogin for the whole browser wait — the login is not done
|
|
// until the token returns, so a client that (re)attaches mid-wait
|
|
// (restarted UI, second `netbird up`) reads "login required" and offers
|
|
// the affordance instead of a Connecting that never resolves.
|
|
state.Set(internal.StatusNeedsLogin)
|
|
|
|
s.mutex.Lock()
|
|
flowInfo := s.oauthAuthFlow.info
|
|
s.mutex.Unlock()
|
|
|
|
if flowInfo.UserCode != msg.UserCode {
|
|
state.Set(internal.StatusLoginFailed)
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "sso user code is invalid")
|
|
}
|
|
|
|
if s.oauthAuthFlow.waitCancel != nil {
|
|
s.oauthAuthFlow.waitCancel()
|
|
}
|
|
|
|
waitCTX, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow.waitCancel = cancel
|
|
s.mutex.Unlock()
|
|
|
|
tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo)
|
|
if err != nil {
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow.expiresAt = time.Now()
|
|
s.mutex.Unlock()
|
|
switch {
|
|
case errors.Is(err, context.Canceled):
|
|
// External abort. If our caller cancelled (the client closed
|
|
// the browser-login popup, or the UI went away — callerCtx is
|
|
// done), clear the abandoned OAuth flow so a fresh Login starts
|
|
// a new device code instead of reusing this one. The entry
|
|
// NeedsLogin stays in place, so a reattaching client shows the
|
|
// login affordance. An internal abort (actCancel from a new
|
|
// Login/WaitSSOLogin, callerCtx still live) leaves the flow for
|
|
// the new owner — don't clobber it.
|
|
if callerCtx.Err() != nil {
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow = oauthAuthFlow{}
|
|
s.mutex.Unlock()
|
|
}
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
// OAuth device-code window expired with no user action.
|
|
// Retryable — leave the daemon in NeedsLogin so the UI
|
|
// keeps the Login affordance instead of reading as a
|
|
// hard failure.
|
|
state.Set(internal.StatusNeedsLogin)
|
|
default:
|
|
state.Set(internal.StatusLoginFailed)
|
|
}
|
|
log.Errorf("waiting for browser login failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow.expiresAt = time.Now()
|
|
s.mutex.Unlock()
|
|
|
|
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
|
|
state.Set(loginStatus)
|
|
return nil, err
|
|
}
|
|
|
|
log.Infof("SSO login flow finished, returning success to caller")
|
|
return &proto.WaitSSOLoginResponse{
|
|
Email: tokenInfo.Email,
|
|
}, nil
|
|
}
|
|
|
|
// Up starts engine work in the daemon.
|
|
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
|
|
log.Infof("up request received")
|
|
s.mutex.Lock()
|
|
// clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
|
|
// by Down). connectionGoroutineRunning() reports whether the previous retry-loop
|
|
// goroutine is still trying. When intent is up AND goroutine is alive,
|
|
// the existing engine is on the job — just wait for it. When intent
|
|
// is up but the goroutine has given up (backoff exhausted) OR when
|
|
// intent is down, fall through to spawn a fresh retry loop.
|
|
if s.clientRunning && s.connectionGoroutineRunning() {
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
s.mutex.Unlock()
|
|
return nil, err
|
|
}
|
|
if status == internal.StatusNeedsLogin {
|
|
s.actCancel()
|
|
}
|
|
s.mutex.Unlock()
|
|
|
|
return s.waitForUp(callerCtx)
|
|
}
|
|
if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil {
|
|
log.Warnf(errRestoreResidualState, err)
|
|
}
|
|
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
|
|
// if current state contains any error, return it
|
|
// in all other cases we can continue execution only if status is idle and up command was
|
|
// not in the progress or already successfully established connection.
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
s.mutex.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
// StatusNeedsLogin is a legitimate fresh-start entry state: a successful
|
|
// WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is
|
|
// done, the token is in hand, but the engine hasn't been brought up yet —
|
|
// see WaitSSOLogin's state-transition table). The same holds after a
|
|
// mid-session expiry tore the engine down (clientRunning == false) and the
|
|
// user re-authenticated. In both cases the caller's Up is expected to drive
|
|
// the connection; treat NeedsLogin like Idle and reset to Idle so the
|
|
// engine's own StatusConnecting → StatusConnected progression starts from a
|
|
// clean slate. Without this, the first Up after an SSO login fails with
|
|
// "up already in progress" and the user has to trigger Up a second time
|
|
// (CLI: re-run `netbird up`; GUI: click Connect again).
|
|
if status == internal.StatusNeedsLogin {
|
|
status = internal.StatusIdle
|
|
state.Set(internal.StatusIdle)
|
|
}
|
|
|
|
if status != internal.StatusIdle {
|
|
s.mutex.Unlock()
|
|
return nil, fmt.Errorf("up already in progress: current status %s", status)
|
|
}
|
|
|
|
// it should be nil here, but in case it isn't we cancel it.
|
|
if s.actCancel != nil {
|
|
s.actCancel()
|
|
}
|
|
ctx, cancel := context.WithCancel(s.rootCtx)
|
|
md, ok := metadata.FromIncomingContext(callerCtx)
|
|
if ok {
|
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
|
}
|
|
|
|
s.actCancel = cancel
|
|
|
|
if s.config == nil {
|
|
s.mutex.Unlock()
|
|
return nil, fmt.Errorf("config is not defined, please call login command first")
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
s.mutex.Unlock()
|
|
log.Errorf("failed to get active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
if msg != nil && msg.ProfileName != nil {
|
|
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
|
s.mutex.Unlock()
|
|
log.Errorf("failed to switch profile: %v", err)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
activeProf, err = s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
s.mutex.Unlock()
|
|
log.Errorf("failed to get active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
|
|
|
|
config, _, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
s.mutex.Unlock()
|
|
log.Errorf("failed to get active profile config: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile config: %w", err)
|
|
}
|
|
s.config = config
|
|
|
|
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
|
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
|
s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
|
|
|
|
s.clientRunning = true
|
|
s.clientRunningChan = make(chan struct{})
|
|
s.clientGiveUpChan = make(chan struct{})
|
|
|
|
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
|
s.publishConfigChangedEvent(proto.MetadataSourceUpRPC)
|
|
|
|
s.mutex.Unlock()
|
|
if msg.GetAsync() {
|
|
return &proto.UpResponse{}, nil
|
|
}
|
|
return s.waitForUp(callerCtx)
|
|
}
|
|
|
|
// todo: handle potential race conditions
|
|
func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) {
|
|
timeoutCtx, cancel := context.WithTimeout(callerCtx, 50*time.Second)
|
|
defer cancel()
|
|
|
|
select {
|
|
case <-s.clientGiveUpChan:
|
|
return nil, fmt.Errorf("client gave up to connect")
|
|
case <-s.clientRunningChan:
|
|
s.isSessionActive.Store(true)
|
|
return &proto.UpResponse{}, nil
|
|
case <-callerCtx.Done():
|
|
log.Debug("context done, stopping the wait for engine to become ready")
|
|
return nil, callerCtx.Err()
|
|
case <-timeoutCtx.Done():
|
|
log.Debug("up is timed out, stopping the wait for engine to become ready")
|
|
return nil, timeoutCtx.Err()
|
|
}
|
|
}
|
|
|
|
// storedProfileConfig loads the on-disk config of the profile a request
|
|
// targets, so a privileged-change decision can be made against the values the
|
|
// profile currently holds. A profile that has no config file yet yields nil,
|
|
// which every caller must read as "nothing enabled yet".
|
|
func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) {
|
|
resolved, err := s.resolveProfileHandle(handle, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
path := resolved.Path
|
|
if path == "" {
|
|
path = profilemanager.DefaultConfigPath
|
|
}
|
|
|
|
return s.storedConfigAtPath(path)
|
|
}
|
|
|
|
// storedLoginConfig loads the on-disk config of the profile a login request
|
|
// targets: the one it names, or the active one when it names none. Used to decide
|
|
// a privileged change before the request is allowed to switch profiles.
|
|
func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) {
|
|
if msg.ProfileName == nil {
|
|
cfgPath, err := activeProf.FilePath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("active profile file path: %w", err)
|
|
}
|
|
return s.storedConfigAtPath(cfgPath)
|
|
}
|
|
|
|
// Mirrors switchProfileIfNeeded: the default profile resolves without a
|
|
// username, so this reads the same profile the switch would activate.
|
|
handle := *msg.ProfileName
|
|
username := ""
|
|
if handle != profilemanager.DefaultProfileName {
|
|
username = msg.GetUsername()
|
|
}
|
|
return s.storedProfileConfig(handle, username)
|
|
}
|
|
|
|
// storedConfigAtPath reads a profile config file, yielding nil when it does not
|
|
// exist yet.
|
|
//
|
|
// Reading it has no side effect: profilemanager.GetExistingConfig does not
|
|
// write, so a request that the gates go on to refuse leaves the profile file as
|
|
// it found it.
|
|
func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return nil, fmt.Errorf("stat profile config: %w", err)
|
|
}
|
|
|
|
cfg, err := profilemanager.GetExistingConfig(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read profile config: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// resolveProfileHandle resolves a wire-level profile handle (display
|
|
// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
|
|
// status errors so handlers can return them directly.
|
|
func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) {
|
|
p, err := s.profileManager.ResolveProfile(handle, username)
|
|
if err == nil {
|
|
return p, nil
|
|
}
|
|
var amb *profilemanager.ErrAmbiguousHandle
|
|
if errors.As(err, &amb) {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb)
|
|
}
|
|
if errors.Is(err, profilemanager.ErrProfileNotFound) {
|
|
return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle)
|
|
}
|
|
return nil, fmt.Errorf("resolve profile: %w", err)
|
|
}
|
|
|
|
// switchProfileIfNeeded resolves the user-supplied handle, updates the
|
|
// active profile state if it differs from the current one, and returns
|
|
// the resolved profile so callers can include its ID in RPC responses.
|
|
func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
|
|
if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
|
|
log.Errorf("profile name is set to %s, but username is not provided", handle)
|
|
return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
|
|
}
|
|
|
|
var username string
|
|
if handle != profilemanager.DefaultProfileName {
|
|
username = *userName
|
|
}
|
|
|
|
resolved, err := s.resolveProfileHandle(handle, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resolved.ID != activeProf.ID || username != activeProf.Username {
|
|
if s.checkProfilesDisabled() {
|
|
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
|
|
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
|
}
|
|
|
|
log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
|
|
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
ID: resolved.ID,
|
|
Username: username,
|
|
}); err != nil {
|
|
log.Errorf("failed to set active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to set active profile state: %w", err)
|
|
}
|
|
}
|
|
|
|
return resolved, nil
|
|
}
|
|
|
|
// SwitchProfile switches the active profile in the daemon.
|
|
func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfileRequest) (*proto.SwitchProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
if msg != nil && msg.ProfileName != nil {
|
|
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
|
log.Errorf("failed to switch profile: %v", err)
|
|
return nil, err
|
|
}
|
|
}
|
|
activeProf, err = s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
config, _, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
log.Errorf("failed to get default profile config: %v", err)
|
|
return nil, fmt.Errorf("failed to get default profile config: %w", err)
|
|
}
|
|
|
|
s.config = config
|
|
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
|
|
|
|
s.jwtCache.clear()
|
|
|
|
if msg != nil && msg.ProfileName != nil {
|
|
s.publishProfileListChanged(*msg.ProfileName)
|
|
}
|
|
|
|
return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
|
|
}
|
|
|
|
// Down engine work in the daemon.
|
|
func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownResponse, error) {
|
|
s.mutex.Lock()
|
|
|
|
giveUpChan := s.clientGiveUpChan
|
|
|
|
if err := s.cleanupConnection(); err != nil {
|
|
s.mutex.Unlock()
|
|
if errors.Is(err, ErrServiceNotUp) {
|
|
log.Debugf("Down called while service not up: %v", err)
|
|
return nil, err
|
|
}
|
|
log.Errorf("failed to shut down properly: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
s.mutex.Unlock()
|
|
|
|
// Wait for the connectWithRetryRuns goroutine to finish with a short timeout.
|
|
// This prevents the goroutine from setting ErrResetConnection after Down() returns.
|
|
// The giveUpChan is closed by the goroutine's deferred cleanup (see
|
|
// connectWithRetryRuns) on every exit path. A timeout here typically
|
|
// means the goroutine is still wedged inside a slow teardown step.
|
|
if giveUpChan != nil {
|
|
select {
|
|
case <-giveUpChan:
|
|
log.Debugf("client goroutine finished, giveUpChan closed")
|
|
case <-time.After(5 * time.Second):
|
|
log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway")
|
|
}
|
|
}
|
|
|
|
// Set Idle only after the retry goroutine has exited (or timed out).
|
|
// Setting it earlier races with the goroutine's own Set(StatusConnecting)
|
|
// at the top of each retry attempt, which would leave the snapshot
|
|
// stuck at Connecting long after the user asked to disconnect.
|
|
internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle)
|
|
|
|
// Clear stale management/signal errors so the next Up() (typically for a
|
|
// different profile) starts with a clean status snapshot. Without this,
|
|
// a managementError left over from a LoginFailed cycle persists in the
|
|
// statusRecorder and appears in the new profile's initial
|
|
// SubscribeStatus snapshot, making the new profile look like it also
|
|
// failed to log in.
|
|
s.statusRecorder.MarkManagementDisconnected(nil)
|
|
s.statusRecorder.MarkSignalDisconnected(nil)
|
|
|
|
return &proto.DownResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) cleanupConnection() error {
|
|
s.oauthAuthFlow = oauthAuthFlow{}
|
|
|
|
if s.actCancel == nil {
|
|
return ErrServiceNotUp
|
|
}
|
|
|
|
// Daemon intent flips to "down" — all callers (Down RPC,
|
|
// Logout RPC handlers) tear down the connection because the user
|
|
// explicitly asked for it. MDM restart does NOT go through this
|
|
// path, so its clientRunning stays true.
|
|
s.clientRunning = false
|
|
|
|
// Capture the engine reference before cancelling the context.
|
|
// After actCancel(), the connectWithRetryRuns goroutine wakes up
|
|
// and sets connectClient.engine = nil, causing connectClient.Stop()
|
|
// to skip the engine shutdown entirely.
|
|
var engine *internal.Engine
|
|
if s.connectClient != nil {
|
|
engine = s.connectClient.Engine()
|
|
}
|
|
|
|
s.actCancel()
|
|
|
|
if s.connectClient == nil {
|
|
return nil
|
|
}
|
|
|
|
// TODO: consider calling s.connectClient.Stop() instead of engine.Stop().
|
|
// actCancel() lets the run loop stop the engine too, so both stop it
|
|
// concurrently; ConnectClient.Stop cancels and waits for the run loop,
|
|
// making the run loop the sole owner of engine shutdown.
|
|
if engine != nil {
|
|
if err := engine.Stop(); err != nil {
|
|
log.Errorf("failed to stop engine during cleanup: %v", err)
|
|
}
|
|
}
|
|
|
|
s.connectClient = nil
|
|
s.isSessionActive.Store(false)
|
|
|
|
log.Infof("service is down")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if msg.ProfileName != nil && *msg.ProfileName != "" {
|
|
return s.handleProfileLogout(ctx, msg)
|
|
}
|
|
|
|
return s.handleActiveProfileLogout(ctx)
|
|
}
|
|
|
|
func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
|
|
if msg.Username == nil || *msg.Username == "" {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified")
|
|
}
|
|
username := *msg.Username
|
|
|
|
resolved, err := s.resolveProfileHandle(*msg.ProfileName, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
|
|
}
|
|
|
|
if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.logoutFromProfile(ctx, resolved, username); err != nil {
|
|
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
|
|
// A refused deregistration is already a status error carrying the reason
|
|
// and the command to run; rewrapping it as Internal would flatten both
|
|
// into a gRPC dump for the user.
|
|
if _, isStatus := gstatus.FromError(err); isStatus {
|
|
return nil, err
|
|
}
|
|
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
|
|
}
|
|
|
|
s.cleanupAfterProfileLogout(resolved.ID, username)
|
|
|
|
return &proto.LogoutResponse{}, nil
|
|
}
|
|
|
|
// cleanupAfterProfileLogout tears the connection down and asks for a new login
|
|
// when the profile that was just deregistered is the one the daemon is running.
|
|
// The active profile is read again here rather than reused from the pre-flight
|
|
// check: Login switches profiles under guardedConfigMu, which this path does not
|
|
// hold, so a login that landed meanwhile must not have its fresh connection
|
|
// dropped by a logout that targeted the profile it replaced.
|
|
func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err)
|
|
return
|
|
}
|
|
|
|
if !isActiveProfile(activeProf, id, username) {
|
|
return
|
|
}
|
|
|
|
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
|
|
log.Errorf("failed to cleanup connection: %v", err)
|
|
}
|
|
s.jwtCache.clear()
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
state.Set(internal.StatusNeedsLogin)
|
|
}
|
|
|
|
func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) {
|
|
if s.config == nil {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
|
|
}
|
|
|
|
config, _, err := s.getConfig(activeProf)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "not logged in")
|
|
}
|
|
s.config = config
|
|
}
|
|
|
|
if err := s.sendLogoutRequest(ctx); err != nil {
|
|
log.Errorf("failed to send logout request: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
|
|
// todo review to update the status in case any type of error
|
|
log.Errorf("failed to cleanup connection: %v", err)
|
|
return nil, err
|
|
}
|
|
s.jwtCache.clear()
|
|
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
state.Set(internal.StatusNeedsLogin)
|
|
|
|
return &proto.LogoutResponse{}, nil
|
|
}
|
|
|
|
// provisionProfileIdentity resolves the active profile's config and puts the
|
|
// keys that identify the peer on disk, reporting whether the config file
|
|
// already existed.
|
|
//
|
|
// This is the daemon's provisioning point: the config resolved here is the one
|
|
// the peer runs with, so it needs its identity, and that has to reach disk — a
|
|
// key that stays in memory would come back different on the next start and
|
|
// re-register the peer. Reads themselves are pure, so the write is here, in
|
|
// the open, instead of hiding inside the reader.
|
|
func provisionProfileIdentity(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) {
|
|
cfgPath, err := activeProf.FilePath()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to get active profile file path: %w", err)
|
|
}
|
|
|
|
_, err = os.Stat(cfgPath)
|
|
configExisted := !os.IsNotExist(err)
|
|
|
|
log.Infof("active profile config existed: %t, err %v", configExisted, err)
|
|
|
|
config, err := profilemanager.ReadConfigOrDefault(cfgPath)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to get config: %w", err)
|
|
}
|
|
|
|
generated, err := config.EnsureIdentity()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("ensure profile identity: %w", err)
|
|
}
|
|
|
|
if generated || !configExisted {
|
|
if err := profilemanager.WriteOutConfig(cfgPath, config); err != nil {
|
|
return nil, false, fmt.Errorf("write out profile config: %w", err)
|
|
}
|
|
}
|
|
|
|
return config, configExisted, nil
|
|
}
|
|
|
|
// getConfig resolves the active profile's config, provisions its identity and
|
|
// reports whether the config file already existed.
|
|
func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) {
|
|
config, configExisted, err := provisionProfileIdentity(activeProf)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
// Apply the daemon-owned MDM policy on top of the just-resolved Config.
|
|
// profilemanager's apply() initialises the policy to empty — the Loader
|
|
// lives outside Config, so this overlay step is driven externally here.
|
|
// After the write above, on purpose: the overlay is runtime-only and
|
|
// re-derived on every load, so the file keeps the profile's own values.
|
|
config.ApplyMDMPolicy(s.mdmLoader.Load())
|
|
|
|
return config, configExisted, nil
|
|
}
|
|
|
|
// validateProfileLogout gates a profile-addressed logout. Deregistering the
|
|
// profile the daemon already runs is what a plain `netbird logout` does, so the
|
|
// profiles-disabled kill switch must not block it. Logging out of any other
|
|
// profile is profile management and stays gated.
|
|
func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error {
|
|
if id == "" {
|
|
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
|
}
|
|
|
|
if isActive {
|
|
return nil
|
|
}
|
|
|
|
if s.checkProfilesDisabled() {
|
|
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isActiveProfile reports whether id is the profile the daemon runs for
|
|
// username. The username is part of the comparison because legacy profile IDs
|
|
// are display names, which two users can both hold; the default profile is
|
|
// shared by every user and carries no username.
|
|
func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool {
|
|
if activeProf == nil || activeProf.ID != id {
|
|
return false
|
|
}
|
|
|
|
return id == profilemanager.DefaultProfileName || activeProf.Username == username
|
|
}
|
|
|
|
// logoutFromProfile deregisters profile, reusing the running config when
|
|
// profile is the one the daemon is connected with. The username takes part in
|
|
// that decision for the same reason it does in the logout gate: a legacy
|
|
// profile ID is a display name two users can share, and sending the running
|
|
// config for a namesake would deregister the active peer instead of the
|
|
// requested one.
|
|
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error {
|
|
activeProf, err := s.profileManager.GetActiveProfileState()
|
|
if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil {
|
|
return s.sendLogoutRequest(ctx)
|
|
}
|
|
|
|
cfgPath := profile.Path
|
|
if cfgPath == "" {
|
|
cfgPath = profilemanager.DefaultConfigPath
|
|
}
|
|
|
|
config, err := profilemanager.GetExistingConfig(cfgPath)
|
|
if err != nil {
|
|
return fmt.Errorf("profile '%s' not found", profile.ID)
|
|
}
|
|
// Honour any MDM-enforced ManagementURL when issuing the logout
|
|
// RPC: the user-stored value may have been overridden by policy.
|
|
config.ApplyMDMPolicy(s.mdmLoader.Load())
|
|
|
|
return s.sendLogoutRequestWithConfig(ctx, config)
|
|
}
|
|
|
|
func (s *Server) sendLogoutRequest(ctx context.Context) error {
|
|
return s.sendLogoutRequestWithConfig(ctx, s.config)
|
|
}
|
|
|
|
func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error {
|
|
// Privilege gate: deregistering frees this machine's key to be registered
|
|
// against another management server, which is only restricted while the SSH
|
|
// server makes that a privilege handover.
|
|
// Ahead of the privilege gate on purpose. A profile with no identity was
|
|
// never registered — a logout clears the keys in place, so logging the same
|
|
// profile out twice lands here — so there is nothing to deregister and
|
|
// nothing for the gate to protect: what it guards against is handing this
|
|
// machine's registered key to another management server. Behind the gate,
|
|
// an unprivileged caller would be refused instead, and for a profile whose
|
|
// ServerSSHAllowed is unset that is every caller, since an absent value
|
|
// counts as SSH enabled.
|
|
if config.PrivateKey == "" {
|
|
log.Infof("profile carries no identity, nothing to deregister")
|
|
return nil
|
|
}
|
|
|
|
if err := requirePrivilegeForDeregistration(ctx, config); err != nil {
|
|
return err
|
|
}
|
|
|
|
key, err := wgtypes.ParseKey(config.PrivateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("parse private key: %w", err)
|
|
}
|
|
|
|
mgmTlsEnabled := config.ManagementURL.Scheme == "https"
|
|
mgmClient, err := mgm.NewClient(ctx, config.ManagementURL.Host, key, mgmTlsEnabled)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to management server: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := mgmClient.Close(); err != nil {
|
|
log.Errorf("close management client: %v", err)
|
|
}
|
|
}()
|
|
|
|
if err := mgmClient.Logout(); err != nil {
|
|
// The peer is already gone from the management server (e.g. deleted
|
|
// from the dashboard). The logout's goal — deregistering this peer —
|
|
// is therefore already satisfied, so treat NotFound as success rather
|
|
// than blocking the logout/profile-removal flow.
|
|
if logoutPeerGone(err) {
|
|
log.Infof("peer already removed from management server, treating logout as successful")
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Status returns the daemon status
|
|
func (s *Server) Status(
|
|
ctx context.Context,
|
|
msg *proto.StatusRequest,
|
|
) (*proto.StatusResponse, error) {
|
|
s.mutex.Lock()
|
|
// Only wait if the retry-loop goroutine is alive and making
|
|
// progress. clientRunning=true with connectionGoroutineRunning=false means the
|
|
// backoff has given up — there is nothing to wait for; let the
|
|
// caller observe the failed status directly.
|
|
alive := s.connectionGoroutineRunning()
|
|
s.mutex.Unlock()
|
|
|
|
if msg.WaitForReady != nil && *msg.WaitForReady && alive {
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if status != internal.StatusIdle && status != internal.StatusConnected && status != internal.StatusConnecting {
|
|
s.actCancel()
|
|
}
|
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-s.clientGiveUpChan:
|
|
ticker.Stop()
|
|
break loop
|
|
case <-s.clientRunningChan:
|
|
ticker.Stop()
|
|
break loop
|
|
case <-ticker.C:
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if status != internal.StatusIdle && status != internal.StatusConnected && status != internal.StatusConnecting {
|
|
s.actCancel()
|
|
}
|
|
continue
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
return s.buildStatusResponse(ctx, msg)
|
|
}
|
|
|
|
// buildStatusResponse composes a StatusResponse from the current daemon
|
|
// state. Shared between the unary Status RPC and the SubscribeStatus
|
|
// stream so both paths return identical snapshots. ctx scopes the health
|
|
// probe runProbes may trigger — a caller that disconnects cancels it.
|
|
func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) {
|
|
state := internal.CtxGetState(s.rootCtx)
|
|
status, err := state.Status()
|
|
if err != nil {
|
|
// state.Status() blanks the status when err is set (e.g. management
|
|
// retry loop wrapped a connection error). The underlying status is
|
|
// still meaningful and the failure is already surfaced via
|
|
// FullStatus.ManagementState.Error, so don't propagate err — that
|
|
// would tear down the SubscribeStatus stream and cause the UI to
|
|
// mark the daemon as unreachable on every retry.
|
|
status = state.CurrentStatus()
|
|
}
|
|
|
|
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
|
|
log.Debug("status requested while session is active, returning SessionExpired")
|
|
status = internal.StatusSessionExpired
|
|
s.isSessionActive.Store(false)
|
|
}
|
|
|
|
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
|
|
|
|
if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
|
|
statusResponse.SessionExpiresAt = timestamppb.New(deadline)
|
|
}
|
|
|
|
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
|
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
|
|
|
if msg.GetFullPeerStatus {
|
|
s.runProbes(ctx, msg.ShouldRunProbes)
|
|
fullStatus := s.statusRecorder.GetFullStatus()
|
|
pbFullStatus := fullStatus.ToProto()
|
|
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
|
pbFullStatus.SshServerState = s.getSSHServerState()
|
|
pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
|
statusResponse.FullStatus = pbFullStatus
|
|
}
|
|
|
|
return &statusResponse, nil
|
|
}
|
|
|
|
// getSSHServerState retrieves the current SSH server state including enabled status and active sessions
|
|
func (s *Server) getSSHServerState() *proto.SSHServerState {
|
|
s.mutex.Lock()
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
|
|
if connectClient == nil {
|
|
return nil
|
|
}
|
|
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
|
|
enabled, sessions := engine.GetSSHServerStatus()
|
|
sshServerState := &proto.SSHServerState{
|
|
Enabled: enabled,
|
|
}
|
|
|
|
for _, session := range sessions {
|
|
sshServerState.Sessions = append(sshServerState.Sessions, &proto.SSHSessionInfo{
|
|
Username: session.Username,
|
|
RemoteAddress: session.RemoteAddress,
|
|
Command: session.Command,
|
|
JwtUsername: session.JWTUsername,
|
|
PortForwards: session.PortForwards,
|
|
})
|
|
}
|
|
|
|
return sshServerState
|
|
}
|
|
|
|
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
|
func (s *Server) GetPeerSSHHostKey(
|
|
ctx context.Context,
|
|
req *proto.GetPeerSSHHostKeyRequest,
|
|
) (*proto.GetPeerSSHHostKeyResponse, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
connectClient := s.connectClient
|
|
statusRecorder := s.statusRecorder
|
|
s.mutex.Unlock()
|
|
|
|
if connectClient == nil {
|
|
return nil, errors.New("client not initialized")
|
|
}
|
|
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return nil, errors.New("engine not started")
|
|
}
|
|
|
|
peerAddress := req.GetPeerAddress()
|
|
hostKey, found := engine.GetPeerSSHKey(peerAddress)
|
|
|
|
response := &proto.GetPeerSSHHostKeyResponse{
|
|
Found: found,
|
|
}
|
|
|
|
if !found {
|
|
return response, nil
|
|
}
|
|
|
|
response.SshHostKey = hostKey
|
|
|
|
if statusRecorder == nil {
|
|
return response, nil
|
|
}
|
|
|
|
fullStatus := statusRecorder.GetFullStatus()
|
|
for _, peerState := range fullStatus.Peers {
|
|
if peerState.IP == peerAddress || peerState.FQDN == peerAddress {
|
|
response.PeerIP = peerState.IP
|
|
response.PeerFQDN = peerState.FQDN
|
|
break
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// getJWTCacheTTL returns the JWT cache TTL from config or default (disabled)
|
|
func (s *Server) getJWTCacheTTL() time.Duration {
|
|
s.mutex.Lock()
|
|
config := s.config
|
|
s.mutex.Unlock()
|
|
|
|
if config == nil || config.SSHJWTCacheTTL == nil {
|
|
return defaultJWTCacheTTL
|
|
}
|
|
|
|
seconds := *config.SSHJWTCacheTTL
|
|
if seconds == 0 {
|
|
log.Debug("SSH JWT cache disabled (configured to 0)")
|
|
return 0
|
|
}
|
|
|
|
ttl := time.Duration(seconds) * time.Second
|
|
log.Debugf("SSH JWT cache TTL set to %v from config", ttl)
|
|
return ttl
|
|
}
|
|
|
|
// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a
|
|
// miss on a control channel that carries no caller identity.
|
|
func (s *Server) cachedJWT(ctx context.Context) (string, bool) {
|
|
caller, ok := ipcauth.CallerIdentity(ctx)
|
|
if !ok {
|
|
// Expected and handled on a control channel with no peer identity: the
|
|
// caller re-authenticates. daemonServerOptions warns about it once at
|
|
// startup, so this stays out of the per-request log.
|
|
log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel")
|
|
return "", false
|
|
}
|
|
return s.jwtCache.get(caller)
|
|
}
|
|
|
|
// RequestJWTAuth initiates JWT authentication flow for SSH
|
|
func (s *Server) RequestJWTAuth(
|
|
ctx context.Context,
|
|
msg *proto.RequestJWTAuthRequest,
|
|
) (*proto.RequestJWTAuthResponse, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// The generation is read here, with the config and under the same lock, not
|
|
// where the flow is stored below: RequestAuthInfo talks to the IdP in
|
|
// between, and a switch or a logout during that call would otherwise be
|
|
// read as the generation this flow belongs to. SwitchProfile holds
|
|
// s.mutex across its own clear(), so the pair cannot be torn.
|
|
s.mutex.Lock()
|
|
config := s.config
|
|
cacheGeneration := s.jwtCache.currentGeneration()
|
|
s.mutex.Unlock()
|
|
|
|
if config == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured")
|
|
}
|
|
|
|
jwtCacheTTL := s.getJWTCacheTTL()
|
|
if jwtCacheTTL > 0 {
|
|
if cachedToken, found := s.cachedJWT(ctx); found {
|
|
log.Debugf("JWT token found in cache, returning cached token for SSH authentication")
|
|
|
|
return &proto.RequestJWTAuthResponse{
|
|
CachedToken: cachedToken,
|
|
MaxTokenAge: int64(jwtCacheTTL.Seconds()),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
hint := ""
|
|
if msg.Hint != nil {
|
|
hint = *msg.Hint
|
|
}
|
|
|
|
if hint == "" {
|
|
hint = profilemanager.GetLoginHint()
|
|
}
|
|
|
|
// the daemon has no graphical session of its own, only the caller can answer this
|
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
|
}
|
|
|
|
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err)
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow.flow = oAuthFlow
|
|
s.oauthAuthFlow.info = authInfo
|
|
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
|
|
s.oauthAuthFlow.cacheGeneration = cacheGeneration
|
|
s.mutex.Unlock()
|
|
|
|
return &proto.RequestJWTAuthResponse{
|
|
VerificationURI: authInfo.VerificationURI,
|
|
VerificationURIComplete: authInfo.VerificationURIComplete,
|
|
UserCode: authInfo.UserCode,
|
|
DeviceCode: authInfo.DeviceCode,
|
|
ExpiresIn: int64(authInfo.ExpiresIn),
|
|
MaxTokenAge: int64(jwtCacheTTL.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// WaitJWTToken waits for JWT authentication completion
|
|
func (s *Server) WaitJWTToken(
|
|
ctx context.Context,
|
|
req *proto.WaitJWTTokenRequest,
|
|
) (*proto.WaitJWTTokenResponse, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
oAuthFlow := s.oauthAuthFlow.flow
|
|
authInfo := s.oauthAuthFlow.info
|
|
// Recorded when the flow was created, not read here: the flow survives a
|
|
// profile switch, and everything from RequestJWTAuth to the IdP answering
|
|
// has to count as the same session for the cache.
|
|
generation := s.oauthAuthFlow.cacheGeneration
|
|
s.mutex.Unlock()
|
|
|
|
if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active auth flow")
|
|
}
|
|
|
|
tokenInfo, err := oAuthFlow.WaitToken(ctx, authInfo)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to get token: %v", err)
|
|
}
|
|
|
|
token := tokenInfo.GetTokenToUse()
|
|
|
|
jwtCacheTTL := s.getJWTCacheTTL()
|
|
switch caller, ok := ipcauth.CallerIdentity(ctx); {
|
|
case jwtCacheTTL <= 0:
|
|
log.Debug("JWT caching disabled, not storing token")
|
|
case !ok:
|
|
log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel")
|
|
default:
|
|
if s.jwtCache.store(token, caller, jwtCacheTTL, generation) {
|
|
log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL)
|
|
} else {
|
|
log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled")
|
|
}
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
s.oauthAuthFlow = oauthAuthFlow{}
|
|
s.mutex.Unlock()
|
|
return &proto.WaitJWTTokenResponse{
|
|
Token: tokenInfo.GetTokenToUse(),
|
|
TokenType: tokenInfo.TokenType,
|
|
ExpiresIn: int64(tokenInfo.ExpiresIn),
|
|
}, nil
|
|
}
|
|
|
|
// RequestExtendAuthSession initiates the SSO session-extension flow and
|
|
// returns the verification URI the UI should open. The flow state is held
|
|
// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it.
|
|
func (s *Server) RequestExtendAuthSession(
|
|
ctx context.Context,
|
|
msg *proto.RequestExtendAuthSessionRequest,
|
|
) (*proto.RequestExtendAuthSessionResponse, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
config := s.config
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
|
|
if config == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured")
|
|
}
|
|
if connectClient == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
|
}
|
|
if connectClient.Engine() == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
|
|
}
|
|
|
|
hint := ""
|
|
if msg.Hint != nil {
|
|
hint = *msg.Hint
|
|
}
|
|
if hint == "" {
|
|
hint = profilemanager.GetLoginHint()
|
|
}
|
|
|
|
// the daemon has no graphical session of its own, only the caller can answer this
|
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
|
}
|
|
|
|
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
|
|
if err != nil {
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err)
|
|
}
|
|
|
|
s.extendAuthSessionFlow.Set(oAuthFlow, authInfo)
|
|
|
|
return &proto.RequestExtendAuthSessionResponse{
|
|
VerificationURI: authInfo.VerificationURI,
|
|
VerificationURIComplete: authInfo.VerificationURIComplete,
|
|
UserCode: authInfo.UserCode,
|
|
DeviceCode: authInfo.DeviceCode,
|
|
ExpiresIn: int64(authInfo.ExpiresIn),
|
|
}, nil
|
|
}
|
|
|
|
// WaitExtendAuthSession blocks until the user completes the SSO step
|
|
// initiated by RequestExtendAuthSession, then forwards the resulting JWT
|
|
// to the management server's ExtendAuthSession RPC. The returned deadline
|
|
// is also applied locally via the engine so SubscribeStatus consumers see
|
|
// the refreshed state.
|
|
func (s *Server) WaitExtendAuthSession(
|
|
ctx context.Context,
|
|
req *proto.WaitExtendAuthSessionRequest,
|
|
) (*proto.WaitExtendAuthSessionResponse, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get()
|
|
|
|
s.mutex.Lock()
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
|
|
if !ok || authInfo.DeviceCode != req.DeviceCode {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow")
|
|
}
|
|
|
|
// Preempt a previous WaitExtendAuthSession (e.g. when the tray
|
|
// notification and the about-to-expire dialog both start a flow on
|
|
// the same deadline). The older waiter exits via context.Canceled;
|
|
// the new one takes over the IdP poll.
|
|
s.extendAuthSessionFlow.CancelWait()
|
|
|
|
waitCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
s.extendAuthSessionFlow.SetWaitCancel(cancel)
|
|
|
|
tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted")
|
|
}
|
|
return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err)
|
|
}
|
|
|
|
// Clear pending flow before talking to mgm so a retry can re-initiate.
|
|
s.extendAuthSessionFlow.Clear()
|
|
|
|
if connectClient == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
|
|
}
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised")
|
|
}
|
|
|
|
deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse())
|
|
if err != nil {
|
|
// Log the full wrapped chain, but return only the innermost gRPC
|
|
// status (code + clean desc) so the UI shows the root cause, not
|
|
// the daemon's wrapping layers.
|
|
log.Errorf("management ExtendAuthSession failed: %v", err)
|
|
if st := innermostStatus(err); st != nil {
|
|
return nil, gstatus.Error(st.Code(), st.Message())
|
|
}
|
|
return nil, gstatus.Errorf(codes.Internal, "%v", err)
|
|
}
|
|
|
|
resp := &proto.WaitExtendAuthSessionResponse{}
|
|
if !deadline.IsZero() {
|
|
resp.SessionExpiresAt = timestamppb.New(deadline)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// DismissSessionWarning forwards the user's "Dismiss" click on the
|
|
// T-WarningLead notification down to the engine's sessionWatcher so the
|
|
// T-FinalWarningLead fallback is suppressed for the current deadline.
|
|
// Best-effort: when the client/engine is not yet running the call is a
|
|
// successful no-op (the watcher has no deadline to dismiss anyway).
|
|
func (s *Server) DismissSessionWarning(
|
|
_ context.Context,
|
|
_ *proto.DismissSessionWarningRequest,
|
|
) (*proto.DismissSessionWarningResponse, error) {
|
|
s.mutex.Lock()
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
if connectClient == nil {
|
|
return &proto.DismissSessionWarningResponse{}, nil
|
|
}
|
|
if engine := connectClient.Engine(); engine != nil {
|
|
engine.DismissSessionWarning()
|
|
}
|
|
return &proto.DismissSessionWarningResponse{}, nil
|
|
}
|
|
|
|
// ExposeService exposes a local port via the NetBird reverse proxy.
|
|
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
|
|
s.mutex.Lock()
|
|
if !s.clientRunning {
|
|
s.mutex.Unlock()
|
|
return gstatus.Errorf(codes.FailedPrecondition, "client is not running, run 'netbird up' first")
|
|
}
|
|
connectClient := s.connectClient
|
|
s.mutex.Unlock()
|
|
|
|
if connectClient == nil {
|
|
return gstatus.Errorf(codes.FailedPrecondition, "client not initialized")
|
|
}
|
|
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return gstatus.Errorf(codes.FailedPrecondition, "engine not initialized")
|
|
}
|
|
|
|
if engine.IsBlockInbound() {
|
|
return gstatus.Errorf(codes.FailedPrecondition, "expose requires inbound connections but 'block inbound' is enabled, disable it first")
|
|
}
|
|
|
|
mgr := engine.GetExposeManager()
|
|
if mgr == nil {
|
|
return gstatus.Errorf(codes.Internal, "expose manager not available")
|
|
}
|
|
|
|
ctx := srv.Context()
|
|
|
|
exposeCtx, exposeCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer exposeCancel()
|
|
|
|
mgmReq := expose.NewRequest(req)
|
|
result, err := mgr.Expose(exposeCtx, *mgmReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := srv.Send(&proto.ExposeServiceEvent{
|
|
Event: &proto.ExposeServiceEvent_Ready{
|
|
Ready: &proto.ExposeServiceReady{
|
|
ServiceName: result.ServiceName,
|
|
ServiceUrl: result.ServiceURL,
|
|
Domain: result.Domain,
|
|
PortAutoAssigned: result.PortAutoAssigned,
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
err = mgr.KeepAlive(ctx, result.Domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
|
if s.connectClient == nil {
|
|
return
|
|
}
|
|
|
|
engine := s.connectClient.Engine()
|
|
if engine == nil {
|
|
return
|
|
}
|
|
|
|
s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult)
|
|
}
|
|
|
|
// GetConfig of the daemon.
|
|
func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*proto.GetConfigResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username)
|
|
if err != nil {
|
|
log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err)
|
|
return nil, err
|
|
}
|
|
cfgPath := resolved.Path
|
|
if cfgPath == "" {
|
|
cfgPath = profilemanager.DefaultConfigPath
|
|
}
|
|
|
|
cfg, err := profilemanager.GetExistingConfig(cfgPath)
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile config: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile config: %w", err)
|
|
}
|
|
// Overlay the active MDM policy so the response's MDMManagedFields
|
|
// list reflects what the GUI / CLI must render as read-only.
|
|
// profilemanager.GetConfig itself returns a Config without the
|
|
// overlay (Loader lives outside profilemanager).
|
|
cfg.ApplyMDMPolicy(s.mdmLoader.Load())
|
|
managementURL := cfg.ManagementURL
|
|
adminURL := cfg.AdminURL
|
|
|
|
var preSharedKey = cfg.PreSharedKey
|
|
if preSharedKey != "" {
|
|
preSharedKey = "**********"
|
|
}
|
|
|
|
disableNotifications := true
|
|
if cfg.DisableNotifications != nil {
|
|
disableNotifications = *cfg.DisableNotifications
|
|
}
|
|
|
|
networkMonitor := false
|
|
if cfg.NetworkMonitor != nil {
|
|
networkMonitor = *cfg.NetworkMonitor
|
|
}
|
|
|
|
disableDNS := cfg.DisableDNS
|
|
disableClientRoutes := cfg.DisableClientRoutes
|
|
disableServerRoutes := cfg.DisableServerRoutes
|
|
disableIPv6 := cfg.DisableIPv6
|
|
blockLANAccess := cfg.BlockLANAccess
|
|
|
|
enableSSHRoot := false
|
|
if cfg.EnableSSHRoot != nil {
|
|
enableSSHRoot = *cfg.EnableSSHRoot
|
|
}
|
|
|
|
enableSSHSFTP := false
|
|
if cfg.EnableSSHSFTP != nil {
|
|
enableSSHSFTP = *cfg.EnableSSHSFTP
|
|
}
|
|
|
|
enableSSHLocalPortForwarding := false
|
|
if cfg.EnableSSHLocalPortForwarding != nil {
|
|
enableSSHLocalPortForwarding = *cfg.EnableSSHLocalPortForwarding
|
|
}
|
|
|
|
enableSSHRemotePortForwarding := false
|
|
if cfg.EnableSSHRemotePortForwarding != nil {
|
|
enableSSHRemotePortForwarding = *cfg.EnableSSHRemotePortForwarding
|
|
}
|
|
|
|
disableSSHAuth := false
|
|
if cfg.DisableSSHAuth != nil {
|
|
disableSSHAuth = *cfg.DisableSSHAuth
|
|
}
|
|
|
|
sshJWTCacheTTL := int32(0)
|
|
if cfg.SSHJWTCacheTTL != nil {
|
|
sshJWTCacheTTL = int32(*cfg.SSHJWTCacheTTL)
|
|
}
|
|
|
|
return &proto.GetConfigResponse{
|
|
ManagementUrl: managementURL.String(),
|
|
PreSharedKey: preSharedKey,
|
|
AdminURL: adminURL.String(),
|
|
InterfaceName: cfg.WgIface,
|
|
WireguardPort: int64(cfg.WgPort),
|
|
Mtu: int64(cfg.MTU),
|
|
DisableAutoConnect: cfg.DisableAutoConnect,
|
|
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
|
RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed),
|
|
RosenpassEnabled: cfg.RosenpassEnabled,
|
|
RosenpassPermissive: cfg.RosenpassPermissive,
|
|
BlockInbound: cfg.BlockInbound,
|
|
DisableNotifications: disableNotifications,
|
|
NetworkMonitor: networkMonitor,
|
|
DisableDns: disableDNS,
|
|
DisableClientRoutes: disableClientRoutes,
|
|
DisableServerRoutes: disableServerRoutes,
|
|
DisableIpv6: disableIPv6,
|
|
BlockLanAccess: blockLANAccess,
|
|
EnableSSHRoot: enableSSHRoot,
|
|
EnableSSHSFTP: enableSSHSFTP,
|
|
EnableSSHLocalPortForwarding: enableSSHLocalPortForwarding,
|
|
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
|
|
DisableSSHAuth: disableSSHAuth,
|
|
SshJWTCacheTTL: sshJWTCacheTTL,
|
|
MDMManagedFields: cfg.Policy().ManagedKeys(),
|
|
}, nil
|
|
}
|
|
|
|
// AddProfile adds a new profile to the daemon.
|
|
func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (*proto.AddProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if s.checkProfilesDisabled() {
|
|
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
|
}
|
|
|
|
if msg.ProfileName == "" || msg.Username == "" {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
|
|
}
|
|
|
|
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
|
|
if err != nil {
|
|
log.Errorf("failed to create profile: %v", err)
|
|
return nil, fmt.Errorf("failed to create profile: %w", err)
|
|
}
|
|
|
|
s.publishProfileListChanged(msg.ProfileName)
|
|
|
|
return &proto.AddProfileResponse{Id: created.ID.String()}, nil
|
|
}
|
|
|
|
func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if s.checkProfilesDisabled() {
|
|
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
|
}
|
|
|
|
if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
|
|
}
|
|
|
|
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
|
|
if err != nil {
|
|
log.Errorf("failed to rename profile: %v", err)
|
|
return nil, fmt.Errorf("failed to rename profile: %w", err)
|
|
}
|
|
|
|
s.publishProfileListChanged(msg.NewProfileName)
|
|
|
|
return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
|
|
}
|
|
|
|
// RemoveProfile removes a profile from the daemon.
|
|
func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequest) (*proto.RemoveProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if s.checkProfilesDisabled() {
|
|
return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
|
|
}
|
|
|
|
if msg.ProfileName == "" {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
|
}
|
|
|
|
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil {
|
|
// Deregistration is best-effort here: the local profile is removed
|
|
// either way, so an unprivileged caller leaves the peer registered on
|
|
// the management server rather than being blocked from removing it.
|
|
log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err)
|
|
}
|
|
|
|
if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
|
|
log.Errorf("failed to remove profile: %v", err)
|
|
return nil, fmt.Errorf("failed to remove profile: %w", err)
|
|
}
|
|
|
|
s.publishProfileListChanged(msg.ProfileName)
|
|
|
|
return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil
|
|
}
|
|
|
|
// publishProfileListChanged nudges the desktop UI to refresh its profile list
|
|
// after a CLI-driven add/remove. The daemon exposes no dedicated
|
|
// profile-changed RPC event, and a profile add/remove doesn't move the
|
|
// connection status, so the UI's SubscribeStatus path never fires for it (and
|
|
// the tray's status-string guard would swallow it anyway). Instead we publish
|
|
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
|
|
// recognises the metadata "kind" marker and translates it into its internal
|
|
// profile-changed signal that both the tray menu and the React profile views
|
|
// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in
|
|
// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this
|
|
// stays a silent refresh signal rather than a user-facing notification.
|
|
func (s *Server) publishProfileListChanged(profileName string) {
|
|
s.statusRecorder.PublishEvent(
|
|
proto.SystemEvent_INFO,
|
|
proto.SystemEvent_SYSTEM,
|
|
"Profile list changed",
|
|
"",
|
|
map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
|
|
)
|
|
}
|
|
|
|
// publishLogLevelChanged signals the desktop UI that the daemon log level
|
|
// changed, so it can attach/detach its rotated gui-client.log. Like
|
|
// publishProfileListChanged, this rides the SubscribeEvents stream as a marked
|
|
// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus
|
|
// name); the UI's dispatchSystemEvent recognises the marker and routes it to
|
|
// the logging toggle instead of an OS toast (userMessage is empty so it stays
|
|
// a silent control signal). The "level" value matches log.Level.String()
|
|
// (e.g. "debug", "info") so the UI can parse it directly. See
|
|
// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go.
|
|
func (s *Server) publishLogLevelChanged(level string) {
|
|
s.statusRecorder.PublishEvent(
|
|
proto.SystemEvent_INFO,
|
|
proto.SystemEvent_SYSTEM,
|
|
"Log level changed",
|
|
"",
|
|
map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
|
|
)
|
|
}
|
|
|
|
// ListProfiles lists all profiles in the daemon.
|
|
func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if msg.Username == "" {
|
|
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
|
|
}
|
|
|
|
profiles, err := s.profileManager.ListProfiles(msg.Username)
|
|
if err != nil {
|
|
log.Errorf("failed to list profiles: %v", err)
|
|
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
|
}
|
|
|
|
response := &proto.ListProfilesResponse{
|
|
Profiles: make([]*proto.Profile, len(profiles)),
|
|
}
|
|
for i, profile := range profiles {
|
|
response.Profiles[i] = &proto.Profile{
|
|
Id: profile.ID.String(),
|
|
Name: profile.Name,
|
|
IsActive: profile.IsActive,
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// GetActiveProfile returns the active profile in the daemon. The ProfileName
|
|
// field carries the display name for backwards compatibility with UI clients,
|
|
// new callers should prefer Id.
|
|
func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
activeProfile, err := s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
log.Errorf("failed to get active profile state: %v", err)
|
|
return nil, fmt.Errorf("failed to get active profile state: %w", err)
|
|
}
|
|
|
|
// Fallback to legacy name == ID
|
|
displayName := activeProfile.ID.String()
|
|
if activeProfile.ID != profilemanager.DefaultProfileName {
|
|
if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil {
|
|
for _, p := range profiles {
|
|
if p.ID == activeProfile.ID {
|
|
displayName = p.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &proto.GetActiveProfileResponse{
|
|
ProfileName: displayName,
|
|
Username: activeProfile.Username,
|
|
Id: activeProfile.ID.String(),
|
|
}, nil
|
|
}
|
|
|
|
// GetFeatures returns the features supported by the daemon.
|
|
func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) (*proto.GetFeaturesResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
features := &proto.GetFeaturesResponse{
|
|
DisableProfiles: s.checkProfilesDisabled(),
|
|
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
|
DisableNetworks: s.checkNetworksDisabled(),
|
|
DisableAdvancedView: s.checkDisableAdvancedView(),
|
|
}
|
|
|
|
return features, nil
|
|
}
|
|
|
|
// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it
|
|
// (rather than returning Unimplemented) tells the UI this daemon is new enough.
|
|
func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) {
|
|
return &proto.WailsUIReadyResponse{}, nil
|
|
}
|
|
|
|
// checkDisableAdvancedView reports the MDM-policy directive for the
|
|
// upcoming UI's advanced-view section. Tristate: returns nil when no
|
|
// MDM directive is set so the UI applies its own default; returns
|
|
// &true / &false when MDM explicitly enforces. No CLI flag backs
|
|
// this feature — MDM is the sole source.
|
|
func (s *Server) checkDisableAdvancedView() *bool {
|
|
if s.config == nil {
|
|
return nil
|
|
}
|
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableAdvancedView); ok {
|
|
return &v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error {
|
|
log.Tracef("running client connection")
|
|
client := internal.NewConnectClient(ctx, config, statusRecorder)
|
|
client.SetUpdateManager(s.updateManager)
|
|
client.SetSyncResponsePersistence(s.persistSyncResponse)
|
|
|
|
s.mutex.Lock()
|
|
s.connectClient = client
|
|
s.mutex.Unlock()
|
|
|
|
if err := client.Run(runningChan, s.logFile); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MDM authority: when the platform-native MDM source sets a kill switch
|
|
// key (regardless of true/false value), that value wins. The CLI flag
|
|
// supplied at service install time is the fallback used only when the
|
|
// MDM source is silent on the key. This honors the "MDM decides
|
|
// everything" semantic agreed for NET-1214 — an admin pushing
|
|
// disableX=false via MDM explicitly re-enables the feature even on a
|
|
// box installed with --disable-X.
|
|
func (s *Server) checkProfilesDisabled() bool {
|
|
if s.config != nil {
|
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
|
|
return v
|
|
}
|
|
}
|
|
return s.profilesDisabled
|
|
}
|
|
|
|
// checkNetworksDisabled reports whether the networks/exit-node feature
|
|
// is disabled on this daemon instance. Resolved MDM-first: when the
|
|
// active policy declares mdm.KeyDisableNetworks the policy value wins
|
|
// (regardless of true/false), so an admin can re-enable the feature
|
|
// via MDM even on a host that was installed with --disable-networks.
|
|
// Falls back to the s.networksDisabled CLI flag when the policy is
|
|
// silent on the key. Mirrors checkProfilesDisabled and
|
|
// checkUpdateSettingsDisabled.
|
|
func (s *Server) checkNetworksDisabled() bool {
|
|
if s.config != nil {
|
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
|
|
return v
|
|
}
|
|
}
|
|
return s.networksDisabled
|
|
}
|
|
|
|
func (s *Server) checkUpdateSettingsDisabled() bool {
|
|
if s.config != nil {
|
|
if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
|
|
return v
|
|
}
|
|
}
|
|
return s.updateSettingsDisabled
|
|
}
|
|
|
|
func (s *Server) startUpdateManagerForGUI() {
|
|
if s.updateManager == nil {
|
|
return
|
|
}
|
|
s.updateManager.Start(s.rootCtx)
|
|
s.updateManager.NotifyUI()
|
|
}
|
|
|
|
func (s *Server) onSessionExpire() {
|
|
if runtime.GOOS != "windows" {
|
|
isUIActive := internal.CheckUIApp()
|
|
if !isUIActive && s.config.DisableNotifications != nil && !*s.config.DisableNotifications {
|
|
if err := sendTerminalNotification(); err != nil {
|
|
log.Errorf("send session expire terminal notification: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// getConnectWithBackoff returns a backoff with exponential backoff strategy for connection retries
|
|
func getConnectWithBackoff(ctx context.Context) backoff.BackOff {
|
|
initialInterval := parseEnvDuration(retryInitialIntervalVar, defaultInitialRetryTime)
|
|
maxInterval := parseEnvDuration(maxRetryIntervalVar, defaultMaxRetryInterval)
|
|
maxElapsedTime := parseEnvDuration(maxRetryTimeVar, defaultMaxRetryTime)
|
|
multiplier := defaultRetryMultiplier
|
|
|
|
if envValue := os.Getenv(retryMultiplierVar); envValue != "" {
|
|
// parse the multiplier from the environment variable string value to float64
|
|
value, err := strconv.ParseFloat(envValue, 64)
|
|
if err != nil {
|
|
log.Warnf("unable to parse environment variable %s: %s. using default: %f", retryMultiplierVar, envValue, multiplier)
|
|
} else {
|
|
multiplier = value
|
|
}
|
|
}
|
|
|
|
return backoff.WithContext(&backoff.ExponentialBackOff{
|
|
InitialInterval: initialInterval,
|
|
RandomizationFactor: 1,
|
|
Multiplier: multiplier,
|
|
MaxInterval: maxInterval,
|
|
MaxElapsedTime: maxElapsedTime, // 14 days
|
|
Stop: backoff.Stop,
|
|
Clock: backoff.SystemClock,
|
|
}, ctx)
|
|
}
|
|
|
|
// parseEnvDuration parses the environment variable and returns the duration
|
|
func parseEnvDuration(envVar string, defaultDuration time.Duration) time.Duration {
|
|
if envValue := os.Getenv(envVar); envValue != "" {
|
|
if duration, err := time.ParseDuration(envValue); err == nil {
|
|
return duration
|
|
}
|
|
log.Warnf("unable to parse environment variable %s: %s. using default: %s", envVar, envValue, defaultDuration)
|
|
}
|
|
return defaultDuration
|
|
}
|
|
|
|
// sendTerminalNotification sends a terminal notification message
|
|
// to inform the user that the NetBird connection session has expired.
|
|
func sendTerminalNotification() error {
|
|
message := "NetBird connection session expired\n\nPlease re-authenticate to connect to the network."
|
|
echoCmd := exec.Command("echo", message)
|
|
wallCmd := exec.Command("sudo", "wall")
|
|
|
|
echoCmdStdout, err := echoCmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wallCmd.Stdin = echoCmdStdout
|
|
|
|
if err := echoCmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := wallCmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := echoCmd.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return wallCmd.Wait()
|
|
}
|
|
|
|
// afterLoginPreCheck is a seam for tests to run a concurrent config change
|
|
// between Login's first privilege check and the authoritative one.
|
|
var afterLoginPreCheck func()
|
|
|
|
// authorizeAndPrepareLogin makes the authoritative privilege decision for a login
|
|
// and, when it passes, carries out every state change that decision authorizes:
|
|
// cancelling an login already in progress, switching to the requested profile, and
|
|
// persisting the config overrides the request carries.
|
|
//
|
|
// All of it happens under guardedConfigMu, which SetConfig also holds across its
|
|
// own check and write. Login's earlier check refuses the ordinary case before any
|
|
// of this is reached; this one exists because that check is not synchronized
|
|
// against a concurrent privileged request that enables the SSH server, and a
|
|
// caller refused here must not have cancelled or switched anything either.
|
|
func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) {
|
|
if afterLoginPreCheck != nil {
|
|
afterLoginPreCheck()
|
|
}
|
|
|
|
s.guardedConfigMu.Lock()
|
|
defer s.guardedConfigMu.Unlock()
|
|
|
|
stored, err := s.storedLoginConfig(activeProf, msg)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// The update-settings decision is re-taken here for the same reason as the
|
|
// privilege one: Login's earlier check ran outside this lock, so the stored
|
|
// config it compared against could have moved since. This one is the
|
|
// authoritative check, and it is the last read before persistLoginOverrides
|
|
// writes.
|
|
if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) {
|
|
return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled)
|
|
}
|
|
|
|
s.mutex.Lock()
|
|
if s.actCancel != nil {
|
|
s.actCancel()
|
|
}
|
|
ctx, cancel := context.WithCancel(callerCtx)
|
|
if md, ok := metadata.FromIncomingContext(callerCtx); ok {
|
|
ctx = metadata.NewOutgoingContext(ctx, md)
|
|
}
|
|
s.actCancel = cancel
|
|
s.mutex.Unlock()
|
|
|
|
if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil {
|
|
log.Warnf(errRestoreResidualState, err)
|
|
}
|
|
|
|
if msg.ProfileName != nil {
|
|
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
|
return nil, nil, fmt.Errorf("switch profile: %w", err)
|
|
}
|
|
}
|
|
|
|
activeProf, err = s.profileManager.GetActiveProfileState()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("active profile state: %w", err)
|
|
}
|
|
|
|
if err := persistLoginOverrides(activeProf, msg); err != nil {
|
|
return nil, nil, fmt.Errorf("persist login overrides: %w", err)
|
|
}
|
|
|
|
// Provisioning under the same lock as the decision above, and next to the
|
|
// write it guards. getConfig would otherwise mint the identity and persist
|
|
// it once this returns: between its read and its write, a SetConfig that
|
|
// had already answered its caller would be overwritten by the config this
|
|
// login read before it landed.
|
|
if _, _, err := provisionProfileIdentity(activeProf); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return ctx, activeProf, nil
|
|
}
|
|
|
|
// persistLoginOverrides writes the config fields a login request is allowed to
|
|
// carry into the active profile. It shares its input builder with the
|
|
// update-settings gate, so the gate judges exactly the fields this writes.
|
|
func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) error {
|
|
input := loginOverridesInput(msg)
|
|
if input.ManagementURL == "" && input.PreSharedKey == nil {
|
|
return nil
|
|
}
|
|
|
|
cfgPath, err := activeProf.FilePath()
|
|
if err != nil {
|
|
return fmt.Errorf("active profile file path: %w", err)
|
|
}
|
|
|
|
input.ConfigPath = cfgPath
|
|
if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil {
|
|
return fmt.Errorf("update config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// logoutPeerGone reports whether a management Logout failed because the peer
|
|
// no longer exists server-side (gRPC NotFound), walking the wrap chain since
|
|
// the client wraps the gRPC status with fmt.Errorf.
|
|
func logoutPeerGone(err error) bool {
|
|
for e := err; e != nil; e = errors.Unwrap(e) {
|
|
if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// innermostStatus walks the wrap chain and returns the deepest gRPC status,
|
|
// or nil when none is present. gstatus.FromError does not unwrap, so a status
|
|
// wrapped with fmt.Errorf %w would otherwise be missed.
|
|
func innermostStatus(err error) *gstatus.Status {
|
|
var found *gstatus.Status
|
|
for e := err; e != nil; e = errors.Unwrap(e) {
|
|
if s, ok := gstatus.FromError(e); ok {
|
|
found = s
|
|
}
|
|
}
|
|
return found
|
|
}
|