Bring an administratively down interface up in AddAddress before writing its addresses and routes. The kernel installs an address's connected route only while its link is up, so adding a gateway to a down interface was rejected as unreachable. A link raised this way is part of the pre-change state AddAddress restores, so it is returned to down on any path that does not complete. Also fix the rollback masking its own error: deleting the address it added takes the default route with it, since that route resolved its nexthop through the address's connected subnet. The following RouteDel then answered ESRCH and was reported in place of "aborted operation due to loss of internet".
385 lines
13 KiB
Go
385 lines
13 KiB
Go
package netconfig
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func mustCIDR(t *testing.T, s string) *net.IPNet {
|
|
t.Helper()
|
|
_, n, err := net.ParseCIDR(s)
|
|
require.NoError(t, err)
|
|
return n
|
|
}
|
|
|
|
func TestRouteString(t *testing.T) {
|
|
r := &Route{
|
|
Destination: mustCIDR(t, "10.0.0.0/24"),
|
|
Gateway: net.ParseIP("10.0.0.1"),
|
|
Metric: 100,
|
|
}
|
|
assert.Equal(t, "10.0.0.0/24 via 10.0.0.1 metric 100", r.String())
|
|
}
|
|
|
|
func TestInterfaceString(t *testing.T) {
|
|
mac, _ := net.ParseMAC("00:11:22:33:44:55")
|
|
iface := &Interface{
|
|
Name: "eth0",
|
|
MAC: mac,
|
|
Addresses: []*net.IPNet{mustCIDR(t, "192.168.1.10/24")},
|
|
Gateway4: net.ParseIP("192.168.1.1"),
|
|
Routes: []*Route{
|
|
{Destination: mustCIDR(t, "10.0.0.0/24"), Gateway: net.ParseIP("10.0.0.1"), Metric: 5},
|
|
},
|
|
}
|
|
want := "Name: eth0 MAC: 00:11:22:33:44:55 Addresses: [192.168.1.0/24] Gateway4: 192.168.1.1 Gateway6: <nil> Routes: [10.0.0.0/24 via 10.0.0.1 metric 5]"
|
|
assert.Equal(t, want, iface.String())
|
|
}
|
|
|
|
func TestFindInterfaceByName(t *testing.T) {
|
|
eth0 := &Interface{Name: "eth0", Gateway4: net.ParseIP("203.0.113.1")}
|
|
eth1 := &Interface{Name: "eth1"}
|
|
pub6 := &Interface{Name: "eth2", Gateway6: net.ParseIP("2001:db8::1")}
|
|
ifaces := []*Interface{eth1, eth0, pub6}
|
|
|
|
t.Run("by name", func(t *testing.T) {
|
|
assert.Equal(t, eth1, FindInterfaceByName("eth1", ifaces))
|
|
})
|
|
|
|
t.Run("public picks gateway4 interface", func(t *testing.T) {
|
|
assert.Equal(t, eth0, FindInterfaceByName(Public, ifaces))
|
|
})
|
|
|
|
t.Run("public6 picks gateway6 interface", func(t *testing.T) {
|
|
assert.Equal(t, pub6, FindInterfaceByName(Public6, ifaces))
|
|
})
|
|
|
|
t.Run("not found", func(t *testing.T) {
|
|
assert.Nil(t, FindInterfaceByName("missing", ifaces))
|
|
})
|
|
|
|
t.Run("public with no gateway returns nil", func(t *testing.T) {
|
|
assert.Nil(t, FindInterfaceByName(Public, []*Interface{eth1}))
|
|
})
|
|
|
|
t.Run("public6 with no gateway returns nil", func(t *testing.T) {
|
|
assert.Nil(t, FindInterfaceByName(Public6, []*Interface{eth1}))
|
|
})
|
|
}
|
|
|
|
// names extracts the interface names of a result slice, for comparing an
|
|
// expected ordering in one assertion.
|
|
func names(ifaces []*Interface) []string {
|
|
out := make([]string, 0, len(ifaces))
|
|
for _, iface := range ifaces {
|
|
out = append(out, iface.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestFindPhysicalInterfaces(t *testing.T) {
|
|
t.Run("drops virtual interfaces", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "docker0", Up: true},
|
|
{Name: "eth0", Up: true, Physical: true},
|
|
{Name: "veth1a2b", Up: true},
|
|
}
|
|
assert.Equal(t, []string{"eth0"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("up before down", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "eth0", Physical: true},
|
|
{Name: "eth1", Up: true, Physical: true},
|
|
}
|
|
assert.Equal(t, []string{"eth1", "eth0"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("gateway before none, v4 before v6", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "eth0", Up: true, Physical: true},
|
|
{Name: "eth1", Up: true, Physical: true, Gateway6: net.ParseIP("2001:db8::1")},
|
|
{Name: "eth2", Up: true, Physical: true, Gateway4: net.ParseIP("203.0.113.1")},
|
|
}
|
|
assert.Equal(t, []string{"eth2", "eth1", "eth0"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("up outranks a gateway on a down interface", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "eth0", Physical: true, Gateway4: net.ParseIP("203.0.113.1")},
|
|
{Name: "eth1", Up: true, Physical: true},
|
|
}
|
|
assert.Equal(t, []string{"eth1", "eth0"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("wired before wireless before other", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "ppp0", Up: true, Physical: true},
|
|
{Name: "wlp2s0", Up: true, Physical: true},
|
|
{Name: "enp1s0", Up: true, Physical: true},
|
|
}
|
|
assert.Equal(t, []string{"enp1s0", "wlp2s0", "ppp0"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("names ordered as a human reads them", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "eth10", Up: true, Physical: true},
|
|
{Name: "eth2", Up: true, Physical: true},
|
|
{Name: "eth0", Up: true, Physical: true},
|
|
}
|
|
assert.Equal(t, []string{"eth0", "eth2", "eth10"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("windows friendly names rank as wired and wireless", func(t *testing.T) {
|
|
ifaces := []*Interface{
|
|
{Name: "Wi-Fi", Up: true, Physical: true},
|
|
{Name: "Ethernet 2", Up: true, Physical: true},
|
|
}
|
|
assert.Equal(t, []string{"Ethernet 2", "Wi-Fi"}, names(FindPhysicalInterfaces(ifaces)))
|
|
})
|
|
|
|
t.Run("no physical interfaces returns empty", func(t *testing.T) {
|
|
assert.Empty(t, FindPhysicalInterfaces([]*Interface{{Name: "br0", Up: true}}))
|
|
assert.Empty(t, FindPhysicalInterfaces(nil))
|
|
})
|
|
}
|
|
|
|
func TestReorderPrimaryAddress(t *testing.T) {
|
|
// addrStrings renders a list of addresses for stable comparison.
|
|
addrStrings := func(addrs []*net.IPNet) []string {
|
|
out := make([]string, len(addrs))
|
|
for i, a := range addrs {
|
|
out[i] = a.IP.String()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hostIP keeps the host bits so IP.Equal matching is exercised, unlike the
|
|
// network address returned by mustCIDR.
|
|
hostIP := func(ip, cidr string) *net.IPNet {
|
|
n := mustCIDR(t, cidr)
|
|
return &net.IPNet{IP: net.ParseIP(ip), Mask: n.Mask}
|
|
}
|
|
|
|
a := hostIP("192.168.1.10", "192.168.1.10/24")
|
|
b := hostIP("192.168.1.11", "192.168.1.11/24")
|
|
c := hostIP("192.168.1.12", "192.168.1.12/24")
|
|
v6 := hostIP("2001:db8::5", "2001:db8::5/64")
|
|
|
|
t.Run("middle moves to front", func(t *testing.T) {
|
|
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, b)
|
|
require.True(t, ok)
|
|
assert.Equal(t, []string{"192.168.1.11", "192.168.1.10", "192.168.1.12"}, addrStrings(got))
|
|
})
|
|
|
|
t.Run("already first is unchanged", func(t *testing.T) {
|
|
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, a)
|
|
require.True(t, ok)
|
|
assert.Equal(t, []string{"192.168.1.10", "192.168.1.11", "192.168.1.12"}, addrStrings(got))
|
|
})
|
|
|
|
t.Run("v6 target preserves v4 relative order", func(t *testing.T) {
|
|
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, v6}, v6)
|
|
require.True(t, ok)
|
|
assert.Equal(t, []string{"2001:db8::5", "192.168.1.10", "192.168.1.11"}, addrStrings(got))
|
|
})
|
|
|
|
t.Run("absent target returns false", func(t *testing.T) {
|
|
got, ok := reorderPrimaryAddress([]*net.IPNet{a, b}, c)
|
|
assert.False(t, ok)
|
|
// The original slice is returned unchanged.
|
|
assert.Equal(t, []string{"192.168.1.10", "192.168.1.11"}, addrStrings(got))
|
|
})
|
|
}
|
|
|
|
// fakeIfaceBackend records the calls made to it and optionally returns an error.
|
|
type fakeIfaceBackend struct {
|
|
addrCalls int
|
|
routeCalls int
|
|
dnsCalls int
|
|
dhcpCalls int
|
|
lastAddrs []*net.IPNet
|
|
lastDHCP4 bool
|
|
lastDHCP6 bool
|
|
err error
|
|
}
|
|
|
|
func (f *fakeIfaceBackend) SetIfaceDHCP(_ context.Context, iface string, dhcp4, dhcp6 bool) error {
|
|
f.dhcpCalls++
|
|
f.lastDHCP4, f.lastDHCP6 = dhcp4, dhcp6
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeIfaceBackend) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) error {
|
|
f.addrCalls++
|
|
f.lastAddrs = addrs
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeIfaceBackend) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) error {
|
|
f.routeCalls++
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeIfaceBackend) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) error {
|
|
f.dnsCalls++
|
|
return f.err
|
|
}
|
|
|
|
// fakePanelBackend records the calls made to it and optionally returns an error.
|
|
type fakePanelBackend struct {
|
|
reloadCalls int
|
|
setMainIPCalls int
|
|
removeIPCalls int
|
|
err error
|
|
}
|
|
|
|
func (f *fakePanelBackend) reload(_ context.Context) error {
|
|
f.reloadCalls++
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakePanelBackend) setMainIP(_ context.Context, addr net.IP) error {
|
|
f.setMainIPCalls++
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakePanelBackend) removeIP(_ context.Context, addr net.IP) error {
|
|
f.removeIPCalls++
|
|
return f.err
|
|
}
|
|
|
|
// Every registered backend must be invoked even when an earlier backend
|
|
// returns an error, since each writes an independent configuration.
|
|
func TestApplyIfaceAddresses(t *testing.T) {
|
|
failing := &fakeIfaceBackend{err: errors.New("boom")}
|
|
ok := &fakeIfaceBackend{}
|
|
backends := []namedIfaceBackend{
|
|
{name: "failing", backend: failing},
|
|
{name: "ok", backend: ok},
|
|
}
|
|
applyIfaceAddresses(context.Background(), backends, "eth0", nil, nil, nil)
|
|
assert.Equal(t, 1, failing.addrCalls)
|
|
assert.Equal(t, 1, ok.addrCalls)
|
|
}
|
|
|
|
func TestApplyIfaceRoutes(t *testing.T) {
|
|
failing := &fakeIfaceBackend{err: errors.New("boom")}
|
|
ok := &fakeIfaceBackend{}
|
|
backends := []namedIfaceBackend{
|
|
{name: "failing", backend: failing},
|
|
{name: "ok", backend: ok},
|
|
}
|
|
applyIfaceRoutes(context.Background(), backends, "eth0", nil)
|
|
assert.Equal(t, 1, failing.routeCalls)
|
|
assert.Equal(t, 1, ok.routeCalls)
|
|
}
|
|
|
|
func TestReloadPanels(t *testing.T) {
|
|
failing := &fakePanelBackend{err: errors.New("boom")}
|
|
ok := &fakePanelBackend{}
|
|
backends := []namedPanelBackend{
|
|
{name: "failing", backend: failing},
|
|
{name: "ok", backend: ok},
|
|
}
|
|
reloadPanels(context.Background(), backends)
|
|
assert.Equal(t, 1, failing.reloadCalls)
|
|
assert.Equal(t, 1, ok.reloadCalls)
|
|
}
|
|
|
|
func TestSetMainIPOnPanels(t *testing.T) {
|
|
failing := &fakePanelBackend{err: errors.New("boom")}
|
|
ok := &fakePanelBackend{}
|
|
backends := []namedPanelBackend{
|
|
{name: "failing", backend: failing},
|
|
{name: "ok", backend: ok},
|
|
}
|
|
setMainIPOnPanels(context.Background(), backends, net.ParseIP("192.0.2.5"))
|
|
assert.Equal(t, 1, failing.setMainIPCalls)
|
|
assert.Equal(t, 1, ok.setMainIPCalls)
|
|
}
|
|
|
|
func TestRemoveIPFromPanels(t *testing.T) {
|
|
failing := &fakePanelBackend{err: errors.New("boom")}
|
|
ok := &fakePanelBackend{}
|
|
backends := []namedPanelBackend{
|
|
{name: "failing", backend: failing},
|
|
{name: "ok", backend: ok},
|
|
}
|
|
removeIPFromPanels(context.Background(), backends, net.ParseIP("192.0.2.5"))
|
|
assert.Equal(t, 1, failing.removeIPCalls)
|
|
assert.Equal(t, 1, ok.removeIPCalls)
|
|
}
|
|
|
|
// fakeDNSReaderBackend is a file backend that also reports back persisted
|
|
// interfaces (including DNS), satisfying both ifaceBackend and ifaceDNSReader.
|
|
type fakeDNSReaderBackend struct {
|
|
ifaces []*Interface
|
|
err error
|
|
}
|
|
|
|
func (f *fakeDNSReaderBackend) SetIfaceAddresses(context.Context, string, []*net.IPNet, net.IP, net.IP) error {
|
|
return nil
|
|
}
|
|
func (f *fakeDNSReaderBackend) SetIfaceRoutes(context.Context, string, []*Route) error { return nil }
|
|
func (f *fakeDNSReaderBackend) SetIfaceDNS(context.Context, string, []net.IP, []string) error {
|
|
return nil
|
|
}
|
|
func (f *fakeDNSReaderBackend) SetIfaceDHCP(context.Context, string, bool, bool) error { return nil }
|
|
func (f *fakeDNSReaderBackend) GetInterfaces() ([]*Interface, error) { return f.ifaces, f.err }
|
|
|
|
// mergeBackendState must union DNS from every backend into the matching runtime
|
|
// interface (by name), de-duplicate entries so multi-backend hosts do not list a
|
|
// resolver twice, OR the DHCP flags so a client enabled in any backend is
|
|
// reported, skip backends with no matching interface, and keep going when one
|
|
// backend errors.
|
|
func TestMergeBackendState(t *testing.T) {
|
|
runtime := []*Interface{{Name: "eth0"}, {Name: "eth1"}}
|
|
backends := []namedIfaceBackend{
|
|
{name: "broken", backend: &fakeDNSReaderBackend{err: errors.New("boom")}},
|
|
{name: "netplan", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
|
|
{Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888")}, SearchDomains: []string{"example.com"}, DHCP4: true},
|
|
}}},
|
|
{name: "cloud-init", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
|
|
// Overlaps netplan (must dedupe) and adds one new entry. DHCP4 is
|
|
// false here and must not clear the true netplan reported.
|
|
{Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")}, SearchDomains: []string{"example.com", "corp.example"}, DHCP6: true},
|
|
}}},
|
|
{name: "ifupdown", backend: &fakeDNSReaderBackend{ifaces: []*Interface{
|
|
{Name: "eth1", DNS: []net.IP{net.ParseIP("9.9.9.9")}, SearchDomains: []string{"a.test"}},
|
|
// No runtime match; must be ignored.
|
|
{Name: "eth9", DNS: []net.IP{net.ParseIP("10.0.0.1")}, DHCP4: true},
|
|
}}},
|
|
}
|
|
mergeBackendState(backends, runtime)
|
|
|
|
eth0 := runtime[0]
|
|
wantDNS := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"), net.ParseIP("1.1.1.1")}
|
|
assert.True(t, equalIPs(eth0.DNS, wantDNS))
|
|
wantSearch := []string{"example.com", "corp.example"}
|
|
assert.Equal(t, wantSearch, eth0.SearchDomains)
|
|
assert.True(t, eth0.DHCP4, "netplan reported DHCP4; cloud-init's false must not clear it")
|
|
assert.True(t, eth0.DHCP6, "cloud-init reported DHCP6")
|
|
|
|
eth1 := runtime[1]
|
|
assert.True(t, equalIPs(eth1.DNS, []net.IP{net.ParseIP("9.9.9.9")}))
|
|
assert.Equal(t, []string{"a.test"}, eth1.SearchDomains)
|
|
assert.False(t, eth1.DHCP4, "eth9's DHCP4 must not leak onto eth1")
|
|
}
|
|
|
|
func equalIPs(a, b []net.IP) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if !a[i].Equal(b[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|