mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
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.
302 lines
9.8 KiB
Go
302 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"cmp"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog"
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/net/tsaddr"
|
|
)
|
|
|
|
// hardwarePortIPhoneUSB is the macOS port name of a phone that shares its
|
|
// connection over the cable.
|
|
const hardwarePortIPhoneUSB = "iPhone USB"
|
|
|
|
// snapshotInterfaceLimit bounds the interface list of one snapshot. A
|
|
// container host holds hundreds of interfaces, and each entry costs bytes in
|
|
// every journal line and in every log file header.
|
|
const snapshotInterfaceLimit = 32
|
|
|
|
// v6GlobalPrefix holds the v6 addresses that reach the Internet.
|
|
var v6GlobalPrefix = netip.MustParsePrefix("2000::/3")
|
|
|
|
// hotspotGatewayPrefix is the range that an iPhone hotspot hands out. A
|
|
// gateway inside it names a tethered host on every platform.
|
|
var hotspotGatewayPrefix = netip.MustParsePrefix("172.20.10.0/28")
|
|
|
|
// ethernetPortWords name a wired port. macOS spells the same link in several
|
|
// ways, one per adapter family.
|
|
var ethernetPortWords = []string{"Ethernet", "LAN", "Thunderbolt"}
|
|
|
|
// defaultRoute names the next hop and the interface of one default route. Both
|
|
// values come from the same entry of the route table, so a reader can trust
|
|
// the pair.
|
|
type defaultRoute struct {
|
|
Gateway string
|
|
Interface string
|
|
}
|
|
|
|
// snapshotInterface is one interface of a network snapshot.
|
|
type snapshotInterface struct {
|
|
Name string
|
|
Class string
|
|
Up bool
|
|
IPs []string
|
|
HardwarePort string
|
|
Service string
|
|
}
|
|
|
|
// networkSnapshot describes the host network at one moment. The journal event
|
|
// and the header of a log file render the same value.
|
|
type networkSnapshot struct {
|
|
DefaultRouteV4 string
|
|
DefaultRouteV6 string
|
|
GatewayV4 string
|
|
GatewayV6 string
|
|
HaveV4 bool
|
|
HaveV6 bool
|
|
Interfaces []snapshotInterface
|
|
InterfacesOmitted int
|
|
Resolvers []string
|
|
SourceIPv4 string
|
|
SourceIPv6 string
|
|
InterceptTarget string
|
|
BypassActive bool
|
|
RecoveryRunning bool
|
|
PFStabilizing bool
|
|
LinkType string
|
|
Tethered bool
|
|
CLATPresent bool
|
|
NAT64Prefix string
|
|
DNSLessTarget bool
|
|
}
|
|
|
|
// snapshotInputs holds every value a snapshot needs. The caller reads them, so
|
|
// the builder stays free of locks and of system calls.
|
|
type snapshotInputs struct {
|
|
State *netmon.State
|
|
RouteV4 defaultRoute
|
|
RouteV6 defaultRoute
|
|
Resolvers []string
|
|
SourceIPv4 string
|
|
SourceIPv6 string
|
|
InterceptTarget string
|
|
BypassActive bool
|
|
RecoveryRunning bool
|
|
PFStabilizing bool
|
|
NAT64Prefix string
|
|
CLATPresent bool
|
|
Meta interfaceMetaFunc
|
|
}
|
|
|
|
// buildNetworkSnapshot makes the snapshot of one moment. It is pure, so a
|
|
// caller can take the inputs under a lock and build outside it.
|
|
func buildNetworkSnapshot(in snapshotInputs) networkSnapshot {
|
|
snapshot := networkSnapshot{
|
|
GatewayV4: in.RouteV4.Gateway,
|
|
GatewayV6: in.RouteV6.Gateway,
|
|
DefaultRouteV4: in.RouteV4.Interface,
|
|
DefaultRouteV6: in.RouteV6.Interface,
|
|
Resolvers: in.Resolvers,
|
|
SourceIPv4: in.SourceIPv4,
|
|
SourceIPv6: in.SourceIPv6,
|
|
InterceptTarget: in.InterceptTarget,
|
|
BypassActive: in.BypassActive,
|
|
RecoveryRunning: in.RecoveryRunning,
|
|
PFStabilizing: in.PFStabilizing,
|
|
NAT64Prefix: in.NAT64Prefix,
|
|
CLATPresent: in.CLATPresent,
|
|
DNSLessTarget: in.InterceptTarget != "",
|
|
}
|
|
|
|
routeMeta := interfaceMeta{}
|
|
var routeAddresses []netip.Prefix
|
|
if in.State != nil {
|
|
meta := interfaceMetaWithClass(in.Meta)
|
|
routeMeta = meta(in.State.DefaultRouteInterface)
|
|
routeAddresses = in.State.InterfaceIPs[in.State.DefaultRouteInterface]
|
|
snapshot.HaveV4, snapshot.HaveV6 = in.State.HaveV4, in.State.HaveV6
|
|
snapshot.Interfaces, snapshot.InterfacesOmitted = snapshotInterfaces(in.State, meta)
|
|
if snapshot.DefaultRouteV4 == "" {
|
|
snapshot.DefaultRouteV4 = defaultRouteV4Interface(in.State)
|
|
}
|
|
}
|
|
snapshot.LinkType = linkTypeFor(routeMeta.HardwarePort, routeMeta.Class)
|
|
snapshot.Tethered = tetheredNetwork(in.RouteV4.Gateway, routeMeta.HardwarePort, routeAddresses)
|
|
return snapshot
|
|
}
|
|
|
|
// interfaceMetaWithClass fills the class that the platform metadata leaves
|
|
// empty. A host that names no port still tells a tunnel from a hardware port
|
|
// by the interface name.
|
|
func interfaceMetaWithClass(meta interfaceMetaFunc) interfaceMetaFunc {
|
|
return func(name string) interfaceMeta {
|
|
info := interfaceMeta{}
|
|
if meta != nil {
|
|
info = meta(name)
|
|
}
|
|
if info.Class == "" {
|
|
info.Class = interfaceClass(name, false)
|
|
}
|
|
return info
|
|
}
|
|
}
|
|
|
|
// snapshotInterfaces lists the interfaces of the state, sorted by name, and
|
|
// counts the ones it leaves out. A reader compares two snapshots line by line,
|
|
// so the order must not move. An interface that is down and holds no address
|
|
// carries no traffic, and a container host holds hundreds of them.
|
|
func snapshotInterfaces(state *netmon.State, meta interfaceMetaFunc) (list []snapshotInterface, omitted int) {
|
|
list = make([]snapshotInterface, 0, len(state.Interface))
|
|
for name, iface := range state.Interface {
|
|
up := iface.Interface != nil && iface.IsUp()
|
|
ips := prefixStrings(state.InterfaceIPs[name])
|
|
if !up && len(ips) == 0 {
|
|
omitted++
|
|
continue
|
|
}
|
|
info := meta(name)
|
|
list = append(list, snapshotInterface{
|
|
Name: name,
|
|
Class: info.Class,
|
|
Up: up,
|
|
IPs: ips,
|
|
HardwarePort: info.HardwarePort,
|
|
Service: info.Service,
|
|
})
|
|
}
|
|
slices.SortFunc(list, func(a, b snapshotInterface) int {
|
|
return cmp.Compare(a.Name, b.Name)
|
|
})
|
|
if len(list) > snapshotInterfaceLimit {
|
|
omitted += len(list) - snapshotInterfaceLimit
|
|
list = list[:snapshotInterfaceLimit]
|
|
}
|
|
return list, omitted
|
|
}
|
|
|
|
// defaultRouteV4Interface names the interface that the network monitor holds
|
|
// for the default route. It fills the v4 route of a platform that reads no
|
|
// route table. An interface without a usable v4 address carries no v4 traffic,
|
|
// whatever the monitor says.
|
|
func defaultRouteV4Interface(state *netmon.State) string {
|
|
route := state.DefaultRouteInterface
|
|
if route == "" {
|
|
return ""
|
|
}
|
|
for _, prefix := range state.InterfaceIPs[route] {
|
|
if usableV4(prefix.Addr().Unmap()) {
|
|
return route
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// globalAddress reports whether an address reaches another host.
|
|
func globalAddress(address netip.Addr) bool {
|
|
return address.IsGlobalUnicast() && !address.IsLoopback() && !address.IsLinkLocalUnicast()
|
|
}
|
|
|
|
// usableV4 and usableV6 grade one address the way the network monitor grades
|
|
// it. A fresh read of the interfaces must report the same families as a
|
|
// monitor callback, or two snapshots of one moment disagree. The monitor
|
|
// counts a unique local address of the v6 family, because some networks reach
|
|
// the Internet from it. The range of the Tailscale mesh is the exception,
|
|
// because that mesh carries no Internet traffic.
|
|
func usableV4(address netip.Addr) bool {
|
|
return address.Is4() && globalAddress(address)
|
|
}
|
|
|
|
func usableV6(address netip.Addr) bool {
|
|
if !address.Is6() {
|
|
return false
|
|
}
|
|
if v6GlobalPrefix.Contains(address) {
|
|
return true
|
|
}
|
|
return address.IsPrivate() && !tsaddr.TailscaleULARange().Contains(address)
|
|
}
|
|
|
|
// tetheredNetwork reports whether the host runs on a phone connection. A
|
|
// tethered host pays for its traffic and loses the link on every phone move.
|
|
// A platform that reads no route table gives no gateway, so the address that
|
|
// the phone handed to the host tells the same story.
|
|
func tetheredNetwork(gatewayV4, hardwarePort string, routeAddresses []netip.Prefix) bool {
|
|
if hardwarePort == hardwarePortIPhoneUSB {
|
|
return true
|
|
}
|
|
if gateway, err := netip.ParseAddr(gatewayV4); err == nil && hotspotGatewayPrefix.Contains(gateway.Unmap()) {
|
|
return true
|
|
}
|
|
for _, prefix := range routeAddresses {
|
|
if hotspotGatewayPrefix.Contains(prefix.Addr().Unmap()) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// linkTypeFor names the link of the default route. The port name tells more
|
|
// than the interface name, which is only a number on macOS.
|
|
func linkTypeFor(hardwarePort, class string) string {
|
|
switch {
|
|
case hardwarePort == "Wi-Fi":
|
|
return "wifi"
|
|
case hardwarePort == hardwarePortIPhoneUSB:
|
|
return "usb_tether"
|
|
case ethernetPort(hardwarePort):
|
|
return "ethernet"
|
|
case class == "tunnel":
|
|
return "tunnel"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func ethernetPort(hardwarePort string) bool {
|
|
for _, word := range ethernetPortWords {
|
|
if strings.Contains(hardwarePort, word) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// snapshotDict renders the snapshot with the field names of the spec. The
|
|
// journal event and the header line share it, so both stay in one shape.
|
|
func snapshotDict(snapshot networkSnapshot) *zerolog.Event {
|
|
interfaces := zerolog.Arr()
|
|
for _, iface := range snapshot.Interfaces {
|
|
interfaces = interfaces.Dict(zerolog.Dict().
|
|
Str("name", iface.Name).
|
|
Str("class", iface.Class).
|
|
Bool("up", iface.Up).
|
|
Strs("ips", iface.IPs).
|
|
Str("hardware_port", iface.HardwarePort).
|
|
Str("service", iface.Service))
|
|
}
|
|
return zerolog.Dict().
|
|
Str("default_route_v4", snapshot.DefaultRouteV4).
|
|
Str("default_route_v6", snapshot.DefaultRouteV6).
|
|
Str("gateway_v4", snapshot.GatewayV4).
|
|
Str("gateway_v6", snapshot.GatewayV6).
|
|
Bool("have_v4", snapshot.HaveV4).
|
|
Bool("have_v6", snapshot.HaveV6).
|
|
Array("interfaces", interfaces).
|
|
Int("interfaces_omitted", snapshot.InterfacesOmitted).
|
|
Strs("resolvers", snapshot.Resolvers).
|
|
Str("source_ipv4", snapshot.SourceIPv4).
|
|
Str("source_ipv6", snapshot.SourceIPv6).
|
|
Str("intercept_target", snapshot.InterceptTarget).
|
|
Bool("bypass_active", snapshot.BypassActive).
|
|
Bool("recovery_running", snapshot.RecoveryRunning).
|
|
Bool("pf_stabilizing", snapshot.PFStabilizing).
|
|
Str("link_type", snapshot.LinkType).
|
|
Bool("tethered", snapshot.Tethered).
|
|
Bool("clat_present", snapshot.CLATPresent).
|
|
Str("nat64_prefix", snapshot.NAT64Prefix).
|
|
Bool("dns_less_target", snapshot.DNSLessTarget)
|
|
}
|