1 Commits

Author SHA1 Message Date
Patricio Whittingslow b46b4c7bc5 draft DHCP allocator abstraction 2026-06-20 18:20:18 -03:00
10 changed files with 770 additions and 177 deletions
+65
View File
@@ -0,0 +1,65 @@
package dhcp
import "net/netip"
// Request is the decoded, allocation-relevant view of an inbound client message
// passed to an [Allocator]. All slice fields alias the caller's receive buffer
// and are only valid for the duration of the call; an implementation that needs
// to persist them must copy.
type Request struct {
// ClientID identifies the client. For DHCPv4 this is the client identifier
// option when present, otherwise the client hardware address. For DHCPv6 it
// is the client DUID.
ClientID []byte
// Requested is the address the client asked for (DHCPv4 "Requested IP
// Address" option), or the zero value when the client expressed no
// preference.
Requested netip.Addr
// Subnet is the server's configured allocation prefix. It is the zero value
// when the server does not constrain allocation to a prefix.
Subnet netip.Prefix
// Hostname is the client-supplied hostname, or nil.
Hostname []byte
// ParamReqList is the client's parameter request list, or nil.
ParamReqList []byte
}
// Allocator owns a DHCP server's lease database: the address pool, the
// persisted client-to-address bindings, and the lease lifetime / expiration
// policy. A server delegates address assignment to an Allocator so that it only
// has to drive the protocol state machine.
//
// Implementations that expire leases must obtain time from a caller-injected
// clock rather than calling time.Now directly, in keeping with lneto's
// time-independent design.
//
// Offer and Commit model the two phases of acquisition: Offer makes a tentative
// reservation in response to a DHCPv4 DISCOVER (or DHCPv6 SOLICIT), and Commit
// binds it in response to a REQUEST. Implementations may treat Offer
// idempotently so that a repeated DISCOVER for the same client returns the same
// reservation.
type Allocator interface {
// Offer tentatively reserves a binding for the requesting client and
// returns it. It is called when the server receives a DISCOVER/SOLICIT.
Offer(Request) (Binding, error)
// Commit binds a previously offered reservation, finalizing the lease, and
// returns the committed binding. It is called when the server receives a
// REQUEST.
Commit(Request) (Binding, error)
// Release frees the binding held for the given client identity. It is
// called when the server receives a RELEASE. Releasing an unknown client is
// not an error.
Release(clientID []byte) error
// Decline marks the addresses in the request as unusable, typically because
// the client detected an address conflict. It is called when the server
// receives a DECLINE.
Decline(Request) error
// AppendOptions lets the allocator customize the option bytes the server is
// about to send. dst already contains the options the server derived from
// its own configuration (server identifier, router, subnet mask, DNS, lease
// times, ...) for the given client and binding. The implementation may
// append further options, or rewrite the existing ones, and must return the
// resulting slice. The returned slice must not exceed dst's capacity.
AppendOptions(dst []byte, clientID []byte, b Binding) ([]byte, error)
}
+227
View File
@@ -0,0 +1,227 @@
package dhcpv4
import (
"encoding/binary"
"errors"
"net/netip"
"time"
"github.com/soypat/lneto/dhcp"
"github.com/soypat/lneto/ipv4"
)
// MapAllocator is the built-in [dhcp.Allocator] used by [Server] when no
// allocator is supplied. It keeps the lease database in a map and allocates
// addresses sequentially from the configured subnet, preferring a client's
// requested address when it is free and in-subnet.
//
// When configured with a clock (see [AllocatorConfig.Now]) it reclaims expired
// leases back into the pool; with no clock leases never expire, matching the
// behaviour of a server that does not track time.
//
// The map makes MapAllocator unsuitable for the most memory-constrained
// targets; such deployments should provide their own [dhcp.Allocator]. This
// mirrors the existing allowance for the DHCP server to use a map on capable
// hardware.
type MapAllocator struct {
subnet ipv4.Prefix
serverAddr [4]byte
nextAddr [4]byte
leaseSeconds uint32
now func() time.Time
leases map[[36]byte]mapLease
declined map[[4]byte]struct{}
}
type mapLease struct {
addr [4]byte
expiry time.Time // zero means no expiry (no clock configured).
committed bool
}
// AllocatorConfig configures a [MapAllocator].
type AllocatorConfig struct {
// ServerAddr is the server's own address, excluded from the pool.
ServerAddr [4]byte
// Subnet is the allocation prefix.
Subnet ipv4.Prefix
// LeaseSeconds is the lease duration handed to clients. Zero defaults to 3600.
LeaseSeconds uint32
// Now, when non-nil, is used to expire and reclaim leases. When nil leases
// never expire.
Now func() time.Time
}
// NewMapAllocator returns a configured [MapAllocator].
func NewMapAllocator(cfg AllocatorConfig) (*MapAllocator, error) {
if !cfg.Subnet.IsValid() {
return nil, errors.New("dhcpv4 allocator: invalid subnet")
} else if !cfg.Subnet.Contains(cfg.ServerAddr) {
return nil, errors.New("dhcpv4 allocator: server address outside subnet")
}
lease := cfg.LeaseSeconds
if lease == 0 {
lease = 3600
}
return &MapAllocator{
subnet: cfg.Subnet,
serverAddr: cfg.ServerAddr,
nextAddr: cfg.Subnet.Next(cfg.ServerAddr),
leaseSeconds: lease,
now: cfg.Now,
leases: make(map[[36]byte]mapLease),
declined: make(map[[4]byte]struct{}),
}, nil
}
var _ dhcp.Allocator = (*MapAllocator)(nil)
// Offer implements [dhcp.Allocator]. It returns the binding already reserved for
// the client if one exists, otherwise it reserves a new address.
func (a *MapAllocator) Offer(req dhcp.Request) (dhcp.Binding, error) {
a.sweepExpired()
key, ok := keyOf(req.ClientID)
if !ok {
return dhcp.Binding{}, errors.New("dhcpv4 allocator: client id too long")
}
if existing, ok := a.leases[key]; ok {
existing.expiry = a.expiry()
a.leases[key] = existing
return a.binding(existing.addr), nil
}
addr, ok := a.allocAddr(req.Requested)
if !ok {
return dhcp.Binding{}, errors.New("dhcpv4 allocator: address pool exhausted")
}
a.leases[key] = mapLease{addr: addr, expiry: a.expiry()}
return a.binding(addr), nil
}
// Commit implements [dhcp.Allocator], finalizing the client's lease.
func (a *MapAllocator) Commit(req dhcp.Request) (dhcp.Binding, error) {
a.sweepExpired()
key, ok := keyOf(req.ClientID)
if !ok {
return dhcp.Binding{}, errors.New("dhcpv4 allocator: client id too long")
}
lease, ok := a.leases[key]
if !ok {
// Reservation expired or never made; allocate afresh.
addr, ok := a.allocAddr(req.Requested)
if !ok {
return dhcp.Binding{}, errors.New("dhcpv4 allocator: address pool exhausted")
}
lease = mapLease{addr: addr}
}
lease.committed = true
lease.expiry = a.expiry()
a.leases[key] = lease
return a.binding(lease.addr), nil
}
// Release implements [dhcp.Allocator], freeing the client's lease.
func (a *MapAllocator) Release(clientID []byte) error {
key, ok := keyOf(clientID)
if !ok {
return nil
}
delete(a.leases, key)
return nil
}
// Decline implements [dhcp.Allocator], marking the client's address unusable.
func (a *MapAllocator) Decline(req dhcp.Request) error {
key, ok := keyOf(req.ClientID)
if !ok {
return nil
}
if lease, ok := a.leases[key]; ok {
a.declined[lease.addr] = struct{}{}
delete(a.leases, key)
} else if req.Requested.Is4() {
a.declined[req.Requested.As4()] = struct{}{}
}
return nil
}
// AppendOptions implements [dhcp.Allocator]. The server already wrote the
// configuration-derived options, so the default allocator leaves them as-is.
func (a *MapAllocator) AppendOptions(dst []byte, _ []byte, _ dhcp.Binding) ([]byte, error) {
return dst, nil
}
func (a *MapAllocator) binding(addr [4]byte) dhcp.Binding {
t1, t2 := dhcp.DefaultT1T2(a.leaseSeconds)
return dhcp.Binding{
T1: t1,
T2: t2,
Leases: []dhcp.Lease{{
Addr: netip.AddrFrom4(addr),
Preferred: a.leaseSeconds,
Valid: a.leaseSeconds,
}},
}
}
func (a *MapAllocator) expiry() time.Time {
if a.now == nil {
return time.Time{}
}
return a.now().Add(time.Duration(a.leaseSeconds) * time.Second)
}
func (a *MapAllocator) sweepExpired() {
if a.now == nil {
return
}
now := a.now()
for k, v := range a.leases {
if !v.expiry.IsZero() && now.After(v.expiry) {
delete(a.leases, k)
}
}
}
// allocAddr allocates the next available address from the pool, preferring a
// valid in-subnet requested address that is free.
func (a *MapAllocator) allocAddr(requested netip.Addr) ([4]byte, bool) {
if requested.Is4() {
candidate := requested.As4()
if a.subnet.Contains(candidate) && candidate != a.serverAddr && !a.isAssigned(candidate) {
return candidate, true
}
}
addr := a.nextAddr
a.nextAddr = a.subnet.Next(a.nextAddr)
if a.nextAddr == a.serverAddr {
a.nextAddr = a.subnet.Next(a.nextAddr)
}
// Reject the broadcast address (all host bits set).
hostBits := uint(32 - a.subnet.Bits())
hostMask := ^uint32(0) >> (32 - hostBits)
if binary.BigEndian.Uint32(addr[:])&hostMask == hostMask {
return [4]byte{}, false
}
return addr, true
}
func (a *MapAllocator) isAssigned(addr [4]byte) bool {
if _, ok := a.declined[addr]; ok {
return true
}
for _, v := range a.leases {
if v.addr == addr {
return true
}
}
return false
}
func keyOf(clientID []byte) ([36]byte, bool) {
var key [36]byte
if len(clientID) > len(key) {
return key, false
}
copy(key[:], clientID)
return key, true
}
+164
View File
@@ -0,0 +1,164 @@
package dhcpv4
import (
"net/netip"
"testing"
"time"
"github.com/soypat/lneto/dhcp"
"github.com/soypat/lneto/ipv4"
)
// recordingAllocator is a test [dhcp.Allocator] that hands out a fixed address,
// records whether the server-derived options reached its AppendOptions hook, and
// appends a marker option of its own.
type recordingAllocator struct {
addr netip.Addr
sawRouter bool
appended bool
}
func (a *recordingAllocator) binding() dhcp.Binding {
return dhcp.Binding{
T1: 30,
T2: 52,
Leases: []dhcp.Lease{{Addr: a.addr, Preferred: 60, Valid: 60}},
}
}
func (a *recordingAllocator) Offer(dhcp.Request) (dhcp.Binding, error) { return a.binding(), nil }
func (a *recordingAllocator) Commit(dhcp.Request) (dhcp.Binding, error) { return a.binding(), nil }
func (a *recordingAllocator) Release([]byte) error { return nil }
func (a *recordingAllocator) Decline(dhcp.Request) error { return nil }
func (a *recordingAllocator) AppendOptions(dst []byte, _ []byte, _ dhcp.Binding) ([]byte, error) {
// dst already contains the server-derived options. Confirm the router
// option the server was configured to send is present.
for i := 0; i+1 < len(dst); {
op := OptNum(dst[i])
if op == OptEnd {
break
}
if op == OptRouter {
a.sawRouter = true
}
i += 2 + int(dst[i+1])
}
// Append our own option into the remaining capacity.
tail := dst[len(dst):cap(dst)]
n, err := EncodeOptionString(tail, OptDomainName, "example")
if err != nil {
return dst, err
}
a.appended = true
return dst[:len(dst)+n], nil
}
// TestServerCustomAllocator verifies the server delegates address assignment to
// an injected allocator, that the allocator's AppendOptions hook sees the
// server-derived options, and that options it appends reach the client.
func TestServerCustomAllocator(t *testing.T) {
svAddr := [4]byte{192, 168, 1, 1}
want := netip.AddrFrom4([4]byte{10, 9, 8, 7})
alloc := &recordingAllocator{addr: want}
var sv Server
err := sv.Configure(ServerConfig{
ServerAddr: svAddr,
Gateway: [4]byte{192, 168, 1, 254},
Subnet: ipv4.PrefixFrom(svAddr, 24),
Allocator: alloc,
})
if err != nil {
t.Fatal(err)
}
var cl Client
if err := cl.BeginRequest(42, RequestConfig{ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6}}); err != nil {
t.Fatal(err)
}
var buf [1024]byte
n, err := cl.Encapsulate(buf[:], -1, 0)
if err != nil {
t.Fatal(err)
}
if err := sv.Demux(buf[:n], 0); err != nil {
t.Fatal(err)
}
n, err = sv.Encapsulate(buf[:], -1, 0)
if err != nil {
t.Fatal(err)
} else if n == 0 {
t.Fatal("no offer emitted")
}
frm, _ := NewFrame(buf[:n])
if got := *frm.YIAddr(); got != want.As4() {
t.Errorf("offered address: got %v want %v", got, want)
}
if !alloc.sawRouter {
t.Error("allocator AppendOptions did not see the server-derived router option")
}
var foundDomain bool
frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
if opt == OptDomainName && string(data) == "example" {
foundDomain = true
}
return nil
})
if !foundDomain {
t.Error("allocator-appended domain option did not reach the emitted frame")
}
}
// TestMapAllocatorExpiration verifies that the default allocator reclaims an
// expired lease back into the pool once its valid lifetime has elapsed.
func TestMapAllocatorExpiration(t *testing.T) {
svAddr := [4]byte{192, 168, 1, 1}
now := time.Unix(1_000_000, 0)
clock := func() time.Time { return now }
a, err := NewMapAllocator(AllocatorConfig{
ServerAddr: svAddr,
Subnet: ipv4.PrefixFrom(svAddr, 24),
LeaseSeconds: 10,
Now: clock,
})
if err != nil {
t.Fatal(err)
}
// Client 1 acquires and commits an address.
b1, err := a.Offer(dhcp.Request{ClientID: []byte{1}})
if err != nil {
t.Fatal(err)
}
addr1, _ := b1.Addr()
if _, err := a.Commit(dhcp.Request{ClientID: []byte{1}}); err != nil {
t.Fatal(err)
}
// While client 1 holds the lease, another client requesting the same
// address must not be granted it.
b2, err := a.Offer(dhcp.Request{ClientID: []byte{2}, Requested: addr1})
if err != nil {
t.Fatal(err)
}
if addr2, _ := b2.Addr(); addr2 == addr1 {
t.Fatalf("addr %v handed out while still leased to client 1", addr1)
}
if err := a.Release([]byte{2}); err != nil {
t.Fatal(err)
}
// Advance time past client 1's lease; its address must be reclaimable.
now = now.Add(20 * time.Second)
b3, err := a.Offer(dhcp.Request{ClientID: []byte{3}, Requested: addr1})
if err != nil {
t.Fatal(err)
}
if addr3, _ := b3.Addr(); addr3 != addr1 {
t.Errorf("expired address not reclaimed: got %v want %v", addr3, addr1)
}
}
+2 -2
View File
@@ -265,11 +265,11 @@ func (c *Client) getMessageType(frm Frame) MessageType {
func (c *Client) setOptions(frm Frame) error {
err := frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
switch opt {
case OptRenewTimeValue:
case OptT1Renewal:
c.tRenew = maybeU32(data)
case OptIPAddressLeaseTime:
c.tIPLease = maybeU32(data)
case OptRebindingTimeValue:
case OptT2Rebinding:
c.tRebind = maybeU32(data)
case OptServerIdentification:
c.svip.setmaybe(data)
+2 -2
View File
@@ -128,8 +128,8 @@ const (
OptParameterRequestList OptNum = 55 // Parameter request list
OptMessage OptNum = 56 // DHCP error message
OptMaximumMessageSize OptNum = 57 // DHCP maximum message size
OptRenewTimeValue OptNum = 58 // DHCP renewal (T1) time
OptRebindingTimeValue OptNum = 59 // DHCP rebinding (T2) time
OptT1Renewal OptNum = 58 // DHCP renewal (T1) time
OptT2Rebinding OptNum = 59 // DHCP rebinding (T2) time
OptClientIdentifier OptNum = 60 // Client identifier
OptClientIdentifier1 OptNum = 61 // Client identifier(1)
+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) {
+2 -2
View File
@@ -239,9 +239,9 @@ func TestServerOfferContainsOptions(t *testing.T) {
foundLease = true
gotLease = maybeU32(data)
}
case OptRenewTimeValue:
case OptT1Renewal:
gotRenew = maybeU32(data)
case OptRebindingTimeValue:
case OptT2Rebinding:
gotRebind = maybeU32(data)
}
return nil
+2 -2
View File
@@ -67,8 +67,8 @@ func _() {
_ = x[OptParameterRequestList-55]
_ = x[OptMessage-56]
_ = x[OptMaximumMessageSize-57]
_ = x[OptRenewTimeValue-58]
_ = x[OptRebindingTimeValue-59]
_ = x[OptT1Renewal-58]
_ = x[OptT2Rebinding-59]
_ = x[OptClientIdentifier-60]
_ = x[OptClientIdentifier1-61]
}
+60
View File
@@ -0,0 +1,60 @@
// Package dhcp holds protocol-version-independent types shared by the DHCPv4
// and DHCPv6 implementations. In particular it defines the [Allocator]
// interface a DHCP server delegates address assignment and lease lifetime
// management to, decoupling the higher-level state machine from the backing
// lease database.
//
// The lease lifetime model follows the common shape described by RFC 2131
// (DHCPv4) and RFC 8415 (DHCPv6): a per-client [Binding] carries renewal (T1)
// and rebind (T2) times and holds one or more [Lease]s, each with a preferred
// and valid lifetime. DHCPv4 assigns a single address whose preferred and
// valid lifetimes equal the lease time; DHCPv6 may assign several addresses
// (and prefixes) through identity associations, each with its own lifetimes.
package dhcp
import "net/netip"
// Lease is a single address binding offered or assigned to a client.
type Lease struct {
// Addr is the assigned address. For DHCPv4 this is an IPv4 address.
Addr netip.Addr
// Preferred is the preferred lifetime in seconds (RFC 8415 §7.1). For
// DHCPv4, where there is no distinct preferred lifetime, set it equal to
// Valid.
Preferred uint32
// Valid is the valid lifetime in seconds. This is the DHCPv4 "IP Address
// Lease Time" (RFC 2132 option 51).
Valid uint32
}
// Binding groups the leases assigned to a single client identity association
// together with the renewal and rebind times that govern when the client
// should attempt to extend them.
type Binding struct {
// T1 is the renewal time in seconds: when the client should contact the
// allocating server to extend its leases. RFC 2131 §4.4.5 / RFC 8415 §14.2
// default this to half the (shortest) lease time.
T1 uint32
// T2 is the rebinding time in seconds: when the client should broadcast to
// any server to extend its leases. The RFCs default this to 0.875 of the
// (shortest) lease time.
T2 uint32
// Leases holds the addresses bound to the client. DHCPv4 uses exactly one
// lease; DHCPv6 may use more than one (IA_NA / IA_PD).
Leases []Lease
}
// Addr returns the first lease address and whether the binding holds any lease.
// It is a convenience for single-address (DHCPv4) callers.
func (b Binding) Addr() (netip.Addr, bool) {
if len(b.Leases) == 0 {
return netip.Addr{}, false
}
return b.Leases[0].Addr, true
}
// DefaultT1T2 returns the RFC 2131 §4.4.5 default renewal (T1) and rebind (T2)
// times for a given lease duration: T1 = 0.5·lease and T2 = 0.875·lease.
func DefaultT1T2(leaseSeconds uint32) (t1Renewal, t2Rebinding uint32) {
return leaseSeconds / 2, leaseSeconds * 7 / 8
}
+1 -1
View File
@@ -697,7 +697,7 @@ func (pc *PacketBreakdown) CaptureDHCPv4(dst []Frame, pkt []byte, bitOffset int)
field.Class = FieldClassSize
// Time/duration options (seconds).
case dhcpv4.OptIPAddressLeaseTime, dhcpv4.OptRenewTimeValue, dhcpv4.OptRebindingTimeValue,
case dhcpv4.OptIPAddressLeaseTime, dhcpv4.OptT1Renewal, dhcpv4.OptT2Rebinding,
dhcpv4.OptTimeOffset, dhcpv4.OptARPCacheTimeout, dhcpv4.OptPathMTUAgingTimeout,
dhcpv4.OptTCPKeepaliveInterval, dhcpv4.OptDefaultIPTTL, dhcpv4.OptDefaultTCPTimetoLive:
field.Class = FieldClassTimestamp