mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-09 15:09:08 +02:00
* [client] Force interactive login when extending the auth session A session extend must be answered from the account the peer is registered under. With a silent PKCE flow (DisablePromptLogin or max_age=0) the IdP answers from whatever session it already holds, which need not be the peer's account when several are signed in; the token then fails the user match in ExtendAuthSession with no way to pick another account. Mark the PKCE flow request as a session extend so the management server can force prompt=login for it, overriding the configured silent flow. * [client] Reduce cognitive complexity of Server.Login Login sat at cognitive complexity 27, over the 25 the linter allows. Extract the interactive SSO branch into startSSOLogin, and split the nested in-flight-flow reuse check out of it into reuseOAuthFlow, which flattens the original if/else into early returns: it returns the cached auth info when the previous flow targets the same client and still has more than 90s left, otherwise cancels the stale wait and returns nil so the caller requests a fresh flow. The helpers take the contextState through a small statusSetter interface, since internal.contextState is unexported and re-deriving it with CtxGetState inside the helper would resolve against callerCtx rather than rootCtx. No behavior change: same ordering of state transitions, same mutex scope around the oauthAuthFlow write, same error paths. Login is now at 21. * [client] Respect DisablePromptLogin when extending the auth session Forcing prompt=login on a session extend overrode DisablePromptLogin, which is set for IdPs that break on it: Authentik triggers a double authentication and social logins fail outright. Overriding it there trades a recoverable extend for a login that cannot complete at all. Keep the LoginFlag override, which only replaces max_age=0 or none with prompt=login so the IdP honours login_hint, and leave DisablePromptLogin as configured. Those deployments keep the silent flow, and with several accounts signed in an extend answered from the wrong one still fails the user match. * [client] Guard the shared OAuth flow state with the server mutex reuseOAuthFlow read flow, expiresAt, waitCancel and info without holding s.mutex, while startSSOLogin and WaitSSOLogin write them under it. Reading the fields one at a time could also answer with auth info from a flow that was already replaced, or cancel a wait that no longer belongs to the flow just judged stale. Take one snapshot under the lock and decide from it. WaitSSOLogin read oauthAuthFlow.flow twice outside the lock; both now use a value snapshotted in the critical section that already installs actCancel. Its stale waitCancel was read and called in a separate section from the one installing the new one, so two racing calls could read the same predecessor and leave one wait uncancelled. Swap the two in a single critical section. Both cancels run after unlocking: the displaced wait takes s.mutex as it unwinds. * [client] Verify the SSO login came back for the hinted account login_hint is a suggestion the IdP may ignore: with a silent flow configured (DisablePromptLogin or max_age=0) and a live IdP session for another account, the login completes with that account's token. On a registered peer the management server rejects it as a user mismatch, but on a fresh profile the peer silently registers under the wrong account and the profile is then bound to it — every later login follows the stored hint straight back. After the token exchange, compare the ID token's email against the hint the flow was sent with. On a mismatch, do not log in to management with the token; run one more round asking the IdP to re-decide the account (prompt=login, via ForceAccountPrompt — DisablePromptLogin still wins there). If the prompted round also comes back different, proceed with a warning: the address may legitimately have changed, and refusing forever would lock the user out of the profile while the management server still rejects a token that does not own the peer. A token or profile with no email to compare is not judged. The retry differs per platform because of who opens the browser: - CLI (netbird login foreground) and Android run the whole flow in one process, so the mismatch retries automatically: the browser reopens with the account prompt within the same login attempt. - On desktop the login is split between the daemon and the GUI: Login hands the authorize URL to the GUI, WaitSSOLogin blocks for the token, and only the GUI can open a browser. A new URL cannot be handed out from inside WaitSSOLogin (its response has no field for one, kept that way to avoid a proto change), so the daemon arms forceAccountPrompt, fails the round with "connect again to choose the account", and builds the next Login's flow with the prompt — the user's next connect is the retry. The flag and the flow annotations live in daemon memory only; SwitchProfile drops them so the previous profile's hint cannot judge the next profile's token. The device code flow has no prompt parameter (RFC 8628), so a prompted round there runs as-is and a repeated mismatch is let through with the warning rather than looping. * [client] Address review comments on PKCE session extend flow Fail the PKCE authorization flow test on request error instead of continuing into a nil dereference, and make the godoc comments on the touched exported symbols identifier-leading full sentences. * [client] Match accounts only on the email claim of the ID token The name-claim fallback in the ID token parsing is kept for the login hint and display, but account matching now only considers a value that came from the email claim, so a token without one no longer produces a false account mismatch. * [client] Drop the pending session extend on a profile switch The profile-switch cleanup dropped the pending login flow and the account-prompt flag, but left extendAuthSessionFlow untouched. Its device code was issued by the previous profile's IdP client, so a WaitExtendAuthSession still parked on the browser leg would submit the resulting token against the new profile's engine. * [client] Judge the SSO account against the flow that produced the token WaitSSOLogin snapshotted the flow on entry but re-read the info, hint and accountPrompted from the live s.oauthAuthFlow afterwards, in separate critical sections. WaitToken blocks for the whole browser leg, so a concurrent Login or RequestJWTAuth could replace the flow meanwhile and the mismatch check would compare this wait's token against another flow's account: either arming the prompt spuriously or letting a wrong-account token through against an unrelated profile's hint. Take all of it in the entry snapshot. * [client] Keep the forced account prompt from being lost to flow reuse startSSOLogin consumed forceAccountPrompt and applied the prompt to the freshly built flow, but reuseOAuthFlow could then answer from a cached flow for the same client — one built without prompt=login, e.g. by RequestJWTAuth. The user got the same silent authorization URL that produced the mismatch, with the flag already spent, so no later round asked either. Rule reuse out when the prompt is forced, while still cancelling the predecessor's wait. RequestJWTAuth also wrote the flow fields one by one, leaving the previous login's hint and accountPrompted behind for WaitSSOLogin to judge a later token against. Both sites now replace the whole record. * [client] Consume the forced account prompt after the retry forceAccountPrompt was never cleared, so a flow that outlived the retry it was armed for kept sending prompt=login on every later authorization request and re-authenticated the user each time. RequestAuthInfo now takes the flag as it builds the request. * [client] Cancel the caller context in the SSO login tests WaitSSOLogin parks a goroutine on the caller's context for the whole browser leg. The tests passed context.Background(), which never cancels, so each left one goroutine behind for the lifetime of the test binary. * [client] Cancel the wait displaced by an OAuth flow replacement Replacing the shared record with a whole struct value dropped the previous flow's waitCancel, so an SSO browser wait still parked on it lost its cancel: nothing could preempt it, and it could go on to run attemptLogin or mutate the record behind the new flow. Both replacement sites now take the displaced cancel over in the same critical section, via a shared replaceOAuthFlow, and invoke it after the unlock. * [client] Guard OAuth flow mutations by the flow that owns the wait * [client] Arm the account prompt only from the wait that owns the flow * [client] Adopt the three-value parseEmailFromIDToken in the device flow The main merge brought in the device flow's email extraction from #7193, which still used the two-value signature this branch replaced when account matching was narrowed to the email claim. Git merged the files without a textual conflict, so the branch stopped compiling. Take the fromEmailClaim result and fill EmailClaim from it, the same way the PKCE path does, so device-flow clients get the same account matching. * [client] Populate the pending extend flow in the test server helper SwitchProfile cancels and clears the pending session extend flow unconditionally, the same way it clears the SSH JWT cache. New always populates the field, but the hand-assembled test server did not, so TestSwitchProfile_ClearsJWTCache panicked on a nil PendingFlow.
832 lines
26 KiB
Go
832 lines
26 KiB
Go
//go:build ios
|
|
|
|
package NetBirdSDK
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
nbAnonymize "github.com/netbirdio/netbird/client/anonymize"
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/auth"
|
|
"github.com/netbirdio/netbird/client/internal/debug"
|
|
"github.com/netbirdio/netbird/client/internal/dns"
|
|
"github.com/netbirdio/netbird/client/internal/listener"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/netevents"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
"github.com/netbirdio/netbird/formatter"
|
|
"github.com/netbirdio/netbird/route"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
types "github.com/netbirdio/netbird/upload-server/types"
|
|
)
|
|
|
|
// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted
|
|
// anonymizeLevel values for DebugBundle.
|
|
const (
|
|
AnonymizeLevelDefault = nbAnonymize.LevelDefaultString
|
|
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
|
)
|
|
|
|
var errClientAlreadyRunning = errors.New("client is already running")
|
|
|
|
// RouteListener export internal RouteListener for mobile
|
|
type NetworkChangeListener interface {
|
|
listener.NetworkChangeListener
|
|
}
|
|
|
|
// DnsManager export internal dns Manager for mobile
|
|
type DnsManager interface {
|
|
dns.IosDnsManager
|
|
}
|
|
|
|
// CustomLogger export internal CustomLogger for mobile
|
|
type CustomLogger interface {
|
|
Debug(message string)
|
|
Info(message string)
|
|
Error(message string)
|
|
}
|
|
|
|
type selectRoute struct {
|
|
NetID string
|
|
Network netip.Prefix
|
|
Domains domain.List
|
|
Selected bool
|
|
Status string
|
|
extraNetworks []netip.Prefix
|
|
}
|
|
|
|
func init() {
|
|
formatter.SetLogcatFormatter(log.StandardLogger())
|
|
}
|
|
|
|
// Client struct manage the life circle of background service
|
|
type Client struct {
|
|
cfgFile string
|
|
stateFile string
|
|
cacheDir string
|
|
logFilePath string
|
|
recorder *peer.Status
|
|
deviceName string
|
|
osName string
|
|
osVersion string
|
|
networkChangeListener listener.NetworkChangeListener
|
|
onHostDnsFn func([]string)
|
|
dnsManager dns.IosDnsManager
|
|
loginComplete atomic.Bool
|
|
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
|
// the engine lifecycle. Run injects its state and sweeper into each new
|
|
// ConnectClient.
|
|
netMgr *netevents.Manager
|
|
preloadedConfigJSON atomic.Pointer[string]
|
|
|
|
// mdmSource holds the per-Client MDM policy source and its change
|
|
// detector as one unit. Set by SetMDMPolicyFetcher (called from the
|
|
// Swift side at extension init). Each Run passes the loader to the
|
|
// resolved Config so applyMDMPolicy picks up the active overlay. Nil
|
|
// means "MDM enforcement off for this Client".
|
|
mdmSource atomic.Pointer[mdmSource]
|
|
|
|
// stateMu guards the run lifecycle as one unit: the cancel installed by
|
|
// the current run, the channel it closes on exit, and the state it
|
|
// published. One run at a time: startRun refuses a second Run while the
|
|
// previous one has not exited, and the platform serializes Stop before
|
|
// Start, so no generation tracking is needed.
|
|
stateMu sync.RWMutex
|
|
connectClient *internal.ConnectClient
|
|
config *profilemanager.Config
|
|
runDone chan struct{}
|
|
ctxCancel context.CancelFunc
|
|
}
|
|
|
|
// NewClient instantiate a new Client
|
|
func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
|
|
recorder := peer.NewRecorder("")
|
|
return &Client{
|
|
cfgFile: cfgFile,
|
|
stateFile: stateFile,
|
|
cacheDir: cacheDir,
|
|
logFilePath: logFilePath,
|
|
deviceName: deviceName,
|
|
osName: osName,
|
|
osVersion: osVersion,
|
|
recorder: recorder,
|
|
networkChangeListener: networkChangeListener,
|
|
dnsManager: dnsManager,
|
|
netMgr: netevents.NewManager(recorder),
|
|
}
|
|
}
|
|
|
|
// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS).
|
|
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
|
// Parsed only to reject an unreadable document early; the JSON itself is
|
|
// what is stored, and every load re-parses it. A document carrying no peer
|
|
// identity is readable and accepted: that is a logged-out profile, and the
|
|
// login that follows provisions the keys.
|
|
if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil {
|
|
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
|
return err
|
|
}
|
|
c.preloadedConfigJSON.Store(&jsonStr)
|
|
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) {
|
|
var cfg *profilemanager.Config
|
|
var err error
|
|
if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil {
|
|
cfg, err = profilemanager.ConfigFromJSON(*preloaded)
|
|
} else {
|
|
cfg, err = profilemanager.DirectUpdateOrCreateConfig(input)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.applyMDMOverlay(cfg)
|
|
return cfg, nil
|
|
}
|
|
|
|
// Run start the internal client. It is a blocker function
|
|
func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
|
exportEnvList(envList)
|
|
log.Infof("Starting NetBird client")
|
|
log.Debugf("Tunnel uses interface: %s", interfaceName)
|
|
|
|
cfg, err := c.loadConfig(profilemanager.ConfigInput{
|
|
ConfigPath: c.cfgFile,
|
|
StateFilePath: c.stateFile,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
|
|
|
//nolint
|
|
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
|
runCtx, runCancel := context.WithCancel(ctxWithValues)
|
|
defer runCancel()
|
|
|
|
done, err := c.startRun(runCancel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.finishRun(done)
|
|
ctx := runCtx
|
|
|
|
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
|
|
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
|
|
// this point only duplicated it — costing two extra Login RPCs (IsLoginRequired +
|
|
// Login) on every engine start, since IsLoginRequired is itself a full Login RPC.
|
|
//
|
|
// Auth failures still reach the caller through the engine path: loginToManagement
|
|
// returns PermissionDenied, which marks the shared status recorder
|
|
// (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where
|
|
// IsLoginRequiredCached() reports login-required. The error is also returned out of Run().
|
|
//
|
|
// A pre-flight was also actively harmful when the server is unreachable: its 2-minute
|
|
// backoff blocked the start and then reported "login required" for what was really a
|
|
// timeout. The engine instead keeps retrying and recovers when the server returns.
|
|
// todo do not throw error in case of cancelled context
|
|
ctx = internal.CtxInitState(ctx)
|
|
c.onHostDnsFn = func([]string) {}
|
|
cfg.WgIface = interfaceName
|
|
|
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
|
internal.WithNetEvents(c.netMgr))
|
|
c.setState(cfg, connectClient)
|
|
// Persist the latest sync response so DebugBundle can include the network
|
|
// map. On iOS this is backed by disk to keep it out of the constrained
|
|
// process memory (see the syncstore package).
|
|
connectClient.SetSyncResponsePersistence(true)
|
|
return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
|
|
}
|
|
|
|
// SetNetworkAvailable feeds OS-reported network availability into the client
|
|
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
|
|
// suspend their attempts and the connection listener reports NoNetwork
|
|
// instead of Connecting; when availability returns, the loops resume
|
|
// immediately with a fresh backoff. Losing the last network also sweeps the
|
|
// registered connections, so the client does not keep reporting Connected
|
|
// over stale sockets with no network at all.
|
|
func (c *Client) SetNetworkAvailable(available bool) {
|
|
c.netMgr.SetNetworkAvailable(available)
|
|
}
|
|
|
|
// NotifyNetworkChange marks the management, signal and relay connections
|
|
// stale after the OS switched networks and schedules a sweep that cuts
|
|
// whatever has not redialed on the new network by then. The engine and the
|
|
// TUN device stay untouched.
|
|
func (c *Client) NotifyNetworkChange() {
|
|
c.netMgr.NotifyNetworkChange()
|
|
}
|
|
|
|
// Stop cancels the running client and waits for the run loop to exit, so a
|
|
// caller that restarts immediately cannot race the outgoing teardown.
|
|
func (c *Client) Stop() {
|
|
done := c.cancelRun()
|
|
if done == nil {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(stopRunWaitTimeout):
|
|
log.Warnf("Stop: timed out waiting for the run loop to exit")
|
|
}
|
|
}
|
|
|
|
// StopWithoutWait cancels the running client without waiting for the run loop.
|
|
// Use it where the caller is on a deadline the wait could overrun, such as
|
|
// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds
|
|
// before it kills the extension.
|
|
func (c *Client) StopWithoutWait() {
|
|
c.cancelRun()
|
|
}
|
|
|
|
func (c *Client) cancelRun() chan struct{} {
|
|
c.stateMu.RLock()
|
|
done := c.runDone
|
|
cancel := c.ctxCancel
|
|
c.stateMu.RUnlock()
|
|
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
|
|
return done
|
|
}
|
|
|
|
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
|
|
// It works with or without a running engine: when the engine is up it reuses
|
|
// the live config, sync response and client metrics; otherwise it loads the
|
|
// config from disk (or the preloaded tvOS config). anonymizeLevel is "default"
|
|
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
|
// WireGuard public keys, and implies anonymize.
|
|
func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) {
|
|
cfg, cc := c.stateSnapshot()
|
|
|
|
// If the engine hasn't been started, load config so we can reach management.
|
|
if cfg == nil {
|
|
var err error
|
|
cfg, err = c.loadConfig(profilemanager.ConfigInput{
|
|
ConfigPath: c.cfgFile,
|
|
StateFilePath: c.stateFile,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("load config: %w", err)
|
|
}
|
|
}
|
|
|
|
deps := debug.GeneratorDependencies{
|
|
InternalConfig: cfg,
|
|
StatusRecorder: c.recorder,
|
|
TempDir: c.cacheDir,
|
|
StatePath: c.stateFile,
|
|
LogPath: c.logFilePath,
|
|
}
|
|
|
|
if cc != nil {
|
|
resp, err := cc.GetLatestSyncResponse()
|
|
if err != nil {
|
|
log.Warnf("get latest sync response: %v", err)
|
|
}
|
|
deps.SyncResponse = resp
|
|
|
|
if e := cc.Engine(); e != nil {
|
|
deps.RefreshStatus = func() {
|
|
e.RunHealthProbes(context.Background(), true)
|
|
}
|
|
if cm := e.GetClientMetrics(); cm != nil {
|
|
deps.ClientMetrics = cm
|
|
}
|
|
}
|
|
}
|
|
|
|
bundleGenerator := debug.NewBundleGenerator(
|
|
deps,
|
|
debug.BundleConfig{
|
|
Anonymize: anonymize,
|
|
AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel),
|
|
IncludeSystemInfo: true,
|
|
},
|
|
)
|
|
|
|
path, err := bundleGenerator.Generate()
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate debug bundle: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(path); err != nil {
|
|
log.Errorf("failed to remove debug bundle file: %v", err)
|
|
}
|
|
}()
|
|
|
|
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
|
|
if err != nil {
|
|
return "", fmt.Errorf("upload debug bundle: %w", err)
|
|
}
|
|
|
|
log.Infof("debug bundle uploaded with key %s", key)
|
|
return key, nil
|
|
}
|
|
|
|
// SetTraceLogLevel configure the logger to trace level
|
|
func (c *Client) SetTraceLogLevel() {
|
|
log.SetLevel(log.TraceLevel)
|
|
}
|
|
|
|
// GetStatusDetails return with the list of the PeerInfos
|
|
func (c *Client) GetStatusDetails() *StatusDetails {
|
|
|
|
fullStatus := c.recorder.GetFullStatus()
|
|
|
|
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
|
for n, p := range fullStatus.Peers {
|
|
var routes = RoutesDetails{}
|
|
for r := range p.GetRoutes() {
|
|
routeInfo := RoutesInfo{r}
|
|
routes.items = append(routes.items, routeInfo)
|
|
}
|
|
pi := PeerInfo{
|
|
IP: p.IP,
|
|
IPv6: p.IPv6,
|
|
FQDN: p.FQDN,
|
|
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
|
|
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
|
|
LocalIceCandidateType: p.LocalIceCandidateType,
|
|
RemoteIceCandidateType: p.RemoteIceCandidateType,
|
|
PubKey: p.PubKey,
|
|
Latency: formatDuration(p.Latency),
|
|
BytesRx: p.BytesRx,
|
|
BytesTx: p.BytesTx,
|
|
ConnStatus: p.ConnStatus.String(),
|
|
ConnStatusUpdate: p.ConnStatusUpdate.Format("2006-01-02 15:04:05"),
|
|
LastWireguardHandshake: p.LastWireguardHandshake.String(),
|
|
Relayed: p.Relayed,
|
|
RosenpassEnabled: p.RosenpassEnabled,
|
|
Routes: routes,
|
|
}
|
|
peerInfos[n] = pi
|
|
}
|
|
return &StatusDetails{items: peerInfos, fqdn: fullStatus.LocalPeerState.FQDN, ip: fullStatus.LocalPeerState.IP, ipv6: fullStatus.LocalPeerState.IPv6}
|
|
}
|
|
|
|
// SetConnectionListener set the network connection listener
|
|
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
|
if listener == nil {
|
|
c.recorder.RemoveConnectionListener()
|
|
return
|
|
}
|
|
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
|
}
|
|
|
|
// RemoveConnectionListener remove connection listener
|
|
func (c *Client) RemoveConnectionListener() {
|
|
c.recorder.RemoveConnectionListener()
|
|
}
|
|
|
|
// IsLoginRequiredCached reports whether the LAST observed management error was an
|
|
// auth failure (PermissionDenied/InvalidArgument), using the in-memory status
|
|
// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to
|
|
// call from the connection listener during teardown (e.g. onDisconnected) without
|
|
// blocking on a slow or unavailable network. Returns false while connected to
|
|
// management or when the last error was not auth-related.
|
|
func (c *Client) IsLoginRequiredCached() bool {
|
|
return c.recorder.IsLoginRequired()
|
|
}
|
|
|
|
func (c *Client) IsLoginRequired() bool {
|
|
//nolint
|
|
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
|
ctx, cancel := context.WithCancel(ctxWithValues)
|
|
defer cancel()
|
|
|
|
cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile})
|
|
if err != nil {
|
|
log.Errorf("IsLoginRequired: failed to load config: %v", err)
|
|
return true
|
|
}
|
|
|
|
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
|
if err != nil {
|
|
log.Errorf("IsLoginRequired: failed to create auth client: %v", err)
|
|
return true // Assume login is required if we can't create auth client
|
|
}
|
|
defer authClient.Close()
|
|
|
|
needsLogin, err := authClient.IsLoginRequired(ctx)
|
|
if err != nil {
|
|
log.Errorf("IsLoginRequired: check failed: %v", err)
|
|
// If the check fails, assume login is required to be safe
|
|
return true
|
|
}
|
|
log.Infof("IsLoginRequired: needsLogin=%v", needsLogin)
|
|
return needsLogin
|
|
}
|
|
|
|
// loginForMobileAuthTimeout is the timeout for requesting auth info from the server
|
|
const loginForMobileAuthTimeout = 30 * time.Second
|
|
|
|
const stopRunWaitTimeout = 20 * time.Second
|
|
|
|
func (c *Client) LoginForMobile() string {
|
|
//nolint
|
|
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
|
|
ctx, cancel := context.WithCancel(ctxWithValues)
|
|
loginDone := false
|
|
defer func() {
|
|
if !loginDone {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
|
|
// which are blocked by the tvOS sandbox in App Group containers
|
|
cfg, err := profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: c.cfgFile,
|
|
})
|
|
if err != nil {
|
|
log.Errorf("LoginForMobile: failed to load config: %v", err)
|
|
return fmt.Sprintf("failed to load config: %v", err)
|
|
}
|
|
c.applyMDMOverlay(cfg)
|
|
|
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "", false)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
// Use a bounded timeout for the auth info request to prevent indefinite hangs
|
|
authInfoCtx, authInfoCancel := context.WithTimeout(ctx, loginForMobileAuthTimeout)
|
|
defer authInfoCancel()
|
|
|
|
flowInfo, err := oAuthFlow.RequestAuthInfo(authInfoCtx)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
|
|
// This could cause a potential race condition with loading the extension which need to be handled on swift side
|
|
loginDone = true
|
|
go func() {
|
|
defer cancel()
|
|
tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo)
|
|
if err != nil {
|
|
log.Errorf("LoginForMobile: WaitToken failed: %v", err)
|
|
return
|
|
}
|
|
jwtToken := tokenInfo.GetTokenToUse()
|
|
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
|
if err != nil {
|
|
log.Errorf("LoginForMobile: failed to create auth client: %v", err)
|
|
return
|
|
}
|
|
defer authClient.Close()
|
|
if err, _ := authClient.Login(ctx, "", jwtToken); err != nil {
|
|
log.Errorf("LoginForMobile: Login failed: %v", err)
|
|
return
|
|
}
|
|
c.loginComplete.Store(true)
|
|
}()
|
|
|
|
return flowInfo.VerificationURIComplete
|
|
}
|
|
|
|
func (c *Client) IsLoginComplete() bool {
|
|
return c.loginComplete.Load()
|
|
}
|
|
|
|
func (c *Client) ClearLoginComplete() {
|
|
c.loginComplete.Store(false)
|
|
}
|
|
|
|
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
|
|
_, connectClient := c.stateSnapshot()
|
|
if connectClient == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
routeManager := engine.GetRouteManager()
|
|
if routeManager == nil {
|
|
return nil, fmt.Errorf("could not get route manager")
|
|
}
|
|
routesMap := routeManager.GetClientRoutesWithNetID()
|
|
routeSelector := routeManager.GetRouteSelector()
|
|
if routeSelector == nil {
|
|
return nil, fmt.Errorf("could not get route selector")
|
|
}
|
|
|
|
v6ExitMerged := route.V6ExitMergeSet(routesMap)
|
|
routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged)
|
|
resolvedDomains := c.recorder.GetResolvedDomainsStates()
|
|
|
|
// Compute each route's connection status in the core (mirroring the Android
|
|
// bridge), so the UI doesn't have to infer it by string-matching the joined
|
|
// Network value against peer routes. For a merged exit node the status reflects
|
|
// whichever of the v4/v6 prefixes is served by a connected peer; for dynamic
|
|
// (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String).
|
|
connectedRoutes := c.connectedRouteSet()
|
|
for _, r := range routes {
|
|
r.Status = routeStatus(r, connectedRoutes)
|
|
}
|
|
|
|
return prepareRouteSelectionDetails(routes, resolvedDomains), nil
|
|
}
|
|
|
|
// connectedRouteSet returns the set of route keys (as strings) currently served by a
|
|
// connected peer, gathered across all connected peers' route tables. The keys match
|
|
// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0")
|
|
// and the domain pattern for dynamic routes (e.g. "*.example.com").
|
|
func (c *Client) connectedRouteSet() map[string]struct{} {
|
|
connected := map[string]struct{}{}
|
|
for _, p := range c.recorder.GetFullStatus().Peers {
|
|
if p.ConnStatus != peer.StatusConnected {
|
|
continue
|
|
}
|
|
for r := range p.GetRoutes() {
|
|
connected[r] = struct{}{}
|
|
}
|
|
}
|
|
return connected
|
|
}
|
|
|
|
// routeStatus reports "Connected" if any of the route's keys is served by a connected
|
|
// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the
|
|
// domain pattern for a dynamic DNS route. Otherwise "Idle".
|
|
func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string {
|
|
keys := make([]string, 0, 1+len(r.extraNetworks))
|
|
if len(r.Domains) > 0 {
|
|
keys = append(keys, r.Domains.SafeString())
|
|
} else {
|
|
keys = append(keys, r.Network.String())
|
|
}
|
|
for _, extra := range r.extraNetworks {
|
|
keys = append(keys, extra.String())
|
|
}
|
|
for _, k := range keys {
|
|
if _, ok := connectedRoutes[k]; ok {
|
|
return peer.StatusConnected.String()
|
|
}
|
|
}
|
|
return peer.StatusIdle.String()
|
|
}
|
|
|
|
func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute {
|
|
var routes []*selectRoute
|
|
for id, rt := range routesMap {
|
|
if len(rt) == 0 {
|
|
continue
|
|
}
|
|
if _, ok := v6Merged[id]; ok {
|
|
continue
|
|
}
|
|
|
|
r := &selectRoute{
|
|
NetID: string(id),
|
|
Network: rt[0].Network,
|
|
Domains: rt[0].Domains,
|
|
Selected: isSelected(id),
|
|
}
|
|
|
|
v6ID := route.NetID(string(id) + route.V6ExitSuffix)
|
|
if _, ok := v6Merged[v6ID]; ok {
|
|
r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network}
|
|
}
|
|
|
|
routes = append(routes, r)
|
|
}
|
|
|
|
sort.Slice(routes, func(i, j int) bool {
|
|
iBits, jBits := routes[i].Network.Bits(), routes[j].Network.Bits()
|
|
if iBits != jBits {
|
|
return iBits < jBits
|
|
}
|
|
iAddr, jAddr := routes[i].Network.Addr(), routes[j].Network.Addr()
|
|
if iAddr != jAddr {
|
|
return iAddr.Less(jAddr)
|
|
}
|
|
return routes[i].NetID < routes[j].NetID
|
|
})
|
|
|
|
return routes
|
|
}
|
|
|
|
func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) *RoutesSelectionDetails {
|
|
var routeSelection []RoutesSelectionInfo
|
|
for _, r := range routes {
|
|
// resolvedDomains is keyed by the resolved domain (e.g. api.ipify.org),
|
|
// not the configured pattern (e.g. *.ipify.org). Group entries whose
|
|
// ParentDomain belongs to this route, mirroring the daemon logic in
|
|
// client/server/network.go.
|
|
domainList := make([]DomainInfo, 0, len(r.Domains))
|
|
domainIndex := make(map[domain.Domain]int, len(r.Domains))
|
|
for _, d := range r.Domains {
|
|
domainIndex[d] = len(domainList)
|
|
domainList = append(domainList, DomainInfo{Domain: d.SafeString()})
|
|
}
|
|
|
|
for _, info := range resolvedDomains {
|
|
idx, ok := domainIndex[info.ParentDomain]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, prefix := range info.Prefixes {
|
|
domainList[idx].AddResolvedIP(prefix.Addr().String())
|
|
}
|
|
}
|
|
|
|
domainDetails := DomainDetails{items: domainList}
|
|
|
|
// For dynamic (DNS) routes, expose the joined domain pattern as the
|
|
// Network value so it matches the peer.routes entries on the Swift
|
|
// side (mirroring the Android bridge in client/android/client.go).
|
|
netStr := r.Network.String()
|
|
if len(r.Domains) > 0 {
|
|
netStr = r.Domains.SafeString()
|
|
}
|
|
for _, extra := range r.extraNetworks {
|
|
netStr += ", " + extra.String()
|
|
}
|
|
|
|
routeSelection = append(routeSelection, RoutesSelectionInfo{
|
|
ID: r.NetID,
|
|
Network: netStr,
|
|
Domains: &domainDetails,
|
|
Selected: r.Selected,
|
|
Status: r.Status,
|
|
})
|
|
}
|
|
|
|
routeSelectionDetails := RoutesSelectionDetails{items: routeSelection}
|
|
return &routeSelectionDetails
|
|
}
|
|
|
|
func (c *Client) SelectRoute(id string) error {
|
|
_, connectClient := c.stateSnapshot()
|
|
if connectClient == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
routeManager := engine.GetRouteManager()
|
|
if id == "All" {
|
|
log.Debugf("select all routes")
|
|
routeManager.SelectAllRoutes()
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("select route with id: %s", id)
|
|
if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil {
|
|
log.Debugf("error when selecting routes: %s", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) DeselectRoute(id string) error {
|
|
_, connectClient := c.stateSnapshot()
|
|
if connectClient == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
engine := connectClient.Engine()
|
|
if engine == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
routeManager := engine.GetRouteManager()
|
|
if id == "All" {
|
|
log.Debugf("deselect all routes")
|
|
routeManager.DeselectAllRoutes()
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("deselect route with id: %s", id)
|
|
if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil {
|
|
log.Debugf("error when deselecting routes: %s", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) {
|
|
c.stateMu.Lock()
|
|
defer c.stateMu.Unlock()
|
|
|
|
if c.runDone != nil {
|
|
return nil, errClientAlreadyRunning
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
c.runDone = done
|
|
c.ctxCancel = cancel
|
|
return done, nil
|
|
}
|
|
|
|
func (c *Client) finishRun(done chan struct{}) {
|
|
c.stateMu.Lock()
|
|
c.connectClient = nil
|
|
c.config = nil
|
|
c.runDone = nil
|
|
c.ctxCancel = nil
|
|
c.stateMu.Unlock()
|
|
|
|
close(done)
|
|
}
|
|
|
|
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
|
|
c.stateMu.Lock()
|
|
c.config = cfg
|
|
c.connectClient = cc
|
|
c.stateMu.Unlock()
|
|
}
|
|
|
|
// stateSnapshot returns the current config and ConnectClient under the lock.
|
|
func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) {
|
|
c.stateMu.RLock()
|
|
defer c.stateMu.RUnlock()
|
|
return c.config, c.connectClient
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
ds := d.String()
|
|
dotIndex := strings.Index(ds, ".")
|
|
if dotIndex != -1 {
|
|
// Determine end of numeric part, ensuring we stop at two decimal places or the actual end if fewer
|
|
endIndex := dotIndex + 3
|
|
if endIndex > len(ds) {
|
|
endIndex = len(ds)
|
|
}
|
|
// Find where the numeric part ends by finding the first non-digit character after the dot
|
|
unitStart := endIndex
|
|
for unitStart < len(ds) && (ds[unitStart] >= '0' && ds[unitStart] <= '9') {
|
|
unitStart++
|
|
}
|
|
// Ensures that we only take the unit characters after the numerical part
|
|
if unitStart < len(ds) {
|
|
return ds[:endIndex] + ds[unitStart:]
|
|
}
|
|
return ds[:endIndex] // In case no units are found after the digits
|
|
}
|
|
return ds
|
|
}
|
|
|
|
func toNetIDs(routes []string) []route.NetID {
|
|
var netIDs []route.NetID
|
|
for _, rt := range routes {
|
|
netIDs = append(netIDs, route.NetID(rt))
|
|
}
|
|
return netIDs
|
|
}
|
|
|
|
func exportEnvList(list *EnvList) {
|
|
if list == nil {
|
|
return
|
|
}
|
|
for k, v := range list.AllItems() {
|
|
log.Debugf("Env variable %s's value is currently: %s", k, os.Getenv(k))
|
|
log.Debugf("Setting env variable %s: %s", k, v)
|
|
|
|
if err := os.Setenv(k, v); err != nil {
|
|
log.Errorf("could not set env variable %s: %v", k, err)
|
|
} else {
|
|
log.Debugf("Env variable %s was set successfully", k)
|
|
}
|
|
}
|
|
}
|