mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 02:21:30 +02:00
Adds an SSHClient gomobile binding so the Android app can run an SSH session over the tunnel with a PTY, exposed through a listener interface for the in-app terminal. Server type is auto-detected from the SSH banner, which selects the auth path: JWT device-code flow, NetBird key, or a regular server (NetBird key first, then password). Host keys are verified against the peer registry for NetBird servers and trust-on-first-use for regular ones.
373 lines
13 KiB
Go
373 lines
13 KiB
Go
//go:build ios
|
|
|
|
package NetBirdSDK
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/auth"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
)
|
|
|
|
// SSOListener is async listener for mobile framework
|
|
type SSOListener interface {
|
|
OnSuccess(bool)
|
|
OnError(error)
|
|
}
|
|
|
|
// ErrListener is async listener for mobile framework
|
|
type ErrListener interface {
|
|
OnSuccess()
|
|
OnError(error)
|
|
}
|
|
|
|
// URLOpener it is a callback interface. The Open function will be triggered if
|
|
// the backend want to show an url for the user
|
|
type URLOpener interface {
|
|
Open(url string, userCode string)
|
|
OnLoginSuccess()
|
|
}
|
|
|
|
// Auth can register or login new client
|
|
type Auth struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
config *profilemanager.Config
|
|
cfgPath string
|
|
}
|
|
|
|
// NewAuth instantiate Auth struct and validate the management URL
|
|
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
|
|
inputCfg := profilemanager.ConfigInput{
|
|
ConfigPath: cfgPath,
|
|
ManagementURL: mgmURL,
|
|
}
|
|
|
|
// Load the existing config when a config file is already present so an
|
|
// interactive re-login reuses the peer's persisted WireGuard private key
|
|
// (and thus its identity) instead of generating a fresh one. Generating a
|
|
// new key registers a brand-new peer on the management server on every
|
|
// re-auth (named after the fallback hostname). Only fall back to a fresh
|
|
// in-memory config for the first-time login when no config file exists yet.
|
|
// DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside
|
|
// the tvOS App Group sandbox where atomic temp-file+rename is blocked.
|
|
var cfg *profilemanager.Config
|
|
var err error
|
|
if cfgPath != "" {
|
|
cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg)
|
|
} else {
|
|
cfg, err = profilemanager.CreateInMemoryConfig(inputCfg)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Use a cancellable context so Stop() can abort an in-progress interactive
|
|
// login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
|
|
// bound to a port) until the OAuth callback arrives or the flow expires;
|
|
// cancelling the context unblocks WaitToken, which then shuts that server down
|
|
// and frees the port for the next login attempt. iOS runs login in the main-app
|
|
// process (decoupled from the network extension), so without this the server
|
|
// lingers after the user dismisses the browser and the next connect stalls
|
|
// trying to bind the same port.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &Auth{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
config: cfg,
|
|
cfgPath: cfgPath,
|
|
}, nil
|
|
}
|
|
|
|
// NewAuthWithConfig instantiate Auth based on existing config
|
|
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &Auth{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName.
|
|
// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its
|
|
// loopback HTTP server, freeing the redirect port. Safe to call multiple times and
|
|
// safe to call when no login is running.
|
|
func (a *Auth) Stop() {
|
|
if a.cancel != nil {
|
|
a.cancel()
|
|
}
|
|
}
|
|
|
|
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
|
|
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
|
|
// is not supported and returns false without saving the configuration. For other errors return false.
|
|
func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) {
|
|
if listener == nil {
|
|
log.Errorf("SaveConfigIfSSOSupported: listener is nil")
|
|
return
|
|
}
|
|
go func() {
|
|
sso, err := a.saveConfigIfSSOSupported()
|
|
if err != nil {
|
|
listener.OnError(err)
|
|
} else {
|
|
listener.OnSuccess(sso)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *Auth) saveConfigIfSSOSupported() (bool, error) {
|
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to create auth client: %v", err)
|
|
}
|
|
defer authClient.Close()
|
|
|
|
supportsSSO, err := authClient.IsSSOSupported(a.ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check SSO support: %v", err)
|
|
}
|
|
|
|
if !supportsSSO {
|
|
return false, nil
|
|
}
|
|
|
|
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
|
// which are blocked by the tvOS sandbox in App Group containers
|
|
err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
|
return true, err
|
|
}
|
|
|
|
// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key.
|
|
func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) {
|
|
if resultListener == nil {
|
|
log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil")
|
|
return
|
|
}
|
|
go func() {
|
|
err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName)
|
|
if err != nil {
|
|
resultListener.OnError(err)
|
|
} else {
|
|
resultListener.OnSuccess()
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error {
|
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth client: %v", err)
|
|
}
|
|
defer authClient.Close()
|
|
|
|
//nolint
|
|
ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
|
|
err, _ = authClient.Login(ctxWithValues, setupKey, "")
|
|
if err != nil {
|
|
return fmt.Errorf("login failed: %v", err)
|
|
}
|
|
|
|
// Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename)
|
|
// which are blocked by the tvOS sandbox in App Group containers
|
|
return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config)
|
|
}
|
|
|
|
// LoginSync performs a synchronous login check without UI interaction
|
|
// Used for background VPN connection where user should already be authenticated
|
|
func (a *Auth) LoginSync() error {
|
|
authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth client: %v", err)
|
|
}
|
|
defer authClient.Close()
|
|
|
|
// check if we need to generate JWT token
|
|
needsLogin, err := authClient.IsLoginRequired(a.ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check login requirement: %v", err)
|
|
}
|
|
|
|
jwtToken := ""
|
|
if needsLogin {
|
|
return fmt.Errorf("not authenticated")
|
|
}
|
|
|
|
err, isAuthError := authClient.Login(a.ctx, "", jwtToken)
|
|
if err != nil {
|
|
if isAuthError {
|
|
// PermissionDenied means registration is required or peer is blocked
|
|
return fmt.Errorf("authentication error: %v", err)
|
|
}
|
|
return fmt.Errorf("login failed: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Login performs interactive login with device authentication support
|
|
// Deprecated: Use LoginWithDeviceName instead to ensure proper device naming on tvOS
|
|
func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool) {
|
|
// Use empty device name - system will use hostname as fallback
|
|
a.LoginWithDeviceName(resultListener, urlOpener, forceDeviceAuth, "")
|
|
}
|
|
|
|
// LoginWithDeviceName performs interactive login with device authentication support
|
|
// The deviceName parameter allows specifying a custom device name (required for tvOS)
|
|
func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
|
|
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false)
|
|
}
|
|
|
|
// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the
|
|
// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow.
|
|
//
|
|
// IsLoginRequired() is itself a full Login RPC against the management server, so when the
|
|
// caller has ALREADY established that login is required it is a pure duplicate. On iOS the
|
|
// main app decides to show the browser based on its own isLoginRequired() check and then
|
|
// calls straight into this method, so re-asking the server would add another Login RPC to
|
|
// every interactive login.
|
|
//
|
|
// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login
|
|
// must still be possible; use this when the browser is going to be shown regardless.
|
|
func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) {
|
|
a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true)
|
|
}
|
|
|
|
func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) {
|
|
if resultListener == nil {
|
|
log.Errorf("startLogin: resultListener is nil")
|
|
return
|
|
}
|
|
if urlOpener == nil {
|
|
log.Errorf("startLogin: urlOpener is nil")
|
|
resultListener.OnError(fmt.Errorf("urlOpener is nil"))
|
|
return
|
|
}
|
|
go func() {
|
|
err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck)
|
|
if err != nil {
|
|
resultListener.OnError(err)
|
|
} else {
|
|
resultListener.OnSuccess()
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error {
|
|
// Create context with device name if provided
|
|
ctx := a.ctx
|
|
if deviceName != "" {
|
|
//nolint:staticcheck
|
|
ctx = context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName)
|
|
}
|
|
|
|
authClient, err := auth.NewAuth(ctx, a.config.PrivateKey, a.config.ManagementURL, a.config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth client: %v", err)
|
|
}
|
|
defer authClient.Close()
|
|
|
|
// check if we need to generate JWT token (skipped when the caller already knows)
|
|
needsLogin := true
|
|
if !skipLoginCheck {
|
|
needsLogin, err = authClient.IsLoginRequired(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check login requirement: %v", err)
|
|
}
|
|
}
|
|
|
|
jwtToken := ""
|
|
if needsLogin {
|
|
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth)
|
|
if err != nil {
|
|
return fmt.Errorf("interactive sso login failed: %v", err)
|
|
}
|
|
jwtToken = tokenInfo.GetTokenToUse()
|
|
}
|
|
|
|
err, isAuthError := authClient.Login(ctx, "", jwtToken)
|
|
if err != nil {
|
|
if isAuthError {
|
|
// PermissionDenied means registration is required or peer is blocked
|
|
return fmt.Errorf("authentication error: %v", err)
|
|
}
|
|
return fmt.Errorf("login failed: %v", err)
|
|
}
|
|
|
|
// Save the config before notifying success to ensure persistence completes
|
|
// before the callback potentially triggers teardown on the Swift side.
|
|
// Note: This differs from Android which doesn't save config after login.
|
|
// On iOS/tvOS, we save here because:
|
|
// 1. The config may have been modified during login (e.g., new tokens)
|
|
// 2. On tvOS, the Network Extension context may be the only place with
|
|
// write permissions to the App Group container
|
|
if a.cfgPath != "" {
|
|
if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil {
|
|
log.Warnf("failed to save config after login: %v", err)
|
|
}
|
|
}
|
|
|
|
// Notify caller of successful login synchronously before returning
|
|
urlOpener.OnLoginSuccess()
|
|
|
|
return nil
|
|
}
|
|
|
|
const authInfoRequestTimeout = 30 * time.Second
|
|
|
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) {
|
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
|
}
|
|
|
|
// Use a bounded timeout for the auth info request to prevent indefinite hangs
|
|
authInfoCtx, authInfoCancel := context.WithTimeout(a.ctx, authInfoRequestTimeout)
|
|
defer authInfoCancel()
|
|
|
|
flowInfo, err := oAuthFlow.RequestAuthInfo(authInfoCtx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
|
}
|
|
|
|
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
|
|
|
waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second
|
|
waitCTX, cancel := context.WithTimeout(a.ctx, waitTimeout)
|
|
defer cancel()
|
|
tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
|
}
|
|
|
|
return &tokenInfo, nil
|
|
}
|
|
|
|
// GetConfigJSON returns the current config as a JSON string.
|
|
// This can be used by the caller to persist the config via alternative storage
|
|
// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked).
|
|
func (a *Auth) GetConfigJSON() (string, error) {
|
|
if a.config == nil {
|
|
return "", fmt.Errorf("no config available")
|
|
}
|
|
return profilemanager.ConfigToJSON(a.config)
|
|
}
|
|
|
|
// SetConfigFromJSON loads config from a JSON string.
|
|
// This can be used to restore config from alternative storage mechanisms.
|
|
func (a *Auth) SetConfigFromJSON(jsonStr string) error {
|
|
cfg, err := profilemanager.ConfigFromJSON(jsonStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.config = cfg
|
|
return nil
|
|
}
|