From 69d3925167e18441827603239b4c566124860e9a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 30 Jul 2026 16:46:30 -0400 Subject: [PATCH 1/8] move exit node ping to module --- exitnode/exitnode.go | 176 +++++++++++++++++++++++++++++++++++++++++++ newt/handlers.go | 126 +------------------------------ newt/types.go | 31 +++----- 3 files changed, 187 insertions(+), 146 deletions(-) create mode 100644 exitnode/exitnode.go diff --git a/exitnode/exitnode.go b/exitnode/exitnode.go new file mode 100644 index 0000000..1555796 --- /dev/null +++ b/exitnode/exitnode.go @@ -0,0 +1,176 @@ +// Package exitnode implements the exit-node ping dance run before +// registering with the server: request the candidate exit nodes, ping each +// one over HTTP, and report the results so the server can pick the best one. +// It is shared between newt and olm, which both register the same way. +package exitnode + +import ( + "net/http" + "strings" + "time" + + "github.com/fosrl/newt/logger" +) + +// ExitNodeData is the payload the server sends in response to a +// "*/ping/request" message. +type ExitNodeData struct { + ExitNodes []ExitNode `json:"exitNodes"` + ChainId string `json:"chainId"` +} + +// ExitNode is a candidate exit node offered by the server for ping selection. +type ExitNode struct { + ID int `json:"exitNodeId"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + Weight float64 `json:"weight"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +// ExitNodePingResult is the measured latency (or error) for one exit node, +// sent back to the server in the "*/wg/register" message's pingResults field. +type ExitNodePingResult struct { + ExitNodeID int `json:"exitNodeId"` + LatencyMs int64 `json:"latencyMs"` + Weight float64 `json:"weight"` + Error string `json:"error,omitempty"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} + +// PingExitNodes pings the given exit nodes over HTTP and returns a per-node +// ExitNodePingResult suitable for inclusion in a wg/register message's +// pingResults field, so the server can select the best exit node. +// +// If there's only one exit node, or preferEndpoint names one of them, the +// matching node is returned immediately with LatencyMs 0 and no pinging is +// done. Otherwise every node is pinged pingAttempts times over HTTP GET +// /ping and the average latency of successful attempts is used. +// +// When alreadyConnected is true, a node flagged WasPreviouslyConnected is +// excluded from the results as long as at least one other healthy node is +// available, biasing reconnects toward switching away from a possibly +// degraded node. +func PingExitNodes(exitNodes []ExitNode, preferEndpoint string, alreadyConnected bool) []ExitNodePingResult { + if len(exitNodes) == 0 { + return nil + } + + if len(exitNodes) == 1 || preferEndpoint != "" { + selected := exitNodes[0] + if preferEndpoint != "" { + for _, node := range exitNodes { + if node.Endpoint == preferEndpoint { + selected = node + break + } + } + } + + logger.Debug("Only one exit node available, using it directly: %s", selected.Endpoint) + + return []ExitNodePingResult{ + { + ExitNodeID: selected.ID, + LatencyMs: 0, + Weight: selected.Weight, + Error: "", + Name: selected.Name, + Endpoint: selected.Endpoint, + WasPreviouslyConnected: selected.WasPreviouslyConnected, + }, + } + } + + type nodeResult struct { + Node ExitNode + Latency time.Duration + Err error + } + + results := make([]nodeResult, len(exitNodes)) + const pingAttempts = 3 + for i, node := range exitNodes { + var totalLatency time.Duration + var lastErr error + successes := 0 + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + url := node.Endpoint + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "http://" + url + } + if !strings.HasSuffix(url, "/ping") { + url = strings.TrimRight(url, "/") + "/ping" + } + for j := 0; j < pingAttempts; j++ { + start := time.Now() + resp, err := httpClient.Get(url) + latency := time.Since(start) + if err != nil { + lastErr = err + logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) + continue + } + resp.Body.Close() + totalLatency += latency + successes++ + } + var avgLatency time.Duration + if successes > 0 { + avgLatency = totalLatency / time.Duration(successes) + } + if successes == 0 { + results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} + } else { + results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} + } + } + + var pingResults []ExitNodePingResult + for _, res := range results { + errMsg := "" + if res.Err != nil { + errMsg = res.Err.Error() + } + pingResults = append(pingResults, ExitNodePingResult{ + ExitNodeID: res.Node.ID, + LatencyMs: res.Latency.Milliseconds(), + Weight: res.Node.Weight, + Error: errMsg, + Name: res.Node.Name, + Endpoint: res.Node.Endpoint, + WasPreviouslyConnected: res.Node.WasPreviouslyConnected, + }) + } + + if alreadyConnected { + var filteredPingResults []ExitNodePingResult + previouslyConnectedNodeIdx := -1 + for i, res := range pingResults { + if res.WasPreviouslyConnected { + previouslyConnectedNodeIdx = i + } + } + goodNodeCount := 0 + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { + goodNodeCount++ + } + } + if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx { + filteredPingResults = append(filteredPingResults, res) + } + } + pingResults = filteredPingResults + logger.Info("Excluding previously connected exit node from ping results due to other available nodes") + } + } + + return pingResults +} diff --git a/newt/handlers.go b/newt/handlers.go index 55c7f4e..d1530ab 100644 --- a/newt/handlers.go +++ b/newt/handlers.go @@ -10,13 +10,13 @@ import ( "net/http" "os" "os/signal" - "strings" "syscall" "time" "github.com/fosrl/newt/authdaemon" "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" + "github.com/fosrl/newt/exitnode" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/internal/state" "github.com/fosrl/newt/internal/telemetry" @@ -140,129 +140,7 @@ func (n *Newt) registerHandlers(ctx context.Context) { return } - if len(exitNodes) == 1 || n.config.PreferEndpoint != "" { - logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint) - - if n.config.PreferEndpoint != "" { - for _, node := range exitNodes { - if node.Endpoint == n.config.PreferEndpoint { - exitNodes[0] = node - break - } - } - } - - pingResults := []ExitNodePingResult{ - { - ExitNodeID: exitNodes[0].ID, - LatencyMs: 0, - Weight: exitNodes[0].Weight, - Error: "", - Name: exitNodes[0].Name, - Endpoint: exitNodes[0].Endpoint, - WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected, - }, - } - - chainId := generateChainId() - n.pendingRegisterChainId = chainId - n.stopFunc = n.client.SendMessageInterval(topicWGRegister, map[string]interface{}{ - "publicKey": n.publicKey.String(), - "pingResults": pingResults, - "newtVersion": n.config.Version, - "chainId": chainId, - }, 2*time.Second) - - return - } - - type nodeResult struct { - Node ExitNode - Latency time.Duration - Err error - } - - results := make([]nodeResult, len(exitNodes)) - const pingAttempts = 3 - for i, node := range exitNodes { - var totalLatency time.Duration - var lastErr error - successes := 0 - httpClient := &http.Client{ - Timeout: 5 * time.Second, - } - url := node.Endpoint - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - url = "http://" + url - } - if !strings.HasSuffix(url, "/ping") { - url = strings.TrimRight(url, "/") + "/ping" - } - for j := 0; j < pingAttempts; j++ { - start := time.Now() - resp, err := httpClient.Get(url) - latency := time.Since(start) - if err != nil { - lastErr = err - logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) - continue - } - resp.Body.Close() - totalLatency += latency - successes++ - } - var avgLatency time.Duration - if successes > 0 { - avgLatency = totalLatency / time.Duration(successes) - } - if successes == 0 { - results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} - } else { - results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} - } - } - - var pingResults []ExitNodePingResult - for _, res := range results { - errMsg := "" - if res.Err != nil { - errMsg = res.Err.Error() - } - pingResults = append(pingResults, ExitNodePingResult{ - ExitNodeID: res.Node.ID, - LatencyMs: res.Latency.Milliseconds(), - Weight: res.Node.Weight, - Error: errMsg, - Name: res.Node.Name, - Endpoint: res.Node.Endpoint, - WasPreviouslyConnected: res.Node.WasPreviouslyConnected, - }) - } - - if n.connected { - var filteredPingResults []ExitNodePingResult - previouslyConnectedNodeIdx := -1 - for i, res := range pingResults { - if res.WasPreviouslyConnected { - previouslyConnectedNodeIdx = i - } - } - goodNodeCount := 0 - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { - goodNodeCount++ - } - } - if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { - for i, res := range pingResults { - if i != previouslyConnectedNodeIdx { - filteredPingResults = append(filteredPingResults, res) - } - } - pingResults = filteredPingResults - logger.Info("Excluding previously connected exit node from ping results due to other available nodes") - } - } + pingResults := exitnode.PingExitNodes(exitNodes, n.config.PreferEndpoint, n.connected) chainId := generateChainId() n.pendingRegisterChainId = chainId diff --git a/newt/types.go b/newt/types.go index 649111d..8b2a2e8 100644 --- a/newt/types.go +++ b/newt/types.go @@ -2,6 +2,7 @@ package newt import ( wgclients "github.com/fosrl/newt/clients" + "github.com/fosrl/newt/exitnode" "github.com/fosrl/newt/healthcheck" ) @@ -35,28 +36,14 @@ type TargetData struct { Targets []string `json:"targets"` } -type ExitNodeData struct { - ExitNodes []ExitNode `json:"exitNodes"` - ChainId string `json:"chainId"` -} - -type ExitNode struct { - ID int `json:"exitNodeId"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - Weight float64 `json:"weight"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} - -type ExitNodePingResult struct { - ExitNodeID int `json:"exitNodeId"` - LatencyMs int64 `json:"latencyMs"` - Weight float64 `json:"weight"` - Error string `json:"error,omitempty"` - Name string `json:"exitNodeName"` - Endpoint string `json:"endpoint"` - WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` -} +// ExitNodeData, ExitNode and ExitNodePingResult are aliases for the shared +// exit-node ping dance types in package exitnode, kept here so existing code +// in this package can keep referring to them unqualified. +type ( + ExitNodeData = exitnode.ExitNodeData + ExitNode = exitnode.ExitNode + ExitNodePingResult = exitnode.ExitNodePingResult +) type BlueprintResult struct { Success bool `json:"success"` From 01cb6f39ca88481fdb75f936bcbd535688ce1d1f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 10:34:07 -0400 Subject: [PATCH 2/8] add funcs to add and remove ip from interface --- network/interface.go | 92 +++++++++++++++++++++++++++++++++ network/interface_notwindows.go | 4 ++ network/interface_windows.go | 32 ++++++++++++ network/settings.go | 39 ++++++++++++++ websocket/client.go | 22 +++++++- 5 files changed, 187 insertions(+), 2 deletions(-) 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() From 46f58dd59f8686382b8dcc3b37e1cfd8dcba10ea Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 12:13:22 -0400 Subject: [PATCH 3/8] add test for magic packets --- bind/shared_bind.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bind/shared_bind.go b/bind/shared_bind.go index 502e401..4e56e90 100644 --- a/bind/shared_bind.go +++ b/bind/shared_bind.go @@ -683,6 +683,23 @@ func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes [ } } +// IsMagicPacket reports whether payload is one of our connectivity-test magic +// packets (a MagicTestRequest or MagicTestResponse). These packets are meant to +// travel directly between physical UDP sockets and must never be encapsulated by +// WireGuard - e.g. if OS routing mistakenly sends one into a WireGuard TUN +// interface (because the destination falls inside a routed tunnel subnet), it +// should be dropped there rather than tunneled, which would otherwise make a +// LAN-local endpoint test falsely appear to succeed over the tunnel. +func IsMagicPacket(payload []byte) bool { + if len(payload) >= MagicTestRequestLen && bytes.HasPrefix(payload, MagicTestRequest) { + return true + } + if len(payload) >= MagicTestResponseLen && bytes.HasPrefix(payload, MagicTestResponse) { + return true + } + return false +} + // handleMagicPacket checks if the packet is a magic test packet and responds if so. // Returns true if the packet was a magic packet and was handled (should not be passed to WireGuard). func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool { From dd8713bd2cb4c14807de0883084f2fa70048388e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 14:55:54 -0400 Subject: [PATCH 4/8] Update language to be more clear --- updates/selfupdate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/updates/selfupdate.go b/updates/selfupdate.go index 2e12a5f..8476d0a 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -52,8 +52,8 @@ type versionResponse struct { } // ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not -// available when running inside official Fossorial container images. -var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images") +// available when running inside container images. +var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in container images") // isOfficialContainer returns true when the process is running inside an // official Fossorial-built container image. The image sets From 178554c9ff5a889435a16b0b53c5dea00db63634 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 15:27:19 -0400 Subject: [PATCH 5/8] add functions for including the source for bsd --- network/route.go | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/network/route.go b/network/route.go index 57fe12d..e8dd086 100644 --- a/network/route.go +++ b/network/route.go @@ -39,22 +39,40 @@ var PreferLocalRoutes = false // rather than replacing an existing route to the same destination, so a local // route is never displaced by one we add here. func DarwinAddRoute(destination string, gateway string, interfaceName string) error { + return DarwinAddRouteWithSource(destination, gateway, interfaceName, "") +} + +// DarwinAddRouteWithSource is DarwinAddRoute with an explicit source address +// (route(8) `-ifa`). This is required when the interface carries more than +// one address (e.g. an exit node's secondary tunnel address alongside the +// site tunnel's primary address): without `-ifa`, BSD picks a source address +// for the route on its own - typically the interface's primary address - and +// WireGuard's own reverse-path filtering on the remote end will silently drop +// packets whose source doesn't match the peer's configured AllowedIPs, even +// though the tunnel/handshake itself stays up. +func DarwinAddRouteWithSource(destination string, gateway string, interfaceName string, sourceIP string) error { if runtime.GOOS != "darwin" { return nil } - var cmd *exec.Cmd + var args []string if gateway != "" { // Route with specific gateway - cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-gateway", gateway) + args = []string{"-q", "-n", "add", "-inet", destination, "-gateway", gateway} } else if interfaceName != "" { // Route via interface - cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-interface", interfaceName) + args = []string{"-q", "-n", "add", "-inet", destination, "-interface", interfaceName} } else { return fmt.Errorf("either gateway or interface must be specified") } + if sourceIP != "" { + args = append(args, "-ifa", sourceIP) + } + + cmd := exec.Command("route", args...) + logger.Info("Running command: %v", cmd) out, err := cmd.CombinedOutput() @@ -173,6 +191,15 @@ func LinuxRemoveRoute(destination string, interfaceName string) error { // addRouteForServerIP adds an OS-specific route for the server IP func AddRouteForServerIP(serverIP, interfaceName string) error { + return AddRouteForServerIPWithSource(serverIP, interfaceName, "") +} + +// AddRouteForServerIPWithSource is AddRouteForServerIP with an explicit source +// address for the darwin route (see DarwinAddRouteWithSource) - needed when +// the interface carries more than one address, e.g. an exit node connection +// where the interface's primary address belongs to the site tunnel rather +// than the exit node. +func AddRouteForServerIPWithSource(serverIP, interfaceName string, sourceIP string) error { if interfaceName == "" { return nil } @@ -181,7 +208,7 @@ func AddRouteForServerIP(serverIP, interfaceName string) error { if err := AddRouteForNetworkConfig(serverIP); err != nil { return err } - return DarwinAddRoute(serverIP, "", interfaceName) + return DarwinAddRouteWithSource(serverIP, "", interfaceName, sourceIP) } // else if runtime.GOOS == "windows" { // return WindowsAddRoute(serverIP, "", interfaceName) @@ -245,6 +272,16 @@ func RemoveRouteForNetworkConfig(destination string) error { // addRoutes adds routes for each subnet in RemoteSubnets func AddRoutes(remoteSubnets []string, interfaceName string) error { + return AddRoutesWithSource(remoteSubnets, interfaceName, "") +} + +// AddRoutesWithSource is AddRoutes with an explicit source address for the +// darwin routes (see DarwinAddRouteWithSource) - needed when the interface +// carries more than one address (e.g. a site tunnel address alongside an +// exit node's secondary address), so the routes for these subnets are pinned +// to the address they actually belong to rather than whichever address +// darwin would otherwise default to. +func AddRoutesWithSource(remoteSubnets []string, interfaceName string, sourceIP string) error { if len(remoteSubnets) == 0 { return nil } @@ -268,7 +305,7 @@ func AddRoutes(remoteSubnets []string, interfaceName string) error { switch runtime.GOOS { case "darwin": - if err := DarwinAddRoute(subnet, "", interfaceName); err != nil { + if err := DarwinAddRouteWithSource(subnet, "", interfaceName, sourceIP); err != nil { logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err) } case "windows": From dcf28fa09482804a2111d2590da6486924271d1c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 15:51:15 -0400 Subject: [PATCH 6/8] Enhance route management with explicit gateway handling for network configurations --- network/route.go | 55 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/network/route.go b/network/route.go index e8dd086..bc5905a 100644 --- a/network/route.go +++ b/network/route.go @@ -203,11 +203,17 @@ func AddRouteForServerIPWithSource(serverIP, interfaceName string, sourceIP stri if interfaceName == "" { return nil } + + // Populate the NetworkSettings entry (and its gatewayAddress, for the + // NetworkExtension source-pinning trick above) unconditionally, same as + // AddRoutesWithSource does for remote subnets - mobile packet-tunnel + // providers rely on this regardless of GOOS. + if err := AddRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil { + return err + } + // TODO: does this also need to be ios? if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms - if err := AddRouteForNetworkConfig(serverIP); err != nil { - return err - } return DarwinAddRouteWithSource(serverIP, "", interfaceName, sourceIP) } // else if runtime.GOOS == "windows" { @@ -220,14 +226,24 @@ func AddRouteForServerIPWithSource(serverIP, interfaceName string, sourceIP stri // removeRouteForServerIP removes an OS-specific route for the server IP func RemoveRouteForServerIP(serverIP string, interfaceName string) error { + return RemoveRouteForServerIPWithSource(serverIP, interfaceName, "") +} + +// RemoveRouteForServerIPWithSource is RemoveRouteForServerIP with an explicit +// source/gateway address - must match whatever was passed to +// AddRouteForServerIPWithSource when the route was added (see +// RemoveRouteForNetworkConfigWithGateway). +func RemoveRouteForServerIPWithSource(serverIP string, interfaceName string, sourceIP string) error { if interfaceName == "" { return nil } + + if err := RemoveRouteForNetworkConfigWithGateway(serverIP, sourceIP); err != nil { + return err + } + // TODO: does this also need to be ios? if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms - if err := RemoveRouteForNetworkConfig(serverIP); err != nil { - return err - } return DarwinRemoveRoute(serverIP) } // else if runtime.GOOS == "windows" { @@ -239,6 +255,20 @@ func RemoveRouteForServerIP(serverIP string, interfaceName string) error { } func AddRouteForNetworkConfig(destination string) error { + return AddRouteForNetworkConfigWithGateway(destination, "") +} + +// AddRouteForNetworkConfigWithGateway is AddRouteForNetworkConfig with an +// explicit gateway address for the route entry surfaced via NetworkSettings. +// This is consumed by mobile (iOS/macOS NetworkExtension) packet-tunnel +// providers as NEIPv4Route.gatewayAddress. NetworkExtension gives us no +// direct way to pin a route's source address (no equivalent of BSD's `route +// -ifa`) - but setting gatewayAddress to one of the tunnel interface's own +// addresses makes the OS resolve "how do I reach this gateway" recursively +// to that address/interface pairing, which is what determines the source +// address used for packets matching the route. This is the same underlying +// mechanism as `route add -gateway` (see DarwinAddRoute's gateway branch). +func AddRouteForNetworkConfigWithGateway(destination string, gateway string) error { // Parse the subnet to extract IP and mask _, ipNet, err := net.ParseCIDR(destination) if err != nil { @@ -249,12 +279,21 @@ func AddRouteForNetworkConfig(destination string) error { mask := net.IP(ipNet.Mask).String() destinationAddress := ipNet.IP.String() - AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask}) + AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway}) return nil } func RemoveRouteForNetworkConfig(destination string) error { + return RemoveRouteForNetworkConfigWithGateway(destination, "") +} + +// RemoveRouteForNetworkConfigWithGateway is RemoveRouteForNetworkConfig with +// an explicit gateway address. This must match whatever gateway the route was +// added with (see AddRouteForNetworkConfigWithGateway) - RemoveIPv4IncludedRoute +// matches by full struct equality, so a mismatched gateway means the entry is +// silently never found/removed. +func RemoveRouteForNetworkConfigWithGateway(destination string, gateway string) error { // Parse the subnet to extract IP and mask _, ipNet, err := net.ParseCIDR(destination) if err != nil { @@ -265,7 +304,7 @@ func RemoveRouteForNetworkConfig(destination string) error { mask := net.IP(ipNet.Mask).String() destinationAddress := ipNet.IP.String() - RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask}) + RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask, GatewayAddress: gateway}) return nil } From eadac39ea778a471a61a7272ab191bd356e523ac Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 7 Aug 2026 16:04:20 -0400 Subject: [PATCH 7/8] Add NativeConfigDisabled flag to skip raw interface configuration on Darwin --- network/interface.go | 8 ++++++++ network/route.go | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/network/interface.go b/network/interface.go index 6b196d4..f33ec40 100644 --- a/network/interface.go +++ b/network/interface.go @@ -115,6 +115,10 @@ func FindUnusedUTUN() (string, error) { } func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + if NativeConfigDisabled { + return nil + } + logger.Info("Configuring darwin interface: %s", interfaceName) prefix, _ := ipNet.Mask.Size() @@ -246,6 +250,10 @@ func removeLinuxAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error } func removeDarwinAddress(interfaceName string, ip net.IP, ipNet *net.IPNet) error { + if NativeConfigDisabled { + return nil + } + prefix, _ := ipNet.Mask.Size() ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix) diff --git a/network/route.go b/network/route.go index bc5905a..7081bdf 100644 --- a/network/route.go +++ b/network/route.go @@ -32,6 +32,20 @@ const VPNRouteMetric = 9999 // this to true (e.g. from a config value) before routes are added. var PreferLocalRoutes = false +// NativeConfigDisabled, when true, skips the raw `ifconfig`/`route` subprocess +// calls this package otherwise makes on darwin (configureDarwin, +// removeDarwinAddress, DarwinAddRouteWithSource, DarwinRemoveRoute) while still +// populating the JSON-facing NetworkSettings state. This must be set when the +// TUN device's addresses/routes are instead owned by an external mechanism +// that reconciles them independently - namely Apple's NetworkExtension +// (NEPacketTunnelProvider.setTunnelNetworkSettings), which is the sole +// sanctioned way to configure that virtual interface. Running our own +// ifconfig/route commands in addition to NE applying its own settings was +// observed to install two competing routes to the same destination (one via +// NE's gatewayAddress-based route, one via our own `-ifa` route), so the two +// mechanisms must be mutually exclusive rather than layered. +var NativeConfigDisabled = false + // DarwinAddRoute adds a route via the BSD routing table. Unlike Linux/Windows, // BSD's routing table has no per-route metric - preference between an // overlapping local route and this VPN route is instead resolved by @@ -54,6 +68,9 @@ func DarwinAddRouteWithSource(destination string, gateway string, interfaceName if runtime.GOOS != "darwin" { return nil } + if NativeConfigDisabled { + return nil + } var args []string @@ -87,6 +104,9 @@ func DarwinRemoveRoute(destination string) error { if runtime.GOOS != "darwin" { return nil } + if NativeConfigDisabled { + return nil + } cmd := exec.Command("route", "-q", "-n", "delete", "-inet", destination) logger.Info("Running command: %v", cmd) From abbab85bed3d07689892239f233bb7df9686e72e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 10:49:13 -0400 Subject: [PATCH 8/8] Support reliable SECONDARY address adding --- network/interface.go | 4 +++- network/settings.go | 16 ++++++++++++++-- network/settings_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 network/settings_test.go diff --git a/network/interface.go b/network/interface.go index f33ec40..72af5f4 100644 --- a/network/interface.go +++ b/network/interface.go @@ -184,7 +184,9 @@ func AddSecondaryAddress(interfaceName string, addr string) error { } mask := net.IP(ipNet.Mask).String() - AddIPv4Address(ip.String(), mask) + if err := AddIPv4Address(ip.String(), mask); err != nil { + return err + } if interfaceName == "" { return nil diff --git a/network/settings.go b/network/settings.go index 9203695..f0520ed 100644 --- a/network/settings.go +++ b/network/settings.go @@ -2,6 +2,7 @@ package network import ( "encoding/json" + "fmt" "sync" "github.com/fosrl/newt/logger" @@ -86,14 +87,24 @@ func SetIPv4Settings(addresses []string, subnetMasks []string) { // 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) { +// +// It requires a primary address to already be set (via SetIPv4Settings, +// i.e. ConfigureInterface) and refuses to add otherwise: on mobile +// platforms, array order is what determines which address the OS treats as +// primary, so appending to an empty list would silently make this +// "additional" address the primary one instead. +func AddIPv4Address(address string, subnetMask string) error { networkSettingsMutex.Lock() defer networkSettingsMutex.Unlock() + if len(networkSettings.IPv4Addresses) == 0 { + return fmt.Errorf("cannot add secondary IPv4 address %s: no primary address configured yet", address) + } + for _, a := range networkSettings.IPv4Addresses { if a == address { logger.Info("IPv4 address already exists: %s", address) - return + return nil } } @@ -101,6 +112,7 @@ func AddIPv4Address(address string, subnetMask string) { networkSettings.IPv4SubnetMasks = append(networkSettings.IPv4SubnetMasks, subnetMask) incrementor++ logger.Info("Added IPv4 address: %s/%s", address, subnetMask) + return nil } // RemoveIPv4Address removes a previously added secondary IPv4 address. diff --git a/network/settings_test.go b/network/settings_test.go new file mode 100644 index 0000000..eac4d5f --- /dev/null +++ b/network/settings_test.go @@ -0,0 +1,32 @@ +package network + +import "testing" + +func TestAddIPv4AddressRequiresPrimary(t *testing.T) { + ClearNetworkSettings() + defer ClearNetworkSettings() + + if err := AddIPv4Address("10.10.0.5", "255.255.255.255"); err == nil { + t.Fatal("expected an error adding a secondary address before any primary address is set") + } + if got := GetSettings().IPv4Addresses; len(got) != 0 { + t.Errorf("IPv4Addresses = %v, want empty after a rejected add", got) + } +} + +func TestAddIPv4AddressAfterPrimarySucceeds(t *testing.T) { + ClearNetworkSettings() + defer ClearNetworkSettings() + + SetIPv4Settings([]string{"10.10.0.1"}, []string{"255.255.255.0"}) + + if err := AddIPv4Address("10.10.0.5", "255.255.255.255"); err != nil { + t.Fatalf("unexpected error adding secondary address: %v", err) + } + + got := GetSettings().IPv4Addresses + want := []string{"10.10.0.1", "10.10.0.5"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("IPv4Addresses = %v, want %v", got, want) + } +}