Files
ctrld/cmd/cli/route_darwin.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

159 lines
5.3 KiB
Go

package cli
import (
"net"
"net/netip"
"syscall"
"golang.org/x/net/route"
)
// routeInterfaceNameFn names the interface of one route table index. A test
// replaces it, because the interfaces of the host are not a fixture.
var routeInterfaceNameFn = interfaceNameByIndex
// defaultRoutes reads the default route of each family. It reads the kernel
// route table, because a snapshot follows every network change and a
// subprocess per change costs too much on a busy host. A table that the kernel
// does not give leaves both routes empty.
func defaultRoutes() (v4, v6 defaultRoute) {
rib, err := route.FetchRIB(syscall.AF_UNSPEC, route.RIBTypeRoute, 0)
if err != nil {
return defaultRoute{}, defaultRoute{}
}
messages, err := route.ParseRIB(route.RIBTypeRoute, rib)
if err != nil {
return defaultRoute{}, defaultRoute{}
}
return defaultRoutesFromMessages(messages)
}
// defaultRoutesFromMessages picks the first default route of each family. The
// next hop and the interface come from one message, so a reader sees the pair
// that the kernel holds. The kernel keeps one unscoped default per family, so
// the first match is the only one.
func defaultRoutesFromMessages(messages []route.Message) (v4, v6 defaultRoute) {
foundV4, foundV6 := false, false
for _, message := range messages {
match, ok := matchDefaultRoute(message)
if !ok {
continue
}
found := defaultRoute{Interface: routeInterfaceNameFn(match.index)}
if match.gateway.IsValid() {
found.Gateway = match.gateway.String()
}
if match.v4 && !foundV4 {
v4, foundV4 = found, true
}
if !match.v4 && !foundV6 {
v6, foundV6 = found, true
}
if foundV4 && foundV6 {
return v4, v6
}
}
return v4, v6
}
// defaultRouteMatch is one default route of the table. A tunnel default has
// no next hop, so gateway can be invalid while the interface is set.
type defaultRouteMatch struct {
v4 bool
gateway netip.Addr
index int
}
// defaultRouteGateway reports the next hop and the interface index of one
// default route, for the tests of the selector. See matchDefaultRoute.
func defaultRouteGateway(message route.Message) (netip.Addr, int, bool) {
match, ok := matchDefaultRoute(message)
return match.gateway, match.index, ok
}
// matchDefaultRoute reports whether one message is the default route of the
// host for its family. A route to another destination or to a part of the
// address space gives nothing: a VPN splits the default into two /1 routes,
// and the zero address alone does not make a route the default. A route bound
// to one interface serves that interface only, a host route is no default, and
// a reject or blackhole route delivers nothing. With the gateway flag, the
// next hop must be a host of the same family. Without it, the link itself
// delivers, which is how a tunnel owns the default; the match then has no
// gateway and names the interface.
func matchDefaultRoute(message route.Message) (defaultRouteMatch, bool) {
routeMessage, ok := message.(*route.RouteMessage)
if !ok || len(routeMessage.Addrs) <= syscall.RTAX_NETMASK {
return defaultRouteMatch{}, false
}
if routeMessage.Flags&(syscall.RTF_IFSCOPE|syscall.RTF_HOST|syscall.RTF_REJECT|syscall.RTF_BLACKHOLE) != 0 {
return defaultRouteMatch{}, false
}
destination, ok := routeMessageAddr(routeMessage.Addrs[syscall.RTAX_DST])
if !ok || !destination.IsUnspecified() || !zeroNetmask(routeMessage.Addrs[syscall.RTAX_NETMASK], destination.Is4()) {
return defaultRouteMatch{}, false
}
match := defaultRouteMatch{v4: destination.Is4(), index: routeMessage.Index}
if routeMessage.Flags&syscall.RTF_GATEWAY == 0 {
if _, link := routeMessage.Addrs[syscall.RTAX_GATEWAY].(*route.LinkAddr); !link {
return defaultRouteMatch{}, false
}
return match, true
}
gateway, ok := routeMessageAddr(routeMessage.Addrs[syscall.RTAX_GATEWAY])
if !ok || gateway.Is4() != destination.Is4() {
return defaultRouteMatch{}, false
}
match.gateway = gateway
return match, true
}
// zeroNetmask reports whether the netmask of a route covers the whole address
// space of its family, which only the default route does.
func zeroNetmask(addr route.Addr, v4 bool) bool {
switch mask := addr.(type) {
case *route.Inet4Addr:
return v4 && mask.IP == [4]byte{}
case *route.Inet6Addr:
return !v4 && mask.IP == [16]byte{}
}
return false
}
func routeMessageAddr(addr route.Addr) (netip.Addr, bool) {
switch address := addr.(type) {
case *route.Inet4Addr:
return netip.AddrFrom4(address.IP), true
case *route.Inet6Addr:
return withInterfaceZone(netip.AddrFrom16(address.IP), address.ZoneID), true
}
return netip.Addr{}, false
}
// withInterfaceZone names the interface of a link-local next hop. Every link
// repeats the same range, so the address alone names no host. A tunnel gives
// the bare address fe80:: and the interface is the only part that tells the
// links apart.
func withInterfaceZone(address netip.Addr, zoneID int) netip.Addr {
if zoneID == 0 || !address.IsLinkLocalUnicast() {
return address
}
name := routeInterfaceNameFn(zoneID)
if name == "" {
return address
}
return address.WithZone(name)
}
// interfaceNameByIndex names one interface of the route table. An index that
// no interface holds gives an empty name.
func interfaceNameByIndex(index int) string {
if index == 0 {
return ""
}
iface, err := net.InterfaceByIndex(index)
if err != nil {
return ""
}
return iface.Name
}