Add nanoswitch and cluster testing

Adds nanoswitch and the `switched-multi2` launch target to launch two Smalltown instances on a switched
network and enroll them into a single cluster. Nanoswitch contains a Linux bridge and a minimal DHCP server
and connects to the two Smalltown instances over virtual Ethernet cables. Also moves out the DHCP client into
a package since nanoswitch needs it.

Test Plan:
Manually tested using `bazel run //:launch -- switched-multi2` and observing that the second VM
(whose serial port is mapped to stdout) prints that it is enrolled. Also validated by `bazel run //core/cmd/dbg -- kubectl get node -o wide` returning two ready nodes.

X-Origin-Diff: phab/D572
GitOrigin-RevId: 9f6e2b3d8268749dd81588205646ae3976ad14b3
diff --git a/core/internal/network/BUILD.bazel b/core/internal/network/BUILD.bazel
index 7e45086..9eefc1b 100644
--- a/core/internal/network/BUILD.bazel
+++ b/core/internal/network/BUILD.bazel
@@ -2,16 +2,12 @@
 
 go_library(
     name = "go_default_library",
-    srcs = [
-        "dhcp.go",
-        "main.go",
-    ],
+    srcs = ["main.go"],
     importpath = "git.monogon.dev/source/nexantic.git/core/internal/network",
     visibility = ["//:__subpackages__"],
     deps = [
         "//core/internal/common/supervisor:go_default_library",
-        "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
-        "@com_github_insomniacslk_dhcp//dhcpv4/nclient4:go_default_library",
+        "//core/internal/network/dhcp:go_default_library",
         "@com_github_vishvananda_netlink//:go_default_library",
         "@org_golang_x_sys//unix:go_default_library",
         "@org_uber_go_zap//:go_default_library",
diff --git a/core/internal/network/dhcp/BUILD.bazel b/core/internal/network/dhcp/BUILD.bazel
new file mode 100644
index 0000000..40ac372
--- /dev/null
+++ b/core/internal/network/dhcp/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["dhcp.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/core/internal/network/dhcp",
+    visibility = ["//core:__subpackages__"],
+    deps = [
+        "//core/internal/common/supervisor:go_default_library",
+        "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+        "@com_github_insomniacslk_dhcp//dhcpv4/nclient4:go_default_library",
+        "@com_github_vishvananda_netlink//:go_default_library",
+        "@org_uber_go_zap//:go_default_library",
+    ],
+)
diff --git a/core/internal/network/dhcp.go b/core/internal/network/dhcp/dhcp.go
similarity index 70%
rename from core/internal/network/dhcp.go
rename to core/internal/network/dhcp/dhcp.go
index 983c25c..0eef2cc 100644
--- a/core/internal/network/dhcp.go
+++ b/core/internal/network/dhcp/dhcp.go
@@ -14,7 +14,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package network
+package dhcp
 
 import (
 	"context"
@@ -29,48 +29,48 @@
 	"go.uber.org/zap"
 )
 
-type dhcpClient struct {
+type Client struct {
 	reqC chan *dhcpStatusReq
 }
 
-func newDHCPClient() *dhcpClient {
-	return &dhcpClient{
+func New() *Client {
+	return &Client{
 		reqC: make(chan *dhcpStatusReq),
 	}
 }
 
 type dhcpStatusReq struct {
-	resC chan *dhcpStatus
+	resC chan *Status
 	wait bool
 }
 
-func (r *dhcpStatusReq) fulfill(s *dhcpStatus) {
+func (r *dhcpStatusReq) fulfill(s *Status) {
 	go func() {
 		r.resC <- s
 	}()
 }
 
-// dhcpStatus is the IPv4 configuration provisioned via DHCP for a given interface. It does not necessarily represent
+// Status is the IPv4 configuration provisioned via DHCP for a given interface. It does not necessarily represent
 // a configuration that is active or even valid.
-type dhcpStatus struct {
-	// address is 'our' (the node's) IPv4 address on the network.
-	address net.IPNet
-	// gateway is the default gateway/router of this network, or 0.0.0.0 if none was given.
-	gateway net.IP
-	// dns is a list of IPv4 DNS servers to use.
-	dns []net.IP
+type Status struct {
+	// Address is 'our' (the node's) IPv4 Address on the network.
+	Address net.IPNet
+	// Gateway is the default Gateway/router of this network, or 0.0.0.0 if none was given.
+	Gateway net.IP
+	// DNS is a list of IPv4 DNS servers to use.
+	DNS []net.IP
 }
 
-func (s *dhcpStatus) String() string {
-	return fmt.Sprintf("Address: %s, Gateway: %s, DNS: %v", s.address.String(), s.gateway.String(), s.dns)
+func (s *Status) String() string {
+	return fmt.Sprintf("Address: %s, Gateway: %s, DNS: %v", s.Address.String(), s.Gateway.String(), s.DNS)
 }
 
-func (c *dhcpClient) run(iface netlink.Link) supervisor.Runnable {
+func (c *Client) Run(iface netlink.Link) supervisor.Runnable {
 	return func(ctx context.Context) error {
 		logger := supervisor.Logger(ctx)
 
-		// Channel updated with address once one gets assigned/updated
-		newC := make(chan *dhcpStatus)
+		// Channel updated with Address once one gets assigned/updated
+		newC := make(chan *Status)
 		// Status requests waiting for configuration
 		waiters := []*dhcpStatusReq{}
 
@@ -106,7 +106,7 @@
 		// We start at WAITING, once we get a current config we move to ASSIGNED
 		// Once this becomes more complex (ie. has to handle link state changes)
 		// this should grow into a real state machine.
-		var current *dhcpStatus
+		var current *Status
 		logger.Info("DHCP client WAITING")
 		for {
 			select {
@@ -133,28 +133,28 @@
 	}
 }
 
-// parseAck turns an internal status (from the dhcpv4 library) into a dhcpStatus
-func parseAck(ack *dhcpv4.DHCPv4) *dhcpStatus {
+// parseAck turns an internal Status (from the dhcpv4 library) into a Status
+func parseAck(ack *dhcpv4.DHCPv4) *Status {
 	address := net.IPNet{IP: ack.YourIPAddr, Mask: ack.SubnetMask()}
 
-	// DHCP routers are optional - if none are provided, assume no router and set gateway to 0.0.0.0
-	// (this makes gateway.IsUnspecified() == true)
+	// DHCP routers are optional - if none are provided, assume no router and set Gateway to 0.0.0.0
+	// (this makes Gateway.IsUnspecified() == true)
 	gateway, _, _ := net.ParseCIDR("0.0.0.0/0")
 	if routers := ack.Router(); len(routers) > 0 {
 		gateway = routers[0]
 	}
-	return &dhcpStatus{
-		address: address,
-		gateway: gateway,
-		dns:     ack.DNS(),
+	return &Status{
+		Address: address,
+		Gateway: gateway,
+		DNS:     ack.DNS(),
 	}
 }
 
-// status returns the DHCP configuration requested from us by the local DHCP server.
-// If wait is true, this function will block until a DHCP configuration is available. Otherwise, a nil status may be
+// Status returns the DHCP configuration requested from us by the local DHCP server.
+// If wait is true, this function will block until a DHCP configuration is available. Otherwise, a nil Status may be
 // returned to indicate that no configuration has been received yet.
-func (c *dhcpClient) status(ctx context.Context, wait bool) (*dhcpStatus, error) {
-	resC := make(chan *dhcpStatus)
+func (c *Client) Status(ctx context.Context, wait bool) (*Status, error) {
+	resC := make(chan *Status)
 	c.reqC <- &dhcpStatusReq{
 		resC: resC,
 		wait: wait,
diff --git a/core/internal/network/main.go b/core/internal/network/main.go
index 00d7fb2..2466e05 100644
--- a/core/internal/network/main.go
+++ b/core/internal/network/main.go
@@ -22,11 +22,12 @@
 	"net"
 	"os"
 
-	"git.monogon.dev/source/nexantic.git/core/internal/common/supervisor"
-
 	"github.com/vishvananda/netlink"
 	"go.uber.org/zap"
 	"golang.org/x/sys/unix"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/common/supervisor"
+	"git.monogon.dev/source/nexantic.git/core/internal/network/dhcp"
 )
 
 const (
@@ -36,7 +37,7 @@
 
 type Service struct {
 	config Config
-	dhcp   *dhcpClient
+	dhcp   *dhcp.Client
 
 	logger *zap.Logger
 }
@@ -47,7 +48,7 @@
 func New(config Config) *Service {
 	return &Service{
 		config: config,
-		dhcp:   newDHCPClient(),
+		dhcp:   dhcp.New(),
 	}
 }
 
@@ -76,7 +77,7 @@
 
 func (s *Service) addNetworkRoutes(link netlink.Link, addr net.IPNet, gw net.IP) error {
 	if err := netlink.AddrReplace(link, &netlink.Addr{IPNet: &addr}); err != nil {
-		return err
+		return fmt.Errorf("failed to add DHCP address to network interface \"%v\": %w", link.Attrs().Name, err)
 	}
 
 	if gw.IsUnspecified() {
@@ -96,20 +97,20 @@
 }
 
 func (s *Service) useInterface(ctx context.Context, iface netlink.Link) error {
-	err := supervisor.Run(ctx, "dhcp", s.dhcp.run(iface))
+	err := supervisor.Run(ctx, "dhcp", s.dhcp.Run(iface))
 	if err != nil {
 		return err
 	}
-	status, err := s.dhcp.status(ctx, true)
+	status, err := s.dhcp.Status(ctx, true)
 	if err != nil {
-		return fmt.Errorf("could not get DHCP status: %w", err)
+		return fmt.Errorf("could not get DHCP Status: %w", err)
 	}
 
-	if err := setResolvconf(status.dns, []string{}); err != nil {
+	if err := setResolvconf(status.DNS, []string{}); err != nil {
 		s.logger.Warn("failed to set resolvconf", zap.Error(err))
 	}
 
-	if err := s.addNetworkRoutes(iface, net.IPNet{IP: status.address.IP, Mask: status.address.Mask}, status.gateway); err != nil {
+	if err := s.addNetworkRoutes(iface, status.Address, status.Gateway); err != nil {
 		s.logger.Warn("failed to add routes", zap.Error(err))
 	}
 
@@ -118,11 +119,11 @@
 
 // GetIP returns the current IP (and optionally waits for one to be assigned)
 func (s *Service) GetIP(ctx context.Context, wait bool) (*net.IP, error) {
-	status, err := s.dhcp.status(ctx, wait)
+	status, err := s.dhcp.Status(ctx, wait)
 	if err != nil {
 		return nil, err
 	}
-	return &status.address.IP, nil
+	return &status.Address.IP, nil
 }
 
 func (s *Service) Run(ctx context.Context) error {