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
}