Pull request 2525: 4923-gopacket-dhcp-vol.13

Squashed commit of the following:

commit 79d96a231649cecbb41b90796ddf0ae6c6bdba4c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Nov 18 14:05:37 2025 +0300

    dhcpsvc: imp code, docs

commit 8944c8c44c5306760dda12085af05f37f7bca20f
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Nov 17 17:21:41 2025 +0300

    dhcpsvc: add lease allocation
This commit is contained in:
Eugene Burkov 2025-11-18 19:40:54 +03:00
parent 9a32f73a1e
commit ff0ef4f398
6 changed files with 299 additions and 22 deletions

View file

@ -0,0 +1,22 @@
package dhcpsvc
import "net/netip"
// addressChecker checks addresses for availability.
type addressChecker interface {
// IsAvailable returns true if the address is available in the current
// subnet. Any error is a network error.
IsAvailable(ip netip.Addr) (ok bool, err error)
}
// noopAddressChecker is an implementation of [addressChecker] that doesn't
// perform any checks.
type noopAddressChecker struct{}
// IsAvailable implements the [addressChecker] interface for noopAddressChecker.
func (c noopAddressChecker) IsAvailable(ip netip.Addr) (ok bool, err error) {
return true, nil
}
// TODO(e.burkov): Add ICMP implementation of [addressChecker], as required by
// https://datatracker.ietf.org/doc/html/rfc2131#section-2.2.

View file

@ -0,0 +1,49 @@
package dhcpsvc
const bitsPerWord = 64
// bitSet is a sparse bitSet. A nil *bitSet is an empty bitSet.
type bitSet struct {
words map[uint64]uint64
}
// newBitSet returns a new bitset.
func newBitSet() (s *bitSet) {
return &bitSet{
words: map[uint64]uint64{},
}
}
// isSet returns true if the bit n is set.
func (s *bitSet) isSet(n uint64) (ok bool) {
if s == nil {
return false
}
wordIdx := n / bitsPerWord
bitIdx := n % bitsPerWord
var word uint64
word, ok = s.words[wordIdx]
return ok && word&(1<<bitIdx) != 0
}
// set sets or unsets a bit.
func (s *bitSet) set(n uint64, ok bool) {
if s == nil {
return
}
wordIdx := n / bitsPerWord
bitIdx := n % bitsPerWord
word := s.words[wordIdx]
if ok {
word |= 1 << bitIdx
} else {
word &^= 1 << bitIdx
}
s.words[wordIdx] = word
}

View file

@ -0,0 +1,90 @@
package dhcpsvc
import (
"math"
"testing"
"testing/quick"
"github.com/stretchr/testify/assert"
)
func TestBitSet(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var s *bitSet
ok := s.isSet(0)
assert.False(t, ok)
assert.NotPanics(t, func() {
s.set(0, true)
})
ok = s.isSet(0)
assert.False(t, ok)
assert.NotPanics(t, func() {
s.set(0, false)
})
ok = s.isSet(0)
assert.False(t, ok)
})
t.Run("non_nil", func(t *testing.T) {
s := newBitSet()
ok := s.isSet(0)
assert.False(t, ok)
s.set(0, true)
ok = s.isSet(0)
assert.True(t, ok)
s.set(0, false)
ok = s.isSet(0)
assert.False(t, ok)
})
t.Run("non_nil_long", func(t *testing.T) {
s := newBitSet()
s.set(0, true)
s.set(math.MaxUint64, true)
assert.Len(t, s.words, 2)
ok := s.isSet(0)
assert.True(t, ok)
ok = s.isSet(math.MaxUint64)
assert.True(t, ok)
})
t.Run("compare_to_map", func(t *testing.T) {
m := map[uint64]struct{}{}
s := newBitSet()
mapFunc := func(setNew, checkOld, delOld uint64) (ok bool) {
m[setNew] = struct{}{}
delete(m, delOld)
_, ok = m[checkOld]
return ok
}
setFunc := func(setNew, checkOld, delOld uint64) (ok bool) {
s.set(setNew, true)
s.set(delOld, false)
ok = s.isSet(checkOld)
return ok
}
err := quick.CheckEqual(mapFunc, setFunc, &quick.Config{
MaxCount: 10_000,
MaxCountScale: 10,
})
assert.NoError(t, err)
})
}

View file

