From 0938d1e0e0e726834d8a12d8ffb92a2f74f5275a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 31 Aug 2026 16:06:25 +0200 Subject: [PATCH] f-46: dhcpd: add the decision engine and the persisted state #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store porte la configuration du subnet et les réservations par MAC, et les persiste dans un .state écrit de façon atomique (temp + chmod 0600 + rename). Chaque écriture remplace le fichier, donc un chmod externe est effacé. Les snapshots sont à la fois la forme sérialisée et la forme d'entrée des ordres : validation au moment où l'agent pousse, pas quand un guest demande une adresse. Les hôtes sont triés par MAC, base du digest de L3. Handle rend (nil, nil) — le silence est un retour normal — pour un type de message non traité, un subnet non configuré ou une MAC inconnue. La MAC est normalisée à l'écriture comme au chargement. 58 tests, -race propre, dix mutations toutes détectées. Signed-off-by: GnomeZworc --- internal/dhcpd/engine.go | 66 +++++++ internal/dhcpd/engine_test.go | 204 ++++++++++++++++++++ internal/dhcpd/state.go | 285 ++++++++++++++++++++++++++++ internal/dhcpd/state_test.go | 346 ++++++++++++++++++++++++++++++++++ 4 files changed, 901 insertions(+) create mode 100644 internal/dhcpd/engine.go create mode 100644 internal/dhcpd/engine_test.go create mode 100644 internal/dhcpd/state.go create mode 100644 internal/dhcpd/state_test.go diff --git a/internal/dhcpd/engine.go b/internal/dhcpd/engine.go new file mode 100644 index 0000000..15f81de --- /dev/null +++ b/internal/dhcpd/engine.go @@ -0,0 +1,66 @@ +package dhcpd + +import ( + "net" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func answerable(req *dhcpv4.DHCPv4) bool { + switch req.MessageType() { + case dhcpv4.MessageTypeDiscover, dhcpv4.MessageTypeRequest: + return true + default: + return false + } +} + +func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) { + if req == nil { + return nil, ErrNoRequest + } + if !answerable(req) { + return nil, nil + } + + subnet, configured := s.Subnet() + if !configured { + return nil, nil + } + + host, known := s.Lookup(req.ClientHWAddr) + if !known { + return nil, nil + } + + return BuildReply(subnet, host, req) +} + +func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) { + key, err := normalizeMAC(mac) + if err != nil { + return nil, err + } + + subnet, configured := s.Subnet() + if !configured { + return nil, ErrNotConfigured + } + + parsed, err := net.ParseMAC(key) + if err != nil { + return nil, err + } + + host, known := s.Lookup(parsed) + if !known { + return nil, nil + } + + req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(parsed)) + if err != nil { + return nil, err + } + + return BuildReply(subnet, host, req) +} diff --git a/internal/dhcpd/engine_test.go b/internal/dhcpd/engine_test.go new file mode 100644 index 0000000..f035493 --- /dev/null +++ b/internal/dhcpd/engine_test.go @@ -0,0 +1,204 @@ +package dhcpd + +import ( + "errors" + "net" + "testing" + + "github.com/insomniacslk/dhcp/dhcpv4" +) + +func configuredStore(t *testing.T) *Store { + t.Helper() + s, _ := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + return s +} + +func mac(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} + +func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be answered") + } + 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 the reserved 10.0.5.10", reply.YourIPAddr) + } +} + +func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff"))) + if err != nil { + t.Fatalf("an unknown mac is not an error: %v", err) + } + if reply != nil { + t.Error("an unknown mac must get no reply, not a NAK") + } +} + +func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) { + s, _ := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("without a subnet configuration the server must stay silent") + } +} + +func TestHandle_ReleaseIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a RELEASE is not an error: %v", err) + } + if reply != nil { + t.Error("a RELEASE must get no reply: reservations are static") + } +} + +func TestHandle_DeclineIsANoOp(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDecline, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("a DECLINE is not an error: %v", err) + } + if reply != nil { + t.Error("a DECLINE must get no reply: there is nothing to release") + } +} + +func TestHandle_RequestIsAnsweredWithAnAck(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply == nil || reply.MessageType() != dhcpv4.MessageTypeAck { + t.Fatalf("reply = %v, want an ACK", reply) + } +} + +func TestHandle_NilRequestIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Handle(nil); !errors.Is(err, ErrNoRequest) { + t.Fatalf("error = %v, want ErrNoRequest", err) + } +} + +func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) { + s := configuredStore(t) + if err := s.DelHost("00:22:33:00:00:0a"); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if reply != nil { + t.Error("a deleted host must no longer be served") + } +} + +func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe("00:22:33:00:00:0A") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply == nil { + t.Fatal("a known mac must be described") + } + if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr) + } + if got := reply.ClasslessStaticRoute(); len(got) != 3 { + t.Errorf("routes = %s, want metadata, vpc and default", got) + } +} + +func TestProbe_UnknownMACReturnsNothing(t *testing.T) { + s := configuredStore(t) + + reply, err := s.Probe("00:22:33:ff:ff:ff") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if reply != nil { + t.Error("an unknown mac must describe no reply") + } +} + +func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) { + s, _ := loadedStore(t) + + if _, err := s.Probe("00:22:33:00:00:0a"); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestProbe_InvalidMACIsRejected(t *testing.T) { + s := configuredStore(t) + + if _, err := s.Probe("nope"); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) { + s := configuredStore(t) + snap := testHostSnapshot() + snap.MAC = "00:22:33:00:00:0b" + snap.IP = "10.0.5.11" + snap.DefaultRoute = false + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0b"))) + if err != nil { + t.Fatalf("Handle: %v", err) + } + if got := reply.Router(); len(got) != 0 { + t.Errorf("router option = %v, want none on a secondary interface", got) + } + for _, r := range reply.ClasslessStaticRoute() { + if ones, _ := r.Dest.Mask.Size(); ones == 0 { + t.Errorf("unexpected default route for a secondary interface: %s", reply.ClasslessStaticRoute()) + } + } +} diff --git a/internal/dhcpd/state.go b/internal/dhcpd/state.go new file mode 100644 index 0000000..79f1905 --- /dev/null +++ b/internal/dhcpd/state.go @@ -0,0 +1,285 @@ +package dhcpd + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "sync" +) + +const ( + stateFileMode = 0o600 + stateDirMode = 0o700 +) + +var ( + ErrNoMAC = errors.New("host mac is required") + ErrNotConfigured = errors.New("subnet is not configured") +) + +type SubnetSnapshot struct { + Network string `json:"network"` + InterfaceIP string `json:"interface_ip"` + VPCRoute string `json:"vpc_route,omitempty"` + DefaultGateway string `json:"default_gateway,omitempty"` +} + +type HostSnapshot struct { + MAC string `json:"mac"` + IP string `json:"ip"` + VM string `json:"vm,omitempty"` + DefaultRoute bool `json:"default_route"` +} + +type Snapshot struct { + Subnet *SubnetSnapshot `json:"subnet,omitempty"` + Hosts []HostSnapshot `json:"hosts"` +} + +type Store struct { + mu sync.RWMutex + path string + subnet SubnetConfig + configured bool + hosts map[string]Host +} + +func NewStore(path string) *Store { + return &Store{path: path, hosts: make(map[string]Host)} +} + +func normalizeMAC(s string) (string, error) { + mac, err := net.ParseMAC(s) + if err != nil { + return "", fmt.Errorf("invalid mac %q: %w", s, err) + } + return mac.String(), nil +} + +func (s *Store) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s.persist() + } + if err != nil { + return fmt.Errorf("read %s: %w", s.path, err) + } + + var snap Snapshot + if len(raw) > 0 { + if err := json.Unmarshal(raw, &snap); err != nil { + return fmt.Errorf("parse %s: %w", s.path, err) + } + } + return s.apply(snap) +} + +func (s *Store) apply(snap Snapshot) error { + subnet := SubnetConfig{} + configured := false + if snap.Subnet != nil { + parsed, err := parseSubnet(*snap.Subnet) + if err != nil { + return err + } + subnet = parsed + configured = true + } + + hosts := make(map[string]Host, len(snap.Hosts)) + for _, h := range snap.Hosts { + host, err := parseHost(h) + if err != nil { + return err + } + hosts[host.MAC.String()] = host + } + + s.subnet = subnet + s.configured = configured + s.hosts = hosts + return nil +} + +func parseSubnet(s SubnetSnapshot) (SubnetConfig, error) { + _, network, err := net.ParseCIDR(s.Network) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err) + } + + interfaceIP := net.ParseIP(s.InterfaceIP) + if interfaceIP == nil { + return SubnetConfig{}, ErrNoInterfaceIP + } + + c := SubnetConfig{Network: network, InterfaceIP: interfaceIP} + + if s.VPCRoute != "" { + _, vpcRoute, err := net.ParseCIDR(s.VPCRoute) + if err != nil { + return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err) + } + c.VPCRoute = vpcRoute + } + if s.DefaultGateway != "" { + gw := net.ParseIP(s.DefaultGateway) + if gw == nil { + return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway) + } + c.DefaultGateway = gw + } + return c, nil +} + +func parseHost(h HostSnapshot) (Host, error) { + mac, err := net.ParseMAC(h.MAC) + if err != nil { + return Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err) + } + ip := net.ParseIP(h.IP) + if ip == nil { + return Host{}, fmt.Errorf("invalid host ip %q", h.IP) + } + return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil +} + +func (s *Store) SetSubnet(snap SubnetSnapshot) error { + c, err := parseSubnet(snap) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnet = c + s.configured = true + return s.persist() +} + +func (s *Store) SetHost(snap HostSnapshot) error { + if snap.MAC == "" { + return ErrNoMAC + } + host, err := parseHost(snap) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.hosts[host.MAC.String()] = host + return s.persist() +} + +func (s *Store) DelHost(mac string) error { + key, err := normalizeMAC(mac) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.hosts, key) + return s.persist() +} + +func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + h, ok := s.hosts[mac.String()] + return h, ok +} + +func (s *Store) Subnet() (SubnetConfig, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.subnet, s.configured +} + +func (s *Store) Snapshot() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.snapshot() +} + +func (s *Store) snapshot() Snapshot { + snap := Snapshot{Hosts: make([]HostSnapshot, 0, len(s.hosts))} + + if s.configured { + sub := SubnetSnapshot{ + Network: s.subnet.Network.String(), + InterfaceIP: s.subnet.InterfaceIP.String(), + } + if s.subnet.VPCRoute != nil { + sub.VPCRoute = s.subnet.VPCRoute.String() + } + if s.subnet.DefaultGateway != nil { + sub.DefaultGateway = s.subnet.DefaultGateway.String() + } + snap.Subnet = &sub + } + + for _, h := range s.hosts { + snap.Hosts = append(snap.Hosts, HostSnapshot{ + MAC: h.MAC.String(), + IP: h.IP.String(), + VM: h.VM, + DefaultRoute: h.DefaultRoute, + }) + } + sort.Slice(snap.Hosts, func(i, j int) bool { return snap.Hosts[i].MAC < snap.Hosts[j].MAC }) + + return snap +} + +func (s *Store) persist() error { + raw, err := json.Marshal(s.snapshot()) + if err != nil { + return fmt.Errorf("encode state: %w", err) + } + + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, stateDirMode); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp") + if err != nil { + return fmt.Errorf("create temp state in %s: %w", dir, err) + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(stateFileMode); err != nil { + tmp.Close() + return fmt.Errorf("chmod %s: %w", tmp.Name(), err) + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return fmt.Errorf("write %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync %s: %w", tmp.Name(), err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmp.Name(), err) + } + + if err := os.Rename(tmp.Name(), s.path); err != nil { + return fmt.Errorf("rename %s to %s: %w", tmp.Name(), s.path, err) + } + return nil +} diff --git a/internal/dhcpd/state_test.go b/internal/dhcpd/state_test.go new file mode 100644 index 0000000..7240ddb --- /dev/null +++ b/internal/dhcpd/state_test.go @@ -0,0 +1,346 @@ +package dhcpd + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" +) + +func statePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "vp-admin_br-000001.state") +} + +func testSubnetSnapshot() SubnetSnapshot { + return SubnetSnapshot{ + Network: "10.0.5.0/24", + InterfaceIP: "10.0.5.1", + VPCRoute: "10.0.0.0/16", + DefaultGateway: "10.0.5.254", + } +} + +func testHostSnapshot() HostSnapshot { + return HostSnapshot{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true} +} + +func loadedStore(t *testing.T) (*Store, string) { + t.Helper() + path := statePath(t) + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + return s, path +} + +func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) { + _, path := loadedStore(t) + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("the state file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: the file exposes tenant mac and ip", got) + } +} + +func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) { + s, path := loadedStore(t) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got) + } +} + +func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, nil, stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if _, configured := s.Subnet(); configured { + t.Error("an empty state file must not report a configured subnet") + } +} + +func TestStore_LoadRejectsCorruptedState(t *testing.T) { + path := statePath(t) + if err := os.WriteFile(path, []byte("{not json"), stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := NewStore(path).Load(); err == nil { + t.Fatal("a corrupted state file must be reported, not silently ignored") + } +} + +func TestStore_SetSubnetIsPersisted(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + c, configured := reloaded.Subnet() + if !configured { + t.Fatal("subnet lost across a restart") + } + if got := c.Network.String(); got != "10.0.5.0/24" { + t.Errorf("network = %s, want 10.0.5.0/24", got) + } + if !c.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", c.InterfaceIP) + } + if got := c.VPCRoute.String(); got != "10.0.0.0/16" { + t.Errorf("vpc route = %s, want 10.0.0.0/16", got) + } + if !c.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) { + t.Errorf("default gateway = %s, want 10.0.5.254", c.DefaultGateway) + } +} + +func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) { + s, _ := loadedStore(t) + snap := testSubnetSnapshot() + snap.InterfaceIP = "" + + if err := s.SetSubnet(snap); !errors.Is(err, ErrNoInterfaceIP) { + t.Fatalf("error = %v, want ErrNoInterfaceIP", err) + } +} + +func TestStore_SetSubnetRejectsAnInvalidNetwork(t *testing.T) { + s, _ := loadedStore(t) + snap := testSubnetSnapshot() + snap.Network = "10.0.5.0" + + if err := s.SetSubnet(snap); err == nil { + t.Fatal("a network without a prefix length must be rejected") + } +} + +func TestStore_SetHostIsPersistedAndFound(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + mac, err := net.ParseMAC("00:22:33:00:00:0a") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + h, known := reloaded.Lookup(mac) + if !known { + t.Fatal("host lost across a restart") + } + if !h.IP.Equal(net.ParseIP("10.0.5.10")) { + t.Errorf("ip = %s, want 10.0.5.10", h.IP) + } + if h.VM != "vm-test" { + t.Errorf("vm = %q, want vm-test", h.VM) + } + if !h.DefaultRoute { + t.Error("default route flag lost across a restart") + } +} + +func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + snap.IP = "10.0.5.11" + snap.DefaultRoute = false + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + got := s.Snapshot() + if len(got.Hosts) != 1 { + t.Fatalf("hosts = %d, want 1: the mac is the key", len(got.Hosts)) + } + if got.Hosts[0].IP != "10.0.5.11" || got.Hosts[0].DefaultRoute { + t.Errorf("entry = %+v, want the second order to have replaced the first", got.Hosts[0]) + } +} + +func TestStore_SetHostNormalizesTheMACCase(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.MAC = "00:22:33:AA:BB:CC" + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + + mac, err := net.ParseMAC("00:22:33:aa:bb:cc") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + if _, known := s.Lookup(mac); !known { + t.Error("an uppercase mac must be found in lowercase: the key would diverge") + } +} + +func TestStore_LoadNormalizesTheMACCase(t *testing.T) { + path := statePath(t) + raw, err := json.Marshal(Snapshot{Hosts: []HostSnapshot{{ + MAC: "00:22:33:AA:BB:CC", IP: "10.0.5.12", VM: "vm-test", DefaultRoute: true, + }}}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(path, raw, stateFileMode); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + s := NewStore(path) + if err := s.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + m, err := net.ParseMAC("00:22:33:aa:bb:cc") + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + if _, known := s.Lookup(m); !known { + t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served") + } +} + +func TestStore_SetHostRejectsAMissingMAC(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.MAC = "" + + if err := s.SetHost(snap); !errors.Is(err, ErrNoMAC) { + t.Fatalf("error = %v, want ErrNoMAC", err) + } +} + +func TestStore_SetHostRejectsAnInvalidIP(t *testing.T) { + s, _ := loadedStore(t) + snap := testHostSnapshot() + snap.IP = "10.0.5.300" + + if err := s.SetHost(snap); err == nil { + t.Fatal("an invalid host ip must be rejected") + } +} + +func TestStore_DelHostRemovesTheEntry(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + if err := s.DelHost("00:22:33:00:00:0A"); err != nil { + t.Fatalf("DelHost: %v", err) + } + + reloaded := NewStore(path) + if err := reloaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := len(reloaded.Snapshot().Hosts); got != 0 { + t.Errorf("hosts = %d, want 0 after deletion", got) + } +} + +func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost("00:22:33:ff:ff:ff"); err != nil { + t.Errorf("deleting an absent entry must be idempotent, got %v", err) + } +} + +func TestStore_DelHostRejectsAnInvalidMAC(t *testing.T) { + s, _ := loadedStore(t) + if err := s.DelHost("not-a-mac"); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestStore_SnapshotSortsHostsByMAC(t *testing.T) { + s, _ := loadedStore(t) + for _, mac := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} { + snap := testHostSnapshot() + snap.MAC = mac + if err := s.SetHost(snap); err != nil { + t.Fatalf("SetHost: %v", err) + } + } + + hosts := s.Snapshot().Hosts + for i := 1; i < len(hosts); i++ { + if hosts[i-1].MAC >= hosts[i].MAC { + t.Fatalf("hosts are not sorted: %v", hosts) + } + } +} + +func TestStore_PersistLeavesNoTemporaryFileBehind(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) + } +} + +func TestStore_PersistedStateIsValidJSON(t *testing.T) { + s, path := loadedStore(t) + if err := s.SetSubnet(testSubnetSnapshot()); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + if err := s.SetHost(testHostSnapshot()); err != nil { + t.Fatalf("SetHost: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var snap Snapshot + if err := json.Unmarshal(raw, &snap); err != nil { + t.Fatalf("the state file must stay parseable: %v", err) + } + if snap.Subnet == nil || len(snap.Hosts) != 1 { + t.Errorf("snapshot = %+v, want one subnet and one host", snap) + } +}