mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"cmp"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"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)
|
|
snapshot.HaveV4, snapshot.HaveV6 = in.State.HaveV4, in.State.HaveV6
|
|
snapshot.Interfaces, snapshot.InterfacesOmitted = snapshotInterfaces(in.State, meta)
|
|
// Only a platform that reads no route table takes the route of the
|
|
// network monitor. On macOS an empty v4 route is a fact, and the
|
|
// monitor can still hold the interface of the last network.
|
|
if in.RouteV4.Interface == "" && in.RouteV6.Interface == "" {
|
|
snapshot.DefaultRouteV4 = defaultRouteV4Interface(in.State)
|
|
}
|
|
linkInterface := snapshotLinkInterface(in)
|
|
routeMeta = meta(linkInterface)
|
|
routeAddresses = in.State.InterfaceIPs[linkInterface]
|
|
}
|
|
snapshot.LinkType = linkTypeFor(routeMeta.HardwarePort, routeMeta.Class)
|
|
snapshot.Tethered = tetheredNetwork(in.RouteV4.Gateway, routeMeta.HardwarePort, routeAddresses)
|
|
return snapshot
|
|
}
|
|
|
|
// snapshotLinkInterface names the interface that the link fields describe.
|
|
// The route table wins over the network monitor: during a switch the monitor
|
|
// can still hold the old interface, and one record must not mix the routes of
|
|
// one moment with the link type of another. It must run with a state.
|
|
func snapshotLinkInterface(in snapshotInputs) string {
|
|
for _, name := range []string{in.RouteV4.Interface, in.RouteV6.Interface} {
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return in.State.DefaultRouteInterface
|
|
}
|
|
|
|
// 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) *ctrld.LogEvent {
|
|
interfaces := ctrld.Arr()
|
|
for _, iface := range snapshot.Interfaces {
|
|
interfaces = interfaces.Dict(ctrld.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 ctrld.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)
|
|
}
|