From e40a29ba17749e0276510149b8da7b3b71550ff4 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 6 Dec 2024 16:51:42 +0100 Subject: [PATCH] [client] Add support for state manager on iOS (#2996) --- client/internal/config.go | 21 +++-- client/internal/connect.go | 2 + client/internal/engine.go | 11 +++ client/internal/mobile_dependency.go | 1 + .../routeselector/routeselector_test.go | 85 +++++++++++++++++++ client/ios/NetBirdSDK/client.go | 9 +- client/ios/NetBirdSDK/preferences.go | 5 +- client/ios/NetBirdSDK/preferences_test.go | 11 ++- 8 files changed, 130 insertions(+), 15 deletions(-) diff --git a/client/internal/config.go b/client/internal/config.go index ce87835cd..998690ef1 100644 --- a/client/internal/config.go +++ b/client/internal/config.go @@ -46,6 +46,7 @@ type ConfigInput struct { ManagementURL string AdminURL string ConfigPath string + StateFilePath string PreSharedKey *string ServerSSHAllowed *bool NATExternalIPs []string @@ -105,10 +106,10 @@ type Config struct { // DNSRouteInterval is the interval in which the DNS routes are updated DNSRouteInterval time.Duration - //Path to a certificate used for mTLS authentication + // Path to a certificate used for mTLS authentication ClientCertPath string - //Path to corresponding private key of ClientCertPath + // Path to corresponding private key of ClientCertPath ClientCertKeyPath string ClientCertKeyPair *tls.Certificate `json:"-"` @@ -116,7 +117,7 @@ type Config struct { // ReadConfig read config file and return with Config. If it is not exists create a new with default values func ReadConfig(configPath string) (*Config, error) { - if configFileIsExists(configPath) { + if fileExists(configPath) { err := util.EnforcePermission(configPath) if err != nil { log.Errorf("failed to enforce permission on config dir: %v", err) @@ -149,7 +150,7 @@ func ReadConfig(configPath string) (*Config, error) { // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { - if !configFileIsExists(input.ConfigPath) { + if !fileExists(input.ConfigPath) { return nil, status.Errorf(codes.NotFound, "config file doesn't exist") } @@ -158,7 +159,7 @@ func UpdateConfig(input ConfigInput) (*Config, error) { // UpdateOrCreateConfig reads existing config or generates a new one func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { - if !configFileIsExists(input.ConfigPath) { + if !fileExists(input.ConfigPath) { log.Infof("generating new config %s", input.ConfigPath) cfg, err := createNewConfig(input) if err != nil { @@ -472,11 +473,19 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { return false } -func configFileIsExists(path string) bool { +func fileExists(path string) bool { _, err := os.Stat(path) return !os.IsNotExist(err) } +func createFile(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + return file.Close() +} + // UpdateOldManagementURL checks whether client can switch to the new Management URL with port 443 and the management domain. // If it can switch, then it updates the config and returns a new one. Otherwise, it returns the provided config. // The check is performed only for the NetBird's managed version. diff --git a/client/internal/connect.go b/client/internal/connect.go index 4848b1c11..782984e27 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -91,6 +91,7 @@ func (c *ConnectClient) RunOniOS( fileDescriptor int32, networkChangeListener listener.NetworkChangeListener, dnsManager dns.IosDnsManager, + stateFilePath string, ) error { // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) @@ -99,6 +100,7 @@ func (c *ConnectClient) RunOniOS( FileDescriptor: fileDescriptor, NetworkChangeListener: networkChangeListener, DnsManager: dnsManager, + StateFilePath: stateFilePath, } return c.run(mobileDependency, nil, nil) } diff --git a/client/internal/engine.go b/client/internal/engine.go index 782bb48bb..63caec02a 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -243,6 +243,17 @@ func NewEngineWithProbes( probes: probes, checks: checks, } + if runtime.GOOS == "ios" { + if !fileExists(mobileDep.StateFilePath) { + err := createFile(mobileDep.StateFilePath) + if err != nil { + log.Errorf("failed to create state file: %v", err) + // we are not exiting as we can run without the state manager + } + } + + engine.stateManager = statemanager.New(mobileDep.StateFilePath) + } if path := statemanager.GetDefaultStatePath(); path != "" { engine.stateManager = statemanager.New(path) } diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 2b0c92cc6..4ac0fc141 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -19,4 +19,5 @@ type MobileDependency struct { // iOS only DnsManager dns.IosDnsManager FileDescriptor int32 + StateFilePath string } diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 7df433f92..b1671f254 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -273,3 +273,88 @@ func TestRouteSelector_FilterSelected(t *testing.T) { "route2|192.168.0.0/16": {}, }, filtered) } + +func TestRouteSelector_NewRoutesBehavior(t *testing.T) { + initialRoutes := []route.NetID{"route1", "route2", "route3"} + newRoutes := []route.NetID{"route1", "route2", "route3", "route4", "route5"} + + tests := []struct { + name string + initialState func(rs *routeselector.RouteSelector) error // Setup initial state + wantNewSelected []route.NetID // Expected selected routes after new routes appear + }{ + { + name: "New routes with initial selectAll state", + initialState: func(rs *routeselector.RouteSelector) error { + rs.SelectAllRoutes() + return nil + }, + // When selectAll is true, all routes including new ones should be selected + wantNewSelected: []route.NetID{"route1", "route2", "route3", "route4", "route5"}, + }, + { + name: "New routes after specific selection", + initialState: func(rs *routeselector.RouteSelector) error { + return rs.SelectRoutes([]route.NetID{"route1", "route2"}, false, initialRoutes) + }, + // When specific routes were selected, new routes should remain unselected + wantNewSelected: []route.NetID{"route1", "route2"}, + }, + { + name: "New routes after deselect all", + initialState: func(rs *routeselector.RouteSelector) error { + rs.DeselectAllRoutes() + return nil + }, + // After deselect all, new routes should remain unselected + wantNewSelected: []route.NetID{}, + }, + { + name: "New routes after deselecting specific routes", + initialState: func(rs *routeselector.RouteSelector) error { + rs.SelectAllRoutes() + return rs.DeselectRoutes([]route.NetID{"route1"}, initialRoutes) + }, + // After deselecting specific routes, new routes should remain unselected + wantNewSelected: []route.NetID{"route2", "route3"}, + }, + { + name: "New routes after selecting with append", + initialState: func(rs *routeselector.RouteSelector) error { + return rs.SelectRoutes([]route.NetID{"route1"}, true, initialRoutes) + }, + // When routes were appended, new routes should remain unselected + wantNewSelected: []route.NetID{"route1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := routeselector.NewRouteSelector() + + // Setup initial state + err := tt.initialState(rs) + require.NoError(t, err) + + // Verify selection state with new routes + for _, id := range newRoutes { + assert.Equal(t, rs.IsSelected(id), slices.Contains(tt.wantNewSelected, id), + "Route %s selection state incorrect", id) + } + + // Additional verification using FilterSelected + routes := route.HAMap{ + "route1|10.0.0.0/8": {}, + "route2|192.168.0.0/16": {}, + "route3|172.16.0.0/12": {}, + "route4|10.10.0.0/16": {}, + "route5|192.168.1.0/24": {}, + } + + filtered := rs.FilterSelected(routes) + expectedLen := len(tt.wantNewSelected) + assert.Equal(t, expectedLen, len(filtered), + "FilterSelected returned wrong number of routes, got %d want %d", len(filtered), expectedLen) + }) + } +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 9d65bdbe0..6f501e0c6 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -59,6 +59,7 @@ func init() { // Client struct manage the life circle of background service type Client struct { cfgFile string + stateFile string recorder *peer.Status ctxCancel context.CancelFunc ctxCancelLock *sync.Mutex @@ -73,9 +74,10 @@ type Client struct { } // NewClient instantiate a new Client -func NewClient(cfgFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { +func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { return &Client{ cfgFile: cfgFile, + stateFile: stateFile, deviceName: deviceName, osName: osName, osVersion: osVersion, @@ -91,7 +93,8 @@ func (c *Client) Run(fd int32, interfaceName string) error { log.Infof("Starting NetBird client") log.Debugf("Tunnel uses interface: %s", interfaceName) cfg, err := internal.UpdateOrCreateConfig(internal.ConfigInput{ - ConfigPath: c.cfgFile, + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, }) if err != nil { return err @@ -124,7 +127,7 @@ func (c *Client) Run(fd int32, interfaceName string) error { cfg.WgIface = interfaceName c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager) + return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile) } // Stop the internal client and free the resources diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index b78146679..5a0abd9a7 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -10,9 +10,10 @@ type Preferences struct { } // NewPreferences create new Preferences instance -func NewPreferences(configPath string) *Preferences { +func NewPreferences(configPath string, stateFilePath string) *Preferences { ci := internal.ConfigInput{ - ConfigPath: configPath, + ConfigPath: configPath, + StateFilePath: stateFilePath, } return &Preferences{ci} } diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index aa6a475ae..7e5325a00 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -9,7 +9,8 @@ import ( func TestPreferences_DefaultValues(t *testing.T) { cfgFile := filepath.Join(t.TempDir(), "netbird.json") - p := NewPreferences(cfgFile) + stateFile := filepath.Join(t.TempDir(), "state.json") + p := NewPreferences(cfgFile, stateFile) defaultVar, err := p.GetAdminURL() if err != nil { t.Fatalf("failed to read default value: %s", err) @@ -42,7 +43,8 @@ func TestPreferences_DefaultValues(t *testing.T) { func TestPreferences_ReadUncommitedValues(t *testing.T) { exampleString := "exampleString" cfgFile := filepath.Join(t.TempDir(), "netbird.json") - p := NewPreferences(cfgFile) + stateFile := filepath.Join(t.TempDir(), "state.json") + p := NewPreferences(cfgFile, stateFile) p.SetAdminURL(exampleString) resp, err := p.GetAdminURL() @@ -79,7 +81,8 @@ func TestPreferences_Commit(t *testing.T) { exampleURL := "https://myurl.com:443" examplePresharedKey := "topsecret" cfgFile := filepath.Join(t.TempDir(), "netbird.json") - p := NewPreferences(cfgFile) + stateFile := filepath.Join(t.TempDir(), "state.json") + p := NewPreferences(cfgFile, stateFile) p.SetAdminURL(exampleURL) p.SetManagementURL(exampleURL) @@ -90,7 +93,7 @@ func TestPreferences_Commit(t *testing.T) { t.Fatalf("failed to save changes: %s", err) } - p = NewPreferences(cfgFile) + p = NewPreferences(cfgFile, stateFile) resp, err := p.GetAdminURL() if err != nil { t.Fatalf("failed to read admin url: %s", err)