From b46b4c7bc56659733d239beac1b81b041c8ce7a0 Mon Sep 17 00:00:00 2001 From: Patricio Whittingslow Date: Sat, 20 Jun 2026 18:20:18 -0300 Subject: [PATCH] draft DHCP allocator abstraction --- dhcp/allocator.go | 65 ++++++ dhcp/dhcpv4/allocator.go | 227 +++++++++++++++++++ dhcp/dhcpv4/allocator_test.go | 164 ++++++++++++++ dhcp/dhcpv4/client.go | 4 +- dhcp/dhcpv4/definitions.go | 4 +- dhcp/dhcpv4/server.go | 413 ++++++++++++++++++++-------------- dhcp/dhcpv4/server_test.go | 4 +- dhcp/dhcpv4/stringers.go | 4 +- dhcp/lease.go | 60 +++++ internet/pcap/capture.go | 2 +- 10 files changed, 770 insertions(+), 177 deletions(-) create mode 100644 dhcp/allocator.go create mode 100644 dhcp/dhcpv4/allocator.go create mode 100644 dhcp/dhcpv4/allocator_test.go create mode 100644 dhcp/lease.go diff --git a/dhcp/allocator.go b/dhcp/allocator.go new file mode 100644 index 0000000..0495b6f --- /dev/null +++ b/dhcp/allocator.go @@ -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) +} diff --git a/dhcp/dhcpv4/allocator.go b/dhcp/dhcpv4/allocator.go new file mode 100644 index 0000000..279050f --- /dev/null +++ b/dhcp/dhcpv4/allocator.go @@ -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 +} diff --git a/dhcp/dhcpv4/allocator_test.go b/dhcp/dhcpv4/allocator_test.go new file mode 100644 index 0000000..c6aa284 --- /dev/null +++ b/dhcp/dhcpv4/allocator_test.go @@ -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) + } +} diff --git a/dhcp/dhcpv4/client.go b/dhcp/dhcpv4/client.go index e24d969..1192eac 100644 --- a/dhcp/dhcpv4/client.go +++ b/dhcp/dhcpv4/client.go @@ -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) diff --git a/dhcp/dhcpv4/definitions.go b/dhcp/dhcpv4/definitions.go index b00df18..795a356 100644 --- a/dhcp/dhcpv4/definitions.go +++ b/dhcp/dhcpv4/definitions.go @@ -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) diff --git a/dhcp/dhcpv4/server.go b/dhcp/dhcpv4/server.go index 16f260b..938c972 100644 --- a/dhcp/dhcpv4/server.go +++ b/dhcp/dhcpv4/server.go @@ -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) { diff --git a/dhcp/dhcpv4/server_test.go b/dhcp/dhcpv4/server_test.go index 1eb95bb..479e11e 100644 --- a/dhcp/dhcpv4/server_test.go +++ b/dhcp/dhcpv4/server_test.go @@ -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 diff --git a/dhcp/dhcpv4/stringers.go b/dhcp/dhcpv4/stringers.go index 2141e9e..dd3a76e 100644 --- a/dhcp/dhcpv4/stringers.go +++ b/dhcp/dhcpv4/stringers.go @@ -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] } diff --git a/dhcp/lease.go b/dhcp/lease.go new file mode 100644 index 0000000..ac3c496 --- /dev/null +++ b/dhcp/lease.go @@ -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 +} diff --git a/internet/pcap/capture.go b/internet/pcap/capture.go index 1c253e5..95481ff 100644 --- a/internet/pcap/capture.go +++ b/internet/pcap/capture.go @@ -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