diff --git a/client/internal/engine.go b/client/internal/engine.go index 63ba1c9f2..a471b2d1c 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -44,10 +44,12 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/internal/proxy" "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" + "github.com/netbirdio/netbird/client/internal/routemanager/vars" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updatemanager" "github.com/netbirdio/netbird/client/jobexec" @@ -140,6 +142,11 @@ type EngineConfig struct { ProfileConfig *profilemanager.Config LogPath string + + // ProxyConfig contains system proxy settings for macOS + ProxyEnabled bool + ProxyHost string + ProxyPort int } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -223,6 +230,9 @@ type Engine struct { jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup + + // proxyManager manages system-wide browser proxy settings on macOS + proxyManager *proxy.Manager } // Peer is an instance of the Connection Peer @@ -313,6 +323,12 @@ func (e *Engine) Stop() error { e.updateManager.Stop() } + if e.proxyManager != nil { + if err := e.proxyManager.DisableWebProxy(); err != nil { + log.Warnf("failed to disable system proxy: %v", err) + } + } + log.Info("cleaning up status recorder states") e.statusRecorder.ReplaceOfflinePeers([]peer.State{}) e.statusRecorder.UpdateDNSStates([]peer.NSGroupState{}) @@ -448,6 +464,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } e.stateManager.Start() + // Initialize proxy manager and register state for cleanup + proxy.RegisterState(e.stateManager) + e.proxyManager = proxy.NewManager(e.stateManager) + initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings() if err != nil { e.close() @@ -1312,6 +1332,9 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // If no server of a server group responds this will disable the respective handler and retry later. e.dnsServer.ProbeAvailability() + // Update system proxy state based on routes after network map is fully applied + e.updateSystemProxy(clientRoutes) + return nil } @@ -2303,6 +2326,38 @@ func createFile(path string) error { return file.Close() } +// containsExitNodeRoute checks if the routes contain an exit node (0.0.0.0/0). +func containsExitNodeRoute(clientRoutes route.HAMap) bool { + for _, routes := range clientRoutes { + for _, r := range routes { + if r.Network.String() == vars.ExitNodeCIDR { + return true + } + } + } + return false +} + +// updateSystemProxy triggers a proxy enable/disable cycle after the network map is updated. +func (e *Engine) updateSystemProxy(clientRoutes route.HAMap) { + if runtime.GOOS != "darwin" || e.proxyManager == nil { + log.Errorf("not updating proxy") + return + } + + if err := e.proxyManager.EnableWebProxy(e.config.ProxyHost, e.config.ProxyPort); err != nil { + log.Error("enable system proxy: %v", err) + return + } + log.Error("system proxy enabled after network map update") + + if err := e.proxyManager.DisableWebProxy(); err != nil { + log.Error("disable system proxy: %v", err) + return + } + log.Error("system proxy disabled after network map update") +} + func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { remoteCred, err := signal.UnMarshalCredential(msg) if err != nil { diff --git a/client/internal/proxy/manager_darwin.go b/client/internal/proxy/manager_darwin.go new file mode 100644 index 000000000..31ac3db7c --- /dev/null +++ b/client/internal/proxy/manager_darwin.go @@ -0,0 +1,262 @@ +//go:build darwin && !ios + +package proxy + +import ( + "fmt" + "os/exec" + "strings" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +const networksetupPath = "/usr/sbin/networksetup" + +// Manager handles system-wide proxy configuration on macOS. +type Manager struct { + mu sync.Mutex + stateManager *statemanager.Manager + modifiedServices []string + enabled bool +} + +// NewManager creates a new proxy manager. +func NewManager(stateManager *statemanager.Manager) *Manager { + return &Manager{ + stateManager: stateManager, + } +} + +// GetActiveNetworkServices returns the list of active network services. +func GetActiveNetworkServices() ([]string, error) { + cmd := exec.Command(networksetupPath, "-listallnetworkservices") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list network services: %w", err) + } + + lines := strings.Split(string(out), "\n") + var services []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "*") || strings.Contains(line, "asterisk") { + continue + } + services = append(services, line) + } + return services, nil +} + +// EnableWebProxy enables web proxy for all active network services. +func (m *Manager) EnableWebProxy(host string, port int) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.enabled { + log.Debug("web proxy already enabled") + return nil + } + + services, err := GetActiveNetworkServices() + if err != nil { + return err + } + + var modifiedServices []string + for _, service := range services { + if err := m.enableProxyForService(service, host, port); err != nil { + log.Warnf("enable proxy for %s: %v", service, err) + continue + } + modifiedServices = append(modifiedServices, service) + } + + m.modifiedServices = modifiedServices + m.enabled = true + m.updateState() + + log.Infof("enabled web proxy on %d services -> %s:%d", len(modifiedServices), host, port) + return nil +} + +func (m *Manager) enableProxyForService(service, host string, port int) error { + portStr := fmt.Sprintf("%d", port) + + // Set web proxy (HTTP) + cmd := exec.Command(networksetupPath, "-setwebproxy", service, host, portStr) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("set web proxy: %w, output: %s", err, out) + } + + // Enable web proxy + cmd = exec.Command(networksetupPath, "-setwebproxystate", service, "on") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("enable web proxy state: %w, output: %s", err, out) + } + + // Set secure web proxy (HTTPS) + cmd = exec.Command(networksetupPath, "-setsecurewebproxy", service, host, portStr) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("set secure web proxy: %w, output: %s", err, out) + } + + // Enable secure web proxy + cmd = exec.Command(networksetupPath, "-setsecurewebproxystate", service, "on") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("enable secure web proxy state: %w, output: %s", err, out) + } + + log.Debugf("enabled proxy for service %s", service) + return nil +} + +// DisableWebProxy disables web proxy for all modified network services. +func (m *Manager) DisableWebProxy() error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.enabled { + log.Debug("web proxy already disabled") + return nil + } + + services := m.modifiedServices + if len(services) == 0 { + services, _ = GetActiveNetworkServices() + } + + for _, service := range services { + if err := m.disableProxyForService(service); err != nil { + log.Warnf("disable proxy for %s: %v", service, err) + } + } + + m.modifiedServices = nil + m.enabled = false + m.updateState() + + log.Info("disabled web proxy") + return nil +} + +func (m *Manager) disableProxyForService(service string) error { + // Disable web proxy (HTTP) + cmd := exec.Command(networksetupPath, "-setwebproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("disable web proxy: %w, output: %s", err, out) + } + + // Disable secure web proxy (HTTPS) + cmd = exec.Command(networksetupPath, "-setsecurewebproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("disable secure web proxy: %w, output: %s", err, out) + } + + log.Debugf("disabled proxy for service %s", service) + return nil +} + +// SetAutoproxyURL sets the automatic proxy configuration URL (PAC file). +func (m *Manager) SetAutoproxyURL(pacURL string) error { + m.mu.Lock() + defer m.mu.Unlock() + + services, err := GetActiveNetworkServices() + if err != nil { + return err + } + + var modifiedServices []string + for _, service := range services { + cmd := exec.Command(networksetupPath, "-setautoproxyurl", service, pacURL) + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("set autoproxy for %s: %v, output: %s", service, err, out) + continue + } + + cmd = exec.Command(networksetupPath, "-setautoproxystate", service, "on") + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("enable autoproxy for %s: %v, output: %s", service, err, out) + continue + } + + modifiedServices = append(modifiedServices, service) + log.Debugf("set autoproxy URL for %s -> %s", service, pacURL) + } + + m.modifiedServices = modifiedServices + m.enabled = true + m.updateState() + + return nil +} + +// DisableAutoproxy disables automatic proxy configuration. +func (m *Manager) DisableAutoproxy() error { + m.mu.Lock() + defer m.mu.Unlock() + + services := m.modifiedServices + if len(services) == 0 { + services, _ = GetActiveNetworkServices() + } + + for _, service := range services { + cmd := exec.Command(networksetupPath, "-setautoproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("disable autoproxy for %s: %v, output: %s", service, err, out) + } + } + + m.modifiedServices = nil + m.enabled = false + m.updateState() + + return nil +} + +// IsEnabled returns whether the proxy is currently enabled. +func (m *Manager) IsEnabled() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.enabled +} + +// Restore restores proxy settings from a previous state. +func (m *Manager) Restore(services []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, service := range services { + if err := m.disableProxyForService(service); err != nil { + log.Warnf("restore proxy for %s: %v", service, err) + } + } + + m.modifiedServices = nil + m.enabled = false + + return nil +} + +func (m *Manager) updateState() { + if m.stateManager == nil { + return + } + + if m.enabled && len(m.modifiedServices) > 0 { + state := &ShutdownState{ + ModifiedServices: m.modifiedServices, + } + if err := m.stateManager.UpdateState(state); err != nil { + log.Errorf("update proxy state: %v", err) + } + } else { + if err := m.stateManager.DeleteState(&ShutdownState{}); err != nil { + log.Debugf("delete proxy state: %v", err) + } + } +} diff --git a/client/internal/proxy/manager_other.go b/client/internal/proxy/manager_other.go new file mode 100644 index 000000000..6998d9450 --- /dev/null +++ b/client/internal/proxy/manager_other.go @@ -0,0 +1,45 @@ +//go:build !darwin || ios + +package proxy + +import ( + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// Manager is a no-op proxy manager for non-macOS platforms. +type Manager struct{} + +// NewManager creates a new proxy manager (no-op on non-macOS). +func NewManager(_ *statemanager.Manager) *Manager { + return &Manager{} +} + +// EnableWebProxy is a no-op on non-macOS platforms. +func (m *Manager) EnableWebProxy(host string, port int) error { + return nil +} + +// DisableWebProxy is a no-op on non-macOS platforms. +func (m *Manager) DisableWebProxy() error { + return nil +} + +// SetAutoproxyURL is a no-op on non-macOS platforms. +func (m *Manager) SetAutoproxyURL(pacURL string) error { + return nil +} + +// DisableAutoproxy is a no-op on non-macOS platforms. +func (m *Manager) DisableAutoproxy() error { + return nil +} + +// IsEnabled always returns false on non-macOS platforms. +func (m *Manager) IsEnabled() bool { + return false +} + +// Restore is a no-op on non-macOS platforms. +func (m *Manager) Restore(services []string) error { + return nil +} diff --git a/client/internal/proxy/manager_test.go b/client/internal/proxy/manager_test.go new file mode 100644 index 000000000..93c461e18 --- /dev/null +++ b/client/internal/proxy/manager_test.go @@ -0,0 +1,88 @@ +//go:build darwin && !ios + +package proxy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetActiveNetworkServices(t *testing.T) { + services, err := GetActiveNetworkServices() + assert.NoError(t, err) + assert.NotEmpty(t, services, "should have at least one network service") + + // Check that services don't contain invalid entries + for _, service := range services { + assert.NotEmpty(t, service) + assert.NotContains(t, service, "*") + } +} + +func TestManager_EnableDisableWebProxy(t *testing.T) { + // Skip this test in CI as it requires admin privileges + if testing.Short() { + t.Skip("skipping proxy test in short mode") + } + + m := NewManager(nil) + assert.NotNil(t, m) + assert.False(t, m.IsEnabled()) + + // This test would require admin privileges to actually enable the proxy + // So we just test the basic state management +} + +func TestShutdownState_Name(t *testing.T) { + state := &ShutdownState{} + assert.Equal(t, "proxy_state", state.Name()) +} + +func TestShutdownState_Cleanup_EmptyServices(t *testing.T) { + state := &ShutdownState{ + ModifiedServices: []string{}, + } + err := state.Cleanup() + assert.NoError(t, err) +} + +func TestContains(t *testing.T) { + tests := []struct { + s string + substr string + want bool + }{ + {"Enabled: Yes", "Enabled: Yes", true}, + {"Enabled: No", "Enabled: Yes", false}, + {"Server: 127.0.0.1\nEnabled: Yes\nPort: 8080", "Enabled: Yes", true}, + {"", "Enabled: Yes", false}, + {"Enabled: Yes", "", true}, + } + + for _, tt := range tests { + t.Run(tt.s+"_"+tt.substr, func(t *testing.T) { + got := contains(tt.s, tt.substr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsProxyEnabled(t *testing.T) { + tests := []struct { + output string + want bool + }{ + {"Enabled: Yes\nServer: 127.0.0.1\nPort: 8080", true}, + {"Enabled: No\nServer: \nPort: 0", false}, + {"Server: 127.0.0.1\nEnabled: Yes\nPort: 8080", true}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.output, func(t *testing.T) { + got := isProxyEnabled(tt.output) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/client/internal/proxy/state_darwin.go b/client/internal/proxy/state_darwin.go new file mode 100644 index 000000000..804e38bd7 --- /dev/null +++ b/client/internal/proxy/state_darwin.go @@ -0,0 +1,105 @@ +//go:build darwin && !ios + +package proxy + +import ( + "fmt" + "os/exec" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// ShutdownState stores proxy state for cleanup on unclean shutdown. +type ShutdownState struct { + ModifiedServices []string `json:"modified_services"` +} + +// Name returns the state name for persistence. +func (s *ShutdownState) Name() string { + return "proxy_state" +} + +// Cleanup restores proxy settings after an unclean shutdown. +func (s *ShutdownState) Cleanup() error { + if len(s.ModifiedServices) == 0 { + return nil + } + + log.Infof("cleaning up proxy state for %d services", len(s.ModifiedServices)) + + for _, service := range s.ModifiedServices { + // Disable web proxy (HTTP) + cmd := exec.Command(networksetupPath, "-setwebproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("cleanup web proxy for %s: %v, output: %s", service, err, out) + } + + // Disable secure web proxy (HTTPS) + cmd = exec.Command(networksetupPath, "-setsecurewebproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("cleanup secure web proxy for %s: %v, output: %s", service, err, out) + } + + // Disable autoproxy + cmd = exec.Command(networksetupPath, "-setautoproxystate", service, "off") + if out, err := cmd.CombinedOutput(); err != nil { + log.Warnf("cleanup autoproxy for %s: %v, output: %s", service, err, out) + } + + log.Debugf("cleaned up proxy for service %s", service) + } + + return nil +} + +// RegisterState registers the proxy state with the state manager. +func RegisterState(stateManager *statemanager.Manager) { + if stateManager == nil { + return + } + stateManager.RegisterState(&ShutdownState{}) +} + +// GetProxyState returns the current proxy state from the command line. +func GetProxyState(service string) (webProxy, secureProxy, autoProxy bool, err error) { + // Check web proxy state + cmd := exec.Command(networksetupPath, "-getwebproxy", service) + out, err := cmd.Output() + if err != nil { + return false, false, false, fmt.Errorf("get web proxy: %w", err) + } + webProxy = isProxyEnabled(string(out)) + + // Check secure web proxy state + cmd = exec.Command(networksetupPath, "-getsecurewebproxy", service) + out, err = cmd.Output() + if err != nil { + return false, false, false, fmt.Errorf("get secure web proxy: %w", err) + } + secureProxy = isProxyEnabled(string(out)) + + // Check autoproxy state + cmd = exec.Command(networksetupPath, "-getautoproxyurl", service) + out, err = cmd.Output() + if err != nil { + return false, false, false, fmt.Errorf("get autoproxy: %w", err) + } + autoProxy = isProxyEnabled(string(out)) + + return webProxy, secureProxy, autoProxy, nil +} + +func isProxyEnabled(output string) bool { + return !contains(output, "Enabled: No") && contains(output, "Enabled: Yes") +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/client/internal/proxy/state_other.go b/client/internal/proxy/state_other.go new file mode 100644 index 000000000..f133e4e1f --- /dev/null +++ b/client/internal/proxy/state_other.go @@ -0,0 +1,24 @@ +//go:build !darwin || ios + +package proxy + +import ( + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// ShutdownState is a no-op state for non-macOS platforms. +type ShutdownState struct{} + +// Name returns the state name. +func (s *ShutdownState) Name() string { + return "proxy_state" +} + +// Cleanup is a no-op on non-macOS platforms. +func (s *ShutdownState) Cleanup() error { + return nil +} + +// RegisterState is a no-op on non-macOS platforms. +func RegisterState(stateManager *statemanager.Manager) { +}