diff --git a/network/interface.go b/network/interface.go index 089badd..6b196d4 100644 --- a/network/interface.go +++ b/network/interface.go @@ -167,3 +167,95 @@ func configureLinux(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return nil } + +// AddSecondaryAddress adds an additional IP address (given as CIDR, e.g. "10.10.0.5/32") +// to an already-configured interface. It also records the address in the shared +// NetworkSettings (see AddIPv4Address) so mobile (iOS/Android) packet-tunnel providers +// pick it up on their next settings poll - those platforms have no OS-level interface +// to configure directly, so this is the only way they learn about the address. +func AddSecondaryAddress(interfaceName string, addr string) error { + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return fmt.Errorf("invalid IP address: %v", err) + } + + mask := net.IP(ipNet.Mask).String() + AddIPv4Address(ip.String(), mask) + + if interfaceName == "" { + return nil + } + + switch runtime.GOOS { + case "linux": + return configureLinux(interfaceName, ip, ipNet) + case "darwin": + return configureDarwin(interfaceName, ip, ipNet) + case "windows": + return configureWindows(interfaceName, ip, ipNet) + default: + return nil + } +} + +// RemoveSecondaryAddress removes an IP address (given as CIDR) previously added with +// AddSecondaryAddress, including from the shared NetworkSettings used by mobile +// packet-tunnel providers. +func RemoveSecondaryAddress(interfaceName string, addr string) error { + ip, ipNet, err := net.ParseCIDR(addr) + if err != nil { + return fmt.Errorf("invalid IP address: %v", err) + } + + RemoveIPv4Address(ip.String()) + + if interfaceName == "" { + return nil + } + + switch runtime.GOOS { + case "linux": + return removeLinuxAddress(interfaceName, ip, ipNet) + case "darwin": + return removeDarwinAddress(interfaceName, ip, ipNet) + case "windows": + return removeWindowsAddress(interfaceName, ip, ipNet) + default: + return nil + } +} + +func removeLinuxAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + link, err := netlink.LinkByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + + addr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: ip, + Mask: ipNet.Mask, + }, + } + + if err := netlink.AddrDel(link, addr); err != nil { + return fmt.Errorf("failed to remove IP address: %v", err) + } + + return nil +} + +func removeDarwinAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + prefix, _ := ipNet.Mask.Size() + ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix) + + cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, "-alias") + logger.Info("Running command: %v", cmd) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ifconfig command failed: %v, output: %s", err, out) + } + + return nil +} diff --git a/network/interface_notwindows.go b/network/interface_notwindows.go index 5d15ace..3ca6ed0 100644 --- a/network/interface_notwindows.go +++ b/network/interface_notwindows.go @@ -10,3 +10,7 @@ import ( func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return fmt.Errorf("configureWindows called on non-Windows platform") } + +func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + return fmt.Errorf("removeWindowsAddress called on non-Windows platform") +} diff --git a/network/interface_windows.go b/network/interface_windows.go index 966486b..2b96e0b 100644 --- a/network/interface_windows.go +++ b/network/interface_windows.go @@ -61,3 +61,35 @@ func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error { return nil } + +func removeWindowsAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface %s: %v", interfaceName, err) + } + + luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index)) + if err != nil { + return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err) + } + + maskBits, _ := ipNet.Mask.Size() + + var addr netip.Addr + if ip4 := ip.To4(); ip4 != nil { + addr, _ = netip.AddrFromSlice(ip4) + } else { + addr, _ = netip.AddrFromSlice(ip) + } + if !addr.IsValid() { + return fmt.Errorf("failed to convert IP address") + } + prefix := netip.PrefixFrom(addr, maskBits) + + logger.Info("Removing IP address %s from interface %s", prefix.String(), interfaceName) + if err := luid.DeleteIPAddress(prefix); err != nil { + return fmt.Errorf("failed to remove IP address: %v", err) + } + + return nil +} diff --git a/network/settings.go b/network/settings.go index e361ba1..9203695 100644 --- a/network/settings.go +++ b/network/settings.go @@ -81,6 +81,45 @@ func SetIPv4Settings(addresses []string, subnetMasks []string) { logger.Info("Set IPv4 addresses: %v, subnet masks: %v", addresses, subnetMasks) } +// AddIPv4Address appends an additional IPv4 address/subnet mask pair to the +// tunnel's network settings. This is how a secondary interface address gets +// exposed to mobile (iOS/Android) packet-tunnel providers, which read the +// full IPv4Addresses/IPv4SubnetMasks arrays (not just the first entry) and +// re-apply them on every settings poll. +func AddIPv4Address(address string, subnetMask string) { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + + for _, a := range networkSettings.IPv4Addresses { + if a == address { + logger.Info("IPv4 address already exists: %s", address) + return + } + } + + networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses, address) + networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks, subnetMask) + incrementor++ + logger.Info("Added IPv4 address: %s/%s", address, subnetMask) +} + +// RemoveIPv4Address removes a previously added secondary IPv4 address. +func RemoveIPv4Address(address string) { + networkSettingsMutex.Lock() + defer networkSettingsMutex.Unlock() + + for i, a := range networkSettings.IPv4Addresses { + if a == address { + networkSettings.IPv4Addresses = append(networkSettings.IPv4Addresses[:i], networkSettings.IPv4Addresses[i+1:]...) + networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks[:i], networkSettings.IPv4SubnetMasks[i+1:]...) + incrementor++ + logger.Info("Removed IPv4 address: %s", address) + return + } + } + logger.Info("IPv4 address not found for removal: %s", address) +} + // SetIPv4IncludedRoutes sets the included IPv4 routes func SetIPv4IncludedRoutes(routes []IPv4Route) { networkSettingsMutex.Lock() diff --git a/websocket/client.go b/websocket/client.go index 2fd036e..69f9b83 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -28,6 +28,15 @@ import ( "go.opentelemetry.io/otel" ) +// writeDeadline bounds how long a websocket write may block before it is +// treated as a failure. Without this, a write to a TCP connection whose +// underlying network interface has disappeared (e.g. laptop sleep/resume, +// Wi-Fi roam) can sit buffered in the kernel for minutes without erroring. +// This matters even with the read-deadline/pong machinery below: if the +// WriteJSON call in sendPing blocks, execution never reaches the +// WriteControl ping that would otherwise trigger that read-side detection. +const writeDeadline = 10 * time.Second + type Client struct { conn *websocket.Conn config *Config @@ -257,6 +266,9 @@ func (c *Client) SendMessage(messageType string, data interface{}) error { c.writeMux.Lock() defer c.writeMux.Unlock() + if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + return err + } if err := c.conn.WriteJSON(msg); err != nil { return err } @@ -277,6 +289,9 @@ func (c *Client) SendMessageNoLog(messageType string, data interface{}) error { c.writeMux.Lock() defer c.writeMux.Unlock() + if err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)); err != nil { + return err + } if err := c.conn.WriteJSON(msg); err != nil { return err } @@ -760,14 +775,17 @@ func (c *Client) sendPing() { c.writeMux.Unlock() return } - err := c.conn.WriteJSON(pingMsg) + err := c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err == nil { + err = c.conn.WriteJSON(pingMsg) + } if err == nil { telemetry.IncWSMessage(c.metricsContext(), "out", "ping") // Protocol-level ping: a standards-compliant server replies with a PONG, // which refreshes the read deadline. This is what lets us notice a // half-open connection where writes still succeed (buffered) but the // peer is gone. - _ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)) + _ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)) } c.writeMux.Unlock()