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

227 lines
7.4 KiB
Go

package cli
import (
"net"
"reflect"
"slices"
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// networkSnapshotMessage is the message of the snapshot event. A log tool
// selects every snapshot of a run by this message.
const networkSnapshotMessage = "Network snapshot"
// snapshotTriggerStart names the snapshot of the start of a run.
const snapshotTriggerStart = "start"
// snapshotMinInterval bounds the snapshots of a flapping network.
const snapshotMinInterval = 60 * time.Second
// snapshotNowFn is the clock of the snapshot limit. A test replaces it.
var snapshotNowFn = time.Now
// defaultRoutesFn reads the default route of each family. A test replaces it,
// because the route table of the host is not a fixture.
var defaultRoutesFn = defaultRoutes
// platformInterfaceMetaFn reads the platform names of one interface. A test
// replaces it, because the ports of the host are not a fixture.
var platformInterfaceMetaFn = platformInterfaceMeta
// The delta diff has to know the virtual adapters of the host, and only the
// platform file reads them.
func init() {
virtualInterfaceSetFn = platformVirtualInterfaces
}
// interfaceMetaFor describes one interface for the snapshot and for the delta
// diff.
func interfaceMetaFor(name string) interfaceMeta {
platformClass, hardwarePort, service := platformInterfaceMetaFn(name)
meta := interfaceMeta{
Class: interfaceClassFor(name, platformClass),
HardwarePort: hardwarePort,
Service: service,
}
meta.LinkType = linkTypeFor(hardwarePort, meta.Class)
return meta
}
// interfaceClassFor picks the class of one interface. A name rule wins for the
// loopback, the AirDrop, and the tunnel interfaces, because Windows describes
// a tunnel adapter as an Ethernet adapter.
func interfaceClassFor(name, platformClass string) string {
if class := interfaceClass(name, false); class != "hardware" {
return class
}
if platformClass != "" {
return platformClass
}
if _, virtual := virtualInterfaceSetFn()[name]; virtual {
return "virtual"
}
return "hardware"
}
// snapshotInputs reads the sources of one snapshot from the network that the
// last monitor callback reported. No callback ran before the first header of a
// run, so that header reads the network itself.
func (p *prog) snapshotInputs() snapshotInputs {
state := p.lastNetworkState.Load()
if state == nil {
state = freshNetworkState()
}
return p.snapshotInputsFor(state)
}
// freshSnapshotInputs reads the sources of one snapshot from the network of
// this moment. Support reads the header of an upload to learn the network of
// the moment the user asked, which can differ from the last callback.
func (p *prog) freshSnapshotInputs() snapshotInputs {
return p.snapshotInputsFor(freshNetworkState())
}
// networkSnapshot describes the host network of this moment.
func (p *prog) networkSnapshot() networkSnapshot {
return buildNetworkSnapshot(p.snapshotInputs())
}
// logNetworkSnapshot puts the host network in the journal. Support reads the
// snapshot of each trigger to learn the network at the time of an outage.
func (p *prog) logNetworkSnapshot(trigger string) {
snapshot := p.networkSnapshot()
if !p.snapshotWrites.allow(trigger, snapshot, snapshotNowFn()) {
return
}
journal(mainLog.Load().Info()).
Str("trigger", trigger).
Dict("network", snapshotDict(snapshot)).
Msg(networkSnapshotMessage)
}
// snapshotLimiter holds the snapshot that reached the journal last, so a burst
// of triggers writes one line.
type snapshotLimiter struct {
mu sync.Mutex
last networkSnapshot
written time.Time
seen bool
}
// allow reports whether one snapshot reaches the journal, and records the
// snapshots that do.
func (s *snapshotLimiter) allow(trigger string, snapshot networkSnapshot, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.permits(trigger, snapshot, now) {
return false
}
s.last, s.written, s.seen = snapshot, now, true
return true
}
// permits decides one snapshot. The first snapshot of a run tells a reader
// where the run started. A snapshot equal to the one before it carries
// nothing. A new default route and a new resolver set explain an outage, so
// they pass at once. Every other snapshot waits out the interval, because a
// flapping interface writes some kilobytes per flap.
func (s *snapshotLimiter) permits(trigger string, snapshot networkSnapshot, now time.Time) bool {
if trigger == snapshotTriggerStart || !s.seen {
return true
}
if sameNetworkSnapshot(s.last, snapshot) {
return false
}
if routeOrResolversChanged(s.last, snapshot) {
return true
}
return now.Sub(s.written) >= snapshotMinInterval
}
// sameNetworkSnapshot compares two snapshots field by field. The trigger is no
// part of a snapshot, so two triggers of one network give one line.
func sameNetworkSnapshot(before, after networkSnapshot) bool {
return reflect.DeepEqual(before, after)
}
// routeOrResolversChanged names the changes that a reader of an outage needs
// at once.
func routeOrResolversChanged(before, after networkSnapshot) bool {
return before.DefaultRouteV4 != after.DefaultRouteV4 ||
before.DefaultRouteV6 != after.DefaultRouteV6 ||
!slices.Equal(before.Resolvers, after.Resolvers)
}
// snapshotInputsFor reads every source of one snapshot. It takes no writer
// lock, so any path of the daemon can log a snapshot.
func (p *prog) snapshotInputsFor(state *netmon.State) snapshotInputs {
routeV4, routeV6 := defaultRoutesFn()
inputs := snapshotInputs{
State: state,
RouteV4: routeV4,
RouteV6: routeV6,
Resolvers: ctrld.OsResolverNameservers(),
SourceIPv4: addressString(ctrld.GetDefaultLocalIPv4()),
SourceIPv6: addressString(ctrld.GetDefaultLocalIPv6()),
InterceptTarget: p.interceptTargetSnapshot(),
BypassActive: p.recoveryBypass.Load(),
RecoveryRunning: p.recoveryRunning.Load(),
PFStabilizing: p.pfStabilizing.Load(),
Meta: interfaceMetaFor,
}
if prefix, ok := p.dns64PrefixSnapshot(); ok {
inputs.NAT64Prefix = prefix.String()
}
if state != nil {
inputs.CLATPresent = dns64StateHasCLAT(state)
}
return inputs
}
// freshNetworkState reads the interfaces of the host now. netmon fills the
// route and the families of a callback state, so a read of this package has to
// fill them itself.
func freshNetworkState() *netmon.State {
state, err := readNetworkSourceStateFn()
if err != nil || state == nil {
return nil
}
fresh := *state
if route, err := netmon.DefaultRouteInterface(); err == nil {
fresh.DefaultRouteInterface = route
}
fresh.HaveV4, fresh.HaveV6 = reachableAddressFamilies(&fresh)
return &fresh
}
// reachableAddressFamilies reports which families the host can reach. It
// grades each address with the rule of the network monitor, so the first
// header of a run reports the same families as the first callback.
func reachableAddressFamilies(state *netmon.State) (haveV4, haveV6 bool) {
for name, iface := range state.Interface {
if iface.Interface == nil || !iface.IsUp() {
continue
}
for _, prefix := range state.InterfaceIPs[name] {
address := prefix.Addr().Unmap()
haveV4 = haveV4 || usableV4(address)
haveV6 = haveV6 || usableV6(address)
}
}
return haveV4, haveV6
}
// addressString names one source address. An address that ctrld does not hold
// leaves an empty field, because net.IP prints an empty value as the word for
// a nil address.
func addressString(ip net.IP) string {
if len(ip) == 0 {
return ""
}
return ip.String()
}