Files
ctrld/cmd/cli/network_transition.go
T
Cuong Manh Le b6d5abfae8 cli: hold NAT64 discovery while the OS resolver predates a network change
After a network change, the OS resolver keeps the previous network's
nameservers until a read finds the new ones. In Standard mode that
usually waits for the recovery to remove ctrld's DNS settings: while
127.0.0.1 is configured, the system reports nothing else, and on macOS
an RDNSS-only IPv6 network has no other source. macOS resolves
ipv4only.arpa through ctrld right after the change to discover the
NAT64 prefix; a fast SERVFAIL from the stale set ends that discovery,
macOS never starts its CLAT, and an IPv4-only upstream never recovers.

On every accepted network change, on all platforms and in both Standard
and intercept mode, mark the OS resolver stale and read the nameservers
once before the re-bootstrap, without the scutil retry loop. While the
set is stale, a failed ipv4only.arpa AAAA lookup gets no reply, so the
host retries it. A read that finds nameservers clears the mark, and the
mark expires after 30s.

Network-change recovery also re-reads the nameservers right after the
reset and then at 1s, 2s, 5s and every 30s while it waits, for networks
that publish their resolvers late.
2026-10-20 17:23:47 +07:00

271 lines
8.7 KiB
Go

package cli
import (
"context"
"errors"
"net"
"net/netip"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"tailscale.com/net/netmon"
)
type networkChangeMonitor interface {
Close() error
RegisterChangeCallback(netmon.ChangeFunc) func()
IsMajorChangeFrom(*netmon.State, *netmon.State) bool
Start()
}
// Tests replace these functions to avoid changes to host DNS and PF state.
var (
newNetworkChangeMonitorFn = func(logf func(string, ...any)) (networkChangeMonitor, error) {
return netmon.New(logf)
}
networkChangeCurrentStateFn = func(delta *netmon.ChangeDelta) *netmon.State {
if delta.Monitor != nil {
return delta.Monitor.InterfaceState()
}
return delta.New
}
readNetworkSourceStateFn = readNetworkSourceState
networkChangeValidInterfacesFn = validInterfacesMap
networkChangeDefaultRouteIPFn = defaultRouteIP
networkChangeReconcileFn = (*prog).reconcileNetworkChange
networkChangeIgnoredInterceptFn = (*prog).handleDNSInterceptIgnoredNetworkChange
handleRecoveryForTransitionFn = (*prog).handleRecoveryForTransition
recoveryResetDNSFn = (*prog).resetDNS
recoverySetDNSFn = (*prog).setDNS
networkChangeReadOSResolverFn = (*prog).readOSResolverOnNetworkChange
)
// Minor callbacks have no observation sequence in netmon v1.74.0. Read only
// current interface addresses and flags rather than trusting their arrival order.
func readNetworkSourceState() (*netmon.State, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
return networkSourceState(interfaces, (*net.Interface).Addrs)
}
// networkSourceState builds the state of the given interfaces. The address
// reader is a parameter, so a test feeds addresses without a host network.
func networkSourceState(interfaces []net.Interface, addressesOf func(*net.Interface) ([]net.Addr, error)) (*netmon.State, error) {
state := &netmon.State{Interface: map[string]netmon.Interface{}, InterfaceIPs: map[string][]netip.Prefix{}}
for _, iface := range interfaces {
addresses, err := addressesOf(&iface)
if err != nil {
return nil, err
}
state.Interface[iface.Name] = netmon.Interface{Interface: &iface}
for _, address := range addresses {
prefix, err := interfaceAddressPrefix(address)
if err != nil {
return nil, err
}
state.InterfaceIPs[iface.Name] = append(state.InterfaceIPs[iface.Name], prefix)
}
}
return state, nil
}
// interfaceAddressPrefix turns one interface address into a prefix. An address
// with a subnet mask keeps the mask and its host bits, so a fresh state shows
// the same /24 or /64 as the network monitor. An address without a mask is a
// host prefix.
func interfaceAddressPrefix(address net.Addr) (netip.Prefix, error) {
var ip net.IP
var mask net.IPMask
switch address := address.(type) {
case *net.IPNet:
ip, mask = address.IP, address.Mask
case *net.IPAddr:
ip = address.IP
default:
return netip.Prefix{}, errors.New("unsupported interface address type")
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return netip.Prefix{}, errors.New("invalid interface address")
}
addr = addr.Unmap()
return netip.PrefixFrom(addr, prefixBits(addr, mask)), nil
}
// prefixBits reads the length of a mask for an unmapped address. The mask of
// an IPv4 address is 4 or 16 bytes long, and a 16-byte mask counts the 96
// bits of the IPv6 mapping too. A missing or irregular mask means a host
// prefix.
func prefixBits(addr netip.Addr, mask net.IPMask) int {
bits, total := mask.Size()
if total == 0 {
return addr.BitLen()
}
if addr.Is4() && total == 128 {
bits -= 96
}
if bits < 0 || bits > addr.BitLen() {
return addr.BitLen()
}
return bits
}
// netmon v1.74.0 caches only major snapshots. Minor and time-jump events
// belong to their Old snapshot, not their distinct New snapshot.
func networkSnapshotCurrent(delta *netmon.ChangeDelta, major bool, current *netmon.State) bool {
if major || delta.Monitor == nil {
return current == delta.New
}
return current == delta.Old
}
// Callers hold networkSourceMu. Preserve minor changes within the current
// major epoch, but use a new major snapshot even before its callback runs.
func (p *prog) sourceCommitState(delta *netmon.ChangeDelta) *netmon.State {
if delta.Monitor != nil {
if current := networkChangeCurrentStateFn(delta); current != p.networkSourceEpoch {
return current
}
}
return p.networkSourceState
}
// sourceInvalidReason considers all up interfaces, not just hardware ports: a
// preferred source remains valid if it moved to another active interface.
func sourceInvalidReason(state *netmon.State, ip net.IP) string {
if ip == nil || state == nil {
return ""
}
reason := "address_removed"
for name, prefixes := range state.InterfaceIPs {
for _, prefix := range prefixes {
if !ip.Equal(net.ParseIP(prefix.Addr().String())) {
continue
}
iface, exists := state.Interface[name]
if exists && iface.IsUp() {
return ""
}
if exists {
reason = "interface_down"
} else if reason != "interface_down" {
reason = "interface_removed"
}
}
}
return reason
}
func validateDefaultLocalIPsFromDelta(state *netmon.State, transitionID uint64) {
before4, before6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
reason4, reason6 := sourceInvalidReason(state, before4), sourceInvalidReason(state, before6)
if reason4 != "" {
ctrld.SetDefaultLocalIPv4(nil)
}
if reason6 != "" {
ctrld.SetDefaultLocalIPv6(nil)
}
if reason4 == "" && reason6 == "" {
return
}
// One warning per actual invalidation, not per notification or query. The
// normal warning buffer retains this summary when debug traffic rotates.
mainLog.Load().Warn().Uint64("transition_id", transitionID).
Str("default_route", state.DefaultRouteInterface).
Str("source_ipv4_before", before4.String()).Str("source_ipv6_before", before6.String()).
Str("source_ipv4_after", ctrld.GetDefaultLocalIPv4().String()).
Str("source_ipv6_after", ctrld.GetDefaultLocalIPv6().String()).
Str("ipv4_clear_reason", reason4).Str("ipv6_clear_reason", reason6).
Msg("Removed stale resolver source")
}
type recoveryDiagnostic struct {
transitionID uint64
generation uint64
reason RecoveryReason
started time.Time
firstFailure sync.Once
failed atomic.Bool
// The flow fills these while it runs. The end event reports them, so one
// journal line tells what the recovery found and what it changed.
recoveredUpstream string
dhcpServers []string
interceptTargetAction string
bypassActive bool
}
func recoveryReasonName(reason RecoveryReason) string {
switch reason {
case RecoveryReasonNetworkChange:
return "network_change"
case RecoveryReasonRegularFailure:
return "upstream_failure"
case RecoveryReasonOSFailure:
return "os_resolver_failure"
default:
return "unknown"
}
}
func (d *recoveryDiagnostic) event(e *zerolog.Event) *zerolog.Event {
return e.Uint64("transition_id", d.transitionID).Uint64("recovery_generation", d.generation).
Str("recovery_reason", recoveryReasonName(d.reason))
}
func (d *recoveryDiagnostic) failure(err error) {
d.firstFailure.Do(func() {
d.failed.Store(true)
// Classify only; resolver errors can contain queried names or endpoints.
failure := "upstream_error"
var networkError net.Error
if errors.Is(err, syscall.EADDRNOTAVAIL) {
failure = "source_unavailable"
} else if ctrldnet.IsUnreachable(err) {
failure = "network_unreachable"
} else if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkError) && networkError.Timeout()) {
failure = "timeout"
}
d.event(mainLog.Load().Warn()).Str("failure", failure).
Msg("Recovery waiting for upstream; first failure")
})
}
// The outcomes that one recovery pass ends with.
const (
recoveryOutcomeCompleted = "completed"
recoveryOutcomeCanceled = "canceled"
recoveryOutcomeSuperseded = "superseded"
)
// journalRecoveredUpstream bounds the upstream name of the end event. An empty
// name means that no upstream recovered, so it stays empty.
func journalRecoveredUpstream(upstream string) string {
if upstream == "" {
return ""
}
return journalUpstreamName(upstream)
}
func (d *recoveryDiagnostic) end(outcome string) {
e := mainLog.Load().Info()
if outcome == recoveryOutcomeCanceled || d.failed.Load() {
e = mainLog.Load().Warn()
}
journal(d.event(e)).Str("outcome", outcome).Bool("had_failure", d.failed.Load()).
Dur("duration_ms", time.Since(d.started)).
Str("recovered_upstream", journalRecoveredUpstream(d.recoveredUpstream)).
Strs("dhcp_servers", d.dhcpServers).
Int("dhcp_server_count", len(d.dhcpServers)).
Str("intercept_target_action", d.interceptTargetAction).
Bool("bypass_active", d.bypassActive).
Msg("Recovery end")
}