Files
ctrld/cmd/cli/network_delta.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

302 lines
9.1 KiB
Go

package cli
import (
"net"
"net/netip"
"slices"
"strings"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// interfaceChange describes one interface that a network delta changed.
type interfaceChange struct {
Name string
Action string
Class string
HardwarePort string
Service string
IPsBefore []string
IPsAfter []string
Flags string
MTU int
IsDefaultRoute bool
}
// interfaceMeta holds the platform description of one interface. An empty
// Class asks the caller to read the class from the interface name.
type interfaceMeta struct {
Class string
HardwarePort string
Service string
LinkType string
}
// interfaceMetaFunc reads the platform description of one interface.
type interfaceMetaFunc func(name string) interfaceMeta
// virtualInterfaceSetFn names the virtual adapters of the host. Each platform
// file fills it, because a name alone does not tell a container bridge from a
// physical port.
var virtualInterfaceSetFn = func() map[string]struct{} { return nil }
// interfaceSnapshot holds the part of one interface state that the diff
// compares.
type interfaceSnapshot struct {
present bool
up bool
mac string
mtu int
flags string
ips []string
}
// interfaceClass groups an interface by the traffic it carries. The class
// decides which changes are noise.
func interfaceClass(name string, virtual bool) string {
switch {
case strings.HasPrefix(name, "lo"):
return "loopback"
case hasInterfacePrefix(name, "awdl", "llw"):
return "airdrop"
case hasInterfacePrefix(name, "utun", "tun", "tap", "wg", "ipsec", "ppp"):
return "tunnel"
case virtual:
return "virtual"
default:
return "hardware"
}
}
// deltaBeforeState picks the state that the diff of one callback compares
// against, and makes a current callback the baseline of the next one. netmon
// caches major snapshots only, so every minor callback of an epoch carries the
// same Old, and a difference that lasts would look new in each of them. A
// callback that a newer snapshot replaced reads the baseline and leaves it.
// The caller holds networkSourceMu.
//
// netmon runs its callbacks in their own goroutines, so a minor callback of a
// new epoch can run before the major callback that opened the epoch. That
// major then reports its own changes against its Old and leaves the newer
// baseline in place. A major whose Old is the cache of the epoch before
// continues from the last reported state of that epoch, so a change that a
// minor callback reported does not appear again.
func (p *prog) deltaBeforeState(delta *netmon.ChangeDelta, epoch *netmon.State, current, major bool) *netmon.State {
before := p.networkDeltaState
switch {
case before == nil:
before = delta.Old
case current && major && p.networkDeltaEpoch == epoch:
return delta.Old
case p.networkDeltaEpoch != epoch && delta.Old != p.networkDeltaEpoch:
before = delta.Old
}
if current {
p.networkDeltaState, p.networkDeltaEpoch = delta.New, epoch
}
return before
}
// diffNetworkDelta describes every interface that changed between two states.
// It walks every interface, not only the valid hardware ports, because the
// journal must show the port that went away too.
func diffNetworkDelta(beforeState, afterState *netmon.State) []interfaceChange {
before, after := networkStateOrEmpty(beforeState), networkStateOrEmpty(afterState)
virtual := virtualInterfaceSetFn()
var changes []interfaceChange
for _, name := range interfaceNameUnion(before, after) {
snapBefore, snapAfter := readInterfaceSnapshot(before, name), readInterfaceSnapshot(after, name)
action := interfaceAction(snapBefore, snapAfter)
if action == "" {
continue
}
known := snapAfter
if !snapAfter.present {
known = snapBefore
}
_, isVirtual := virtual[name]
changes = append(changes, interfaceChange{
Name: name,
Action: action,
Class: interfaceClass(name, isVirtual),
IPsBefore: snapBefore.ips,
IPsAfter: snapAfter.ips,
Flags: known.flags,
MTU: known.mtu,
IsDefaultRoute: name == after.DefaultRouteInterface,
})
}
return changes
}
// noiseDelta reports a delta that no part of the daemon must act on. AirDrop
// and the virtual adapters of a container host change every few seconds, and
// each change would otherwise start the whole reconcile chain. A time jump
// means the host woke, so the daemon acts on the delta whatever it holds.
func noiseDelta(beforeState, afterState *netmon.State, timeJumped bool, changes []interfaceChange) bool {
if len(changes) == 0 || timeJumped {
return false
}
before, after := networkStateOrEmpty(beforeState), networkStateOrEmpty(afterState)
if before.DefaultRouteInterface != after.DefaultRouteInterface {
return false
}
if before.HaveV4 != after.HaveV4 || before.HaveV6 != after.HaveV6 {
return false
}
source4, source6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
for _, change := range changes {
if !noiseClass(change.Class) || change.IsDefaultRoute {
return false
}
if carriesHostTraffic(change.IPsBefore, source4, source6) {
return false
}
if carriesHostTraffic(change.IPsAfter, source4, source6) {
return false
}
}
return true
}
// noiseClass names the classes that change on their own. The kernel moves the
// link-local address of an AirDrop or a container adapter every few seconds.
func noiseClass(class string) bool {
return class == "airdrop" || class == "virtual"
}
// carriesHostTraffic reports an address list that reaches another host. A
// bridge, a bond, or a tunnel adapter can own the traffic of the host, so a
// routable address or the resolver source address makes the interface real
// whatever its name says.
func carriesHostTraffic(ips []string, source4, source6 net.IP) bool {
for _, ip := range ips {
prefix, err := netip.ParsePrefix(ip)
if err != nil {
continue
}
addr := prefix.Addr()
if addr.IsGlobalUnicast() || sameAddress(addr, source4) || sameAddress(addr, source6) {
return true
}
}
return false
}
// sameAddress compares one interface address with a resolver source address.
func sameAddress(addr netip.Addr, ip net.IP) bool {
source, ok := netip.AddrFromSlice(ip)
return ok && source.Unmap() == addr
}
// describeInterfaceChanges fills the platform fields of the changes that reach
// the journal. macOS reads them with two subprocesses, so only a delta that
// the daemon acts on pays for them. The platform class wins where it knows
// one, because a name rule cannot see a virtual adapter on Windows.
func describeInterfaceChanges(changes []interfaceChange, meta interfaceMetaFunc) {
if meta == nil {
return
}
for i := range changes {
info := meta(changes[i].Name)
changes[i].HardwarePort, changes[i].Service = info.HardwarePort, info.Service
if info.Class != "" {
changes[i].Class = info.Class
}
}
}
// interfaceAction names the first difference that the journal reports. The
// order runs from the coarse change to the fine one, so one interface gives
// one action.
func interfaceAction(before, after interfaceSnapshot) string {
switch {
case before.present && !after.present:
return "removed"
case !before.present && after.present:
return "added"
case before.up && !after.up:
return "down"
case !before.up && after.up:
return "up"
case hasExtraEntry(before.ips, after.ips):
return "ip_removed"
case hasExtraEntry(after.ips, before.ips):
return "ip_added"
case before.mac != after.mac:
return "mac_changed"
case before.mtu != after.mtu:
return "mtu_changed"
default:
return ""
}
}
func readInterfaceSnapshot(state *netmon.State, name string) interfaceSnapshot {
snapshot := interfaceSnapshot{ips: sortedPrefixStrings(state.InterfaceIPs[name])}
iface, ok := state.Interface[name]
if !ok {
return snapshot
}
snapshot.present = true
if iface.Interface == nil {
return snapshot
}
snapshot.up = iface.IsUp()
snapshot.mtu = iface.MTU
snapshot.flags = iface.Flags.String()
if len(iface.HardwareAddr) > 0 {
snapshot.mac = iface.HardwareAddr.String()
}
return snapshot
}
func interfaceNameUnion(before, after *netmon.State) []string {
names := make([]string, 0, len(before.Interface)+len(after.Interface))
for name := range before.Interface {
names = append(names, name)
}
for name := range after.Interface {
if _, both := before.Interface[name]; !both {
names = append(names, name)
}
}
slices.Sort(names)
return names
}
func sortedPrefixStrings(prefixes []netip.Prefix) []string {
ips := prefixStrings(prefixes)
slices.Sort(ips)
return ips
}
// networkStateOrEmpty keeps the diff free of nil checks. The first delta after
// start carries no old state.
func networkStateOrEmpty(state *netmon.State) *netmon.State {
if state != nil {
return state
}
return &netmon.State{}
}
func hasInterfacePrefix(name string, prefixes ...string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// hasExtraEntry reports an entry of list that other does not hold.
func hasExtraEntry(list, other []string) bool {
for _, entry := range list {
if !slices.Contains(other, entry) {
return true
}
}
return false
}