osbase/net/dhcp4c: move package out of metropolis

Move the dhcp4c package from metropolis/node/core/network/dhcp4c to
osbase/net/dhcp4c. The package is not specific to metropolis, and is
also used by nanoswitch and cloud/agent.

Change-Id: I508261c93c623d5b7a33a2089da11625b7a3abd0
Reviewed-on: https://review.monogon.dev/c/monogon/+/4565
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/osbase/net/dhcp4c/callback/callback_test.go b/osbase/net/dhcp4c/callback/callback_test.go
new file mode 100644
index 0000000..db616bb
--- /dev/null
+++ b/osbase/net/dhcp4c/callback/callback_test.go
@@ -0,0 +1,355 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package callback
+
+import (
+	"fmt"
+	"math"
+	"net"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/insomniacslk/dhcp/dhcpv4"
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"source.monogon.dev/osbase/net/dhcp4c"
+)
+
+func trivialLeaseFromNet(ipnet net.IPNet) *dhcp4c.Lease {
+	opts := make(dhcpv4.Options)
+	opts.Update(dhcpv4.OptSubnetMask(ipnet.Mask))
+	return &dhcp4c.Lease{
+		AssignedIP: ipnet.IP,
+		ExpiresAt:  time.Now().Add(1 * time.Second),
+		Options:    opts,
+	}
+}
+
+var (
+	testNet1          = net.IPNet{IP: net.IP{10, 0, 1, 2}, Mask: net.CIDRMask(24, 32)}
+	testNet1Broadcast = net.IP{10, 0, 1, 255}
+	testNet1Router    = net.IP{10, 0, 1, 1}
+	testNet2          = net.IPNet{IP: net.IP{10, 0, 2, 2}, Mask: net.CIDRMask(24, 32)}
+	testNet2Broadcast = net.IP{10, 0, 2, 255}
+	testNet2Router    = net.IP{10, 0, 2, 1}
+	mainRoutingTable  = 254 // Linux automatically puts all routes into this table unless specified
+)
+
+func TestAssignedIPCallback(t *testing.T) {
+	if os.Getenv("IN_KTEST") != "true" {
+		t.Skip("Not in ktest")
+	}
+
+	var tests = []struct {
+		name          string
+		initialAddrs  []netlink.Addr
+		newLease      *dhcp4c.Lease
+		expectedAddrs []netlink.Addr
+	}{
+		// Lifetimes are necessary, otherwise the Kernel sets the
+		// IFA_F_PERMANENT flag behind our back.
+		{
+			name:          "RemoveOldIPs",
+			initialAddrs:  []netlink.Addr{{IPNet: &testNet1, ValidLft: 60}, {IPNet: &testNet2, ValidLft: 60}},
+			newLease:      nil,
+			expectedAddrs: nil,
+		},
+		{
+			name:         "IgnoresPermanentIPs",
+			initialAddrs: []netlink.Addr{{IPNet: &testNet1, Flags: unix.IFA_F_PERMANENT}, {IPNet: &testNet2, ValidLft: 60}},
+			newLease:     trivialLeaseFromNet(testNet2),
+			expectedAddrs: []netlink.Addr{
+				{IPNet: &testNet1, Flags: unix.IFA_F_PERMANENT, ValidLft: math.MaxUint32, PreferedLft: math.MaxUint32, Broadcast: testNet1Broadcast},
+				{IPNet: &testNet2, ValidLft: 1, PreferedLft: 1, Broadcast: testNet2Broadcast},
+			},
+		},
+		{
+			name:         "AssignsNewIP",
+			initialAddrs: []netlink.Addr{},
+			newLease:     trivialLeaseFromNet(testNet2),
+			expectedAddrs: []netlink.Addr{
+				{IPNet: &testNet2, ValidLft: 1, PreferedLft: 1, Broadcast: testNet2Broadcast},
+			},
+		},
+		{
+			name:         "UpdatesIP",
+			initialAddrs: []netlink.Addr{},
+			newLease:     trivialLeaseFromNet(testNet1),
+			expectedAddrs: []netlink.Addr{
+				{IPNet: &testNet1, ValidLft: 1, PreferedLft: 1, Broadcast: testNet1Broadcast},
+			},
+		},
+		{
+			name:          "RemovesIPOnRelease",
+			initialAddrs:  []netlink.Addr{{IPNet: &testNet1, ValidLft: 60, PreferedLft: 60}},
+			newLease:      nil,
+			expectedAddrs: nil,
+		},
+	}
+	for i, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			testLink := &netlink.Dummy{
+				LinkAttrs: netlink.LinkAttrs{
+					Name:  fmt.Sprintf("aipcb-test-%d", i),
+					Flags: unix.IFF_UP,
+				},
+			}
+			if err := netlink.LinkAdd(testLink); err != nil {
+				t.Fatalf("test cannot set up network interface: %v", err)
+			}
+			defer netlink.LinkDel(testLink)
+			for _, addr := range test.initialAddrs {
+				if err := netlink.AddrAdd(testLink, &addr); err != nil {
+					t.Fatalf("test cannot set up initial addrs: %v", err)
+				}
+			}
+			// Associate dynamically-generated interface name for later comparison
+			for i := range test.expectedAddrs {
+				test.expectedAddrs[i].Label = testLink.Name
+				test.expectedAddrs[i].LinkIndex = testLink.Index
+			}
+			cb := ManageIP(testLink)
+			if err := cb(test.newLease); err != nil {
+				t.Fatalf("callback returned an error: %v", err)
+			}
+			addrs, err := netlink.AddrList(testLink, netlink.FAMILY_V4)
+			if err != nil {
+				t.Fatalf("test cannot read back addrs from interface: %v", err)
+			}
+			if diff := cmp.Diff(test.expectedAddrs, addrs); diff != "" {
+				t.Errorf("Wrong IPs on interface (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func leaseAddRouter(lease *dhcp4c.Lease, router net.IP) *dhcp4c.Lease {
+	lease.Options.Update(dhcpv4.OptRouter(router))
+	return lease
+}
+
+func leaseAddClasslessRoutes(lease *dhcp4c.Lease, routes ...*dhcpv4.Route) *dhcp4c.Lease {
+	lease.Options.Update(dhcpv4.OptClasslessStaticRoute(routes...))
+	return lease
+}
+
+func mustParseCIDR(cidr string) *net.IPNet {
+	_, n, err := net.ParseCIDR(cidr)
+	if err != nil {
+		panic(err)
+	}
+	// Equality checks don't know about net.IP's canonicalization rules.
+	if n.IP.To4() != nil {
+		n.IP = n.IP.To4()
+	}
+	return n
+}
+
+func TestDefaultRouteCallback(t *testing.T) {
+	if os.Getenv("IN_KTEST") != "true" {
+		t.Skip("Not in ktest")
+	}
+	// testRoute is only used as a route destination and not configured on any
+	// interface.
+	testRoute := net.IPNet{IP: net.IP{10, 0, 3, 0}, Mask: net.CIDRMask(24, 32)}
+
+	// A test interface is set up for each test and assigned testNet1 and
+	// testNet2 so that testNet1Router and testNet2Router are valid gateways
+	// for routes in this environment. A LinkIndex of -1 is replaced by the
+	// correct link index for this test interface at runtime for both
+	// initialRoutes and expectedRoutes.
+	var tests = []struct {
+		name           string
+		initialRoutes  []netlink.Route
+		newLease       *dhcp4c.Lease
+		expectedRoutes []netlink.Route
+	}{
+		{
+			name:          "AddsDefaultRoute",
+			initialRoutes: []netlink.Route{},
+			newLease:      leaseAddRouter(trivialLeaseFromNet(testNet1), testNet1Router),
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       mustParseCIDR("0.0.0.0/0"),
+				Family:    unix.AF_INET,
+				Gw:        testNet1Router,
+				Src:       testNet1.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+		{
+			name:           "IgnoresLeasesWithoutRouter",
+			initialRoutes:  []netlink.Route{},
+			newLease:       trivialLeaseFromNet(testNet1),
+			expectedRoutes: nil,
+		},
+		{
+			name: "RemovesUnrelatedOldRoutes",
+			initialRoutes: []netlink.Route{{
+				Dst:       &testRoute,
+				Family:    unix.AF_INET,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Gw:        testNet2Router,
+				Scope:     netlink.SCOPE_UNIVERSE,
+			}},
+			newLease:       nil,
+			expectedRoutes: nil,
+		},
+		{
+			name: "IgnoresNonDHCPRoutes",
+			initialRoutes: []netlink.Route{{
+				Dst:       &testRoute,
+				Family:    unix.AF_INET,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_BIRD,
+				Gw:        testNet2Router,
+			}},
+			newLease: nil,
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_BIRD,
+				Dst:       &testRoute,
+				Family:    unix.AF_INET,
+				Gw:        testNet2Router,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+		{
+			name: "RemovesRoute",
+			initialRoutes: []netlink.Route{{
+				Dst:       nil,
+				Family:    unix.AF_INET,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Gw:        testNet2Router,
+			}},
+			newLease:       nil,
+			expectedRoutes: nil,
+		},
+		{
+			name: "UpdatesRoute",
+			initialRoutes: []netlink.Route{{
+				Dst:       nil,
+				Family:    unix.AF_INET,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Src:       testNet1.IP,
+				Gw:        testNet1Router,
+			}},
+			newLease: leaseAddRouter(trivialLeaseFromNet(testNet2), testNet2Router),
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       mustParseCIDR("0.0.0.0/0"),
+				Family:    unix.AF_INET,
+				Gw:        testNet2Router,
+				Src:       testNet2.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+		{
+			name:          "AddsClasslessStaticRoutes",
+			initialRoutes: []netlink.Route{},
+			newLease: leaseAddClasslessRoutes(
+				// Router should be ignored
+				leaseAddRouter(trivialLeaseFromNet(testNet1), testNet1Router),
+				// P2P/foreign gateway route
+				&dhcpv4.Route{Dest: mustParseCIDR("192.168.42.1/32"), Router: net.IPv4zero},
+				// Standard route over foreign gateway set up by previous route
+				&dhcpv4.Route{Dest: mustParseCIDR("0.0.0.0/0"), Router: net.IPv4(192, 168, 42, 1)},
+			),
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       mustParseCIDR("0.0.0.0/0"),
+				Family:    unix.AF_INET,
+				Gw:        net.IPv4(192, 168, 42, 1).To4(), // Equal() doesn't know about canonicalization
+				Src:       testNet1.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}, {
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       mustParseCIDR("192.168.42.1/32"),
+				Family:    unix.AF_INET,
+				Gw:        nil,
+				Src:       testNet1.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+				Scope:     unix.RT_SCOPE_LINK,
+			}},
+		},
+	}
+	for i, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			testLink := &netlink.Dummy{
+				LinkAttrs: netlink.LinkAttrs{
+					Name:  fmt.Sprintf("drcb-test-%d", i),
+					Flags: unix.IFF_UP,
+				},
+			}
+			if err := netlink.LinkAdd(testLink); err != nil {
+				t.Fatalf("test cannot set up network interface: %v", err)
+			}
+			defer func() { // Clean up after each test
+				routes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &netlink.Route{}, 0)
+				if err == nil {
+					for _, route := range routes {
+						netlink.RouteDel(&route)
+					}
+				}
+			}()
+			defer netlink.LinkDel(testLink)
+			if err := netlink.AddrAdd(testLink, &netlink.Addr{
+				IPNet: &testNet1,
+			}); err != nil {
+				t.Fatalf("test cannot set up test addrs: %v", err)
+			}
+			if err := netlink.AddrAdd(testLink, &netlink.Addr{
+				IPNet: &testNet2,
+			}); err != nil {
+				t.Fatalf("test cannot set up test addrs: %v", err)
+			}
+			for _, route := range test.initialRoutes {
+				if route.LinkIndex == -1 {
+					route.LinkIndex = testLink.Index
+				}
+				if err := netlink.RouteAdd(&route); err != nil {
+					t.Fatalf("test cannot set up initial routes: %v", err)
+				}
+			}
+			for i := range test.expectedRoutes {
+				if test.expectedRoutes[i].LinkIndex == -1 {
+					test.expectedRoutes[i].LinkIndex = testLink.Index
+				}
+			}
+
+			cb := ManageRoutes(testLink)
+			if err := cb(test.newLease); err != nil {
+				t.Fatalf("callback returned an error: %v", err)
+			}
+			routes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &netlink.Route{}, 0)
+			if err != nil {
+				t.Fatalf("test cannot read back routes: %v", err)
+			}
+			var notKernelRoutes []netlink.Route
+			for _, route := range routes {
+				if route.Protocol != unix.RTPROT_KERNEL { // Filter kernel-managed routes
+					notKernelRoutes = append(notKernelRoutes, route)
+				}
+			}
+			if diff := cmp.Diff(test.expectedRoutes, notKernelRoutes); diff != "" {
+				t.Errorf("Expected route mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}