mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 12:16:44 +02:00
Investigating battery costs on a busy tailnet I noticed a large number of nodes regularly reconnecting to control and DERP. In one case I was able to analyze closely `pmset` reported the every-minute wake-ups being triggered by bluetooth. The node was by side effect reconnecting to control constantly, and this was at times visible to peers as well. Three changes here improve the situation: - Short time jumps (less than 10 minutes) no longer produce "major network change" events, and so do not trigger full rebind/reconnect. - Many "incidental" fields on interfaces are ignored, like MTU, flags and so on - if the route is still good, the rest should be manageable. - Additional log output will provide more detail about the cause of major network change events. Updates #3363 Signed-off-by: James Tucker <james@tailscale.com>
876 lines
21 KiB
Go
876 lines
21 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package netmon
|
|
|
|
import (
|
|
"flag"
|
|
"net"
|
|
"net/netip"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/util/eventbus"
|
|
"tailscale.com/util/eventbus/eventbustest"
|
|
"tailscale.com/util/mak"
|
|
)
|
|
|
|
func TestMonitorStartClose(t *testing.T) {
|
|
bus := eventbus.New()
|
|
defer bus.Close()
|
|
|
|
mon, err := New(bus, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mon.Start()
|
|
if err := mon.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMonitorJustClose(t *testing.T) {
|
|
bus := eventbus.New()
|
|
defer bus.Close()
|
|
|
|
mon, err := New(bus, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mon.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMonitorInjectEvent(t *testing.T) {
|
|
bus := eventbus.New()
|
|
defer bus.Close()
|
|
|
|
mon, err := New(bus, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer mon.Close()
|
|
got := make(chan bool, 1)
|
|
mon.RegisterChangeCallback(func(*ChangeDelta) {
|
|
select {
|
|
case got <- true:
|
|
default:
|
|
}
|
|
})
|
|
mon.Start()
|
|
mon.InjectEvent()
|
|
select {
|
|
case <-got:
|
|
// Pass.
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timeout waiting for callback")
|
|
}
|
|
}
|
|
|
|
func TestMonitorInjectEventOnBus(t *testing.T) {
|
|
bus := eventbustest.NewBus(t)
|
|
|
|
mon, err := New(bus, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer mon.Close()
|
|
tw := eventbustest.NewWatcher(t, bus)
|
|
|
|
mon.Start()
|
|
mon.InjectEvent()
|
|
if err := eventbustest.Expect(tw, eventbustest.Type[ChangeDelta]()); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
var (
|
|
monitor = flag.String("monitor", "", `go into monitor mode like 'route monitor'; test never terminates. Value can be either "raw" or "callback"`)
|
|
monitorDuration = flag.Duration("monitor-duration", 0, "if non-zero, how long to run TestMonitorMode. Zero means forever.")
|
|
)
|
|
|
|
func TestMonitorMode(t *testing.T) {
|
|
switch *monitor {
|
|
case "":
|
|
t.Skip("skipping non-test without --monitor")
|
|
case "raw", "callback", "eventbus":
|
|
default:
|
|
t.Skipf(`invalid --monitor value: must be "raw", "callback" or "eventbus"`)
|
|
}
|
|
|
|
bus := eventbustest.NewBus(t)
|
|
tw := eventbustest.NewWatcher(t, bus)
|
|
|
|
mon, err := New(bus, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch *monitor {
|
|
case "raw":
|
|
var closed atomic.Bool
|
|
if *monitorDuration != 0 {
|
|
t := time.AfterFunc(*monitorDuration, func() {
|
|
closed.Store(true)
|
|
mon.Close()
|
|
})
|
|
defer t.Stop()
|
|
}
|
|
for {
|
|
msg, err := mon.om.Receive()
|
|
if closed.Load() {
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("msg: %#v", msg)
|
|
}
|
|
case "callback":
|
|
var done <-chan time.Time
|
|
if *monitorDuration != 0 {
|
|
t := time.NewTimer(*monitorDuration)
|
|
defer t.Stop()
|
|
done = t.C
|
|
}
|
|
n := 0
|
|
mon.RegisterChangeCallback(func(d *ChangeDelta) {
|
|
n++
|
|
t.Logf("cb: changed=%v, ifSt=%v", d.RebindLikelyRequired, d.CurrentState())
|
|
})
|
|
mon.Start()
|
|
<-done
|
|
t.Logf("%v callbacks", n)
|
|
case "eventbus":
|
|
time.AfterFunc(*monitorDuration, bus.Close)
|
|
n := 0
|
|
mon.Start()
|
|
eventbustest.Expect(tw, func(event *ChangeDelta) (bool, error) {
|
|
n++
|
|
t.Logf("cb: changed=%v, ifSt=%v", event.RebindLikelyRequired, event.CurrentState())
|
|
return false, nil // Return false, indicating we wanna look for more events
|
|
})
|
|
t.Logf("%v events", n)
|
|
}
|
|
}
|
|
|
|
// tests (*ChangeDelta).RebindRequired
|
|
func TestRebindRequired(t *testing.T) {
|
|
// s1 must not be nil by definition
|
|
tests := []struct {
|
|
name string
|
|
s1, s2 *State
|
|
tsIfName string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil_mix",
|
|
s2: new(State),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "eq",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "new-with-no-addr",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
"bar": {},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "ignore-tailscale-interface-appearing",
|
|
tsIfName: "tailscale0",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
"tailscale0": {netip.MustParsePrefix("100.69.4.20/32")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "ignore-tailscale-interface-disappearing",
|
|
tsIfName: "tailscale0",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
"tailscale0": {netip.MustParsePrefix("100.69.4.20/32")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "new-with-multicast-addr",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
"bar": {netip.MustParsePrefix("224.0.0.1/32")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "old-with-addr-dropped",
|
|
s1: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/32")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/32")},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "old-with-no-addr-dropped",
|
|
s1: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {},
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/16")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "default-route-changed",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "some-interesting-ip-changed",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.3/16")},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
// (barnstar) TODO: ULA addresses are only useful in some contexts,
|
|
// so maybe this shouldn't trigger rebinds after all? Needs more thought.
|
|
name: "ipv6-ula-addressed-appeared",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {
|
|
netip.MustParsePrefix("10.0.1.2/16"),
|
|
netip.MustParsePrefix("fd15:bbfa:c583:4fce:f4fb:4ff:fe1a:4148/64"),
|
|
},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
// (barnstar) TODO: ULA addresses are only useful in some contexts,
|
|
// so maybe this shouldn't trigger rebinds after all? Needs more thought.
|
|
name: "ipv6-ula-addressed-disappeared",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {
|
|
netip.MustParsePrefix("10.0.1.2/16"),
|
|
netip.MustParsePrefix("fd15:bbfa:c583:4fce:f4fb:4ff:fe1a:4148/64"),
|
|
},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "ipv6-link-local-addressed-appeared",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {netip.MustParsePrefix("10.0.1.2/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {
|
|
netip.MustParsePrefix("10.0.1.2/16"),
|
|
netip.MustParsePrefix("fe80::f242:25ff:fe64:b280/64"),
|
|
},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "ipv6-addressed-changed",
|
|
s1: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {
|
|
netip.MustParsePrefix("10.0.1.2/16"),
|
|
netip.MustParsePrefix("2001::f242:25ff:fe64:b280/64"),
|
|
netip.MustParsePrefix("fe80::f242:25ff:fe64:b280/64"),
|
|
},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "foo",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"foo": {
|
|
netip.MustParsePrefix("10.0.1.2/16"),
|
|
netip.MustParsePrefix("2001::beef:8bad:f00d:b280/64"),
|
|
netip.MustParsePrefix("fe80::f242:25ff:fe64:b280/64"),
|
|
},
|
|
},
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "have-addr-changed",
|
|
s1: &State{
|
|
HaveV6: false,
|
|
HaveV4: false,
|
|
},
|
|
|
|
s2: &State{
|
|
HaveV6: true,
|
|
HaveV4: true,
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "have-addr-unchanged",
|
|
s1: &State{
|
|
HaveV6: true,
|
|
HaveV4: true,
|
|
},
|
|
|
|
s2: &State{
|
|
HaveV6: true,
|
|
HaveV4: true,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "new-is-less-expensive",
|
|
s1: &State{
|
|
IsExpensive: true,
|
|
},
|
|
|
|
s2: &State{
|
|
IsExpensive: false,
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "new-is-more-expensive",
|
|
s1: &State{
|
|
IsExpensive: false,
|
|
},
|
|
|
|
s2: &State{
|
|
IsExpensive: true,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "uninteresting-interface-added",
|
|
s1: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/16")},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "bar",
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"bar": {netip.MustParsePrefix("192.168.0.1/16")},
|
|
"boring": {netip.MustParsePrefix("fd7a:115c:a1e0:ab12:4843:cd96:625e:13ce/64")},
|
|
},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "interface-flags-changed-no-ip-change",
|
|
s1: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast, // FlagRunning removed
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "interface-mtu-changed-no-ip-change",
|
|
s1: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
MTU: 1500,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
MTU: 9000,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "interface-went-down",
|
|
s1: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagBroadcast | net.FlagMulticast, // FlagUp removed
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
},
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
withIsInterestingInterface(t, func(ni Interface, pfxs []netip.Prefix) bool {
|
|
return !strings.HasPrefix(ni.Name, "boring")
|
|
})
|
|
saveAndRestoreTailscaleIfaceProps(t)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
// Populate dummy interfaces where missing.
|
|
for _, s := range []*State{tt.s1, tt.s2} {
|
|
if s == nil {
|
|
continue
|
|
}
|
|
for name := range s.InterfaceIPs {
|
|
if _, ok := s.Interface[name]; !ok {
|
|
mak.Set(&s.Interface, name, Interface{Interface: &net.Interface{
|
|
Name: name,
|
|
}})
|
|
}
|
|
}
|
|
}
|
|
|
|
SetTailscaleInterfaceProps(tt.tsIfName, 1)
|
|
cd, err := NewChangeDelta(tt.s1, tt.s2, 0, true)
|
|
if err != nil {
|
|
t.Fatalf("NewChangeDelta error: %v", err)
|
|
}
|
|
_ = cd // in case we need it later
|
|
if got := cd.RebindLikelyRequired; got != tt.want {
|
|
t.Errorf("RebindRequired = %v; want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTimeJumpedDoesNotTriggerRebind(t *testing.T) {
|
|
s := &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
|
|
},
|
|
HaveV4: true,
|
|
}
|
|
|
|
// A short time jump (e.g., macOS DarkWake maintenance cycle ~55s)
|
|
// with unchanged network state should NOT trigger rebind.
|
|
cd, err := NewChangeDelta(s, s, 55*time.Second, true)
|
|
if err != nil {
|
|
t.Fatalf("NewChangeDelta error: %v", err)
|
|
}
|
|
if cd.RebindLikelyRequired {
|
|
t.Error("RebindLikelyRequired = true for short time jump with unchanged state; want false")
|
|
}
|
|
if !cd.TimeJumped() {
|
|
t.Error("TimeJumped = false; want true")
|
|
}
|
|
|
|
// A major time jump (>10m) with unchanged state SHOULD trigger rebind,
|
|
// because NAT mappings are likely stale.
|
|
cd2, err := NewChangeDelta(s, s, 2*time.Hour, true)
|
|
if err != nil {
|
|
t.Fatalf("NewChangeDelta error: %v", err)
|
|
}
|
|
if !cd2.RebindLikelyRequired {
|
|
t.Error("RebindLikelyRequired = false for major time jump (2h); want true")
|
|
}
|
|
|
|
// A short time jump with changed state SHOULD trigger rebind.
|
|
s2 := &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
|
|
}},
|
|
},
|
|
InterfaceIPs: map[string][]netip.Prefix{
|
|
"en0": {netip.MustParsePrefix("10.0.0.99/24")}, // IP changed
|
|
},
|
|
HaveV4: true,
|
|
}
|
|
|
|
saveAndRestoreTailscaleIfaceProps(t)
|
|
SetTailscaleInterfaceProps("", 0)
|
|
|
|
cd3, err := NewChangeDelta(s, s2, 55*time.Second, true)
|
|
if err != nil {
|
|
t.Fatalf("NewChangeDelta error: %v", err)
|
|
}
|
|
if !cd3.RebindLikelyRequired {
|
|
t.Error("RebindLikelyRequired = false for time jump with changed IP; want true")
|
|
}
|
|
}
|
|
|
|
func saveAndRestoreTailscaleIfaceProps(t *testing.T) {
|
|
t.Helper()
|
|
index, _ := TailscaleInterfaceIndex()
|
|
name, _ := TailscaleInterfaceName()
|
|
t.Cleanup(func() {
|
|
SetTailscaleInterfaceProps(name, index)
|
|
})
|
|
}
|
|
|
|
func withIsInterestingInterface(t *testing.T, fn func(Interface, []netip.Prefix) bool) {
|
|
t.Helper()
|
|
old := IsInterestingInterface
|
|
IsInterestingInterface = fn
|
|
t.Cleanup(func() { IsInterestingInterface = old })
|
|
}
|
|
|
|
func TestIncludesRoutableIP(t *testing.T) {
|
|
routable := []netip.Prefix{
|
|
netip.MustParsePrefix("1.2.3.4/32"),
|
|
netip.MustParsePrefix("10.0.0.1/24"), // RFC1918 IPv4 (private)
|
|
netip.MustParsePrefix("172.16.0.1/12"), // RFC1918 IPv4 (private)
|
|
netip.MustParsePrefix("192.168.1.1/24"), // RFC1918 IPv4 (private)
|
|
netip.MustParsePrefix("fd15:dead:beef::1/64"), // IPv6 ULA
|
|
netip.MustParsePrefix("2001:db8::1/64"), // global IPv6
|
|
}
|
|
|
|
nonRoutable := []netip.Prefix{
|
|
netip.MustParsePrefix("ff00::/8"), // multicast IPv6 (should be filtered)
|
|
netip.MustParsePrefix("fe80::1/64"), // link-local IPv6
|
|
netip.MustParsePrefix("::1/128"), // loopback IPv6
|
|
netip.MustParsePrefix("::/128"), // unspecified IPv6
|
|
netip.MustParsePrefix("224.0.0.1/32"), // multicast IPv4
|
|
netip.MustParsePrefix("127.0.0.1/32"), // loopback IPv4
|
|
}
|
|
|
|
got, want := filterRoutableIPs(
|
|
append(nonRoutable, routable...),
|
|
), routable
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("filterRoutableIPs returned %v; want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPrefixesEqual(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
a, b []netip.Prefix
|
|
want bool
|
|
}{
|
|
{
|
|
name: "empty",
|
|
a: []netip.Prefix{},
|
|
b: []netip.Prefix{},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "single-equal",
|
|
a: []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")},
|
|
b: []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "single-different",
|
|
a: []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")},
|
|
b: []netip.Prefix{netip.MustParsePrefix("10.0.0.2/24")},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "unordered-equal",
|
|
a: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.1/24"),
|
|
netip.MustParsePrefix("10.0.2.1/24"),
|
|
},
|
|
b: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.2.1/24"),
|
|
netip.MustParsePrefix("10.0.0.1/24"),
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "subset",
|
|
a: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.2.1/24"),
|
|
},
|
|
b: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.2.1/24"),
|
|
netip.MustParsePrefix("10.0.0.1/24"),
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := prefixesEqual(tt.a, tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("prefixesEqual(%v, %v) = %v; want %v", tt.a, tt.b, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInterfaceDiff(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
s1, s2 *State
|
|
wantDiff string // substring expected in diff output; "" means no diff
|
|
}{
|
|
{
|
|
name: "equal",
|
|
s1: &State{HaveV4: true, DefaultRouteInterface: "en0"},
|
|
s2: &State{HaveV4: true, DefaultRouteInterface: "en0"},
|
|
wantDiff: "",
|
|
},
|
|
{
|
|
name: "flags-changed",
|
|
s1: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp | net.FlagRunning,
|
|
}},
|
|
},
|
|
},
|
|
s2: &State{
|
|
DefaultRouteInterface: "en0",
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{
|
|
Name: "en0",
|
|
Flags: net.FlagUp,
|
|
}},
|
|
},
|
|
},
|
|
wantDiff: "flags",
|
|
},
|
|
{
|
|
name: "mtu-changed",
|
|
s1: &State{
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{Name: "en0", MTU: 1500}},
|
|
},
|
|
},
|
|
s2: &State{
|
|
Interface: map[string]Interface{
|
|
"en0": {Interface: &net.Interface{Name: "en0", MTU: 9000}},
|
|
},
|
|
},
|
|
wantDiff: "MTU",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.s1.InterfaceDiff(tt.s2)
|
|
if tt.wantDiff == "" {
|
|
if got != "" {
|
|
t.Errorf("InterfaceDiff = %q; want empty", got)
|
|
}
|
|
} else {
|
|
if !strings.Contains(got, tt.wantDiff) {
|
|
t.Errorf("InterfaceDiff = %q; want substring %q", got, tt.wantDiff)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForeachInterface(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
addrs []net.Addr
|
|
want []string
|
|
}{
|
|
{
|
|
name: "Mixed_IPv4_and_IPv6",
|
|
addrs: []net.Addr{
|
|
&net.IPNet{IP: net.IPv4(1, 2, 3, 4), Mask: net.CIDRMask(24, 32)},
|
|
&net.IPAddr{IP: net.IP{5, 6, 7, 8}, Zone: ""},
|
|
&net.IPNet{IP: net.ParseIP("2001:db8::1"), Mask: net.CIDRMask(64, 128)},
|
|
&net.IPAddr{IP: net.ParseIP("2001:db8::2"), Zone: ""},
|
|
},
|
|
want: []string{"1.2.3.4", "5.6.7.8", "2001:db8::1", "2001:db8::2"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got []string
|
|
ifaces := InterfaceList{
|
|
{
|
|
Interface: &net.Interface{Name: "eth0"},
|
|
AltAddrs: tt.addrs,
|
|
},
|
|
}
|
|
ifaces.ForeachInterface(func(iface Interface, prefixes []netip.Prefix) {
|
|
for _, prefix := range prefixes {
|
|
ip := prefix.Addr()
|
|
got = append(got, ip.String())
|
|
}
|
|
})
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|