mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-05 00:15:26 -04:00
## Describe your changes Removes redundant `Login` RPCs from the iOS SDK bindings. ## Issue ticket number and link ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6931"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787779607&installation_model_id=427504&pr_number=6931&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6931&signature=ce1631be2a5ffdba58c44b4669b0d480dc9f956102cf10a0c7b5845a800cdc68"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an interactive iOS login option that starts authentication directly when needed. * Improved login flow handling, including clearer error reporting and successful-login notifications. * Login configuration is now saved automatically after successful authentication when applicable. * **Bug Fixes** * Prevented duplicate login requests during iOS startup. * Improved startup behavior and error propagation when the management service is unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Removes redundant `Login` RPCs from the iOS SDK bindings. Both changed files are behind the `ios` build tag — Android, desktop and the shared core are not affected. ### Problem `auth.Auth.IsLoginRequired()` is not a cheap probe: it calls `doMgmLogin()` and classifies the resulting error, so every "is login required?" check costs a **full `Login` RPC**. There is no lighter way to ask. As a result the iOS client issued ~7 `Login` requests before the first `Sync`, where Android issues ~3, and the extra ones were indistinguishable from real logins in the management logs. Three of those came from this package: 1. `Run()` called `LoginSync()` before starting the engine, which performs `IsLoginRequired` **and** `Login` — two RPCs. This duplicated the engine's own `loginToManagement` (`client/internal/connect.go`), which runs immediately before the first `Sync` and is the authoritative login. The `Login(ctx, "", "")` inside `LoginSync` could not even establish anything: with an empty setup key and empty JWT, a registration attempt fails by construction, so it was a pure check. 2. `Auth.login()` called `IsLoginRequired()` again before opening the browser, even when the caller had already determined that login is needed. This is not only wasted traffic: - **It pushes peers toward the server-side login ban.** In `management/internals/shared/grpc/loginfilter.go`, every login with unchanged metadata increments `sessionCounter`, and exceeding `reconnLimitForBan` (30) within `reconnThreshold` (5 min) bans the peer for `baseBlockDuration` (10 min), doubling on repeat. Redundant logins carry identical metadata, so they count against exactly this budget. At 7 logins per connect the budget is exhausted after ~4 reconnects instead of ~10 — reachable on flaky mobile networks. - **Each redundant check is a potential 2-minute stall.** `IsLoginRequired` retries with backoff up to `MaxElapsedTime` (2 min) and returns `true` on failure, so an unreachable server was reported as "login required" rather than as a timeout, and the `LoginSync` pre-flight could abort engine startup on that basis. ### Changes **`client/ios/NetBirdSDK/client.go`** — `Run()` no longer performs the `LoginSync()` pre-flight. The engine's `loginToManagement` remains the single authoritative login. **`client/ios/NetBirdSDK/login.go`** — new exported `LoginInteractive`, which skips the `IsLoginRequired()` pre-flight and goes straight to the browser / device-code flow, for callers that have already established login is required. `LoginWithDeviceName` keeps the check for callers where the auth state is unknown (tvOS). Both now delegate to a shared `startLogin()`. ### Why this is safe An expired or revoked session still fails the connection, one step later and through a single path: `loginToManagement` returns `PermissionDenied` → the deferred `MarkManagementDisconnected` records it on the shared status recorder → `ClientStop` fires the listener's disconnect callback, where `IsLoginRequiredCached()` reports login-required → the client tears the tunnel down. The error is also returned out of `Run()`. Where the server is unreachable, the engine now retries with backoff and recovers on its own instead of aborting the start. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
746 lines
24 KiB
Go
746 lines
24 KiB
Go
//go:build ios
|
|
|
|
package NetBirdSDK
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"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/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"
|
|
)
|
|
|
|
// ConnectionListener export internal Listener for mobile
|
|
type ConnectionListener interface {
|
|
peer.Listener
|
|
}
|
|
|
|
// 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
|
|
ctxCancel context.CancelFunc
|
|
ctxCancelLock *sync.Mutex
|
|
deviceName string
|
|
osName string
|
|
osVersion string
|
|
networkChangeListener listener.NetworkChangeListener
|
|
onHostDnsFn func([]string)
|
|
dnsManager dns.IosDnsManager
|
|
loginComplete bool
|
|
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
|
|
preloadedConfig *profilemanager.Config
|
|
|
|
stateMu sync.RWMutex
|
|
connectClient *internal.ConnectClient
|
|
config *profilemanager.Config
|
|
}
|
|
|
|
// NewClient instantiate a new Client
|
|
func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
|
|
return &Client{
|
|
cfgFile: cfgFile,
|
|
stateFile: stateFile,
|
|
cacheDir: cacheDir,
|
|
logFilePath: logFilePath,
|
|
deviceName: deviceName,
|
|
osName: osName,
|
|
osVersion: osVersion,
|
|
recorder: peer.NewRecorder(""),
|
|
ctxCancelLock: &sync.Mutex{},
|
|
networkChangeListener: networkChangeListener,
|
|
dnsManager: dnsManager,
|
|
}
|
|
}
|
|
|
|
// SetConfigFromJSON loads config from a JSON string into memory.
|
|
// This is used on tvOS where file writes to App Group containers are blocked.
|
|
// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file.
|
|
func (c *Client) SetConfigFromJSON(jsonStr string) error {
|
|
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
|
if err != nil {
|
|
log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err)
|
|
return err
|
|
}
|
|
c.preloadedConfig = cfg
|
|
log.Infof("SetConfigFromJSON: config loaded successfully from JSON")
|
|
return 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)
|
|
|
|
var cfg *profilemanager.Config
|
|
var err error
|
|
|
|
// Use preloaded config if available (tvOS where file writes are blocked)
|
|
if c.preloadedConfig != nil {
|
|
log.Infof("Run: using preloaded config from memory")
|
|
cfg = c.preloadedConfig
|
|
} else {
|
|
log.Infof("Run: loading config from file")
|
|
// 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,
|
|
StateFilePath: c.stateFile,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
|
|
|
var ctx context.Context
|
|
//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)
|
|
c.ctxCancelLock.Lock()
|
|
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
|
defer c.ctxCancel()
|
|
c.ctxCancelLock.Unlock()
|
|
|
|
// 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)
|
|
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)
|
|
}
|
|
|
|
// Stop the internal client and free the resources
|
|
func (c *Client) Stop() {
|
|
c.ctxCancelLock.Lock()
|
|
defer c.ctxCancelLock.Unlock()
|
|
if c.ctxCancel == nil {
|
|
return
|
|
}
|
|
|
|
c.ctxCancel()
|
|
c.setState(nil, nil)
|
|
}
|
|
|
|
// 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).
|
|
func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
|
cfg, cc := c.stateSnapshot()
|
|
|
|
// If the engine hasn't been started, load config so we can reach management.
|
|
if cfg == nil {
|
|
if c.preloadedConfig != nil {
|
|
cfg = c.preloadedConfig
|
|
} else {
|
|
var err error
|
|
// Use DirectUpdateOrCreateConfig to avoid atomic file operations
|
|
// (temp file + rename) blocked by the tvOS sandbox.
|
|
cfg, err = profilemanager.DirectUpdateOrCreateConfig(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,
|
|
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) {
|
|
c.recorder.SetConnectionListener(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 {
|
|
var ctx context.Context
|
|
//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)
|
|
c.ctxCancelLock.Lock()
|
|
defer c.ctxCancelLock.Unlock()
|
|
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
|
|
|
var cfg *profilemanager.Config
|
|
var err error
|
|
|
|
// Use preloaded config if available (tvOS where file writes are blocked)
|
|
if c.preloadedConfig != nil {
|
|
log.Infof("IsLoginRequired: using preloaded config from memory")
|
|
cfg = c.preloadedConfig
|
|
} else {
|
|
log.Infof("IsLoginRequired: loading config from file")
|
|
// 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("IsLoginRequired: failed to load config: %v", err)
|
|
// If we can't load config, assume login is required
|
|
return true
|
|
}
|
|
}
|
|
|
|
if cfg == nil {
|
|
log.Errorf("IsLoginRequired: config is nil")
|
|
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
|
|
|
|
func (c *Client) LoginForMobile() string {
|
|
var ctx context.Context
|
|
//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)
|
|
c.ctxCancelLock.Lock()
|
|
defer c.ctxCancelLock.Unlock()
|
|
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
|
|
|
// 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)
|
|
}
|
|
|
|
oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, 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
|
|
go func() {
|
|
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 = true
|
|
}()
|
|
|
|
return flowInfo.VerificationURIComplete
|
|
}
|
|
|
|
func (c *Client) IsLoginComplete() bool {
|
|
return c.loginComplete
|
|
}
|
|
|
|
func (c *Client) ClearLoginComplete() {
|
|
c.loginComplete = 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
|
|
}
|
|
|
|
// setState stores the running engine state so DebugBundle can reuse the live
|
|
// config and ConnectClient. It is cleared on Stop.
|
|
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
|
|
c.stateMu.Lock()
|
|
defer c.stateMu.Unlock()
|
|
c.config = cfg
|
|
c.connectClient = cc
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|