mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 15:39:07 +02:00
* implement certificate posture check * log signal address * add keychain and cert store support * read the console user's keychain through a user session helper A root daemon cannot reach a login keychain: securityd is per session and a key ACL needs a session to prompt in, so dropping uid is not enough. The daemon now answers certificate challenges from the System keychain itself, where MDM installs device identities, and launches "netbird posture cert-proof" into the console user's desktop session with launchctl asuser for the login keychain. Only the signature and the chain cross back, never the private key. The console user comes from SCDynamicStoreCopyConsoleUser, bound with purego like the keychain calls. The login window reports no user, root, or "loginwindow", and all three are treated as no keychain to read, so a Mac at the lock screen sends device proofs alone. Adds info logging across the path: the keychain search list, per class query status and item counts, the chain built per candidate, and the verification error for every rejected candidate. A run that sends nothing now says why. README.md documents the trust model, the console user limitation and how to read the logs. * read the signed-in user's certificate store on Windows A service reads LocalMachine\MY, where AD and Intune enrol device certificates. CurrentUser\MY lives in the signed-in user's registry hive with keys protected against their profile, and a service that opens it does not fail: "current user" resolves to HKU\S-1-5-18, so it silently reads the service account's own empty store. The service therefore reads the machine store itself and launches "netbird posture cert-proof" with the session token for the rest, mirroring the macOS console user helper. Windows lets a privileged service assume a user identity, so the token goes straight into the child process and no external tooling is involved. CREATE_NO_WINDOW keeps a console window from flashing on the desktop every sync. In-process impersonation would also work but is per OS thread while goroutines migrate, so the child process avoids that class of bug. Session selection prefers the physical console and falls back to any active session, so remote desktop and VDI hosts are covered. WTSQueryUserToken needs SE_TCB_NAME, so a user-run client skips the helper and reads the machine store alone. SystemStore takes a store location, gaining NewUserStore alongside NewSystemStore and the per candidate logging macOS already had. The request building and proof merging move to helper_spawn.go, shared by both platforms, and helperStore picks what the helper reads per platform. * start TPM support * split goreleaser to support pkcs11 and exclude on docker * update goreleaser * go mod tidy * add tpm pin to netbird config * split cert and key location and allow key lookup on tpm * add unsupported flag for mobile devices * Isolate the cert proof helper from the service environment and cap its output * Read the PKCS#11 token PIN from NB_TPM_PIN instead of the profile config * Bound certificate proof collection so a stuck token or keychain cannot hold the sync loop * Stop retrying a PKCS#11 PIN the token rejected * Log certificate posture details at debug level * Sign only nonces and peer keys of the size management issues * Skip certificate files whose key belongs to another certificate * Bound PKCS#11 driver sizes, pin template values, and log out only a login the session owns * Never pass NULL to CFRelease and skip unreadable keychain identities * Keep the macOS keychain code out of iOS and the PKCS#11 driver out of Android * Find a chain to each challenge's CAs through every intermediate the store holds * Require a token label whenever a PKCS#11 PIN is set * Read user certificates only from the session of the active profile's owner * Collect certificate proofs again when the owner's session changes and report lost proofs * Test the PKCS#11 build against SoftHSM in CI and warn once where the build has no driver * Document where an inline PKCS#11 PIN is stored and how it is protected * Refuse PKCS#11 URIs that this client cannot honour instead of widening the match * Trust certificate and key files only when no other user can write or redirect them * Explain a Windows certificate whose key only a legacy CryptoAPI provider holds * Use platform absolute module paths in tests and add a real owner session test for Windows * Match the Windows profile owner by name instead of resolving it through the domain controller * Keep the certificate stores and TPM library out of the WebAssembly build * [client] Read TSS2 key files on go-tpm, checked against the library it replaces The TSS2 parser was the only reason this repository depended on a crypto suite whose own build tooling it inherits. The replacement sits on go-tpm, which was already a direct dependency and is in fact what that suite calls underneath, so this removes a wrapper rather than porting onto a different library: the load, the derived storage root key and the signing commands are the same calls. Swapping a parser on the one path a customer actually runs is not something to assert, so the two are held side by side for this commit. One test feeds the replacement bytes the old library wrote and requires the same key type, empty auth flag, parent handle, blobs and decoded public key; the other feeds both the fixtures the tests are built on, so those are the shape the format calls for and not merely the shape the new parser reads. The scaffolding goes away with the dependency in the commit that follows. The encoder behind the fixtures is written out separately from the parser under test, so an encoder bug and a decoder bug cannot cancel each other out. * [client] Drop go.step.sm/crypto and the repo-wide upgrades it imposed The TSS2 parser was the only thing in the repository that used this module, and it brought 302 modules into the graph to do it — 35 of them linters, along with Google Cloud KMS and IAM, the AWS SDK and a terminal styling library. Those are the module's own development dependencies, which minimal version selection turns into floors in ours, and they are the whole reason gRPC, protobuf, the AWS SDK, OpenTelemetry, logrus and five x/ packages had moved. Management, signal, relay and proxy inherited every one of them for a feature none of them runs. Removing the import is not enough, because tidy never downgrades: the raised floors stay written in go.mod. Each one is pinned back to the version main had, then tidy is left to raise again whatever something still genuinely needs. It raised nothing: all 43 are back where they were, and go-tpm was already in the graph at the same version, so the certificate feature now costs no new module at all. The differential tests go with it. They existed to check the swap against the library while both were present, and there is nothing left to compare against. * [client] Clear the lint findings only the macOS and Windows runners see golangci-lint analyses one build at a time, so running it on Linux says nothing about the two platforms CI also lints. Against those builds the feature's packages reported eight findings, and the structural one is Config.dir: it is dead on macOS and Windows because neither reads a directory at all, their collectors take the configuration and discard it. Moving the method beside its only callers makes that visible in the layout instead of in a linter, and leaves the gap itself — no file or token store on those platforms — where it belongs, as something to decide rather than something to silence. An absent key file beside a certificate was reported as a nil signer with a nil error, which the caller then had to recognise by its nilness. It is a sentinel now, so the meaning is in the error rather than in the absence of one. The rest follow the standard library: the elliptic coordinates and the private scalar come from the encoding helpers rather than the deprecated big.Int fields, and an error string loses its trailing colon. Lint is clean on linux, darwin and windows; the hardware TPM path was exercised separately against a real device and passes. * Accept the TSS2 emptyAuth boolean OpenSSL writes and persistent parents on 32-bit builds * Count the certificates field in the peer meta store test * Check the store directory before listing it, refuse group-writable files, and reject a URI with two PIN sources * Share a PKCS#11 login between sessions and send each PIN at most once at a time * Collect certificate proofs again when the meta sync carrying them failed * Use no Windows user store when a domainless owner matches accounts of several domains * Use no user certificate store when the active profile's owner cannot be read * Document the PIN sources on CertPKCS11URI and keep the README PIN example off the command line * Test that the PKCS#11 URI stays out of the debug bundle and run the wrong-PIN test only on a disposable token * Refuse a TPM PSS signature request for the maximum salt length * Add the certificate fields to the network map golden data * Retry posture checks whose meta sync timed out instead of dropping them * Start no system info gathering while a timed-out one is still running * Guard the applied posture checks across goroutines and keep refreshing proofs while a pending update times out * Log what a successful certificate proof helper wrote to stderr * Send recollected certificate proofs to management only when the proven chains changed * Explain a macOS keychain key whose access list does not allow netbird * Kill the whole macOS certificate helper process group when it times out * End sudo option parsing before the macOS certificate helper binary * Hold off system info gathering only while a timed-out one is still running * Collect certificate proofs on the posture watcher instead of under the sync lock * Read the certificate store directory and PKCS#11 URI from the daemon environment, not the profile config * Install the RPM sysconfig file readable by root only and show the certificate posture variables * Move the certificate posture README into the package doc and the docs site * Name NB_CERT_PKCS11_URI in the PIN-without-token error * Keep the file check results of the latest-started system info refresh * Give the full import command for a keychain key netbird may not use, and correct the package doc * Restrict the service environment file to root on every package install * Search only the System keychain in the macOS daemon and only the login keychain in the user helper * Let the certificate proof helper read the PKCS#11 token from the environment on Linux * Ask a macOS user's keychain again only after an hour when it proved nothing * Clear the lint findings in certificate posture * Hold off the keychain helper only after a completed or timed-out run, independent of CA order * Keep free functions out of the method lists of PKCS11Store, URI and Challenger * Name the post-install permission helper in snake case and shorten the sysconfig certificate block * Drop the certificate store directory from certproof.Config, which only NB_CERT_STORE_DIR sets * [management] Renew certificate challenge nonces on quiet accounts A certificate challenge nonce is accepted for its own window and the one before it, and it only reaches a peer attached to a network map. An account where nothing changes sends no map, so after a day the peer re-sends the nonce it still holds, verification rejects its whole proof set, and the certificates stored for it are dropped. It fails the certificate check and loses every policy gated on it until some unrelated change happens to push a map. The outage repairs itself in seconds, which is what makes it expensive: it is intermittent, it only hits stable networks, and it is not reproducible on demand. Push the account's peers an update often enough that the nonce they hold is never close to expiring. Only accounts whose posture checks actually ask for a certificate are tracked, so a deployment without the feature does no extra work. The refresh runs from one goroutine over a map of accounts rather than a timer per account: the period is hours, so one pass every few minutes costs nothing next to it, and there is no timer to re-arm when an account that falls due sooner appears. Each account's first run is offset by a hash of its ID, because the challenge window is global and an instance restart would otherwise arm every account in the same moment. The push carries no administrative change, so it is counted as a refresh rather than an update and stays out of the figures that track what was edited. (cherry picked from commit7ad4a0df37) * [management] Make the certificate challenge window one knob to turn Renewal was timed against the window in two different ways: the period derived from it, the sweep interval did not. Shortening the window to watch a renewal in an end-to-end run would have left the refresher still looking for due accounts every quarter of an hour, so nothing would have been renewed in time and the test would have reported the feature broken. Derive the sweep from the period, within bounds that keep a very short window from spinning and a normal one from checking less often than is useful, and allow the window itself to be set through the environment so a run can take seconds instead of half a day. A value that cannot be parsed or falls outside the bounds keeps the default, because a window nobody intended is a security property nobody chose, and an override is logged at warning level since it sets how long a device keeps passing the check after its key is gone. Every instance has to be given the same value: the window is part of the nonce, so instances that disagree reject each other's. (cherry picked from commit0e38fcf409) * [management] Pin the property that makes per-peer nonce state unnecessary A nonce carries the window it was minted in, not the instant, and is accepted for that window and the one before it. So a peer re-stamped at least once per window can never be left holding one outside the accepted pair, whenever it was last served and however much life its own nonce had left. That is the whole reason management tracks nothing per peer, and it was resting on an argument rather than a test. The phases are part of the property, not decoration: accounts are deliberately given a refresh phase of their own, so the guarantee has to hold off the window boundary too. The negative case shows why that matters — a cadence of exactly two windows lands inside the grace window when it is aligned to the boundary and leaves a gap when it is not. (cherry picked from commitdee68facfd) * [management] Renew challenges only for the peers that answer one The refresh pushed an update to every connected peer of the account, while only the peers a certificate check applies to carry a nonce. On an account where a handful of peers sit behind the check and the rest do not, everyone was woken several times a day to be handed a map that changed nothing for them. Push to the sources of the enabled policies whose posture checks include a certificate check, which is exactly the set that is sent a challenge. Resolving the set the other way round than the gRPC layer does is the risk here: a peer the refresh forgets stops being renewed and falls out of its policies silently, which is the failure this whole mechanism exists to prevent. So the selection is held against processPeerPostureChecks, the per-peer rule that decides who receives a challenge in the first place, by a test that asks both the same question and requires the same answer. (cherry picked from commitdc4d0e0274) * [management] Derive certificate challenge nonces from the stored encryption key The nonce secret came from the server's WireGuard key, which is generated afresh in every process and never persisted. A nonce carries no state, so the only thing that lets one instance verify what another issued is deriving the same secret — and that premise, written in the comment above the challenger, was not met: every instance had its own key. A peer reconnecting after a restart therefore presented a nonce minted under the previous secret, verification failed with a mismatch, its whole proof set was rejected and the certificates stored for it were dropped until it signed again. Reproduced three times on the lab, each one logging "nonce was not issued to this peer", which only a changed secret produces. On a single instance it costs seconds of lost policy access per restart; across instances it is not transient at all, because every reconnect that lands elsewhere is rejected the same way. Derive from the data store encryption key instead: it is generated once, written back to the configuration and read by every instance, so it survives restarts and is shared. Where none is configured the secret falls back to the WireGuard key with a warning — degraded but still unpredictable, which is the property that matters most: a peer able to guess it could mint the nonces of future windows, sign them while its key is present and keep passing after it is gone. The challenger is now built once and passed to the two places that need it, rather than re-derived per message. (cherry picked from commit278f2f3807) * [management] Register an account for renewal where its nonce is issued Renewal was armed when a peer connected or when a posture check was saved, both of which ask the store whether the account has a certificate check. That misses the case it most needs to catch: the check is created through one instance while the peers are connected to another, so the instance serving them never learns it has anything to renew and their nonce expires. It also charged a query to every peer connect in every account, including the ones that will never use the feature, which a fleet reconnecting after a restart pays all at once. Register where the nonce is actually stamped instead. A nonce is verified from a shared secret and so travels between instances, but the renewal that keeps it fresh cannot: only the instance holding a peer's stream can push to it. Issuing and renewing now line up by construction — an instance renews exactly the accounts it has issued nonces for — and an instance that never issues one has nothing to renew, so there is no case left to miss. The registration is a map insert with no store access, which is what lets it sit on a path taken by every login and every initial sync. Reported by Viktor Liu, who also proposed registering at the point of issue. (cherry picked from commit 2d16dd7d7cf54762f2e64c5630ea092f32ef63ab) * [management] Register for renewal on pushed updates, not only on connect Registering where the nonce is stamped only covered the login and the initial sync, which both happen when a peer opens a stream. That left out the path the mechanism exists for. On the cloud the network map controller is wrapped so that an update publishes to an event bus instead of pushing locally: an instance handling a REST change broadcasts, and every instance holding a peer of that account pushes to its own. Those pushes stamp a nonce through the update handler, and nothing there registered, so an instance learned about an account only when one of its peers happened to reconnect. For a quiet fleet that is the original bug: the check is created, the peers are told about it, and nobody renews what they were told. Registering on the pushed update closes it, and is the difference between stamping and marking a peer connected — one happens on every push, the other only when a stream opens. Reported by Viktor Liu; the broadcast that makes it work was pointed out by Pascal Fischer. (cherry picked from commit 59efe8d93e53bacdf57cb546f4ab2c19dc4eddab) * [management] Let the challenge refresh loop stop with the manager that owns it The loop was started on a context explicitly detached from the caller's, so nothing could ever stop it. Production is unaffected either way, since BuildManager is called with context.Background(), but a test that builds a manager leaked a sweeping goroutine for the rest of the run, and a shutdown path added later would have had no way to reach it. Take the manager's context as the request buffer built on the line above already does. The test pins the contract the loop offers, so a detached context cannot come back inside Start either. * [management] Bound one account's challenge refresh so it cannot starve the rest Resolving which peers answer a challenge reads the store three times, and the refresher sweeps accounts one after another on a single goroutine. A read that never returns held the sweep for the life of the process, so every other account on the instance stopped being renewed and its peers fell out of the policies gated on the check: one account's bad luck became an outage for all of them. Give each refresh the sweep interval it is allowed to occupy, capped at 30s so a 12-hour window does not grant minutes to a query that should take milliseconds. A refresh that runs out of time keeps its account tracked, since a deadline says nothing about whether that account still has a certificate check. * [management] Send challenge refreshes down the path the rest of management uses The refresh dispatched through UpdateAffectedPeers, the one variant that takes no reason, so it was missing from the update counters and coalesced with nothing. An administrator editing a policy while the sweep ran made the account's network map twice over, and UpdateOperationRefresh, added for exactly this caller, was never referenced. Buffer it with a posture_check/refresh reason instead. The periodic push is now visible in the metrics as what it is, distinct from an edit, and the send detaches from the sweep deadline on its own, so that deadline bounds the store reads it was meant for. * [management] Keep the certificate challenge comments to what the history does not say Four of these ran to three and four times the comment budget, the longest at 992 characters. Most of the excess argued against designs that were never written or explained a bug that no longer exists in the code, which is what the commit that fixed it is for. What is left is the part a reader cannot recover from the code: that the nonce secret has to be persisted and unpredictable, that stamping and renewing are decided together because only the serving instance can push, and that the target rule is the inverse of processPeerPostureChecks. * Keep the newest posture checks pending whatever made their meta sync fail * Report no lost certificate when the engine stops during a proof collection * Share the proof collection single-flight across engine restarts * Close a PKCS#11 module that loads but cannot be used * Fix the pending checks comments * Renew certificate challenges only for the peers streamed to this instance * Ignore a challenge stamp from an older sync stream of the same peer * Kill the Windows certificate proof helper with its whole process tree * Expect the challenge untrack in the session ownership test * Drop an invalid certificate proof without discarding the valid ones * Start a system info gathering beside one that has been stuck for ten timeouts --------- Co-authored-by: pascal <pascal@netbird.io> Co-authored-by: mlsmaycon <mlsmaycon@gmail.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
1498 lines
52 KiB
Go
1498 lines
52 KiB
Go
package profilemanager
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
"github.com/netbirdio/netbird/client/ssh"
|
|
mgm "github.com/netbirdio/netbird/shared/management/client"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
const (
|
|
// managementLegacyPortString is the port that was used before by the Management gRPC server.
|
|
// It is used for backward compatibility now.
|
|
managementLegacyPortString = "33073"
|
|
// DefaultManagementURL points to the NetBird's cloud management endpoint
|
|
DefaultManagementURL = "https://api.netbird.io:443"
|
|
// oldDefaultManagementURL points to the NetBird's old cloud management endpoint
|
|
oldDefaultManagementURL = "https://api.wiretrustee.com:443"
|
|
// DefaultAdminURL points to NetBird's cloud management console
|
|
DefaultAdminURL = "https://app.netbird.io:443"
|
|
)
|
|
|
|
// mgmProber is the subset of management client needed for URL migration probes.
|
|
type mgmProber interface {
|
|
HealthCheck() error
|
|
Close() error
|
|
}
|
|
|
|
// newMgmProber creates a management client for probing URL reachability.
|
|
// Overridden in tests to avoid real network calls.
|
|
var newMgmProber = func(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (mgmProber, error) {
|
|
return mgm.NewClient(ctx, addr, key, tlsEnabled)
|
|
}
|
|
|
|
var DefaultInterfaceBlacklist = []string{
|
|
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
|
|
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
|
}
|
|
|
|
// ConfigInput carries configuration changes to the client
|
|
type ConfigInput struct {
|
|
ManagementURL string
|
|
AdminURL string
|
|
ConfigPath string
|
|
StateFilePath string
|
|
PreSharedKey *string
|
|
ServerSSHAllowed *bool
|
|
RemoteJobsAllowed *bool
|
|
EnableSSHRoot *bool
|
|
EnableSSHSFTP *bool
|
|
EnableSSHLocalPortForwarding *bool
|
|
EnableSSHRemotePortForwarding *bool
|
|
DisableSSHAuth *bool
|
|
SSHJWTCacheTTL *int
|
|
NATExternalIPs []string
|
|
CustomDNSAddress []byte
|
|
RosenpassEnabled *bool
|
|
RosenpassPermissive *bool
|
|
InterfaceName *string
|
|
WireguardPort *int
|
|
NetworkMonitor *bool
|
|
DisableAutoConnect *bool
|
|
ExtraIFaceBlackList []string
|
|
DNSRouteInterval *time.Duration
|
|
ClientCertPath string
|
|
ClientCertKeyPath string
|
|
|
|
DisableClientRoutes *bool
|
|
DisableServerRoutes *bool
|
|
DisableDNS *bool
|
|
DisableFirewall *bool
|
|
BlockLANAccess *bool
|
|
BlockInbound *bool
|
|
DisableIPv6 *bool
|
|
SyncMessageVersion *int
|
|
|
|
DisableNotifications *bool
|
|
|
|
DNSLabels domain.List
|
|
|
|
MTU *uint16
|
|
|
|
LocalMetricsEnabled *bool
|
|
LocalMetricsAddress *string
|
|
}
|
|
|
|
// Config Configuration type
|
|
type Config struct {
|
|
// Name is the human-readable profile name shown in CLI/UI listings.
|
|
// It is independent of the profile's on-disk filename (which is the ID).
|
|
Name string
|
|
|
|
// Wireguard private key of local peer
|
|
PrivateKey string
|
|
PreSharedKey string
|
|
ManagementURL *url.URL
|
|
AdminURL *url.URL
|
|
WgIface string
|
|
WgPort int
|
|
NetworkMonitor *bool
|
|
IFaceBlackList []string
|
|
DisableIPv6Discovery bool
|
|
RosenpassEnabled bool
|
|
RosenpassPermissive bool
|
|
ServerSSHAllowed *bool
|
|
RemoteJobsAllowed *bool
|
|
EnableSSHRoot *bool
|
|
EnableSSHSFTP *bool
|
|
EnableSSHLocalPortForwarding *bool
|
|
EnableSSHRemotePortForwarding *bool
|
|
DisableSSHAuth *bool
|
|
SSHJWTCacheTTL *int
|
|
|
|
DisableClientRoutes bool
|
|
DisableServerRoutes bool
|
|
DisableDNS bool
|
|
DisableFirewall bool
|
|
BlockLANAccess bool
|
|
BlockInbound bool
|
|
DisableIPv6 bool
|
|
SyncMessageVersion *int
|
|
|
|
DisableNotifications *bool
|
|
|
|
DNSLabels domain.List
|
|
|
|
// LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
|
|
LocalMetricsEnabled bool
|
|
// LocalMetricsAddress is the listen address of the local /metrics endpoint.
|
|
LocalMetricsAddress string
|
|
|
|
// SSHKey is a private SSH key in a PEM format
|
|
SSHKey string
|
|
|
|
// ExternalIP mappings, if different from the host interface IP
|
|
//
|
|
// External IP must not be behind a CGNAT and port-forwarding for incoming UDP packets from WgPort on ExternalIP
|
|
// to WgPort on host interface IP must be present. This can take form of single port-forwarding rule, 1:1 DNAT
|
|
// mapping ExternalIP to host interface IP, or a NAT DMZ to host interface IP.
|
|
//
|
|
// A single mapping will take the form of: external[/internal]
|
|
// external (required): either the external IP address or "stun" to use STUN to determine the external IP address
|
|
// internal (optional): either the internal/interface IP address or an interface name
|
|
//
|
|
// examples:
|
|
// "12.34.56.78" => all interfaces IPs will be mapped to external IP of 12.34.56.78
|
|
// "12.34.56.78/eth0" => IPv4 assigned to interface eth0 will be mapped to external IP of 12.34.56.78
|
|
// "12.34.56.78/10.1.2.3" => interface IP 10.1.2.3 will be mapped to external IP of 12.34.56.78
|
|
|
|
NATExternalIPs []string
|
|
// CustomDNSAddress sets the DNS resolver listening address in format ip:port
|
|
CustomDNSAddress string
|
|
|
|
// DisableAutoConnect determines whether the client should not start with the service
|
|
// it's set to false by default due to backwards compatibility
|
|
DisableAutoConnect bool
|
|
|
|
// DNSRouteInterval is the interval in which the DNS routes are updated
|
|
DNSRouteInterval time.Duration
|
|
// Path to a certificate used for mTLS authentication
|
|
ClientCertPath string
|
|
|
|
// Path to corresponding private key of ClientCertPath
|
|
ClientCertKeyPath string
|
|
|
|
ClientCertKeyPair *tls.Certificate `json:"-"`
|
|
|
|
// CertStoreDir is no longer read: certificate posture takes the directory from
|
|
// NB_CERT_STORE_DIR in the daemon's environment. The field is kept only to report a
|
|
// value left from an earlier version.
|
|
CertStoreDir string `json:",omitempty"`
|
|
|
|
// CertPKCS11URI is no longer read, as the URI may carry the token PIN: certificate
|
|
// posture takes it from NB_CERT_PKCS11_URI in the daemon's environment. The field is
|
|
// kept only to report a value left from an earlier version.
|
|
CertPKCS11URI string `json:",omitempty"`
|
|
|
|
// LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/"").
|
|
// Runtime-only: re-derived from MDM policy on each load, never persisted.
|
|
LazyConnection string `json:"-"`
|
|
|
|
// DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override.
|
|
// When set, it takes precedence over the management-supplied upload URL for
|
|
// remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each
|
|
// load, never persisted.
|
|
DebugBundleUploadURL string `json:"-"`
|
|
|
|
MTU uint16
|
|
|
|
// probing marks a config that exists only to be compared against and then
|
|
// thrown away, so apply() can skip the work that feeds no verdict.
|
|
// Unexported, so it never reaches the JSON.
|
|
probing bool
|
|
|
|
// policy is the MDM policy that produced the currently-set values
|
|
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
|
// invocation. Never persisted to disk. Callers query enforcement
|
|
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
|
// IsEmpty).
|
|
policy *mdm.Policy `json:"-"`
|
|
}
|
|
|
|
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
|
|
// Config values and records it as Policy(). The overlay is not reversible:
|
|
// an empty Policy only clears the enforcement metadata, so resolve the base
|
|
// Config again (from disk or JSON) before applying a changed policy, the way
|
|
// the lifecycle owners do on every load.
|
|
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
|
|
if config == nil {
|
|
return
|
|
}
|
|
config.applyMDMPolicy(policy)
|
|
}
|
|
|
|
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
|
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
|
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
|
func (config *Config) Policy() *mdm.Policy {
|
|
if config == nil || config.policy == nil {
|
|
return mdm.NewPolicy(nil)
|
|
}
|
|
return config.policy
|
|
}
|
|
|
|
var ConfigDirOverride string
|
|
|
|
func getConfigDir() (string, error) {
|
|
if ConfigDirOverride != "" {
|
|
return ConfigDirOverride, nil
|
|
}
|
|
|
|
base, err := baseConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
configDir := filepath.Join(base, "netbird")
|
|
// Under sudo this is the invoking user's directory and strictly read-only:
|
|
// anything root creates in it would be root-owned and break the user's own
|
|
// runs. Reads of a missing directory fall through to defaults.
|
|
if sudoActive() {
|
|
return configDir, nil
|
|
}
|
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return configDir, nil
|
|
}
|
|
|
|
func baseConfigDir() (string, error) {
|
|
if u, ok := sudoInvokingUser(); ok {
|
|
return userBaseConfigDir(u)
|
|
}
|
|
// Fail closed instead of falling through to root's own config directory:
|
|
// reading root's active-profile and email state for what is actually the
|
|
// invoking user's invocation is the very confusion this resolution exists
|
|
// to prevent.
|
|
if sudoActive() {
|
|
return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser))
|
|
}
|
|
if runtime.GOOS == "darwin" {
|
|
if u, err := user.Current(); err == nil && u.HomeDir != "" {
|
|
return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
|
|
}
|
|
}
|
|
return os.UserConfigDir()
|
|
}
|
|
|
|
func getConfigDirForUser(username string) (string, error) {
|
|
if ConfigDirOverride != "" {
|
|
return ConfigDirOverride, nil
|
|
}
|
|
|
|
username = sanitizeProfileName(username)
|
|
|
|
configDir := filepath.Join(DefaultConfigPathDir, username)
|
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
|
if err := os.MkdirAll(configDir, 0700); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return configDir, nil
|
|
}
|
|
|
|
func fileExists(path string) (bool, error) {
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
// newConfigSkeleton returns the field values a brand-new profile config starts
|
|
// from, before apply() fills in the rest. Shared with the dry-run baseline so
|
|
// the two cannot disagree about what "a new config" means.
|
|
func newConfigSkeleton() *Config {
|
|
return &Config{
|
|
// defaults to false only for new (post 0.26) configurations
|
|
ServerSSHAllowed: util.False(),
|
|
// Remote jobs are an explicit opt-in and default off, including for
|
|
// legacy configs (a nil value materializes to false at connect time).
|
|
RemoteJobsAllowed: util.False(),
|
|
WgPort: iface.DefaultWgPort,
|
|
}
|
|
}
|
|
|
|
// resolveUnsetDefaults is the single place where an optional field that carries
|
|
// no value gets one, and the only place that states what each of those defaults
|
|
// is. apply() runs it before it compares anything, and that ordering is the
|
|
// point: with the values named, every comparison below it diffs values instead
|
|
// of presence.
|
|
//
|
|
// Presence-based comparison is what broke `netbird up` for a client configured
|
|
// through the environment. These fields mean "the effective default" when they
|
|
// hold nothing — every consumer already reads a nil as the value resolved here,
|
|
// the SSH toggles in engine_ssh.go and the network monitor in
|
|
// createEngineConfig — so naming them changes nothing about what runs. But
|
|
// while they stayed nil, an input restating the default read as a change, and
|
|
// since the CLI sends every flag whose value came from an environment variable
|
|
// on each `netbird up`, a client with NB_ENABLE_SSH_ROOT=false restated it
|
|
// every time and the update-settings gate refused it.
|
|
//
|
|
// Filling a field in is not a settings change, so a caller measuring change
|
|
// must not read the returned bool as one: see WouldChange, which runs a pass
|
|
// for this and discards its verdict.
|
|
//
|
|
// ServerSSHAllowed is the one field whose default depends on the config's age.
|
|
// A brand-new profile gets false from newConfigSkeleton, which runs before
|
|
// this, so what is resolved here is only the legacy case: a config written by a
|
|
// version that had no such field keeps SSH on, for backwards compatibility.
|
|
func (config *Config) resolveUnsetDefaults() (updated bool) {
|
|
// Fields that default to false on every platform.
|
|
for _, field := range []**bool{
|
|
&config.EnableSSHRoot,
|
|
&config.EnableSSHSFTP,
|
|
&config.EnableSSHLocalPortForwarding,
|
|
&config.EnableSSHRemotePortForwarding,
|
|
&config.DisableSSHAuth,
|
|
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
|
|
// with no value defaults to disabled rather than being turned on.
|
|
&config.RemoteJobsAllowed,
|
|
} {
|
|
if *field == nil {
|
|
*field = util.False()
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
if config.DisableNotifications == nil {
|
|
log.Infof("setting notifications to disabled by default")
|
|
config.DisableNotifications = util.True()
|
|
updated = true
|
|
}
|
|
|
|
if config.SSHJWTCacheTTL == nil {
|
|
// A zero TTL disables the JWT cache, which is what no value meant.
|
|
config.SSHJWTCacheTTL = new(int)
|
|
updated = true
|
|
}
|
|
|
|
if config.NetworkMonitor == nil {
|
|
// network monitoring is on by default on windows and darwin clients
|
|
enabled := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
|
config.NetworkMonitor = &enabled
|
|
updated = true
|
|
}
|
|
|
|
if config.ServerSSHAllowed == nil {
|
|
if runtime.GOOS == "android" {
|
|
// default to disabled SSH on Android for security
|
|
log.Infof("setting SSH server to false by default on Android")
|
|
config.ServerSSHAllowed = util.False()
|
|
} else {
|
|
// enables SSH for configs from old versions to preserve backwards compatibility
|
|
log.Infof("falling back to enabled SSH server for pre-existing configuration")
|
|
config.ServerSSHAllowed = util.True()
|
|
}
|
|
updated = true
|
|
}
|
|
|
|
return updated
|
|
}
|
|
|
|
// createNewConfig resolves a new config in memory, with no identity: whoever
|
|
// needs the peer's keys calls EnsureIdentity and persists the result, so a read
|
|
// that lands on a missing file cannot hand back a config carrying keys that
|
|
// nothing will ever write down.
|
|
func createNewConfig(input ConfigInput) (*Config, error) {
|
|
config := newConfigSkeleton()
|
|
|
|
if _, err := config.apply(input); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// createProvisionedConfig is createNewConfig plus the peer's identity, for the
|
|
// callers that go on to persist the config or to connect with it.
|
|
func createProvisionedConfig(input ConfigInput) (*Config, error) {
|
|
config, err := createNewConfig(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := config.EnsureIdentity(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// EnsureIdentity generates the keys that identify this peer if the config does
|
|
// not carry them yet, reporting whether it had to generate any.
|
|
//
|
|
// It is deliberately not part of apply(). Everything apply() fills in is a
|
|
// default it can recompute on the next read, but a generated key is not: it
|
|
// has to be persisted, or the peer comes back with a different WireGuard
|
|
// identity and re-registers. Having apply() generate keys is what forced every
|
|
// read of a config to write it back — so identity provisioning is its own step
|
|
// now, and the callers that perform it write the result out explicitly.
|
|
func (config *Config) EnsureIdentity() (bool, error) {
|
|
generated := false
|
|
|
|
if config.PrivateKey == "" {
|
|
log.Infof("generated new Wireguard key")
|
|
config.PrivateKey = generateKey()
|
|
generated = true
|
|
}
|
|
|
|
if config.SSHKey == "" {
|
|
log.Infof("generated new SSH key")
|
|
pem, err := ssh.GeneratePrivateKey(ssh.ED25519)
|
|
if err != nil {
|
|
return generated, err
|
|
}
|
|
config.SSHKey = string(pem)
|
|
generated = true
|
|
}
|
|
|
|
return generated, nil
|
|
}
|
|
|
|
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
|
if config.Name != "" {
|
|
sanitized, err := sanitizeDisplayName(config.Name)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid profile name: %w", err)
|
|
}
|
|
if sanitized != config.Name {
|
|
config.Name = sanitized
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
// Every optional field gets its value here, before anything below compares
|
|
// one. See resolveUnsetDefaults for why that ordering is the point.
|
|
if config.resolveUnsetDefaults() {
|
|
updated = true
|
|
}
|
|
|
|
if config.ManagementURL == nil {
|
|
log.Infof("using default Management URL %s", DefaultManagementURL)
|
|
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
// The comparison is on the endpoint the URL addresses, not on its
|
|
// spelling: the same endpoint can be written several ways (an implicit
|
|
// :443, a trailing slash, a different host case), and treating an
|
|
// equivalent URL as new would rewrite the config and report a settings
|
|
// change where the configuration does not actually change.
|
|
if input.ManagementURL != "" {
|
|
URL, err := parseURL("Management URL", input.ManagementURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !SameServiceURL(URL, config.ManagementURL) {
|
|
log.Infof("new Management URL provided, updated to %#v (old value %#v)",
|
|
URL.String(), config.ManagementURL.String())
|
|
config.ManagementURL = URL
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
if config.AdminURL == nil {
|
|
log.Infof("using default Admin URL %s", DefaultAdminURL)
|
|
config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
// The admin panel is opened, not dialed, so unlike the Management URL its
|
|
// path is part of what identifies it: a panel served under /netbird is not
|
|
// the one served at the root.
|
|
if input.AdminURL != "" {
|
|
newURL, err := parseURL("Admin Panel URL", input.AdminURL)
|
|
if err != nil {
|
|
return updated, err
|
|
}
|
|
if !SameServiceURLIncludingPath(newURL, config.AdminURL) {
|
|
log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)",
|
|
newURL.String(), config.AdminURL.String())
|
|
config.AdminURL = newURL
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
if input.WireguardPort != nil && *input.WireguardPort != config.WgPort {
|
|
log.Infof("updating Wireguard port %d (old value %d)",
|
|
*input.WireguardPort, config.WgPort)
|
|
config.WgPort = *input.WireguardPort
|
|
updated = true
|
|
}
|
|
|
|
if input.InterfaceName != nil && *input.InterfaceName != config.WgIface {
|
|
log.Infof("updating Wireguard interface %#v (old value %#v)",
|
|
*input.InterfaceName, config.WgIface)
|
|
config.WgIface = *input.InterfaceName
|
|
updated = true
|
|
} else if config.WgIface == "" {
|
|
config.WgIface = iface.WgInterfaceDefault
|
|
log.Infof("using default Wireguard interface %s", config.WgIface)
|
|
updated = true
|
|
}
|
|
|
|
// slices.Equal, not reflect.DeepEqual, and for the same reason the DNS
|
|
// labels below use it: DeepEqual calls a nil slice and an empty one
|
|
// different, while both mean "no NAT mappings". A profile stores the
|
|
// absent list as JSON null and reads it back nil, and `netbird up` sends
|
|
// CleanNATExternalIPs — an empty list — whenever NB_EXTERNAL_IP_MAP is set
|
|
// to nothing, so the two met on every start and the gate read a no-op as a
|
|
// settings change.
|
|
if input.NATExternalIPs != nil && !slices.Equal(config.NATExternalIPs, input.NATExternalIPs) {
|
|
log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])",
|
|
strings.Join(input.NATExternalIPs, " "),
|
|
strings.Join(config.NATExternalIPs, " "))
|
|
config.NATExternalIPs = input.NATExternalIPs
|
|
updated = true
|
|
}
|
|
|
|
if input.PreSharedKey != nil && *input.PreSharedKey != config.PreSharedKey {
|
|
log.Infof("new pre-shared key provided, replacing old key")
|
|
config.PreSharedKey = *input.PreSharedKey
|
|
updated = true
|
|
}
|
|
|
|
if input.RosenpassEnabled != nil && *input.RosenpassEnabled != config.RosenpassEnabled {
|
|
log.Infof("switching Rosenpass to %t", *input.RosenpassEnabled)
|
|
config.RosenpassEnabled = *input.RosenpassEnabled
|
|
updated = true
|
|
}
|
|
|
|
if input.RosenpassPermissive != nil && *input.RosenpassPermissive != config.RosenpassPermissive {
|
|
log.Infof("switching Rosenpass permissive to %t", *input.RosenpassPermissive)
|
|
config.RosenpassPermissive = *input.RosenpassPermissive
|
|
updated = true
|
|
}
|
|
|
|
if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
|
|
log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
|
|
config.LocalMetricsEnabled = *input.LocalMetricsEnabled
|
|
updated = true
|
|
}
|
|
|
|
if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
|
|
log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
|
|
config.LocalMetricsAddress = *input.LocalMetricsAddress
|
|
updated = true
|
|
}
|
|
|
|
if input.NetworkMonitor != nil && *input.NetworkMonitor != *config.NetworkMonitor {
|
|
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
|
config.NetworkMonitor = input.NetworkMonitor
|
|
updated = true
|
|
}
|
|
|
|
if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress {
|
|
log.Infof("updating custom DNS address %#v (old value %#v)",
|
|
string(input.CustomDNSAddress), config.CustomDNSAddress)
|
|
config.CustomDNSAddress = string(input.CustomDNSAddress)
|
|
updated = true
|
|
}
|
|
|
|
if len(config.IFaceBlackList) == 0 {
|
|
log.Infof("filling in interface blacklist with defaults: [ %s ]",
|
|
strings.Join(DefaultInterfaceBlacklist, " "))
|
|
config.IFaceBlackList = append(config.IFaceBlackList, DefaultInterfaceBlacklist...)
|
|
updated = true
|
|
}
|
|
|
|
if len(input.ExtraIFaceBlackList) > 0 {
|
|
for _, iFace := range util.SliceDiff(input.ExtraIFaceBlackList, config.IFaceBlackList) {
|
|
log.Infof("adding new entry to interface blacklist: %s", iFace)
|
|
config.IFaceBlackList = append(config.IFaceBlackList, iFace)
|
|
updated = true
|
|
}
|
|
}
|
|
|
|
if input.DisableAutoConnect != nil && *input.DisableAutoConnect != config.DisableAutoConnect {
|
|
if *input.DisableAutoConnect {
|
|
log.Infof("turning off automatic connection on startup")
|
|
} else {
|
|
log.Infof("enabling automatic connection on startup")
|
|
}
|
|
config.DisableAutoConnect = *input.DisableAutoConnect
|
|
updated = true
|
|
}
|
|
|
|
if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
|
|
if *input.ServerSSHAllowed {
|
|
log.Infof("enabling SSH server")
|
|
} else {
|
|
log.Infof("disabling SSH server")
|
|
}
|
|
config.ServerSSHAllowed = input.ServerSSHAllowed
|
|
updated = true
|
|
}
|
|
|
|
if input.RemoteJobsAllowed != nil && *input.RemoteJobsAllowed != *config.RemoteJobsAllowed {
|
|
if *input.RemoteJobsAllowed {
|
|
log.Infof("enabling remote jobs")
|
|
} else {
|
|
log.Infof("disabling remote jobs")
|
|
}
|
|
config.RemoteJobsAllowed = input.RemoteJobsAllowed
|
|
updated = true
|
|
}
|
|
|
|
if input.EnableSSHRoot != nil && *input.EnableSSHRoot != *config.EnableSSHRoot {
|
|
if *input.EnableSSHRoot {
|
|
log.Infof("enabling SSH root login")
|
|
} else {
|
|
log.Infof("disabling SSH root login")
|
|
}
|
|
config.EnableSSHRoot = input.EnableSSHRoot
|
|
updated = true
|
|
}
|
|
|
|
if input.EnableSSHSFTP != nil && *input.EnableSSHSFTP != *config.EnableSSHSFTP {
|
|
if *input.EnableSSHSFTP {
|
|
log.Infof("enabling SSH SFTP subsystem")
|
|
} else {
|
|
log.Infof("disabling SSH SFTP subsystem")
|
|
}
|
|
config.EnableSSHSFTP = input.EnableSSHSFTP
|
|
updated = true
|
|
}
|
|
|
|
if input.EnableSSHLocalPortForwarding != nil && *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding {
|
|
if *input.EnableSSHLocalPortForwarding {
|
|
log.Infof("enabling SSH local port forwarding")
|
|
} else {
|
|
log.Infof("disabling SSH local port forwarding")
|
|
}
|
|
config.EnableSSHLocalPortForwarding = input.EnableSSHLocalPortForwarding
|
|
updated = true
|
|
}
|
|
|
|
if input.EnableSSHRemotePortForwarding != nil && *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding {
|
|
if *input.EnableSSHRemotePortForwarding {
|
|
log.Infof("enabling SSH remote port forwarding")
|
|
} else {
|
|
log.Infof("disabling SSH remote port forwarding")
|
|
}
|
|
config.EnableSSHRemotePortForwarding = input.EnableSSHRemotePortForwarding
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableSSHAuth != nil && *input.DisableSSHAuth != *config.DisableSSHAuth {
|
|
if *input.DisableSSHAuth {
|
|
log.Infof("disabling SSH authentication")
|
|
} else {
|
|
log.Infof("enabling SSH authentication")
|
|
}
|
|
config.DisableSSHAuth = input.DisableSSHAuth
|
|
updated = true
|
|
}
|
|
|
|
if input.SSHJWTCacheTTL != nil && *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL {
|
|
log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL)
|
|
config.SSHJWTCacheTTL = input.SSHJWTCacheTTL
|
|
updated = true
|
|
}
|
|
|
|
if input.DNSRouteInterval != nil && *input.DNSRouteInterval != config.DNSRouteInterval {
|
|
log.Infof("updating DNS route interval to %s (old value %s)",
|
|
input.DNSRouteInterval.String(), config.DNSRouteInterval.String())
|
|
config.DNSRouteInterval = *input.DNSRouteInterval
|
|
updated = true
|
|
} else if config.DNSRouteInterval == 0 {
|
|
config.DNSRouteInterval = dynamic.DefaultInterval
|
|
log.Infof("using default DNS route interval %s", config.DNSRouteInterval)
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableClientRoutes != nil && *input.DisableClientRoutes != config.DisableClientRoutes {
|
|
if *input.DisableClientRoutes {
|
|
log.Infof("disabling client routes")
|
|
} else {
|
|
log.Infof("enabling client routes")
|
|
}
|
|
config.DisableClientRoutes = *input.DisableClientRoutes
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableServerRoutes != nil && *input.DisableServerRoutes != config.DisableServerRoutes {
|
|
if *input.DisableServerRoutes {
|
|
log.Infof("disabling server routes")
|
|
} else {
|
|
log.Infof("enabling server routes")
|
|
}
|
|
config.DisableServerRoutes = *input.DisableServerRoutes
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableDNS != nil && *input.DisableDNS != config.DisableDNS {
|
|
if *input.DisableDNS {
|
|
log.Infof("disabling DNS configuration")
|
|
} else {
|
|
log.Infof("enabling DNS configuration")
|
|
}
|
|
config.DisableDNS = *input.DisableDNS
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableFirewall != nil && *input.DisableFirewall != config.DisableFirewall {
|
|
if *input.DisableFirewall {
|
|
log.Infof("disabling firewall configuration")
|
|
} else {
|
|
log.Infof("enabling firewall configuration")
|
|
}
|
|
config.DisableFirewall = *input.DisableFirewall
|
|
updated = true
|
|
}
|
|
|
|
if input.BlockLANAccess != nil && *input.BlockLANAccess != config.BlockLANAccess {
|
|
if *input.BlockLANAccess {
|
|
log.Infof("blocking LAN access")
|
|
} else {
|
|
log.Infof("allowing LAN access")
|
|
}
|
|
config.BlockLANAccess = *input.BlockLANAccess
|
|
updated = true
|
|
}
|
|
|
|
if input.BlockInbound != nil && *input.BlockInbound != config.BlockInbound {
|
|
if *input.BlockInbound {
|
|
log.Infof("blocking inbound connections")
|
|
} else {
|
|
log.Infof("allowing inbound connections")
|
|
}
|
|
config.BlockInbound = *input.BlockInbound
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableIPv6 != nil && *input.DisableIPv6 != config.DisableIPv6 {
|
|
log.Infof("setting IPv6 overlay disabled=%v", *input.DisableIPv6)
|
|
config.DisableIPv6 = *input.DisableIPv6
|
|
updated = true
|
|
}
|
|
|
|
// Assigning the pointer, not writing through it: a config that carries no
|
|
// version yet would otherwise be a nil dereference, and a panic inside a
|
|
// request handler is not a way to fail.
|
|
if input.SyncMessageVersion != nil && (config.SyncMessageVersion == nil || *input.SyncMessageVersion != *config.SyncMessageVersion) {
|
|
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
|
|
config.SyncMessageVersion = input.SyncMessageVersion
|
|
updated = true
|
|
}
|
|
|
|
if input.DisableNotifications != nil && *input.DisableNotifications != *config.DisableNotifications {
|
|
if *input.DisableNotifications {
|
|
log.Infof("disabling notifications")
|
|
} else {
|
|
log.Infof("enabling notifications")
|
|
}
|
|
config.DisableNotifications = input.DisableNotifications
|
|
updated = true
|
|
}
|
|
|
|
// Compared, not just assigned: restating the path a config already holds
|
|
// changes nothing, and reporting it as an update makes a caller that
|
|
// re-sends its own configuration look like one asking to change it.
|
|
if input.ClientCertKeyPath != "" && input.ClientCertKeyPath != config.ClientCertKeyPath {
|
|
config.ClientCertKeyPath = input.ClientCertKeyPath
|
|
updated = true
|
|
}
|
|
|
|
if input.ClientCertPath != "" && input.ClientCertPath != config.ClientCertPath {
|
|
config.ClientCertPath = input.ClientCertPath
|
|
updated = true
|
|
}
|
|
|
|
// Not on a probe: the loaded pair feeds the connection, never the
|
|
// comparison, and this would otherwise run on every gated SetConfig and
|
|
// Login — twice per request — including those that are refused or change
|
|
// nothing, logging an error per request when the files are missing.
|
|
if !config.probing && config.ClientCertPath != "" && config.ClientCertKeyPath != "" {
|
|
cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath)
|
|
if err != nil {
|
|
log.Error("Failed to load mTLS cert/key pair: ", err)
|
|
} else {
|
|
config.ClientCertKeyPair = &cert
|
|
log.Info("Loaded client mTLS cert/key pair")
|
|
}
|
|
}
|
|
|
|
if input.DNSLabels != nil && !slices.Equal(config.DNSLabels, input.DNSLabels) {
|
|
log.Infof("updating DNS labels [ %s ] (old value: [ %s ])",
|
|
input.DNSLabels.SafeString(),
|
|
config.DNSLabels.SafeString())
|
|
config.DNSLabels = input.DNSLabels
|
|
updated = true
|
|
}
|
|
|
|
if input.MTU != nil && *input.MTU != config.MTU {
|
|
log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU)
|
|
config.MTU = *input.MTU
|
|
updated = true
|
|
} else if config.MTU == 0 {
|
|
config.MTU = iface.DefaultMTU
|
|
log.Infof("using default MTU %d", config.MTU)
|
|
updated = true
|
|
}
|
|
|
|
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
|
// never returns a stale or nil policy on a freshly applied Config.
|
|
// Lifecycle owners that want to enforce a real MDM policy invoke
|
|
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
|
|
config.applyMDMPolicy(mdm.NewPolicy(nil))
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
|
|
// The provided Policy is also stored on the Config so callers can later query
|
|
// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
|
|
// and skipped to avoid bricking the client; the field keeps its previous
|
|
// resolved value but is still marked as managed (Policy.HasKey returns true
|
|
// for the key, so per-field rejection of user writes still applies).
|
|
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
|
config.policy = policy
|
|
|
|
// DebugBundleUploadURL is a runtime-only override re-derived from MDM on
|
|
// every apply. Resolve it unconditionally (before the IsEmpty early return)
|
|
// so a policy that drops the key, becomes empty, or carries an invalid
|
|
// value can never leave a previously-enforced upload target active on a
|
|
// reused Config instance.
|
|
config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy)
|
|
|
|
if policy.IsEmpty() {
|
|
return
|
|
}
|
|
|
|
// Helper: log the application of a single MDM-managed key. Values for
|
|
// keys in mdm.SecretKeys are redacted.
|
|
logApplied := func(key string, displayValue any) {
|
|
if _, secret := mdm.SecretKeys[key]; secret {
|
|
log.Infof("MDM override %s = ********** (secret)", key)
|
|
return
|
|
}
|
|
log.Infof("MDM override %s = %v", key, displayValue)
|
|
}
|
|
|
|
if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
|
|
if u, err := parseURL("Management URL", v); err != nil {
|
|
log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
|
|
} else {
|
|
config.ManagementURL = u
|
|
logApplied(mdm.KeyManagementURL, u.String())
|
|
}
|
|
}
|
|
|
|
if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
|
|
// Defensive: refuse the redaction mask in case it round-tripped
|
|
// through a manifest by mistake.
|
|
if !isPreSharedKeyHidden(&v) {
|
|
config.PreSharedKey = v
|
|
logApplied(mdm.KeyPreSharedKey, "")
|
|
}
|
|
}
|
|
|
|
// applyBool collapses the per-key "read + set + log" boilerplate
|
|
// for every plain bool MDM key into a single helper. Keeps the
|
|
// outer function's cognitive complexity below SonarCube's
|
|
// threshold; functional behaviour is identical to the inlined
|
|
// branches it replaces.
|
|
applyBool := func(key string, setter func(bool)) {
|
|
v, ok := policy.GetBool(key)
|
|
if !ok {
|
|
return
|
|
}
|
|
setter(v)
|
|
logApplied(key, v)
|
|
}
|
|
|
|
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
|
applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv })
|
|
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
|
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
|
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
|
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
|
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
|
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
|
applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
|
|
|
|
if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
|
|
config.LocalMetricsAddress = v
|
|
logApplied(mdm.KeyLocalMetricsAddress, v)
|
|
}
|
|
|
|
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
|
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
|
// upper bound and reject obviously-invalid values to avoid the
|
|
// engine binding to an unusable port if the admin pushes garbage.
|
|
if v >= 1 && v <= 65535 {
|
|
config.WgPort = int(v)
|
|
logApplied(mdm.KeyWireguardPort, v)
|
|
} else {
|
|
log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
|
|
}
|
|
}
|
|
|
|
if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok {
|
|
state := "off"
|
|
if v {
|
|
state = "on"
|
|
}
|
|
config.LazyConnection = state
|
|
logApplied(mdm.KeyLazyConnection, state)
|
|
}
|
|
|
|
}
|
|
|
|
// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty
|
|
// value is accepted — the executor falls back to the default upload service. A
|
|
// non-empty value must be a well-formed https URL with a host; a malformed
|
|
// value or a plaintext scheme is rejected. It deliberately does not constrain
|
|
// which host may receive the bundle. This is the single source of truth for the
|
|
// rule, shared by the remote-job executor (client/internal) and the MDM policy
|
|
// override below so the two validation paths cannot drift.
|
|
func ValidateBundleUploadURL(raw string) error {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("parse upload URL: %w", err)
|
|
}
|
|
// Hostname(), not Host: an authority like ":443" is non-empty but has no
|
|
// host, and would fail the actual upload.
|
|
if parsed.Scheme != "https" || parsed.Hostname() == "" {
|
|
return fmt.Errorf("upload URL must be an https URL with a host")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL
|
|
// override from the policy, returning the empty string when the policy does
|
|
// not carry a valid KeyBundleUploadURL. An absent or invalid value fails
|
|
// closed to "" so it falls back to the management-supplied or default upload
|
|
// target rather than a previously-enforced one. The URL is never logged: it
|
|
// can embed credentials or signed query tokens (KeyBundleUploadURL is in
|
|
// mdm.SecretKeys).
|
|
func mdmDebugBundleUploadURL(policy *mdm.Policy) string {
|
|
v, ok := policy.GetString(mdm.KeyBundleUploadURL)
|
|
if !ok || v == "" {
|
|
return ""
|
|
}
|
|
// Must be a well-formed https URL with a host, matching the client's
|
|
// remote-job upload-URL validation (shared validator, single source of truth).
|
|
if err := ValidateBundleUploadURL(v); err != nil {
|
|
log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override")
|
|
return ""
|
|
}
|
|
log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL)
|
|
return v
|
|
}
|
|
|
|
// parseURL parses and validates the URL for the named service. The URL
|
|
// must use the http or https scheme; if no port is present, ":443" is
|
|
// appended for https or ":80" for http. The serviceName parameter is
|
|
// used to contextualise error messages. On success returns the parsed
|
|
// *url.URL; on failure returns a non-nil error.
|
|
// ParseServiceURL normalises a service URL exactly as the config layer does when
|
|
// it stores one, so callers comparing a requested URL against a stored one do not
|
|
// have to reimplement the scheme validation and default-port handling.
|
|
func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
|
|
return parseURL(serviceName, serviceURL)
|
|
}
|
|
|
|
// SameServiceURL reports whether two service URLs address the same endpoint:
|
|
// same scheme, same host compared case-insensitively as DNS names are, and
|
|
// same effective port, where an absent port means the scheme's default.
|
|
//
|
|
// This is the one comparison every caller deciding "did this URL change?" must
|
|
// use. A string comparison answers a different question: "https://host",
|
|
// "https://host/" and "https://HOST:443" are one endpoint written three ways,
|
|
// and reading them as three values makes a client that restates its own
|
|
// management URL look like a client asking to be repointed. A nil operand
|
|
// matches only another nil one.
|
|
//
|
|
// The path plays no part: a management URL is dialed, and only its host and
|
|
// port are. util.SameServiceURL is this comparison plus the path, which is
|
|
// what SameServiceURLIncludingPath needs and delegates to.
|
|
func SameServiceURL(a, b *url.URL) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
|
|
return strings.EqualFold(a.Scheme, b.Scheme) &&
|
|
strings.EqualFold(a.Hostname(), b.Hostname()) &&
|
|
util.ServiceURLPort(a) == util.ServiceURLPort(b)
|
|
}
|
|
|
|
// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries
|
|
// past its endpoint: path, query, fragment and userinfo.
|
|
//
|
|
// Use it for a URL that gets opened rather than dialed. The admin panel can
|
|
// live under a path, so two URLs with the same endpoint and different paths are
|
|
// two different panels — where for a URL the client dials over gRPC only the
|
|
// endpoint is ever used. Equivalent spellings still compare equal: a missing
|
|
// path and "/" are the same root, and so is a trailing slash on any path.
|
|
func SameServiceURLIncludingPath(a, b *url.URL) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
|
|
return util.SameServiceURL(a, b) &&
|
|
a.RawQuery == b.RawQuery &&
|
|
a.Fragment == b.Fragment &&
|
|
a.User.String() == b.User.String()
|
|
}
|
|
|
|
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
|
|
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
|
|
if err != nil {
|
|
log.Errorf("failed parsing %s URL %s: [%s]", serviceName, serviceURL, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
if parsedMgmtURL.Scheme != "https" && parsedMgmtURL.Scheme != "http" {
|
|
return nil, fmt.Errorf(
|
|
"invalid %s URL provided %s. Supported format [http|https]://[host]:[port]",
|
|
serviceName, serviceURL)
|
|
}
|
|
|
|
if parsedMgmtURL.Port() == "" {
|
|
switch parsedMgmtURL.Scheme {
|
|
case "https":
|
|
parsedMgmtURL.Host += ":443"
|
|
case "http":
|
|
parsedMgmtURL.Host += ":80"
|
|
default:
|
|
log.Infof("unable to determine a default port for schema %s in URL %s", parsedMgmtURL.Scheme, serviceURL)
|
|
}
|
|
}
|
|
|
|
return parsedMgmtURL, err
|
|
}
|
|
|
|
// generateKey generates a new Wireguard private key
|
|
func generateKey() string {
|
|
key, err := wgtypes.GeneratePrivateKey()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return key.String()
|
|
}
|
|
|
|
// don't overwrite pre-shared key if we receive asterisks from UI
|
|
func isPreSharedKeyHidden(preSharedKey *string) bool {
|
|
if preSharedKey != nil && *preSharedKey == "**********" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WouldChange reports whether applying input would modify any field the
|
|
// config persists, leaving the receiver untouched. It is the dry-run half of
|
|
// UpdateConfig and reuses the very same diff logic (Config.apply), so a
|
|
// caller asking "is this a settings change?" cannot drift from what an
|
|
// actual update would do, nor go stale when a new field is added.
|
|
//
|
|
// A redacted pre-shared key is collapsed to "unset" exactly as
|
|
// UpdateOrCreateConfig does, so a UI that round-trips the mask is not read as
|
|
// a request for a new key.
|
|
//
|
|
// A nil receiver means the profile holds no config yet, so the baseline is the
|
|
// config the daemon would create for it: input values matching those defaults
|
|
// change nothing, anything else does.
|
|
func (config *Config) WouldChange(input ConfigInput) (bool, error) {
|
|
probe := config.clone()
|
|
if probe == nil {
|
|
baseline, err := newDryRunBaseline(input.ConfigPath)
|
|
if err != nil {
|
|
return true, fmt.Errorf("build default config baseline: %w", err)
|
|
}
|
|
probe = baseline
|
|
}
|
|
probe.probing = true
|
|
|
|
// Normalize before measuring. 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. Only the first is a settings change, so
|
|
// the filling-in gets a pass of its own whose verdict is discarded, and the
|
|
// pass that answers the caller runs against a config with nothing left to
|
|
// fill in.
|
|
//
|
|
// Readers already hand out normalized configs — readConfig applies an empty
|
|
// input for this very reason — so this is normally a no-op. But a gate that
|
|
// refuses a request must not depend on where its caller got the config
|
|
// from, and it must not start reading "this profile predates a field" as
|
|
// "the caller asked for a change" the day someone adds one.
|
|
if _, err := probe.apply(ConfigInput{ConfigPath: input.ConfigPath}); err != nil {
|
|
return true, fmt.Errorf("normalize the config to diff against: %w", err)
|
|
}
|
|
|
|
if isPreSharedKeyHidden(input.PreSharedKey) {
|
|
input.PreSharedKey = nil
|
|
}
|
|
|
|
return probe.apply(input)
|
|
}
|
|
|
|
// newDryRunBaseline builds the config a brand-new profile would start from, for
|
|
// a dry run to compare an input against. It is createNewConfig without the
|
|
// identity: this config exists only to be compared against and thrown away, and
|
|
// no ConfigInput field maps to either key.
|
|
func newDryRunBaseline(configPath string) (*Config, error) {
|
|
baseline := newConfigSkeleton()
|
|
|
|
if _, err := baseline.apply(ConfigInput{ConfigPath: configPath}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return baseline, nil
|
|
}
|
|
|
|
// clone returns a copy of the config that apply can be run against without the
|
|
// original observing the writes, or nil for a nil receiver. Only what apply
|
|
// mutates in place needs detaching, which is the slices it replaces or appends
|
|
// to: every pointer field it touches is reassigned rather than written through,
|
|
// and ClientCertKeyPair is only overwritten.
|
|
func (config *Config) clone() *Config {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
|
|
probe := *config
|
|
probe.IFaceBlackList = slices.Clone(config.IFaceBlackList)
|
|
probe.NATExternalIPs = slices.Clone(config.NATExternalIPs)
|
|
probe.DNSLabels = slices.Clone(config.DNSLabels)
|
|
return &probe
|
|
}
|
|
|
|
// UpdateConfig update existing configuration according to input configuration and return with the configuration
|
|
func UpdateConfig(input ConfigInput) (*Config, error) {
|
|
configExists, err := fileExists(input.ConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
|
|
}
|
|
if !configExists {
|
|
return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath)
|
|
}
|
|
|
|
// A UI that round-trips the mask GetConfig hands it back is asking to keep
|
|
// the stored key, not to set the mask as the new one. UpdateOrCreateConfig
|
|
// and DirectUpdateOrCreateConfig already collapse it; this one did not, so
|
|
// the same round-trip through SetConfig replaced the key with asterisks.
|
|
if isPreSharedKeyHidden(input.PreSharedKey) {
|
|
input.PreSharedKey = nil
|
|
}
|
|
|
|
return update(input)
|
|
}
|
|
|
|
// UpdateOrCreateConfig reads existing config or generates a new one
|
|
func UpdateOrCreateConfig(input ConfigInput) (*Config, error) {
|
|
configExists, err := fileExists(input.ConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
|
|
}
|
|
if !configExists {
|
|
log.Infof("generating new config %s", input.ConfigPath)
|
|
cfg, err := createProvisionedConfig(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = util.WriteJsonWithRestrictedPermission(context.Background(), input.ConfigPath, cfg)
|
|
return cfg, err
|
|
}
|
|
|
|
if isPreSharedKeyHidden(input.PreSharedKey) {
|
|
input.PreSharedKey = nil
|
|
}
|
|
err = util.EnforcePermission(input.ConfigPath)
|
|
if err != nil {
|
|
log.Errorf("failed to enforce permission on config dir: %v", err)
|
|
}
|
|
return update(input)
|
|
}
|
|
|
|
func update(input ConfigInput) (*Config, error) {
|
|
config := &Config{}
|
|
|
|
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// A write path is a provisioning point: a stored profile can legitimately
|
|
// carry no identity (a mobile logout clears the keys in place), and the
|
|
// next config write is what has to mint a new one. Reads leave that alone.
|
|
identityGenerated, err := config.EnsureIdentity()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updated, err := config.apply(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if updated || identityGenerated {
|
|
if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// GetExistingConfig reads and returns the config if it exists on disk. Fails otherwise.
|
|
func GetExistingConfig(configPath string) (*Config, error) {
|
|
return readConfig(configPath, false)
|
|
}
|
|
|
|
// UpdateOldManagementURL checks whether client can switch to the new Management URL with port 443 and the management domain.
|
|
// If it can switch, then it updates the config and returns a new one. Otherwise, it returns the provided config.
|
|
// The check is performed only for the NetBird's managed version.
|
|
func UpdateOldManagementURL(ctx context.Context, config *Config, configPath string) (*Config, error) {
|
|
defaultManagementURL, err := parseURL("Management URL", DefaultManagementURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsedOldDefaultManagementURL, err := parseURL("Management URL", oldDefaultManagementURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if config.ManagementURL.Hostname() != defaultManagementURL.Hostname() &&
|
|
config.ManagementURL.Hostname() != parsedOldDefaultManagementURL.Hostname() {
|
|
// only do the check for the NetBird's managed version
|
|
return config, nil
|
|
}
|
|
|
|
var mgmTlsEnabled bool
|
|
if config.ManagementURL.Scheme == "https" {
|
|
mgmTlsEnabled = true
|
|
}
|
|
|
|
if !mgmTlsEnabled {
|
|
// only do the check for HTTPs scheme (the hosted version of the Management service is always HTTPs)
|
|
return config, nil
|
|
}
|
|
|
|
if config.ManagementURL.Port() != managementLegacyPortString &&
|
|
config.ManagementURL.Hostname() == defaultManagementURL.Hostname() {
|
|
return config, nil
|
|
}
|
|
|
|
newURL, err := parseURL("Management URL", fmt.Sprintf("%s://%s", config.ManagementURL.Scheme, net.JoinHostPort(defaultManagementURL.Hostname(), "443")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// here we check whether we could switch from the legacy 33073 port to the new 443
|
|
log.Infof("attempting to switch from the legacy Management URL %s to the new one %s",
|
|
config.ManagementURL.String(), newURL.String())
|
|
key, err := wgtypes.ParseKey(config.PrivateKey)
|
|
if err != nil {
|
|
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
|
return config, err
|
|
}
|
|
|
|
client, err := newMgmProber(ctx, newURL.Host, key, mgmTlsEnabled)
|
|
if err != nil {
|
|
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
|
return config, err
|
|
}
|
|
defer func() {
|
|
if err := client.Close(); err != nil {
|
|
log.Warnf("failed to close the Management service client %v", err)
|
|
}
|
|
}()
|
|
|
|
// gRPC check
|
|
if err = client.HealthCheck(); err != nil {
|
|
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
|
return nil, err
|
|
}
|
|
|
|
// everything is alright => update the config
|
|
newConfig, err := UpdateConfig(ConfigInput{
|
|
ManagementURL: newURL.String(),
|
|
ConfigPath: configPath,
|
|
})
|
|
if err != nil {
|
|
log.Infof("couldn't switch to the new Management %s", newURL.String())
|
|
return config, fmt.Errorf("failed updating config file: %v", err)
|
|
}
|
|
log.Infof("successfully switched to the new Management URL: %s", newURL.String())
|
|
|
|
return newConfig, nil
|
|
}
|
|
|
|
// CreateInMemoryConfig generate a new config but do not write out it to the store.
|
|
// It carries an identity: callers connect with what they get back.
|
|
func CreateInMemoryConfig(input ConfigInput) (*Config, error) {
|
|
return createProvisionedConfig(input)
|
|
}
|
|
|
|
// ReadConfigOrDefault reads the profile config at configPath, or resolves the
|
|
// default config in memory when the file does not exist. It never writes, and
|
|
// never mints an identity — EnsureIdentity is where that happens, so the
|
|
// caller that provisions is also the one that persists.
|
|
func ReadConfigOrDefault(configPath string) (*Config, error) {
|
|
return readConfig(configPath, true)
|
|
}
|
|
|
|
// readConfig reads the profile config at configPath. createIfMissing resolves a
|
|
// default config in memory when the file is absent, rather than erroring.
|
|
//
|
|
// Reads are pure. This used to write the config back whenever apply() had to
|
|
// fill in a default the file was missing, which quietly made every reader a
|
|
// writer: a gate deciding whether to refuse a request, a UI listing profiles,
|
|
// a mobile getter reading a single preference.
|
|
func readConfig(configPath string, createIfMissing bool) (*Config, error) {
|
|
configExists, err := fileExists(configPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
|
|
}
|
|
|
|
if configExists {
|
|
err := util.EnforcePermission(configPath)
|
|
if err != nil {
|
|
log.Errorf("failed to enforce permission on config dir: %v", err)
|
|
}
|
|
|
|
config := &Config{}
|
|
if _, err := util.ReadJson(configPath, config); err != nil {
|
|
return nil, err
|
|
}
|
|
// initialize through apply() without changes
|
|
if _, err := config.apply(ConfigInput{}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return config, nil
|
|
} else if !createIfMissing {
|
|
return nil, fmt.Errorf("config file %s does not exist", configPath)
|
|
}
|
|
|
|
return createNewConfig(ConfigInput{ConfigPath: configPath})
|
|
}
|
|
|
|
// WriteOutConfig write put the prepared config to the given path
|
|
func WriteOutConfig(path string, config *Config) error {
|
|
return util.WriteJson(context.Background(), path, config)
|
|
}
|
|
|
|
// DirectWriteOutConfig writes config directly without atomic temp file operations.
|
|
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
|
|
func DirectWriteOutConfig(path string, config *Config) error {
|
|
return util.DirectWriteJson(context.Background(), path, config)
|
|
}
|
|
|
|
// DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes.
|
|
// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox).
|
|
func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) {
|
|
configExists, err := fileExists(input.ConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if config file exists: %w", err)
|
|
}
|
|
if !configExists {
|
|
log.Infof("generating new config %s", input.ConfigPath)
|
|
cfg, err := createProvisionedConfig(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = util.DirectWriteJson(context.Background(), input.ConfigPath, cfg)
|
|
return cfg, err
|
|
}
|
|
|
|
if isPreSharedKeyHidden(input.PreSharedKey) {
|
|
input.PreSharedKey = nil
|
|
}
|
|
|
|
// Enforce permissions on existing config files (same as UpdateOrCreateConfig)
|
|
if err := util.EnforcePermission(input.ConfigPath); err != nil {
|
|
log.Errorf("failed to enforce permission on config file: %v", err)
|
|
}
|
|
|
|
return directUpdate(input)
|
|
}
|
|
|
|
func directUpdate(input ConfigInput) (*Config, error) {
|
|
config := &Config{}
|
|
|
|
if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Same provisioning point as update(); see the note there.
|
|
identityGenerated, err := config.EnsureIdentity()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updated, err := config.apply(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if updated || identityGenerated {
|
|
if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// ConfigToJSON serializes a Config struct to a JSON string.
|
|
// This is useful for exporting config to alternative storage mechanisms
|
|
// (e.g., UserDefaults on tvOS where file writes are blocked).
|
|
func ConfigToJSON(config *Config) (string, error) {
|
|
bs, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(bs), nil
|
|
}
|
|
|
|
// ConfigFromJSON deserializes a JSON string to a Config struct.
|
|
// This is useful for restoring config from alternative storage mechanisms.
|
|
// After unmarshaling, defaults are applied to ensure the config is fully
|
|
// initialized.
|
|
//
|
|
// The peer identity is deliberately none of its business, in either direction.
|
|
// It does not generate one: a read cannot hand back keys that nothing will
|
|
// write down (see ReadConfigOrDefault). Nor does it refuse a document that
|
|
// carries none, because a config legitimately has no identity between a logout
|
|
// and the next login — mobile logout clears both keys in place — and this is
|
|
// also the deserializer the iOS SDK copies a config through. Whoever goes on
|
|
// to connect is where an absent identity has to be answered.
|
|
func ConfigFromJSON(jsonStr string) (*Config, error) {
|
|
config := &Config{}
|
|
err := json.Unmarshal([]byte(jsonStr), config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Apply defaults to ensure required fields are initialized.
|
|
// This mirrors what readConfig does after loading from file.
|
|
if _, err := config.apply(ConfigInput{}); err != nil {
|
|
return nil, fmt.Errorf("failed to apply defaults to config: %w", err)
|
|
}
|
|
|
|
return config, nil
|
|
}
|