a73x

internal/agent/netenv/uplink_test.go

Ref:   Size: 7.4 KiB   History

package netenv

import (
	"errors"
	"net"
	"strings"
	"testing"
)

// ifaceFrom describes one interface the way a host would present it. A CIDR
// per address, because a bare address tells the check nothing it needs.
func ifaceFrom(t *testing.T, name string, cidrs ...string) Iface {
	t.Helper()
	iface := Iface{Name: name}
	for _, c := range cidrs {
		ip, ipnet, err := net.ParseCIDR(c)
		if err != nil {
			t.Fatalf("bad test address %q: %v", c, err)
		}
		iface.Addrs = append(iface.Addrs, &net.IPNet{IP: ip, Mask: ipnet.Mask})
	}
	return iface
}

// newNetFor builds a Net on cidr whose interface enumeration answers ifaces.
// The command runner is nil: this check reads interfaces and runs nothing.
func newNetFor(t *testing.T, cidr string, ifaces ...Iface) *Net {
	t.Helper()
	n, err := New(nil, cidr, nil)
	if err != nil {
		t.Fatalf("New(%q): %v", cidr, err)
	}
	n.ifaces = func() ([]Iface, error) { return ifaces, nil }
	return n
}

// TestCheckUplinkCollisionRefusesASubnetContainingThisHost pins the refusal
// that keeps a nested agent reachable. An agent whose guest subnet overlaps its
// own uplink assigns that subnet's .1 to the bridge, its own address becomes
// local, and it stops answering anything that dials it — including the fleet
// that would have to fix it.
func TestCheckUplinkCollisionRefusesASubnetContainingThisHost(t *testing.T) {
	n := newNetFor(t, "10.78.1.0/24",
		ifaceFrom(t, "lo", "127.0.0.1/8"),
		ifaceFrom(t, "eth0", "10.78.1.3/24"),
	)
	err := n.CheckUplinkCollision()
	if err == nil {
		t.Fatal("a subnet containing this host's own address must be refused")
	}
	// Actionable without further digging: both sides named, and the way out.
	for _, want := range []string{"10.78.1.0/24", "10.78.1.3", "eth0", "--bridge-cidr"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error must name %q, got: %v", want, err)
		}
	}
}

// TestCheckUplinkCollisionAllowsADisjointSubnet pins the ordinary case: a host
// whose guests live somewhere it does not.
func TestCheckUplinkCollisionAllowsADisjointSubnet(t *testing.T) {
	n := newNetFor(t, "10.77.1.0/24",
		ifaceFrom(t, "lo", "127.0.0.1/8"),
		ifaceFrom(t, "eth0", "192.168.1.50/24"),
		ifaceFrom(t, "docker0", "172.17.0.1/16"),
	)
	if err := n.CheckUplinkCollision(); err != nil {
		t.Errorf("a disjoint subnet must be allowed, got: %v", err)
	}
}

// TestCheckUplinkCollisionIgnoresTheBridgeItself pins the exemption that makes
// the check survive a restart. Every agent that has run once finds its own
// bridge sitting on its own subnet; that is the normal case, not a collision.
func TestCheckUplinkCollisionIgnoresTheBridgeItself(t *testing.T) {
	n := newNetFor(t, "10.77.1.0/24",
		ifaceFrom(t, "eth0", "192.168.1.50/24"),
		ifaceFrom(t, Bridge, "10.77.1.1/24"),
	)
	if err := n.CheckUplinkCollision(); err != nil {
		t.Errorf("the bridge's own address must not read as a collision, got: %v", err)
	}
}

// TestCheckUplinkCollisionSkipsAddressesItCannotCompare pins that nothing a
// guest subnet could not contain is treated as a collision. The subnet is IPv4
// by construction (New enforces it), so an IPv6 address or an address shape
// that is not a network is simply not a candidate.
func TestCheckUplinkCollisionSkipsAddressesItCannotCompare(t *testing.T) {
	v6 := ifaceFrom(t, "eth0", "192.168.1.50/24")
	_, v6net, _ := net.ParseCIDR("fd00::/64")
	v6.Addrs = append(v6.Addrs, &net.IPNet{IP: net.ParseIP("fd00::1"), Mask: v6net.Mask})
	// A non-IPNet address: interfaces do not normally present these, but the
	// type assertion has to have an answer.
	v6.Addrs = append(v6.Addrs, &net.TCPAddr{IP: net.ParseIP("10.77.1.9")})

	n := newNetFor(t, "10.77.1.0/24", v6)
	if err := n.CheckUplinkCollision(); err != nil {
		t.Errorf("uncomparable addresses must be skipped, got: %v", err)
	}
}

