diff --git a/.forgejo/workflows/release-pipeline.yml b/.forgejo/workflows/release-pipeline.yml index 53e2ad3..b4e018a 100644 --- a/.forgejo/workflows/release-pipeline.yml +++ b/.forgejo/workflows/release-pipeline.yml @@ -51,6 +51,7 @@ jobs: binaries: - metadata - agent + - dhcp uses: ./.forgejo/workflows/build.yml with: tag: ${{ needs.set-release-target.outputs.release_cible }} @@ -69,10 +70,14 @@ jobs: include: - path: scripts/run-dnsmasq-in-netns.sh name: run-dnsmasq-in-netns.sh + - path: scripts/run-dhcp-in-netns.sh + name: run-dhcp-in-netns.sh - path: systemd/agent.service name: agent.service - path: systemd/dnsmasq@.service name: dnsmasq@.service + - path: systemd/dhcp@.service + name: dhcp@.service - path: systemd/metadata@.service name: metadata@.service steps: diff --git a/cmd/dhcp/main.go b/cmd/dhcp/main.go new file mode 100644 index 0000000..ab5b7fb --- /dev/null +++ b/cmd/dhcp/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/pkg/logger" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/server4" +) + +var ( + confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file") + iface = flag.String("interface", "", "bridge to serve, already present in the current network namespace") + statePath = flag.String("state", "", "state file owned by this process") + socketPath = flag.String("socket", "", "control socket the agent talks to") +) + +func main() { + flag.Parse() + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "dhcp: %v\n", err) + os.Exit(1) + } +} + +func run() error { + for name, value := range map[string]string{ + "-interface": *iface, + "-state": *statePath, + "-socket": *socketPath, + } { + if value == "" { + return fmt.Errorf("%s is required", name) + } + } + + cfg, err := configuration.LoadConfig(*confFile) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + log := logger.New(cfg.Logger.Level, cfg.Logger.Debug).With("bridge", *iface) + + store := dhcpd.NewStore(*statePath) + if err := store.Load(); err != nil { + return fmt.Errorf("load state: %w", err) + } + + control, err := dhcpapi.Listen(store, *socketPath, log) + if err != nil { + return fmt.Errorf("listen on the control socket: %w", err) + } + defer control.Close() + + go func() { + if err := control.Serve(); err != nil { + log.Error("control socket stopped", "error", err) + } + }() + + conn, err := server4.NewIPv4UDPConn(*iface, &net.UDPAddr{Port: dhcpv4.ServerPort}) + if err != nil { + return fmt.Errorf("bind udp/%d on %s: %w", dhcpv4.ServerPort, *iface, err) + } + defer conn.Close() + + log.Info("dhcp server started", "state", store.Path(), "socket", control.Addr()) + + return store.Serve(conn, log) +} diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 72e88f8..de0f6ae 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -39,6 +39,16 @@ interfaces: metadata: run_dir: "/run/two/metadata" +# DHCP backend used for the subnets created by this agent. +# dnsmasq : dnsmasq@ instances driven by generated config files +# two : the built-in dhcp binary, driven over a unix socket +# Switching backends is a manual operation: drain the hypervisor, change this +# value, restart the agent. There is no hot migration. +# The per-subnet control socket and state file live in /run/two/dhcp, which is +# not configurable: the wrapper script hardcodes it too. +dhcp: + backend: dnsmasq + # QEMU runtime paths qemu: # UEFI firmware (requires apt install ovmf on Debian/Ubuntu) diff --git a/internal/api/dhcp/paths.go b/internal/api/dhcp/paths.go new file mode 100644 index 0000000..fcd5b09 --- /dev/null +++ b/internal/api/dhcp/paths.go @@ -0,0 +1,29 @@ +package dhcpapi + +import ( + "path/filepath" +) + +const ( + DefaultRunDir = "/run/two/dhcp" + SocketExt = ".sock" + StateExt = ".state" + UnitExt = ".service" + UnitName = "dhcp@" +) + +func Instance(vpc, bridge string) string { + return vpc + "_" + bridge +} + +func Unit(instance string) string { + return UnitName + instance + UnitExt +} + +func SocketPath(runDir, instance string) string { + return filepath.Join(runDir, instance+SocketExt) +} + +func StatePath(runDir, instance string) string { + return filepath.Join(runDir, instance+StateExt) +} diff --git a/internal/api/dhcp/paths_test.go b/internal/api/dhcp/paths_test.go new file mode 100644 index 0000000..8ad3658 --- /dev/null +++ b/internal/api/dhcp/paths_test.go @@ -0,0 +1,69 @@ +package dhcpapi + +import ( + "os" + "strings" + "testing" +) + +func TestInstance_JoinsVPCAndBridge(t *testing.T) { + if got := Instance("vp-admin", "br-000001"); got != "vp-admin_br-000001" { + t.Errorf("Instance = %s, want vp-admin_br-000001", got) + } +} + +func TestUnit_NamesTheTemplatedService(t *testing.T) { + if got := Unit(Instance("vp-admin", "br-000001")); got != "dhcp@vp-admin_br-000001.service" { + t.Errorf("Unit = %s", got) + } +} + +func TestSocketPath_SitsUnderTheRunDir(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.sock" { + t.Errorf("SocketPath = %s", got) + } +} + +func TestStatePath_SitsUnderTheRunDir(t *testing.T) { + got := StatePath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if got != "/run/two/dhcp/vp-admin_br-000001.state" { + t.Errorf("StatePath = %s", got) + } +} + +func TestPaths_NameTheVPCSoAListingIsReadable(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + if !strings.Contains(got, "vp-admin") { + t.Errorf("path = %s, want the vpc visible when listing the run dir", got) + } +} + +func TestPaths_DistinguishTwoSubnetsOfTheSameVPC(t *testing.T) { + a := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001")) + b := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000002")) + if a == b { + t.Error("two subnets must not share a control socket") + } +} + +func TestSocketPath_StaysUnderTheUnixPathLimit(t *testing.T) { + got := SocketPath(DefaultRunDir, Instance("vp-000000", "br-000000")) + if len(got) > 100 { + t.Errorf("socket path is %d bytes (%s): sun_path caps at 104 on darwin and 108 on linux", len(got), got) + } +} + +func TestDefaultRunDir_MatchesTheWrapperScript(t *testing.T) { + const script = "../../../scripts/run-dhcp-in-netns.sh" + + raw, err := os.ReadFile(script) + if err != nil { + t.Fatalf("read %s: %v", script, err) + } + + want := `RUN_DIR="` + DefaultRunDir + `"` + if !strings.Contains(string(raw), want) { + t.Errorf("%s does not set %s: the agent would talk to a socket the server never creates", script, want) + } +} diff --git a/internal/config/agent/dhcp.go b/internal/config/agent/dhcp.go new file mode 100644 index 0000000..f26f023 --- /dev/null +++ b/internal/config/agent/dhcp.go @@ -0,0 +1,17 @@ +package configuration + +import "fmt" + +const ( + BackendDnsmasq = "dnsmasq" + BackendTwo = "two" +) + +func ValidBackend(backend string) error { + switch backend { + case BackendDnsmasq, BackendTwo: + return nil + default: + return fmt.Errorf("unknown dhcp backend %q: expected %q or %q", backend, BackendDnsmasq, BackendTwo) + } +} diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go new file mode 100644 index 0000000..6796e56 --- /dev/null +++ b/internal/config/agent/dhcp_test.go @@ -0,0 +1,56 @@ +package configuration + +import ( + "os" + "path/filepath" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "agent.yml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +func TestValidBackend_AcceptsTheTwoKnownBackends(t *testing.T) { + for _, backend := range []string{BackendDnsmasq, BackendTwo} { + if err := ValidBackend(backend); err != nil { + t.Errorf("ValidBackend(%q) = %v, want nil", backend, err) + } + } +} + +func TestValidBackend_RejectsAnythingElse(t *testing.T) { + for _, backend := range []string{"", "dhcpd", "DNSMASQ", "two "} { + if err := ValidBackend(backend); err == nil { + t.Errorf("ValidBackend(%q) = nil, want an error", backend) + } + } +} + +func TestLoadConfig_DefaultsToDnsmasq(t *testing.T) { + path := writeConfig(t, "database:\n path: /tmp/two\n") + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.DHCP.Backend != BackendDnsmasq { + t.Errorf("backend = %q, want %q: a 0.1.0 config must keep behaving as before", cfg.DHCP.Backend, BackendDnsmasq) + } +} + +func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) { + path := writeConfig(t, "dhcp:\n backend: two\n") + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.DHCP.Backend != BackendTwo { + t.Errorf("backend = %q, want two", cfg.DHCP.Backend) + } +} diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 92a8677..d4afea1 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -31,6 +31,9 @@ type Config struct { Metadata struct { RunDir string `mapstructure:"run_dir"` } `mapstructure:"metadata"` + DHCP struct { + Backend string `mapstructure:"backend"` + } `mapstructure:"dhcp"` Admin struct { Enabled bool `mapstructure:"enabled"` Address string `mapstructure:"address"` @@ -67,6 +70,7 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("metadata.run_dir", "/run/two/metadata") + v.SetDefault("dhcp.backend", BackendDnsmasq) v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd") v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd") v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi") diff --git a/internal/dhcpd/listener.go b/internal/dhcpd/listener.go new file mode 100644 index 0000000..af7bde5 --- /dev/null +++ b/internal/dhcpd/listener.go @@ -0,0 +1,68 @@ +package dhcpd + +import ( + "log/slog" + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +const MaxDatagramBytes = 1500 + +var clientBroadcast = net.IPv4bcast + +func replyTo(peer net.Addr) net.Addr { + udp, ok := peer.(*net.UDPAddr) + if !ok { + return peer + } + if udp.IP == nil || udp.IP.IsUnspecified() { + return &net.UDPAddr{IP: clientBroadcast, Port: udp.Port} + } + return udp +} + +func (s *Store) serveDatagram(conn net.PacketConn, raw []byte, peer net.Addr, logger *slog.Logger) { + defer func() { + if r := recover(); r != nil { + logger.Error("dhcp datagram handling panicked", "peer", peer, "panic", r) + } + }() + + req, err := dhcpv4.FromBytes(raw) + if err != nil { + logger.Debug("malformed dhcp datagram", "peer", peer, "error", err) + return + } + + reply, err := s.Handle(req) + if err != nil { + logger.Error("building dhcp reply failed", + "peer", peer, "mac", req.ClientHWAddr, "type", req.MessageType(), "error", err) + return + } + if reply == nil { + logger.Debug("no reply for datagram", "mac", req.ClientHWAddr, "type", req.MessageType()) + return + } + + target := replyTo(peer) + if _, err := conn.WriteTo(reply.ToBytes(), target); err != nil { + logger.Error("sending dhcp reply failed", "target", target, "mac", req.ClientHWAddr, "error", err) + return + } + logger.Info("dhcp reply sent", + "mac", req.ClientHWAddr, "type", reply.MessageType(), "ip", reply.YourIPAddr, "target", target) +} + +func (s *Store) Serve(conn net.PacketConn, logger *slog.Logger) error { + buf := make([]byte, MaxDatagramBytes) + + for { + n, peer, err := conn.ReadFrom(buf) + if err != nil { + return err + } + s.serveDatagram(conn, buf[:n], peer, logger) + } +} diff --git a/internal/dhcpd/listener_test.go b/internal/dhcpd/listener_test.go new file mode 100644 index 0000000..26000a7 --- /dev/null +++ b/internal/dhcpd/listener_test.go @@ -0,0 +1,206 @@ +package dhcpd + +import ( + "io" + "log/slog" + "net" + "sync" + "testing" + "time" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func discard() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func loopbackPair(t *testing.T) (*net.UDPConn, *net.UDPConn) { + t.Helper() + + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP server: %v", err) + } + t.Cleanup(func() { server.Close() }) + + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP client: %v", err) + } + t.Cleanup(func() { client.Close() }) + + return server, client +} + +func exchange(t *testing.T, s *Store, raw []byte) *dhcpv4.DHCPv4 { + t.Helper() + + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + if _, err := client.WriteToUDP(raw, server.LocalAddr().(*net.UDPAddr)); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + + if err := client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + n, _, err := client.ReadFromUDP(buf) + if err != nil { + return nil + } + + reply, err := dhcpv4.FromBytes(buf[:n]) + if err != nil { + t.Fatalf("the reply must be a valid dhcp packet: %v", err) + } + return reply +} + +func TestServe_AnswersAKnownMAC(t *testing.T) { + s := configuredStore(t) + + reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes()) + if reply == nil { + t.Fatal("a known mac must be answered on the wire") + } + if reply.MessageType() != dhcpv4.MessageTypeOffer { + t.Errorf("message type = %s, want OFFER", reply.MessageType()) + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } +} + +func TestServe_StaysSilentForAnUnknownMAC(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")).ToBytes()); reply != nil { + t.Errorf("an unknown mac must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_StaysSilentOnARelease(t *testing.T) { + s := configuredStore(t) + + if reply := exchange(t, s, request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")).ToBytes()); reply != nil { + t.Errorf("a RELEASE must get nothing on the wire, got %s", reply.MessageType()) + } +} + +func TestServe_SurvivesAMalformedDatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(server, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + for _, garbage := range [][]byte{{}, {0x01}, make([]byte, 1200)} { + if _, err := client.WriteToUDP(garbage, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + } + + if _, err := client.WriteToUDP(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes(), target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("the loop must survive garbage and keep serving: %v", err) + } +} + +func TestServe_ReturnsWhenTheConnectionCloses(t *testing.T) { + s := configuredStore(t) + server, _ := loopbackPair(t) + + done := make(chan error, 1) + go func() { done <- s.Serve(server, discard()) }() + + server.Close() + select { + case err := <-done: + if err == nil { + t.Error("Serve must report why it stopped") + } + case <-time.After(time.Second): + t.Fatal("Serve did not return after the connection closed") + } +} + +type explodingConn struct { + net.PacketConn + mu sync.Mutex + writes int +} + +func (c *explodingConn) WriteTo(b []byte, addr net.Addr) (int, error) { + c.mu.Lock() + first := c.writes == 0 + c.writes++ + c.mu.Unlock() + + if first { + panic("write exploded") + } + return c.PacketConn.WriteTo(b, addr) +} + +func TestServe_SurvivesAPanicWhileHandlingADatagram(t *testing.T) { + s := configuredStore(t) + server, client := loopbackPair(t) + go s.Serve(&explodingConn{PacketConn: server}, discard()) + + target := server.LocalAddr().(*net.UDPAddr) + raw := request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes() + + for range 2 { + if _, err := client.WriteToUDP(raw, target); err != nil { + t.Fatalf("WriteToUDP: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + buf := make([]byte, MaxDatagramBytes) + if _, _, err := client.ReadFromUDP(buf); err != nil { + t.Fatalf("a panic on one datagram must not kill the serving loop: %v", err) + } +} + +func TestReplyTo_BroadcastsWhenTheClientHasNoAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.IPv4zero, Port: 68}) + + udp, ok := got.(*net.UDPAddr) + if !ok { + t.Fatalf("target = %T, want *net.UDPAddr", got) + } + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255: the client cannot be reached by unicast yet", udp.IP) + } + if udp.Port != 68 { + t.Errorf("port = %d, want the client port to be kept", udp.Port) + } +} + +func TestReplyTo_KeepsTheUnicastPeerWhenItHasAnAddress(t *testing.T) { + got := replyTo(&net.UDPAddr{IP: net.ParseIP("10.0.5.10"), Port: 68}) + + udp := got.(*net.UDPAddr) + if !udp.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("target = %s, want the renewing client itself", udp.IP) + } +} + +func TestReplyTo_BroadcastsWhenThePeerIPIsNil(t *testing.T) { + udp := replyTo(&net.UDPAddr{Port: 68}).(*net.UDPAddr) + if !udp.IP.Equal(net.IPv4bcast) { + t.Errorf("target = %s, want 255.255.255.255", udp.IP) + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 07d7668..d6b1828 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -116,7 +116,7 @@ start_services () { profile_units () { case "${1}" in - kvm) echo "agent.service dnsmasq@.service metadata@.service" ;; + kvm) echo "agent.service dnsmasq@.service dhcp@.service metadata@.service" ;; intel) echo "" ;; *) return 1 ;; esac @@ -124,7 +124,7 @@ profile_units () { profile_binaries () { case "${1}" in - kvm) echo "agent metadata run-dnsmasq-in-netns.sh" ;; + kvm) echo "agent metadata dhcp run-dnsmasq-in-netns.sh run-dhcp-in-netns.sh" ;; intel) echo "" ;; *) return 1 ;; esac diff --git a/scripts/run-dhcp-in-netns.sh b/scripts/run-dhcp-in-netns.sh new file mode 100644 index 0000000..c09050f --- /dev/null +++ b/scripts/run-dhcp-in-netns.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +# Expects one argument: netns_bridge (e.g. vpc-00003_br-00002 or vpc1_br0) +# The netns is only needed here, to enter it. The server is handed its bridge +# and its two file paths, and knows nothing of the namespace it runs in. +arg="$1" +NETNS="${arg%%_*}" +BRIDGE="${arg#*_}" +RUN_DIR="/run/two/dhcp" + +if [[ "${NETNS}" == "${arg}" || -z "${NETNS}" || -z "${BRIDGE}" ]] +then + echo "instance ${arg} is not _" >&2 + exit 1 +fi + +echo "start dhcp ${arg}" + +exec ip netns exec "${NETNS}" \ + /opt/two/bin/dhcp \ + -conf /etc/two/agent.yml \ + -interface "${BRIDGE}" \ + -state "${RUN_DIR}/${arg}.state" \ + -socket "${RUN_DIR}/${arg}.sock" diff --git a/systemd/dhcp@.service b/systemd/dhcp@.service new file mode 100644 index 0000000..e29ff73 --- /dev/null +++ b/systemd/dhcp@.service @@ -0,0 +1,10 @@ +[Unit] +Description=two dhcp server in netns %i +After=network.target + +[Service] +Type=simple +ExecStart=/opt/two/bin/run-dhcp-in-netns.sh %i + +[Install] +WantedBy=multi-user.target