draft DHCP allocator abstraction

This commit is contained in:
Patricio Whittingslow
2026-06-20 18:20:18 -03:00
parent 8f9d765cb0
commit b46b4c7bc5
10 changed files with 770 additions and 177 deletions
+245 -168
View File
@@ -4,26 +4,38 @@ import (
"encoding/binary"
"errors"
"fmt"
"net/netip"
"time"
"github.com/soypat/lneto"
"github.com/soypat/lneto/dhcp"
"github.com/soypat/lneto/internal"
"github.com/soypat/lneto/ipv4"
)
var errOptionNotFit = errors.New("DHCPv4: options dont fit")
const defaultMaxPending = 8
// Server implements the DHCPv4 server state machine (RFC 2131). It drives the
// DISCOVER->OFFER->REQUEST->ACK exchange and delegates address assignment,
// lease lifetime and per-client option customization to a [dhcp.Allocator].
//
// The Server itself holds no lease database: it keeps only a bounded table of
// in-flight transactions. The persistent client-to-address bindings live in the
// allocator, which may be supplied through [ServerConfig.Allocator] or, when
// omitted, defaults to a [MapAllocator] built from the configuration.
type Server struct {
connID uint64
nextAddr [4]byte
subnet ipv4.Prefix
hosts map[[36]byte]serverEntry
vld lneto.Validator
pending int
leaseSeconds uint32
port uint16
siaddr [4]byte
gwaddr [4]byte
dns [4]byte
connID uint64
subnet ipv4.Prefix
alloc dhcp.Allocator
txns []txn
vld lneto.Validator
maxPending int
port uint16
siaddr [4]byte
gwaddr [4]byte
dns [4]byte
}
// ServerConfig contains configuration parameters for [Server.Configure].
@@ -36,64 +48,82 @@ type ServerConfig struct {
DNS [4]byte
// Subnet defines the network prefix for address allocation and subnet mask responses.
Subnet ipv4.Prefix
// LeaseSeconds is the lease duration. Zero defaults to 3600.
// LeaseSeconds is the lease duration. Zero defaults to 3600. Only used when
// building the default allocator (Allocator is nil).
LeaseSeconds uint32
// Port is the server listening port. Zero defaults to DefaultServerPort.
Port uint16
// Allocator delegates address assignment and lease management. When nil a
// [MapAllocator] is built from ServerAddr, Subnet, LeaseSeconds and Now.
Allocator dhcp.Allocator
// MaxPending bounds the number of simultaneous in-flight transactions the
// server tracks. Zero defaults to a small built-in value.
MaxPending int
// Now, when non-nil, is passed to the default allocator to enable lease
// expiration. Ignored when Allocator is non-nil.
Now func() time.Time
}
type serverEntry struct {
hostname string
xid uint32
port uint16
addr [4]byte
requestlist [10]byte
hwaddr [6]byte
clientIdlen uint8
// Possible states:
// - 0: No entry/uninitialized
// - Init: Server received discover, pending Offer sent out.
// - Selecting: Server sent out offer, request not received.
// - Requesting: Request received, pending Ack sent out.
// - Bound: Ack sent out, no more pending data to be sent.
state ClientState
// txn is an in-flight DORA transaction awaiting a response or a follow-up
// request. The committed lease lives in the allocator; txn only carries what
// the server needs to emit OFFER/ACK frames.
type txn struct {
binding dhcp.Binding
clientID [36]byte
xid uint32
port uint16
offered [4]byte
hwaddr [6]byte
clientLen uint8
state ClientState // StateSelecting (offer pending/sent) or StateRequesting (ack pending).
respond MessageType // MsgOffer, MsgAck, or 0 when nothing is queued to send.
}
// Configure resets and configures the server with the given configuration.
// The connection ID is incremented on each call to invalidate existing connections.
// The hosts map is reused across calls to avoid reallocation.
func (sv *Server) Configure(cfg ServerConfig) error {
if !cfg.Subnet.IsValid() {
return errors.New("dhcpv4 server: invalid subnet")
} else if !cfg.Subnet.Contains(cfg.ServerAddr) {
return errors.New("dhcpv4 server: server address outside subnet")
alloc := cfg.Allocator
if alloc == nil {
if !cfg.Subnet.IsValid() {
return errors.New("dhcpv4 server: invalid subnet")
} else if !cfg.Subnet.Contains(cfg.ServerAddr) {
return errors.New("dhcpv4 server: server address outside subnet")
}
a, err := NewMapAllocator(AllocatorConfig{
ServerAddr: cfg.ServerAddr,
Subnet: cfg.Subnet,
LeaseSeconds: cfg.LeaseSeconds,
Now: cfg.Now,
})
if err != nil {
return err
}
alloc = a
}
port := cfg.Port
if port == 0 {
port = DefaultServerPort
}
lease := cfg.LeaseSeconds
if lease == 0 {
lease = 3600
maxPending := cfg.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
hosts := sv.hosts
if hosts == nil {
hosts = make(map[[36]byte]serverEntry)
txns := sv.txns
if cap(txns) < maxPending {
txns = make([]txn, 0, maxPending)
} else {
for k := range hosts {
delete(hosts, k)
}
txns = txns[:0]
}
*sv = Server{
connID: sv.connID + 1,
siaddr: cfg.ServerAddr,
gwaddr: cfg.Gateway,
dns: cfg.DNS,
subnet: cfg.Subnet,
port: port,
leaseSeconds: lease,
nextAddr: cfg.Subnet.Next(cfg.ServerAddr),
hosts: hosts,
connID: sv.connID + 1,
siaddr: cfg.ServerAddr,
gwaddr: cfg.Gateway,
dns: cfg.DNS,
subnet: cfg.Subnet,
port: port,
alloc: alloc,
txns: txns,
maxPending: maxPending,
}
return nil
}
@@ -148,115 +178,114 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
if err != nil {
return err
}
var clientIDRaw [36]byte
var client serverEntry
var clientExists bool
// Resolve client identity: the client identifier option when present,
// otherwise the hardware address.
if len(clientID) == 0 {
client, clientIDRaw, clientExists = sv.getClientByIP(*dfrm.CIAddr())
} else {
copy(clientIDRaw[:], clientID)
client, clientExists = sv.getClient(clientIDRaw)
clientID = dfrm.CHAddrAs6()[:]
}
req := dhcp.Request{
ClientID: clientID,
Hostname: hostname,
ParamReqList: reqlist,
}
if sv.subnet.IsValid() {
req.Subnet = sv.subnet.NetipPrefix()
}
if len(reqAddr) == 4 {
req.Requested = netip.AddrFrom4([4]byte(reqAddr))
}
switch msgType {
case MsgDiscover:
if clientExists && (client.state == StateInit || client.state == StateRequesting) {
sv.pending-- // Cancel unfulfilled pending response.
binding, err := sv.alloc.Offer(req)
if err != nil {
return fmt.Errorf("dhcpv4 server offer: %w", err)
}
if !clientExists {
addr, ok := sv.allocAddr(reqAddr)
if !ok {
return errors.New("dhcpv4 server: address pool exhausted")
}
client.addr = addr
offered, ok := binding.Addr()
if !ok || !offered.Is4() {
return errors.New("dhcpv4 server: allocator returned no IPv4 address")
}
copy(client.requestlist[:], reqlist)
client.state = StateInit
client.hostname = string(hostname)
client.xid = dfrm.XID()
client.hwaddr = *dfrm.CHAddrAs6()
t := sv.upsertTxn(clientID)
if t == nil {
return errors.New("dhcpv4 server: too many pending transactions")
}
t.binding = binding
t.offered = offered.As4()
t.xid = dfrm.XID()
t.hwaddr = *dfrm.CHAddrAs6()
if isIPLayer {
_, client.port, _ = getSrcIPPort(carrierData)
_, t.port, _ = getSrcIPPort(carrierData)
}
client.clientIdlen = uint8(len(clientID))
sv.pending++
t.state = StateSelecting
t.respond = MsgOffer
case MsgRequest:
if !clientExists {
err = errors.New("request for non existing client")
} else if dfrm.XID() != client.xid {
err = errors.New("unexpected XID for client")
} else if client.state != StateSelecting && client.state != StateRequesting {
err = errors.New("DHCP request unexpected state")
t := sv.findTxn(clientID)
if t == nil {
return errors.New("request for non existing client")
} else if dfrm.XID() != t.xid {
return errors.New("unexpected XID for client")
} else if t.state != StateSelecting && t.state != StateRequesting {
return errors.New("DHCP request unexpected state")
}
binding, err := sv.alloc.Commit(req)
if err != nil {
break
return fmt.Errorf("dhcpv4 server commit: %w", err)
}
if client.state == StateSelecting {
client.state = StateRequesting
sv.pending++
offered, ok := binding.Addr()
if !ok || !offered.Is4() {
return errors.New("dhcpv4 server: allocator returned no IPv4 address")
}
t.binding = binding
t.offered = offered.As4()
t.state = StateRequesting
t.respond = MsgAck
case MsgRelease:
if clientExists {
if client.state == StateInit || client.state == StateRequesting {
sv.pending--
}
delete(sv.hosts, clientIDRaw)
return nil
err = sv.alloc.Release(clientID)
sv.dropTxn(clientID)
if err != nil {
return fmt.Errorf("dhcpv4 server release: %w", err)
}
case MsgDecline:
err = sv.alloc.Decline(req)
sv.dropTxn(clientID)
if err != nil {
return fmt.Errorf("dhcpv4 server decline: %w", err)
}
default:
err = fmt.Errorf("unhandled message type %s", msgType.String())
return fmt.Errorf("unhandled message type %s", msgType.String())
}
if err != nil {
return fmt.Errorf("msgtype=%s client=%+v: %w", msgType.String(), client, err)
}
sv.hosts[clientIDRaw] = client
return nil
}
func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (int, error) {
carrierIsIP := offsetToIP >= 0
dfrm, err := NewFrame(carrierData[offsetToFrame:])
optBuf := dfrm.OptionsPayload()[:]
if err != nil {
return 0, err
} else if len(optBuf) < 255 {
}
optBuf := dfrm.OptionsPayload()
if len(optBuf) < 255 {
return 0, errOptionNotFit
}
if sv.pending == 0 {
t := sv.nextPending()
if t == nil {
return 0, nil // No pending outgoing frames.
}
var client serverEntry
var clientID [36]byte
for k, v := range sv.hosts {
pending := v.state == StateInit || v.state == StateRequesting
if pending {
client = v
clientID = k
break
}
}
if client.state == 0 {
return 0, nil // Nothing to do.
}
futureState := ClientState(0)
var nopt int
switch client.state {
case StateInit:
futureState = StateSelecting
nopt, err = EncodeOption(optBuf[nopt:], OptMessageType, byte(MsgOffer))
case StateRequesting:
futureState = StateBound
nopt, err = EncodeOption(optBuf[nopt:], OptMessageType, byte(MsgAck))
*dfrm.CIAddr() = client.addr
}
n, err := EncodeOption(optBuf[nopt:], OptMessageType, byte(t.respond))
if err != nil {
return 0, err
}
n, _ := EncodeOption(optBuf[nopt:], OptServerIdentification, sv.siaddr[:]...)
nopt += n
n, _ = EncodeOption(optBuf[nopt:], OptServerIdentification, sv.siaddr[:]...)
nopt += n
if sv.gwaddr != [4]byte{} {
n, _ = EncodeOption(optBuf[nopt:], OptRouter, sv.gwaddr[:]...)
@@ -274,88 +303,136 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
n, _ = EncodeOption(optBuf[nopt:], OptDNSServers, sv.dns[:]...)
nopt += n
}
if sv.leaseSeconds > 0 {
n, _ = EncodeOption32(optBuf[nopt:], OptIPAddressLeaseTime, sv.leaseSeconds)
if lease, ok := leaseOf(t.binding); ok && lease.Valid > 0 {
n, _ = EncodeOption32(optBuf[nopt:], OptIPAddressLeaseTime, lease.Valid)
nopt += n
n, _ = EncodeOption32(optBuf[nopt:], OptRenewTimeValue, sv.leaseSeconds/2)
n, _ = EncodeOption32(optBuf[nopt:], OptT1Renewal, t.binding.T1)
nopt += n
n, _ = EncodeOption32(optBuf[nopt:], OptRebindingTimeValue, sv.leaseSeconds*7/8)
n, _ = EncodeOption32(optBuf[nopt:], OptT2Rebinding, t.binding.T2)
nopt += n
}
// Let the allocator append or rewrite options. dst already holds the
// server-derived options for this client and binding.
optBuf, err = sv.alloc.AppendOptions(optBuf[:nopt], t.clientID[:t.clientLen], t.binding)
if err != nil {
return 0, err
}
nopt = len(optBuf)
optBuf = dfrm.OptionsPayload()
if nopt >= len(optBuf) {
return 0, errOptionNotFit
}
optBuf[nopt] = byte(OptEnd)
nopt++
dfrm.ClearHeader()
dfrm.SetOp(OpReply)
dfrm.SetHardware(1, 6, 0)
dfrm.SetXID(client.xid)
dfrm.SetXID(t.xid)
dfrm.SetSecs(0)
dfrm.SetFlags(0)
*dfrm.YIAddr() = client.addr // Offer here.
*dfrm.YIAddr() = t.offered
if t.respond == MsgAck {
*dfrm.CIAddr() = t.offered
}
*dfrm.SIAddr() = sv.siaddr
*dfrm.GIAddr() = sv.gwaddr
copy(dfrm.CHAddrAs6()[:], client.hwaddr[:])
copy(dfrm.CHAddrAs6()[:], t.hwaddr[:])
dfrm.SetMagicCookie(MagicCookie)
if carrierIsIP {
err = internal.SetIPAddrs(carrierData[offsetToIP:], 0, sv.siaddr[:], client.addr[:])
err = internal.SetIPAddrs(carrierData[offsetToIP:], 0, sv.siaddr[:], t.offered[:])
if err != nil {
return 0, err
}
}
client.state = futureState
// Set server state.
sv.hosts[clientID] = client
sv.pending--
if t.respond == MsgAck {
// Transaction complete; the lease now lives in the allocator.
sv.dropTxnAt(t)
} else {
t.respond = 0 // Offer sent, await the client's REQUEST.
}
return OptionsOffset + nopt, nil
}
// allocAddr allocates the next available address from the pool.
// If reqAddr is a valid 4-byte address within the subnet and not already assigned,
// it is preferred. Returns false if the pool is exhausted.
func (sv *Server) allocAddr(reqAddr []byte) ([4]byte, bool) {
if len(reqAddr) == 4 {
candidate := [4]byte(reqAddr)
if sv.subnet.Contains(candidate) && candidate != sv.siaddr && !sv.isAddrAssigned(candidate) {
return candidate, true
}
// leaseOf returns the first lease of a binding.
func leaseOf(b dhcp.Binding) (dhcp.Lease, bool) {
if len(b.Leases) == 0 {
return dhcp.Lease{}, false
}
// Reject broadcast address (all host bits set).
a := sv.nextAddr
sv.nextAddr = sv.subnet.Next(sv.nextAddr)
if sv.nextAddr == sv.siaddr {
sv.nextAddr = sv.subnet.Next(sv.nextAddr)
}
hostBits := uint(32 - sv.subnet.Bits())
hostMask := ^uint32(0) >> (32 - hostBits)
if binary.BigEndian.Uint32(a[:])&hostMask == hostMask {
return [4]byte{}, false
}
return a, true
return b.Leases[0], true
}
func (sv *Server) isAddrAssigned(addr [4]byte) bool {
for _, v := range sv.hosts {
if v.addr == addr {
return true
// findTxn returns the in-flight transaction for clientID, or nil.
func (sv *Server) findTxn(clientID []byte) *txn {
for i := range sv.txns {
if sv.txns[i].matches(clientID) {
return &sv.txns[i]
}
}
return false
return nil
}
func (sv *Server) getClient(clientID [36]byte) (serverEntry, bool) {
entry, ok := sv.hosts[clientID]
return entry, ok
// upsertTxn returns the existing transaction for clientID, reusing its slot, or
// appends a new one. It returns nil if the table is full.
func (sv *Server) upsertTxn(clientID []byte) *txn {
if t := sv.findTxn(clientID); t != nil {
t.binding = dhcp.Binding{}
t.respond = 0
return t
}
if len(sv.txns) >= sv.maxPending {
return nil
}
var t txn
t.clientLen = uint8(copy(t.clientID[:], clientID))
sv.txns = append(sv.txns, t)
return &sv.txns[len(sv.txns)-1]
}
func (sv *Server) getClientByIP(ip [4]byte) (serverEntry, [36]byte, bool) {
for k, v := range sv.hosts {
if v.addr == ip {
return v, k, true
// nextPending returns the next transaction with a queued response, or nil.
func (sv *Server) nextPending() *txn {
for i := range sv.txns {
if sv.txns[i].respond != 0 {
return &sv.txns[i]
}
}
return serverEntry{}, [36]byte{}, false
return nil
}
// dropTxn removes the transaction for clientID if present.
func (sv *Server) dropTxn(clientID []byte) {
for i := range sv.txns {
if sv.txns[i].matches(clientID) {
sv.removeAt(i)
return
}
}
}
// dropTxnAt removes the transaction pointed to by t.
func (sv *Server) dropTxnAt(t *txn) {
for i := range sv.txns {
if &sv.txns[i] == t {
sv.removeAt(i)
return
}
}
}
func (sv *Server) removeAt(i int) {
last := len(sv.txns) - 1
sv.txns[i] = sv.txns[last]
sv.txns[last] = txn{}
sv.txns = sv.txns[:last]
}
func (t *txn) matches(clientID []byte) bool {
if int(t.clientLen) != len(clientID) {
return false
}
return string(t.clientID[:t.clientLen]) == string(clientID)
}
func getSrcIPPort(ipCarrier []byte) (srcaddr []byte, port uint16, err error) {