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>
285 lines
5.9 KiB
Go
285 lines
5.9 KiB
Go
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
|
|
}
|