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 }