net/interfaces: handle iOS network transitions (#10680)

Updates #8022
Updates #6075

On iOS, we currently rely on delegated interface information to figure out the default route interface.  The NetworkExtension framework in iOS seems to set the delegate interface only once, upon the *creation* of the VPN tunnel. If a network transition (e.g. from Wi-Fi to Cellular) happens while the tunnel is connected, it will be ignored and we will still try to set Wi-Fi as the default route because the delegated interface is not getting updated as connectivity transitions.

Here we work around this on the Swift side with a NWPathMonitor instance that observes the interface name of the first currently satisfied network path. Our Swift code will call into `UpdateLastKnownDefaultRouteInterface`, so we can rely on that when it is set.

If for any reason the Swift machinery didn't work and we don't get any updates, here we also have some fallback logic: we try finding a hardcoded Wi-Fi interface called en0. If en0 is down, we fall back to cellular (pdp_ip0) as a last resort. This doesn't handle all edge cases like USB-Ethernet adapters or multiple Ethernet interfaces, but it is good enough to ensure connectivity isn't broken.

I tested this on iPhones and iPads running iOS 17.1 and it appears to work. Switching between different cellular plans on a dual SIM configuration also works (the interface name remains pdp_ip0).

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
This commit is contained in:
Andrea Gottardo 2024-01-04 09:40:18 -08:00 committed by GitHub
parent 10c595d962
commit d9aeb30281
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 134 additions and 15 deletions

View File

@ -0,0 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Common code for FreeBSD and Darwin. This might also work on other
// BSD systems (e.g. OpenBSD) but has not been tested.
// Not used on iOS. See defaultroute_ios.go.
//go:build !ios && (darwin || freebsd)
package interfaces
import "net"
func defaultRoute() (d DefaultRouteDetails, err error) {
idx, err := DefaultRouteInterfaceIndex()
if err != nil {
return d, err
}
iface, err := net.InterfaceByIndex(idx)
if err != nil {
return d, err
}
d.InterfaceName = iface.Name
d.InterfaceIndex = idx
return d, nil
}

View File

@ -0,0 +1,108 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios
package interfaces
import (
"log"
"tailscale.com/syncs"
)
var (
lastKnownDefaultRouteIfName syncs.AtomicValue[string]
)
// UpdateLastKnownDefaultRouteInterface is called by ipn-go-bridge in the iOS app when
// our NWPathMonitor instance detects a network path transition.
func UpdateLastKnownDefaultRouteInterface(ifName string) {
if ifName == "" {
return
}
lastKnownDefaultRouteIfName.Store(ifName)
log.Printf("defaultroute_ios: update from Swift, ifName = %s", ifName)
}
func defaultRoute() (d DefaultRouteDetails, err error) {
// We cannot rely on the delegated interface data on iOS. The NetworkExtension framework
// seems to set the delegate interface only once, upon the *creation* of the VPN tunnel.
// If a network transition (e.g. from Wi-Fi to Cellular) happens while the tunnel is
// connected, it will be ignored and we will still try to set Wi-Fi as the default route
// because the delegated interface is not updated by the NetworkExtension framework.
//
// We work around this on the Swift side with a NWPathMonitor instance that observes
// the interface name of the first currently satisfied network path. Our Swift code will
// call into `UpdateLastKnownDefaultRouteInterface`, so we can rely on that when it is set.
//
// If for any reason the Swift machinery didn't work and we don't get any updates, here
// we also have some fallback logic: we try finding a hardcoded Wi-Fi interface called en0.
// If en0 is down, we fall back to cellular (pdp_ip0) as a last resort. This doesn't handle
// all edge cases like USB-Ethernet adapters or multiple Ethernet interfaces, but is good
// enough to ensure connectivity isn't broken.
// Start by getting all available interfaces.
interfaces, err := netInterfaces()
if err != nil {
log.Printf("defaultroute_ios: could not get interfaces: %v", err)
return d, ErrNoGatewayIndexFound
}
getInterfaceByName := func(name string) *Interface {
for _, ifc := range interfaces {
if ifc.Name != name {
continue
}
if !ifc.IsUp() {
log.Println("defaultroute_ios: %s is down", name)
return nil
}
addrs, _ := ifc.Addrs()
if len(addrs) == 0 {
log.Println("defaultroute_ios: %s has no addresses", name)
return nil
}
return &ifc
}
return nil
}
// Did Swift set lastKnownDefaultRouteInterface? If so, we should use it and don't bother
// with anything else. However, for sanity, do check whether Swift gave us with an interface
// that exists, is up, and has an address.
if swiftIfName := lastKnownDefaultRouteIfName.Load(); swiftIfName != "" {
ifc := getInterfaceByName(swiftIfName)
if ifc != nil {
log.Printf("defaultroute_ios: using %s (provided by Swift)", ifc.Name)
d.InterfaceName = ifc.Name
d.InterfaceIndex = ifc.Index
return d, nil
}
}
// Start of our fallback logic if Swift didn't give us an interface name, or gave us an invalid
// one.
// We start by attempting to use the Wi-Fi interface, which on iPhone is always called en0.
enZeroIf := getInterfaceByName("en0")
if enZeroIf != nil {
log.Println("defaultroute_ios: using en0 (fallback)")
d.InterfaceName = enZeroIf.Name
d.InterfaceIndex = enZeroIf.Index
return d, nil
}
// Did it not work? Let's try with Cellular (pdp_ip0).
cellIf := getInterfaceByName("pdp_ip0")
if cellIf != nil {
log.Println("defaultroute_ios: using pdp_ip0 (fallback)")
d.InterfaceName = cellIf.Name
d.InterfaceIndex = cellIf.Index
return d, nil
}
log.Println("defaultroute_ios: no running interfaces available")
return d, ErrNoGatewayIndexFound
}

View File

@ -12,7 +12,6 @@
"errors"
"fmt"
"log"
"net"
"net/netip"
"syscall"
@ -21,20 +20,6 @@
"tailscale.com/net/netaddr"
)
func defaultRoute() (d DefaultRouteDetails, err error) {
idx, err := DefaultRouteInterfaceIndex()
if err != nil {
return d, err
}
iface, err := net.InterfaceByIndex(idx)
if err != nil {
return d, err
}
d.InterfaceName = iface.Name
d.InterfaceIndex = idx
return d, nil
}
// ErrNoGatewayIndexFound is returned by DefaultRouteInterfaceIndex when no
// default route is found.
var ErrNoGatewayIndexFound = errors.New("no gateway index found")