Skip to content
Merged
26 changes: 24 additions & 2 deletions client/iface/device/device_ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package device

import (
"fmt"
"os"

log "github.com/sirupsen/logrus"
Expand Down Expand Up @@ -45,10 +46,31 @@ func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu
}
}

// ErrInvalidTunnelFD is returned when the tunnel file descriptor is invalid (0).
// This typically means the Swift code couldn't find the utun control socket.
var ErrInvalidTunnelFD = fmt.Errorf("invalid tunnel file descriptor: fd is 0 (Swift failed to locate utun socket)")

func (t *TunDevice) Create() (WGConfigurer, error) {
log.Infof("create tun interface")

dupTunFd, err := unix.Dup(t.tunFd)
var tunDevice tun.Device
var err error

// Validate the tunnel file descriptor.
// On iOS/tvOS, the FD must be provided by the NEPacketTunnelProvider.
// A value of 0 means the Swift code couldn't find the utun control socket
// (the low-level APIs like ctl_info, sockaddr_ctl may not be exposed in
// tvOS SDK headers). This is a hard error - there's no viable fallback
// since tun.CreateTUN() cannot work within the iOS/tvOS sandbox.
if t.tunFd == 0 {
log.Errorf("Tunnel file descriptor is 0 - Swift code failed to locate the utun control socket. " +
"On tvOS, ensure the NEPacketTunnelProvider is properly configured and the tunnel is started.")
return nil, ErrInvalidTunnelFD
}

// Normal iOS/tvOS path: use the provided file descriptor from NEPacketTunnelProvider
var dupTunFd int
dupTunFd, err = unix.Dup(t.tunFd)
if err != nil {
log.Errorf("Unable to dup tun fd: %v", err)
return nil, err
Expand All @@ -60,7 +82,7 @@ func (t *TunDevice) Create() (WGConfigurer, error) {
_ = unix.Close(dupTunFd)
return nil, err
}
tunDevice, err := tun.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0)
tunDevice, err = tun.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0)
if err != nil {
log.Errorf("Unable to create new tun device from fd: %v", err)
_ = unix.Close(dupTunFd)
Expand Down
83 changes: 83 additions & 0 deletions client/internal/profilemanager/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package profilemanager
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/url"
"os"
Expand Down Expand Up @@ -812,3 +813,85 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) {
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) {
if !fileExists(input.ConfigPath) {
log.Infof("generating new config %s", input.ConfigPath)
cfg, err := createNewConfig(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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func directUpdate(input ConfigInput) (*Config, error) {
config := &Config{}

if _, err := util.ReadJson(input.ConfigPath, config); err != nil {
return nil, err
}

updated, err := config.apply(input)
if err != nil {
return nil, err
}

if updated {
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.
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
102 changes: 88 additions & 14 deletions client/ios/NetBirdSDK/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type Client struct {
dnsManager dns.IosDnsManager
loginComplete bool
connectClient *internal.ConnectClient
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
}

// NewClient instantiate a new Client
Expand All @@ -92,17 +94,44 @@ func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName s
}
}

// 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)
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: c.cfgFile,
StateFilePath: c.stateFile,
})
if err != nil {
return err

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)
Expand All @@ -120,7 +149,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.ctxCancelLock.Unlock()

auth := NewAuthWithConfig(ctx, cfg)
err = auth.Login()
err = auth.LoginSync()
if err != nil {
return err
}
Expand Down Expand Up @@ -208,14 +237,45 @@ func (c *Client) IsLoginRequired() bool {
defer c.ctxCancelLock.Unlock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)

cfg, _ := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: c.cfgFile,
})
var cfg *profilemanager.Config
var err error

needsLogin, _ := internal.IsLoginRequired(ctx, cfg)
// 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
}

needsLogin, err := internal.IsLoginRequired(ctx, cfg)
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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
Expand All @@ -228,16 +288,26 @@ func (c *Client) LoginForMobile() string {
defer c.ctxCancelLock.Unlock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)

cfg, _ := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
// 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()
}

flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
// 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()
}
Expand All @@ -249,10 +319,14 @@ func (c *Client) LoginForMobile() string {
defer cancel()
tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo)
if err != nil {
log.Errorf("LoginForMobile: WaitToken failed: %v", err)
return
}
jwtToken := tokenInfo.GetTokenToUse()
_ = internal.Login(ctx, cfg, "", jwtToken)
if err := internal.Login(ctx, cfg, "", jwtToken); err != nil {
log.Errorf("LoginForMobile: Login failed: %v", err)
return
}
c.loginComplete = true
}()

Expand Down
Loading
Loading