Skip to content
54 changes: 37 additions & 17 deletions client/iface/device/device_ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,43 @@ func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu
func (t *TunDevice) Create() (WGConfigurer, error) {
log.Infof("create tun interface")

dupTunFd, err := unix.Dup(t.tunFd)
if err != nil {
log.Errorf("Unable to dup tun fd: %v", err)
return nil, err
}

err = unix.SetNonblock(dupTunFd, true)
if err != nil {
log.Errorf("Unable to set tun fd as non blocking: %v", err)
_ = unix.Close(dupTunFd)
return nil, err
}
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)
return nil, err
var tunDevice tun.Device
var err error

// On tvOS, the file descriptor may be 0 if the Swift code couldn't find the
// utun control socket (the low-level APIs like ctl_info, sockaddr_ctl are not
// exposed in the tvOS SDK headers, though they exist at runtime).
// The Swift code in NetBirdAdapter attempts to find the FD via raw syscalls.
// If FD is still 0, try to create TUN directly as a last resort - this is
// unlikely to work on tvOS but provides a fallback for edge cases.
if t.tunFd == 0 {
log.Warnf("Tunnel file descriptor is 0 - this usually indicates the Swift code couldn't find the utun socket. Attempting fallback...")
tunDevice, err = tun.CreateTUN(t.name, int(t.mtu))
if err != nil {
log.Errorf("Fallback TUN creation failed (expected on tvOS): %v", err)
return nil, err
}
log.Infof("Fallback TUN creation succeeded")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else {
// Normal iOS path: use the provided file descriptor
dupTunFd, err := unix.Dup(t.tunFd)
if err != nil {
log.Errorf("Unable to dup tun fd: %v", err)
return nil, err
}

err = unix.SetNonblock(dupTunFd, true)
if err != nil {
log.Errorf("Unable to set tun fd as non blocking: %v", err)
_ = unix.Close(dupTunFd)
return nil, err
}
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)
return nil, err
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

t.filteredDevice = newDeviceFilter(tunDevice)
Expand Down
69 changes: 69 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,71 @@ 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
}
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.
func ConfigFromJSON(jsonStr string) (*Config, error) {
config := &Config{}
err := json.Unmarshal([]byte(jsonStr), config)
if err != nil {
return nil, err
}
return config, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
65 changes: 54 additions & 11 deletions client/ios/NetBirdSDK/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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 @@ -89,16 +91,43 @@ 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) error {
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 @@ -116,7 +145,7 @@ func (c *Client) Run(fd int32, interfaceName string) error {
c.ctxCancelLock.Unlock()

auth := NewAuthWithConfig(ctx, cfg)
err = auth.Login()
err = auth.LoginSync()
if err != nil {
return err
}
Expand Down Expand Up @@ -204,11 +233,23 @@ 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

// 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, _ = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: c.cfgFile,
})
}

needsLogin, _ := internal.IsLoginRequired(ctx, cfg)
log.Infof("IsLoginRequired: needsLogin=%v", needsLogin)
return needsLogin
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand All @@ -224,7 +265,9 @@ 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, _ := profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: c.cfgFile,
})

Expand Down
Loading
Loading