Implement DHCPv4 default callbacks

This implements common callbacks to manage interface IPs and
routes in the kernel from DHCPv4.

Test Plan: New integration tests against our kernel via ktest.

X-Origin-Diff: phab/D657
GitOrigin-RevId: 3c39dddbd0e4151e6e902de150243296e6e459b4
diff --git a/core/pkg/dhcp4c/callback/BUILD.bazel b/core/pkg/dhcp4c/callback/BUILD.bazel
new file mode 100644
index 0000000..802bf11
--- /dev/null
+++ b/core/pkg/dhcp4c/callback/BUILD.bazel
@@ -0,0 +1,36 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+load("//core/tools/ktest:ktest.bzl", "ktest")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["callback.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c/callback",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//core/pkg/dhcp4c:go_default_library",
+        "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+        "@com_github_vishvananda_netlink//:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["callback_test.go"],
+    embed = [":go_default_library"],
+    pure = "on",
+    deps = [
+        "//core/pkg/dhcp4c:go_default_library",
+        "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+        "@com_github_stretchr_testify//require:go_default_library",
+        "@com_github_vishvananda_netlink//:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
+    ],
+)
+
+ktest(
+    cmdline = "",
+    initramfs_extra = "",
+    tester = ":go_default_test",
+    deps = [],
+)
diff --git a/core/pkg/dhcp4c/callback/callback.go b/core/pkg/dhcp4c/callback/callback.go
new file mode 100644
index 0000000..a1f06bc
--- /dev/null
+++ b/core/pkg/dhcp4c/callback/callback.go
@@ -0,0 +1,149 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package callback contains minimal callbacks for configuring the kernel with options received over DHCP.
+//
+// These directly configure the relevant kernel subsytems and need to own certain parts of them as documented on a per-
+// callback basis to make sure that they can recover from restarts and crashes of the DHCP client.
+// The callbacks in here are not suitable for use in advanced network scenarios like running multiple DHCP clients
+// per interface via ClientIdentifier or when running an external FIB manager. In these cases it's advised to extract
+// the necessary information from the lease in your own callback and communicate it directly to the responsible entity.
+package callback
+
+import (
+	"fmt"
+	"math"
+	"net"
+	"os"
+	"time"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c"
+
+	"github.com/insomniacslk/dhcp/dhcpv4"
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+)
+
+// Compose can be used to chain multiple callbacks
+func Compose(callbacks ...dhcp4c.LeaseCallback) dhcp4c.LeaseCallback {
+	return func(old, new *dhcp4c.Lease) error {
+		for _, cb := range callbacks {
+			if err := cb(old, new); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+}
+
+func isIPNetEqual(a, b *net.IPNet) bool {
+	if a == b {
+		return true
+	}
+	if a == nil || b == nil {
+		return false
+	}
+	aOnes, aBits := a.Mask.Size()
+	bOnes, bBits := b.Mask.Size()
+	return a.IP.Equal(b.IP) && aOnes == bOnes && aBits == bBits
+}
+
+// ManageIP sets up and tears down the assigned IP address. It takes exclusive ownership of all IPv4 addresses
+// on the given interface which do not have IFA_F_PERMANENT set, so it's not possible to run multiple dynamic addressing
+// clients on a single interface.
+func ManageIP(iface netlink.Link) dhcp4c.LeaseCallback {
+	return func(old, new *dhcp4c.Lease) error {
+		newNet := new.IPNet()
+
+		addrs, err := netlink.AddrList(iface, netlink.FAMILY_V4)
+		if err != nil {
+			return fmt.Errorf("netlink failed to list addresses: %w", err)
+		}
+
+		for _, addr := range addrs {
+			if addr.Flags&unix.IFA_F_PERMANENT == 0 {
+				// Linux identifies addreses by IP, mask and peer (see net/ipv4/devinet.find_matching_ifa in Linux 5.10)
+				// So don't touch addresses which match on these properties as AddrReplace will atomically reconfigure
+				// them anyways without interrupting things.
+				if isIPNetEqual(addr.IPNet, newNet) && addr.Peer == nil && new != nil {
+					continue
+				}
+
+				if err := netlink.AddrDel(iface, &addr); !os.IsNotExist(err) && err != nil {
+					return fmt.Errorf("failed to delete address: %w", err)
+				}
+			}
+		}
+
+		if new != nil {
+			remainingLifetimeSecs := int(math.Ceil(new.ExpiresAt.Sub(time.Now()).Seconds()))
+			newBroadcastIP := dhcpv4.GetIP(dhcpv4.OptionBroadcastAddress, new.Options)
+			if err := netlink.AddrReplace(iface, &netlink.Addr{
+				IPNet:       newNet,
+				ValidLft:    remainingLifetimeSecs,
+				PreferedLft: remainingLifetimeSecs,
+				Broadcast:   newBroadcastIP,
+			}); err != nil {
+				return fmt.Errorf("failed to update address: %w", err)
+			}
+		}
+		return nil
+	}
+}
+
+// ManageDefaultRoute manages a default route through the first router offered by DHCP. It does nothing if DHCP
+// doesn't provide any routers. It takes ownership of all RTPROTO_DHCP routes on the given interface, so it's not
+// possible to run multiple DHCP clients on the given interface.
+func ManageDefaultRoute(iface netlink.Link) dhcp4c.LeaseCallback {
+	return func(old, new *dhcp4c.Lease) error {
+		newRouter := new.Router()
+
+		dhcpRoutes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &netlink.Route{
+			Protocol:  unix.RTPROT_DHCP,
+			LinkIndex: iface.Attrs().Index,
+		}, netlink.RT_FILTER_OIF|netlink.RT_FILTER_PROTOCOL)
+		if err != nil {
+			return fmt.Errorf("netlink failed to list routes: %w", err)
+		}
+		ipv4DefaultRoute := net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}
+		for _, route := range dhcpRoutes {
+			// Don't remove routes which can be atomically replaced by RouteReplace to prevent potential traffic
+			// disruptions.
+			if !isIPNetEqual(&ipv4DefaultRoute, route.Dst) && newRouter != nil {
+				continue
+			}
+			err := netlink.RouteDel(&route)
+			if !os.IsNotExist(err) && err != nil {
+				return fmt.Errorf("failed to delete DHCP route: %w", err)
+			}
+		}
+
+		if newRouter != nil {
+			err := netlink.RouteReplace(&netlink.Route{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       &ipv4DefaultRoute,
+				Gw:        newRouter,
+				Src:       new.AssignedIP,
+				LinkIndex: iface.Attrs().Index,
+				Scope:     netlink.SCOPE_UNIVERSE,
+			})
+			if err != nil {
+				return fmt.Errorf("failed to add default route via %s: %w", newRouter, err)
+			}
+		}
+		return nil
+	}
+}
diff --git a/core/pkg/dhcp4c/callback/callback_test.go b/core/pkg/dhcp4c/callback/callback_test.go
new file mode 100644
index 0000000..cf99127
--- /dev/null
+++ b/core/pkg/dhcp4c/callback/callback_test.go
@@ -0,0 +1,313 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package callback
+
+import (
+	"fmt"
+	"math"
+	"net"
+	"os"
+	"testing"
+	"time"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c"
+
+	"github.com/insomniacslk/dhcp/dhcpv4"
+	"github.com/stretchr/testify/require"
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+)
+
+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
+		oldLease, 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}},
+			oldLease:      nil,
+			newLease:      nil,
+			expectedAddrs: nil,
+		},
+		{
+			name:         "IgnoresPermanentIPs",
+			initialAddrs: []netlink.Addr{{IPNet: &testNet1, Flags: unix.IFA_F_PERMANENT}, {IPNet: &testNet2, ValidLft: 60}},
+			oldLease:     nil,
+			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{},
+			oldLease:     nil,
+			newLease:     trivialLeaseFromNet(testNet2),
+			expectedAddrs: []netlink.Addr{
+				{IPNet: &testNet2, ValidLft: 1, PreferedLft: 1, Broadcast: testNet2Broadcast},
+			},
+		},
+		{
+			name:         "UpdatesIP",
+			initialAddrs: []netlink.Addr{},
+			oldLease:     trivialLeaseFromNet(testNet2),
+			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}},
+			oldLease:      trivialLeaseFromNet(testNet1),
+			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
+			}
+			cb := ManageIP(testLink)
+			if err := cb(test.oldLease, 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)
+			}
+			require.Equal(t, test.expectedAddrs, addrs, "Wrong IPs on interface")
+		})
+	}
+}
+
+func leaseAddRouter(lease *dhcp4c.Lease, router net.IP) *dhcp4c.Lease {
+	lease.Options.Update(dhcpv4.OptRouter(router))
+	return lease
+}
+
+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
+		oldLease, newLease *dhcp4c.Lease
+		expectedRoutes     []netlink.Route
+	}{
+		{
+			name:          "AddsDefaultRoute",
+			initialRoutes: []netlink.Route{},
+			oldLease:      nil,
+			newLease:      leaseAddRouter(trivialLeaseFromNet(testNet1), testNet1Router),
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       nil, // Linux weirdly retuns no RTA_DST for default routes, but one for everything else
+				Gw:        testNet1Router,
+				Src:       testNet1.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+		{
+			name:           "IgnoresLeasesWithoutRouter",
+			initialRoutes:  []netlink.Route{},
+			oldLease:       nil,
+			newLease:       trivialLeaseFromNet(testNet1),
+			expectedRoutes: nil,
+		},
+		{
+			name: "RemovesUnrelatedOldRoutes",
+			initialRoutes: []netlink.Route{{
+				Dst:       &testRoute,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Gw:        testNet2Router,
+				Scope:     netlink.SCOPE_UNIVERSE,
+			}},
+			oldLease:       nil,
+			newLease:       nil,
+			expectedRoutes: nil,
+		},
+		{
+			name: "IgnoresNonDHCPRoutes",
+			initialRoutes: []netlink.Route{{
+				Dst:       &testRoute,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_BIRD,
+				Gw:        testNet2Router,
+			}},
+			oldLease: trivialLeaseFromNet(testNet1),
+			newLease: nil,
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_BIRD,
+				Dst:       &testRoute,
+				Gw:        testNet2Router,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+		{
+			name: "RemovesRoute",
+			initialRoutes: []netlink.Route{{
+				Dst:       nil,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Gw:        testNet2Router,
+			}},
+			oldLease:       leaseAddRouter(trivialLeaseFromNet(testNet2), testNet2Router),
+			newLease:       nil,
+			expectedRoutes: nil,
+		},
+		{
+			name: "UpdatesRoute",
+			initialRoutes: []netlink.Route{{
+				Dst:       nil,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Protocol:  unix.RTPROT_DHCP,
+				Src:       testNet1.IP,
+				Gw:        testNet1Router,
+			}},
+			oldLease: leaseAddRouter(trivialLeaseFromNet(testNet1), testNet1Router),
+			newLease: leaseAddRouter(trivialLeaseFromNet(testNet2), testNet2Router),
+			expectedRoutes: []netlink.Route{{
+				Protocol:  unix.RTPROT_DHCP,
+				Dst:       nil,
+				Gw:        testNet2Router,
+				Src:       testNet2.IP,
+				Table:     mainRoutingTable,
+				LinkIndex: -1, // Filled in dynamically with test interface
+				Type:      unix.RTN_UNICAST,
+			}},
+		},
+	}
+	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 := ManageDefaultRoute(testLink)
+			if err := cb(test.oldLease, 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)
+				}
+			}
+			require.Equal(t, test.expectedRoutes, notKernelRoutes, "Wrong Routes")
+		})
+	}
+}