f-46: dhcpapi: add the control socket, its protocol and its client #46

Le contrat et le listener vont dans internal/api/dhcp (package dhcpapi), sur la
forme de internal/api/agent, et le client dans internal/client/dhcp. Chaînage
des imports : statefile <- dhcpd <- dhcpapi <- dhcpclient, sans cycle.

dhcpd parle net.IP et net.IPNet et garde ses structs disque privées ; dhcpapi
parle chaînes JSON et convertit à la frontière. Un même type portait jusqu'ici
le format du fil, la signature du Store et le format du .state — ce qui couplait
le fichier au protocole alors que le ticket le décrit comme un détail interne.

Le digest est calculé sur une forme canonique partagée par les deux côtés : MAC,
IP et CIDR normalisés, hôtes triés, doublon de MAC refusé. Un écart de digest
signale donc une vraie divergence, pas une différence d'écriture.

Le listener pose un recover par connexion, plafonne les lignes à 64 Kio, refuse
une ligne malformée sans fermer la connexion, écoute en 0600 et supprime une
socket résiduelle avant le bind. Le client pose une deadline.

101 tests au total, -race propre, treize mutations toutes détectées.

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-08-31 18:35:43 +02:00
commit 37a9353dc0
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
13 changed files with 1482 additions and 445 deletions

190
internal/api/dhcp/server.go Normal file
View file

@ -0,0 +1,190 @@
package dhcpapi
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"git.g3e.fr/syonad/two/internal/dhcpd"
"git.g3e.fr/syonad/two/pkg/db/statefile"
"github.com/insomniacslk/dhcp/dhcpv4"
)
const SocketMode = 0o600
type Server struct {
store *dhcpd.Store
listener net.Listener
logger *slog.Logger
}
func Listen(store *dhcpd.Store, path string, logger *slog.Logger) (*Server, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, statefile.DirMode); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("remove stale socket %s: %w", path, err)
}
listener, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("listen on %s: %w", path, err)
}
if err := os.Chmod(path, SocketMode); err != nil {
listener.Close()
return nil, fmt.Errorf("chmod %s: %w", path, err)
}
return &Server{store: store, listener: listener, logger: logger}, nil
}
func (s *Server) Addr() string {
return s.listener.Addr().String()
}
func (s *Server) Close() error {
return s.listener.Close()
}
func (s *Server) Serve() error {
for {
conn, err := s.listener.Accept()
if err != nil {
return err
}
go s.handleConn(conn)
}
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
s.logger.Error("control connection panicked", "panic", r)
}
}()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 4096), MaxMessageBytes)
encoder := json.NewEncoder(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if err := encoder.Encode(failure(fmt.Errorf("malformed request: %w", err))); err != nil {
return
}
continue
}
if err := encoder.Encode(s.dispatch(req)); err != nil {
return
}
}
if err := scanner.Err(); err != nil {
s.logger.Error("control connection read failed", "error", err)
}
}
func failure(err error) Response {
return Response{OK: false, Error: err.Error()}
}
func (s *Server) dispatch(req Request) Response {
switch req.Verb {
case VerbSetSubnet:
if req.Subnet == nil {
return failure(errors.New("set-subnet requires a subnet"))
}
config, err := req.Subnet.toConfig()
if err != nil {
return failure(err)
}
if err := s.store.SetSubnet(config); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbSetHost:
if req.Host == nil {
return failure(errors.New("set-host requires a host"))
}
host, err := req.Host.toHost()
if err != nil {
return failure(err)
}
if err := s.store.SetHost(host); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbDelHost:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
if err := s.store.DelHost(mac); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbGetState:
state := stateFromStore(s.store)
digest, err := Digest(state)
if err != nil {
return failure(err)
}
return Response{OK: true, State: &state, Digest: digest}
case VerbProbe:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
reply, err := s.store.Probe(mac)
if err != nil {
return failure(err)
}
if reply == nil {
return Response{OK: true, Served: false}
}
return Response{OK: true, Served: true, Lease: leaseFromReply(mac, reply)}
default:
return failure(fmt.Errorf("unknown verb %q", req.Verb))
}
}
func leaseFromReply(mac net.HardwareAddr, reply *dhcpv4.DHCPv4) *Lease {
lease := &Lease{
MAC: mac.String(),
IP: reply.YourIPAddr.String(),
Netmask: net.IP(reply.SubnetMask()).String(),
DNS: make([]string, 0, 2),
Routes: make([]string, 0, 3),
LeaseSeconds: uint32(reply.IPAddressLeaseTime(0).Seconds()),
}
if routers := reply.Router(); len(routers) > 0 {
lease.Router = routers[0].String()
}
for _, dns := range reply.DNS() {
lease.DNS = append(lease.DNS, dns.String())
}
for _, route := range reply.ClasslessStaticRoute() {
lease.Routes = append(lease.Routes, route.Dest.String()+" via "+route.Router.String())
}
return lease
}