// TestCheckUplinkCollisionAllowsWhenItCannotLook pins the deliberate
// asymmetry: this is a diagnostic, and refusing to start because a diagnostic
// could not be run would turn a guard against unreachability into a cause of
// it.
func TestCheckUplinkCollisionAllowsWhenItCannotLook(t *testing.T) {
	n, err := New(nil, "10.77.1.0/24", nil)
	if err != nil {
		t.Fatal(err)
	}
	n.ifaces = func() ([]Iface, error) { return nil, errors.New("netlink is not available") }
	if err := n.CheckUplinkCollision(); err != nil {
		t.Errorf("an unreadable interface list must allow, got: %v", err)
	}
}

// TestHostIfacesReadsThisMachine is a smoke test over the real enumeration. It
// cannot assert an interface list — it runs on whatever CI is — but it does
// prove the reading works unprivileged and returns something usable, which is
// the half a fabricated Iface can never cover.
func TestHostIfacesReadsThisMachine(t *testing.T) {
	ifaces, err := hostIfaces()
	if err != nil {
		t.Skipf("interfaces unreadable here: %v", err)
	}
	if len(ifaces) == 0 {
		t.Skip("no interfaces on this machine")
	}
	var named int
	for _, iface := range ifaces {
		if iface.Name != "" {
			named++
		}
	}
	if named == 0 {
		t.Error("hostIfaces returned only unnamed interfaces")
	}
}

// TestCheckUplinkCollisionAgainstRealHostShapes puts the three arrangements
// side by side, because the risk in adding a refusal is refusing something that
// works. A bare-metal host finds only its own bridge on the guest subnet; a
// guest host with a disjoint subnet finds nothing; and the nested agent handed
// the subnet its own uplink sits in is the one that has to be stopped.
func TestCheckUplinkCollisionAgainstRealHostShapes(t *testing.T) {
	for _, tc := range []struct {
		name, guestCIDR string
		ifaces          []Iface
		wantRefused     bool
	}{
		{
			name: "bare metal, bridge already up", guestCIDR: "10.78.3.0/24",
			ifaces: []Iface{
				ifaceFrom(t, "lo", "127.0.0.1/8"),
				ifaceFrom(t, "enp4s0", "192.168.0.251/24"),
				ifaceFrom(t, Bridge, "10.78.3.1/24"),
			},
		},
		{
			name: "a guest host whose guests live elsewhere", guestCIDR: "10.101.1.0/24",
			ifaces: []Iface{
				ifaceFrom(t, "lo", "127.0.0.1/8"),
				ifaceFrom(t, "ens3", "10.78.3.4/24"),
			},
		},
		{
			name: "nested, and handed the subnet it is already on", guestCIDR: "10.78.3.0/24",
			ifaces: []Iface{
				ifaceFrom(t, "lo", "127.0.0.1/8"),
				ifaceFrom(t, "ens3", "10.78.3.4/24"),
			},
			wantRefused: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			err := newNetFor(t, tc.guestCIDR, tc.ifaces...).CheckUplinkCollision()
			switch {
			case tc.wantRefused && err == nil:
				t.Error("this host would make itself unreachable and must be refused")
			case !tc.wantRefused && err != nil:
				t.Errorf("a working host must not be refused: %v", err)
			}
		})
	}
}

// TestIfaceAddr pins the reduction of one interface address to a comparable
// IPv4 address.
func TestIfaceAddr(t *testing.T) {
	ipnet := func(ip, mask string) net.Addr {
		_, n, err := net.ParseCIDR(ip + "/" + mask)
		if err != nil {
			t.Fatal(err)
		}
		return &net.IPNet{IP: net.ParseIP(ip), Mask: n.Mask}
	}
	for _, tc := range []struct {
		name string
		addr net.Addr
		want string // "" = not comparable
	}{
		{"an IPv4 address", ipnet("10.0.0.5", "24"), "10.0.0.5"},
		{"IPv6", &net.IPNet{IP: net.ParseIP("fd00::1"), Mask: net.CIDRMask(64, 128)}, ""},
		{"not an IPNet", &net.TCPAddr{IP: net.ParseIP("10.0.0.5")}, ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got, ok := ifaceAddr(tc.addr)
			if tc.want == "" {
				if ok {
					t.Errorf("ifaceAddr accepted %v as %s", tc.addr, got)
				}
				return
			}
			if !ok || got.String() != tc.want {
				t.Errorf("ifaceAddr() = %s, %v; want %s", got, ok, tc.want)
			}
		})
	}
}