Remove interface enumeration

Enumerating the interfaces is not free, especially on Windows where every
address lookup goes through the IP Helper service, so we sample slowly.

Losing a minute before rotating our client key after a network change is
harmless; burning CPU cycles every few seconds on an idle machine is not.
This commit is contained in:
Frank Denis
2026-08-06 23:00:58 +02:00
parent c3ba78fac8
commit 504c287c19
2 changed files with 86 additions and 323 deletions
+8 -109
View File
@@ -14,11 +14,7 @@ import (
"github.com/jedisct1/dlog"
)
const (
defaultNetworkMonitorInterval = 5 * time.Second
maxNetworkMonitorBackoff = 30 * time.Second
offlineNetworkFingerprint = "offline"
)
const defaultNetworkMonitorInterval = 60 * time.Second
type networkInterfaceSnapshot struct {
Name string
@@ -29,35 +25,17 @@ type networkInterfaceSnapshot struct {
Addrs []*net.IPNet
}
type networkMonitorProbeState struct {
address string
retryAt time.Time
backoff time.Duration
}
type networkMonitor struct {
epochValue atomic.Uint64
sampling atomic.Bool
initialized bool
lastInterfaces string
lastRoutes string
probes [2]networkMonitorProbeState
now func() time.Time
interfaces func() ([]networkInterfaceSnapshot, error)
probe func(string) (net.IP, error)
onChange func()
}
func newNetworkMonitor() *networkMonitor {
return &networkMonitor{
probes: [2]networkMonitorProbeState{
{address: "192.0.2.1:9"},
{address: "[2001:db8::1]:9"},
},
now: time.Now,
interfaces: snapshotNetworkInterfaces,
probe: probeNetworkMonitorLocalIP,
}
return &networkMonitor{interfaces: snapshotNetworkInterfaces}
}
func (monitor *networkMonitor) epoch() uint64 {
@@ -120,27 +98,13 @@ func (monitor *networkMonitor) check() {
func (monitor *networkMonitor) sample(notify bool) {
interfaces, err := monitor.interfaces()
interfacesChanged := false
if err == nil {
interfaceFingerprint := buildNetworkInterfaceFingerprint(interfaces)
interfacesChanged = monitor.lastInterfaces != "" && monitor.lastInterfaces != interfaceFingerprint
monitor.lastInterfaces = interfaceFingerprint
if interfacesChanged {
for i := range monitor.probes {
monitor.probes[i].retryAt = time.Time{}
monitor.probes[i].backoff = 0
}
if notify {
monitor.notifyNetworkChange()
}
}
if err != nil {
return
}
localIPs := monitor.discoverLocalIPs(monitor.now())
routeFingerprint := buildNetworkFingerprint(localIPs)
routesChanged := monitor.lastRoutes != "" && monitor.lastRoutes != routeFingerprint
monitor.lastRoutes = routeFingerprint
if notify && routesChanged && !interfacesChanged {
fingerprint := buildNetworkInterfaceFingerprint(interfaces)
changed := monitor.lastInterfaces != "" && monitor.lastInterfaces != fingerprint
monitor.lastInterfaces = fingerprint
if changed && notify {
monitor.notifyNetworkChange()
}
}
@@ -153,53 +117,6 @@ func (monitor *networkMonitor) notifyNetworkChange() {
}
}
func (monitor *networkMonitor) discoverLocalIPs(now time.Time) []net.IP {
localIPs := make([]net.IP, 0, len(monitor.probes))
seen := make(map[string]struct{}, len(monitor.probes))
for i := range monitor.probes {
state := &monitor.probes[i]
if !state.retryAt.IsZero() && now.Before(state.retryAt) {
continue
}
ip, err := monitor.probe(state.address)
if err != nil {
if state.backoff == 0 {
state.backoff = defaultNetworkMonitorInterval
} else {
state.backoff = min(state.backoff*2, maxNetworkMonitorBackoff)
}
state.retryAt = now.Add(state.backoff)
continue
}
state.retryAt = time.Time{}
state.backoff = 0
if ip == nil || ip.IsUnspecified() {
continue
}
ip = append(net.IP(nil), ip...)
key := ip.String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
localIPs = append(localIPs, ip)
}
return localIPs
}
func probeNetworkMonitorLocalIP(address string) (net.IP, error) {
conn, err := net.DialTimeout("udp", address, time.Second)
if err != nil {
return nil, err
}
defer conn.Close()
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || localAddr.IP == nil || localAddr.IP.IsUnspecified() {
return nil, net.InvalidAddrError("missing local IP")
}
return append(net.IP(nil), localAddr.IP...), nil
}
func snapshotNetworkInterfaces() ([]networkInterfaceSnapshot, error) {
interfaces, err := net.Interfaces()
if err != nil {
@@ -299,24 +216,6 @@ func buildNetworkInterfaceFingerprint(interfaces []networkInterfaceSnapshot) str
return hashNetworkFingerprint(parts)
}
func buildNetworkFingerprint(localIPs []net.IP) string {
if len(localIPs) == 0 {
return offlineNetworkFingerprint
}
parts := make([]string, 0, len(localIPs))
for _, ip := range localIPs {
if ip == nil || ip.IsUnspecified() {
continue
}
ip = append(net.IP(nil), ip...)
parts = append(parts, "ip="+ip.String())
}
if len(parts) == 0 {
return offlineNetworkFingerprint
}
return hashNetworkFingerprint(parts)
}
func hashNetworkFingerprint(parts []string) string {
sort.Strings(parts)
h := sha256.New()
+78 -214
View File
@@ -5,7 +5,6 @@ import (
"net"
"slices"
"testing"
"time"
)
func TestNetworkInterfaceFingerprintIsStable(t *testing.T) {
@@ -112,275 +111,140 @@ func TestSnapshotNetworkAddressPreservesHost(t *testing.T) {
}
}
func TestNetworkMonitorPassiveChangeWakesProbesBeforeCallbackReturns(t *testing.T) {
now := time.Unix(1_000_000, 0)
func TestNetworkMonitorNotifiesOnInterfaceChange(t *testing.T) {
interfaces := testNetworkMonitorInterfaces(t)
failing := true
events := make([]string, 0, 3)
changes := 0
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) { return interfaces, nil }
monitor.probe = func(address string) (net.IP, error) {
if address == monitor.probes[0].address {
events = append(events, "ipv4")
if failing {
return nil, errors.New("IPv4 unavailable")
}
return net.ParseIP("192.168.1.10"), nil
}
events = append(events, "ipv6")
if failing {
return nil, errors.New("IPv6 unavailable")
}
return net.ParseIP("2001:db8:1::10"), nil
}
monitor.onChange = func() {
events = append(events, "change")
if got := monitor.epoch(); got != 1 {
t.Errorf("epoch in callback = %d, want 1", got)
changes++
if got := monitor.epoch(); got != uint64(changes) {
t.Errorf("epoch in callback = %d, want %d", got, changes)
}
monitor.check()
}
monitor.init()
now = now.Add(5 * time.Second)
monitor.check()
now = now.Add(5 * time.Second)
if changes != 0 || monitor.epoch() != 0 {
t.Fatalf("stable network produced %d callbacks and epoch %d", changes, monitor.epoch())
}
interfaces = append(cloneNetworkMonitorInterfaces(interfaces), networkInterfaceSnapshot{
Name: "vpn0",
Index: 8,
Flags: net.FlagUp | net.FlagPointToPoint,
Addrs: []*net.IPNet{testNetworkMonitorIPNet(t, "fe80::8/64")},
Addrs: []*net.IPNet{testNetworkMonitorIPNet(t, "10.8.0.2/24")},
})
failing = false
events = events[:0]
monitor.check()
wantEvents := []string{"change", "ipv4", "ipv6"}
if !slices.Equal(events, wantEvents) {
t.Fatalf("events = %v, want %v", events, wantEvents)
}
if got := monitor.epoch(); got != 1 {
t.Fatalf("epoch = %d, want 1", got)
}
now = now.Add(5 * time.Second)
monitor.check()
if got := monitor.epoch(); got != 1 {
t.Fatalf("stable check changed epoch to %d", got)
}
}
func TestNetworkMonitorBacksOffFailedFamilyIndependently(t *testing.T) {
testErrors := []struct {
name string
err error
}{
{"plain error", errors.New("unavailable")},
{"wrapped network error", &net.OpError{Op: "dial", Net: "udp", Err: errors.New("unavailable")}},
}
for _, testError := range testErrors {
t.Run(testError.name, func(t *testing.T) {
now := time.Unix(1_000_000, 0)
ipv4Calls := 0
ipv6Calls := 0
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
return testNetworkMonitorInterfaces(t), nil
}
monitor.probe = func(address string) (net.IP, error) {
if address == monitor.probes[0].address {
ipv4Calls++
return net.ParseIP("192.168.1.10"), nil
}
ipv6Calls++
return nil, testError.err
}
monitor.init()
for _, elapsed := range []time.Duration{5, 10, 15, 20, 35} {
now = time.Unix(1_000_000, 0).Add(elapsed * time.Second)
monitor.check()
}
if ipv4Calls != 6 {
t.Fatalf("IPv4 probes = %d, want 6", ipv4Calls)
}
if ipv6Calls != 4 {
t.Fatalf("IPv6 probes = %d, want 4", ipv6Calls)
}
})
}
}
func TestNetworkMonitorBackoffStopsAtThirtySeconds(t *testing.T) {
now := time.Unix(1_000_000, 0)
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
return testNetworkMonitorInterfaces(t), nil
}
monitor.probe = func(address string) (net.IP, error) {
if address == monitor.probes[0].address {
return net.ParseIP("192.168.1.10"), nil
}
return nil, errors.New("IPv6 unavailable")
}
monitor.init()
for _, want := range []time.Duration{
10 * time.Second,
20 * time.Second,
30 * time.Second,
30 * time.Second,
} {
now = monitor.probes[1].retryAt
monitor.check()
if got := monitor.probes[1].backoff; got != want {
t.Fatalf("backoff = %s, want %s", got, want)
}
if got := monitor.probes[1].retryAt.Sub(now); got != want {
t.Fatalf("retry delay = %s, want %s", got, want)
}
}
}
func TestNetworkMonitorKeepsProbingAfterInterfaceSnapshotError(t *testing.T) {
now := time.Unix(1_000_000, 0)
ipv6Failing := false
changes := 0
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
return nil, errors.New("interfaces unavailable")
}
monitor.probe = func(address string) (net.IP, error) {
if address == monitor.probes[0].address {
return net.ParseIP("192.168.1.10"), nil
}
if ipv6Failing {
return nil, errors.New("IPv6 unavailable")
}
return net.ParseIP("2001:db8:1::10"), nil
}
monitor.onChange = func() { changes++ }
monitor.init()
ipv6Failing = true
now = now.Add(5 * time.Second)
monitor.check()
if changes != 1 || monitor.epoch() != 1 {
t.Fatalf("active change produced %d callbacks and epoch %d", changes, monitor.epoch())
t.Fatalf("interface change produced %d callbacks and epoch %d", changes, monitor.epoch())
}
monitor.check()
if changes != 1 || monitor.epoch() != 1 {
t.Fatalf("stable check produced %d callbacks and epoch %d", changes, monitor.epoch())
}
}
func TestNetworkMonitorProbeRecovery(t *testing.T) {
now := time.Unix(1_000_000, 0)
ipv6Failing := false
ipv6Calls := 0
func TestNetworkMonitorFirstCheckOnlyRecordsBaseline(t *testing.T) {
interfaces := testNetworkMonitorInterfaces(t)
changes := 0
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
return testNetworkMonitorInterfaces(t), nil
}
monitor.probe = func(address string) (net.IP, error) {
if address == monitor.probes[0].address {
return net.ParseIP("192.168.1.10"), nil
}
ipv6Calls++
if ipv6Failing {
return nil, errors.New("IPv6 unavailable")
}
return net.ParseIP("2001:db8:1::10"), nil
}
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) { return interfaces, nil }
monitor.onChange = func() { changes++ }
monitor.init()
ipv6Failing = true
now = now.Add(5 * time.Second)
monitor.check()
now = now.Add(5 * time.Second)
monitor.check()
now = now.Add(5 * time.Second)
monitor.check()
if changes != 1 || ipv6Calls != 3 {
t.Fatalf("during failure: changes = %d, IPv6 probes = %d, want 1 and 3", changes, ipv6Calls)
if changes != 0 || monitor.epoch() != 0 {
t.Fatalf("first check produced %d callbacks and epoch %d", changes, monitor.epoch())
}
ipv6Failing = false
now = now.Add(5 * time.Second)
interfaces = cloneNetworkMonitorInterfaces(interfaces)
interfaces[0].Addrs[0].IP = net.ParseIP("192.168.1.20")
monitor.check()
if changes != 2 || ipv6Calls != 4 {
t.Fatalf("after recovery: changes = %d, IPv6 probes = %d, want 2 and 4", changes, ipv6Calls)
}
now = now.Add(5 * time.Second)
monitor.check()
if changes != 2 {
t.Fatalf("stable recovery changed callback count to %d", changes)
}
ipv6Failing = true
now = now.Add(5 * time.Second)
monitor.check()
now = now.Add(5 * time.Second)
monitor.check()
if changes != 3 || ipv6Calls != 7 {
t.Fatalf("after reset: changes = %d, IPv6 probes = %d, want 3 and 7", changes, ipv6Calls)
if changes != 1 || monitor.epoch() != 1 {
t.Fatalf("change after the baseline produced %d callbacks and epoch %d", changes, monitor.epoch())
}
}
func TestNetworkMonitorCoalescesFamilyChanges(t *testing.T) {
now := time.Unix(1_000_000, 0)
func TestNetworkMonitorIgnoresInterfaceSnapshotErrors(t *testing.T) {
interfaces := testNetworkMonitorInterfaces(t)
failing := false
changes := 0
monitor := newNetworkMonitor()
monitor.now = func() time.Time { return now }
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
return testNetworkMonitorInterfaces(t), nil
}
monitor.probe = func(address string) (net.IP, error) {
if failing {
return nil, errors.New("unavailable")
return nil, errors.New("interfaces unavailable")
}
if address == monitor.probes[0].address {
return net.ParseIP("192.168.1.10"), nil
}
return net.ParseIP("2001:db8:1::10"), nil
return interfaces, nil
}
monitor.onChange = func() { changes++ }
monitor.init()
monitor.init()
failing = true
now = now.Add(5 * time.Second)
monitor.check()
if changes != 0 || monitor.epoch() != 0 {
t.Fatalf("snapshot error produced %d callbacks and epoch %d", changes, monitor.epoch())
}
failing = false
monitor.check()
if changes != 0 || monitor.epoch() != 0 {
t.Fatalf("recovery from a snapshot error produced %d callbacks and epoch %d", changes, monitor.epoch())
}
// A failed enumeration keeps the last known fingerprint around, so a change
// that happens while we are blind is still reported once we can see again.
failing = true
monitor.check()
interfaces = cloneNetworkMonitorInterfaces(interfaces)
interfaces[0].Addrs[0].IP = net.ParseIP("192.168.1.20")
monitor.check()
failing = false
monitor.check()
if changes != 1 || monitor.epoch() != 1 {
t.Fatalf("simultaneous failure produced %d callbacks and epoch %d", changes, monitor.epoch())
t.Fatalf("change during a snapshot error produced %d callbacks and epoch %d", changes, monitor.epoch())
}
failing = false
now = now.Add(5 * time.Second)
}
func TestNetworkMonitorIgnoresReentrantChecks(t *testing.T) {
interfaces := testNetworkMonitorInterfaces(t)
snapshots := 0
changes := 0
monitor := newNetworkMonitor()
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
snapshots++
return interfaces, nil
}
monitor.onChange = func() {
changes++
monitor.check()
}
monitor.init()
interfaces = cloneNetworkMonitorInterfaces(interfaces)
interfaces[0].Addrs[0].IP = net.ParseIP("192.168.1.20")
monitor.check()
if changes != 2 || monitor.epoch() != 2 {
t.Fatalf("simultaneous recovery produced %d callbacks and epoch %d", changes, monitor.epoch())
if changes != 1 || monitor.epoch() != 1 {
t.Fatalf("reentrant check produced %d callbacks and epoch %d", changes, monitor.epoch())
}
if snapshots != 2 {
t.Fatalf("interfaces were sampled %d times, want 2", snapshots)
}
}
func TestNetworkMonitorInitializationIsIdempotent(t *testing.T) {
probeCalls := 0
snapshots := 0
callbackCalls := 0
monitor := newNetworkMonitor()
monitor.interfaces = func() ([]networkInterfaceSnapshot, error) {
snapshots++
return testNetworkMonitorInterfaces(t), nil
}
monitor.probe = func(string) (net.IP, error) {
probeCalls++
return nil, errors.New("unavailable")
}
monitor.onChange = func() { callbackCalls++ }
monitor.init()
monitor.init()
if probeCalls != 2 {
t.Fatalf("initialization made %d probes, want 2", probeCalls)
if snapshots != 1 {
t.Fatalf("initialization sampled the interfaces %d times, want 1", snapshots)
}
if callbackCalls != 0 || monitor.epoch() != 0 {
t.Fatalf("initialization produced %d callbacks and epoch %d", callbackCalls, monitor.epoch())