f-46: dhcpd: add the decision engine and the persisted state #46
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 <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
5918feed83
commit
0938d1e0e0
4 changed files with 901 additions and 0 deletions
66
internal/dhcpd/engine.go
Normal file
66
internal/dhcpd/engine.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
204
internal/dhcpd/engine_test.go
Normal file
204
internal/dhcpd/engine_test.go
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
285
internal/dhcpd/state.go
Normal file
285
internal/dhcpd/state.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
346
internal/dhcpd/state_test.go
Normal file
346
internal/dhcpd/state_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue