a73x

internal/agent/dhcp/dhcp.go

Ref:   Size: 5.3 KiB   History

// Package dhcp is an in-process, reservation-only DHCPv4 responder for the
// eitri bridge. It answers only for MACs it holds a reservation for, replying
// with the agent-allocated IP; unknown MACs get no reply (fail-closed). There
// is no dynamic pool, so there is no lease-conflict or expiry-reclaim logic —
// the agent is the sole authority on addressing.
package dhcp

import (
	"context"
	"log/slog"
	"net"
	"sync"
	"time"

	"github.com/insomniacslk/dhcp/dhcpv4"
	"github.com/insomniacslk/dhcp/dhcpv4/server4"

	"github.com/a73x/eitri/internal/agent/ipalloc"
)

// Server serves reserved DHCPv4 leases on a single interface.
type Server struct {
	iface   string
	cidr    string // host bridge CIDR, e.g. "10.77.1.0/24"; the pool Reserve draws from
	gateway net.IP
	mask    net.IPMask
	dns     []net.IP
	lease   time.Duration

	mu  sync.RWMutex
	res map[string]net.IP // key: normalized MAC string
}

// NewServer builds a Server. gateway is the host's address on the bridge (also
// the DHCP router and server-identifier); mask is the bridge subnet mask; dns
// is handed to guests; lease is the offered lease time.
func NewServer(iface, cidr string, gateway net.IP, mask net.IPMask, dns []net.IP, lease time.Duration) *Server {
	return &Server{
		iface:   iface,
		cidr:    cidr,
		gateway: gateway,
		mask:    mask,
		dns:     dns,
		lease:   lease,
		res:     make(map[string]net.IP),
	}
}

// key normalizes a MAC to a stable lookup string (lower-case, colon-separated).
func key(mac net.HardwareAddr) string { return mac.String() }

// SetReservation pins mac to ip. Overwrites any existing reservation for mac.
func (s *Server) SetReservation(mac net.HardwareAddr, ip net.IP) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.res[key(mac)] = ip
}

// RemoveReservation drops any reservation for mac.
func (s *Server) RemoveReservation(mac net.HardwareAddr) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.res, key(mac))
}

// Reserve returns a sticky IP for mac, allocating one from the host CIDR on
// first call and recording it in the reservation table (which doubles as the
// used-address set). A MAC that already holds a reservation gets it back
// unchanged, so a VM keeps its address across reboots and agent restarts.
// Serialized by the same lock as the table, so concurrent callers never collide
// on an address. Errors when the CIDR is exhausted.
func (s *Server) Reserve(mac net.HardwareAddr) (net.IP, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if ip, ok := s.res[key(mac)]; ok {
		return ip, nil // sticky: already allocated
	}
	used := make([]string, 0, len(s.res))
	for _, ip := range s.res {
		used = append(used, ip.String())
	}
	ipStr, err := ipalloc.Alloc(s.cidr, used)
	if err != nil {
		return nil, err
	}
	ip := net.ParseIP(ipStr)
	s.res[key(mac)] = ip
	return ip, nil
}

// lookup returns the reserved IP for mac.
func (s *Server) lookup(mac net.HardwareAddr) (net.IP, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	ip, ok := s.res[key(mac)]
	return ip, ok
}

// Lookup returns the reserved IP for mac. It never allocates — it is the read
// side of the table, used to answer "what address does this guest have?".
func (s *Server) Lookup(mac net.HardwareAddr) (net.IP, bool) { return s.lookup(mac) }

// buildReply produces the DHCP response for req, or (nil, nil) when req's MAC
// has no reservation (fail-closed) or is a message type we do not serve. It
// touches no sockets, so it is unit-tested directly.
func (s *Server) buildReply(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
	ip, ok := s.lookup(req.ClientHWAddr)
	if !ok {
		return nil, nil // unknown MAC: no lease
	}
	resp, err := dhcpv4.NewReplyFromRequest(req)
	if err != nil {
		return nil, err
	}
	resp.YourIPAddr = make(net.IP, len(ip))
	copy(resp.YourIPAddr, ip)
	resp.UpdateOption(dhcpv4.OptServerIdentifier(s.gateway))
	resp.UpdateOption(dhcpv4.OptSubnetMask(s.mask))
	resp.UpdateOption(dhcpv4.OptRouter(s.gateway))
	resp.UpdateOption(dhcpv4.OptDNS(s.dns...))
	resp.UpdateOption(dhcpv4.OptIPAddressLeaseTime(s.lease))

	switch req.MessageType() {
	case dhcpv4.MessageTypeDiscover:
		resp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer))
	case dhcpv4.MessageTypeRequest:
		resp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeAck))
	default:
		return nil, nil // Release/Decline/Inform: nothing to serve
	}
	return resp, nil
}

// handle is the server4 callback: build a reply and, if any, write it back.
func (s *Server) handle(conn net.PacketConn, peer net.Addr, m *dhcpv4.DHCPv4) {
	resp, err := s.buildReply(m)
	if err != nil {
		slog.Warn("dhcp buildReply", "err", err, "mac", m.ClientHWAddr)
		return
	}
	if resp == nil {
		return // fail-closed: no reservation / unserved type
	}
	if _, err := conn.WriteTo(resp.ToBytes(), peer); err != nil {
		slog.Warn("dhcp write", "err", err, "mac", m.ClientHWAddr)
	}
}

// Start binds a DHCPv4 listener to the configured interface (port 67) and
// serves in the background until ctx is cancelled. The interface must already
// exist (the bridge is created first). Requires privilege to bind :67.
func (s *Server) Start(ctx context.Context) error {
	srv, err := server4.NewServer(s.iface, &net.UDPAddr{Port: 67}, s.handle)
	if err != nil {
		return err
	}
	go func() {
		if err := srv.Serve(); err != nil {
			slog.Info("dhcp server stopped", "err", err)
		}
	}()
	go func() {
		<-ctx.Done()
		_ = srv.Close()
	}()
	return nil
}