@ -4,8 +4,11 @@ import (
"fmt"
"log/slog"
"net"
"net/netip"
"sync"
"time"
"github.com/AdguardTeam/golibs/errors"
)
// macKey contains hardware address as byte array of 6, 8, or 20 bytes.
@ -38,15 +41,22 @@ type netInterface struct {
// TODO(e.burkov): Consider removing it and using the value from context.
logger *slog.Logger
// indexMu protects the index as well as leases in the interfaces.
// indexMu protects the index, leases, and leasedOffsets.
indexMu *sync.RWMutex
// leasedOffsets contains offsets from conf.ipRange.start that have been
// leased.
leasedOffsets *bitSet
// index stores the DHCP leases for quick lookups.
index *leaseIndex
// leases is the set of DHCP leases assigned to this interface.
leases map[macKey]*Lease
// addrSpace is the IPv4 address space allocated for leasing.
addrSpace ipRange
// name is the name of the network interface.
name string
@ -70,6 +80,9 @@ func (iface *netInterface) addLease(l *Lease) (err error) {
iface.leases[mk] = l
off, _ := iface.addrSpace.offset(l.IP)
iface.leasedOffsets.set(off, true)
return nil
}
@ -88,7 +101,7 @@ func (iface *netInterface) updateLease(l *Lease) (prev *Lease, err error) {
}
// removeLease removes an existing lease from iface. It returns an error if
// there is no lease equal to l.
// there is no lease equal to l. l must not be nil.
func (iface *netInterface) removeLease(l *Lease) (err error) {
mk := macToKey(l.HWAddr)
_, found := iface.leases[mk]
@ -98,5 +111,35 @@ func (iface *netInterface) removeLease(l *Lease) (err error) {
delete(iface.leases, mk)
off, _ := iface.addrSpace.offset(l.IP)
iface.leasedOffsets.set(off, false)
return nil
}
// nextIP generates a new free IP.
func (iface *netInterface) nextIP() (ip netip.Addr) {
r := iface.addrSpace
ip = r.find(func(next netip.Addr) (ok bool) {
offset, ok := r.offset(next)
if !ok {
panic(fmt.Errorf("next: %s: %w", next, errors.ErrOutOfRange))
}
return !iface.leasedOffsets.isSet(offset)
})
return ip
}
// findExpiredLease returns the first found lease that has expired. indexMu
// must be locked.
func (iface *netInterface) findExpiredLease(now time.Time) (l *Lease) {
for _, lease := range iface.leases {
if !lease.IsStatic && lease.Expiry.Before(now) {
return lease
}
}
return nil
}

View file

@ -347,6 +347,8 @@ func hostname4(msg *layers.DHCPv4) (hostname string) {
// requestedOptions returns the list of options requested in DHCPv4 message, if
// any.
//
// TODO(e.burkov): Use [iter.Seq1].
func requestedOptions(msg *layers.DHCPv4) (opts []layers.DHCPOpt) {
for _, opt := range msg.Options {
l := len(opt.Data)

View file

@ -131,15 +131,15 @@ type dhcpInterfaceV4 struct {
// clock used to get current time.
clock timeutil.Clock
// addrChecker checks addresses for availability.
addrChecker addressChecker
// gateway is the IP address of the network gateway.
gateway netip.Addr
// subnet is the network subnet of the interface.
subnet netip.Prefix
// addrSpace is the IPv4 address space allocated for leasing.
addrSpace ipRange
// implicitOpts are the options listed in Appendix A of RFC 2131 and
// initialized with default values. It must not have intersections with
// explicitOpts.
@ -172,17 +172,20 @@ func (srv *DHCPServer) newDHCPInterfaceV4(
addrSpace, _ := newIPRange(conf.RangeStart, conf.RangeEnd)
iface = &dhcpInterfaceV4{
gateway: conf.GatewayIP,
clock: conf.Clock,
subnet: netip.PrefixFrom(conf.GatewayIP, maskLen),
addrSpace: addrSpace,
// TODO(e.burkov): Use an ICMP implementation.
addrChecker: noopAddressChecker{},
gateway: conf.GatewayIP,
clock: conf.Clock,
subnet: netip.PrefixFrom(conf.GatewayIP, maskLen),
common: &netInterface{
logger: l,
indexMu: srv.leasesMu,
index: srv.leases,
leases: map[macKey]*Lease{},
name: name,
leaseTTL: conf.LeaseDuration,
logger: l,
indexMu: srv.leasesMu,
index: srv.leases,
leases: map[macKey]*Lease{},
leasedOffsets: newBitSet(),
name: name,
addrSpace: addrSpace,
leaseTTL: conf.LeaseDuration,
},
}
iface.implicitOpts, iface.explicitOpts = conf.options(ctx, l)
@ -195,8 +198,8 @@ func (iface *dhcpInterfaceV4) commitLease(ctx context.Context, l *Lease, hostnam
// TODO(e.burkov): Implement.
}
// sendOffer sends a DHCPOFFER message to the client.
func (iface *dhcpInterfaceV4) sendOffer(
// respondOffer sends a DHCPOFFER message to the client.
func (iface *dhcpInterfaceV4) respondOffer(
ctx context.Context,
rw responseWriter4,
req *layers.DHCPv4,
@ -316,16 +319,21 @@ func (iface *dhcpInterfaceV4) handleDiscover(
l.DebugContext(ctx, "different requested ip", "requested", reqIP, "lease", lease.IP)
}
iface.sendOffer(ctx, rw, req, lease)
iface.respondOffer(ctx, rw, req, lease)
return
}
// TODO(e.burkov): Allocate a new lease.
lease = &Lease{}
lease, err := iface.allocateLease(ctx, mac)
if err != nil {
l.ErrorContext(ctx, "allocating a lease", "error", err)
return
}
// Send DHCPOFFER with new lease.
iface.sendOffer(ctx, rw, req, lease)
iface.respondOffer(ctx, rw, req, lease)
}
// handleSelecting handles messages of type request in SELECTING state. req
@ -481,7 +489,7 @@ func (iface *dhcpInterfaceV4) handleRenew(
type dhcpInterfacesV4 []*dhcpInterfaceV4
// find returns the first network interface within ifaces containing ip. It
// returns false if there is no such interface.
// returns false if there is no such interface. ip must be valid.
func (ifaces dhcpInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok bool) {
i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV4) (contains bool) {
return iface.subnet.Contains(ip)
@ -494,7 +502,7 @@ func (ifaces dhcpInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok boo
}
// findInterface returns the first DHCPv4 interface within ifaces containing
// ip. It returns false if there is no such interface.
// ip. It returns false if there is no such interface. ip must be valid.
func (ifaces dhcpInterfacesV4) findInterface(ip netip.Addr) (iface *dhcpInterfaceV4, ok bool) {
i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV4) (contains bool) {
return iface.subnet.Contains(ip)
@ -505,3 +513,66 @@ func (ifaces dhcpInterfacesV4) findInterface(ip netip.Addr) (iface *dhcpInterfac
return ifaces[i], true
}
// allocateLease allocates a new lease for the MAC address. If there are no IP
// addresses left, both l and err are nil. mac must be a valid according to
// [netutil.ValidateMAC].
func (iface *dhcpInterfaceV4) allocateLease(
ctx context.Context,
mac net.HardwareAddr,
) (l *Lease, err error) {
for {
l, err = iface.reserveLease(ctx, mac)
if err != nil {
return nil, fmt.Errorf("reserving a lease: %w", err)
}
var ok bool
ok, err = iface.addrChecker.IsAvailable(l.IP)
if err != nil {
return nil, fmt.Errorf("checking address availability: %w", err)
} else if ok {
return l, nil
}
iface.common.logger.DebugContext(ctx, "address not available", "ip", l.IP)
// TODO(e.burkov): Implement blacklisting of unavailable addresses.
}
}
// reserveLease reserves a lease for a client by its MAC-address. l is nil if a
// new lease can't be allocated. mac must be a valid according to
// [netutil.ValidateMAC].
func (iface *dhcpInterfaceV4) reserveLease(
ctx context.Context,
mac net.HardwareAddr,
) (l *Lease, err error) {
nextIP := iface.common.nextIP()
if nextIP == (netip.Addr{}) {
l = iface.common.findExpiredLease(iface.clock.Now())
if l == nil {
return nil, nil
}
// TODO(e.burkov): Move validation from index methods into server's
// methods and use index here.
delete(iface.common.leases, macToKey(l.HWAddr))
l.HWAddr = slices.Clone(mac)
iface.common.leases[macToKey(mac)] = l
return l, nil
}
l = &Lease{
HWAddr: slices.Clone(mac),
IP: nextIP,
}
err = iface.common.index.add(ctx, iface.common.logger, l, iface.common)
if err != nil {
return nil, err
}
return l, nil
}