Pull request 2634: AGDNS-3863-gopacket-dhcp-vol.23

Updates #4923.

Squashed commit of the following:

commit 305d3ba116a6abcdb23805af8a5a7dfb82bdc580
Merge: c7512c829 9e153fbd9
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Apr 21 19:30:31 2026 +0300

    Merge branch 'master' into AGDNS-3863-gopacket-dhcp-vol.23

commit c7512c82921b2dbf0543e9c87120d2ce874729fd
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Apr 21 19:11:39 2026 +0300

    dhcpsvc: fix doc

commit f0fe3b6bcf34672de9cbe94d73d7c3b3c3589368
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Apr 21 15:10:16 2026 +0300

    dhcpsvc: imp docs

commit 8eeea478bff002a3683c02ba72861f3db9e550db
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Apr 21 14:53:56 2026 +0300

    dhcpsvc: imp code, docs

commit 96cf3b34feb8a12c77a2c5e49c9c5a61537c95c4
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Apr 20 15:30:58 2026 +0300

    dhcpsvc: add dhcpv6 handle, imp code

commit e94cb9e1da8c117bc4c16634270230125f97677c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Apr 15 15:56:38 2026 +0300

    dhcpsvc: add ipv6 consts
This commit is contained in:
Eugene Burkov 2026-04-21 16:53:45 +00:00
parent 9e153fbd99
commit b08e587660
14 changed files with 529 additions and 106 deletions

View file

@ -140,7 +140,7 @@ func TestIPv6Config_Validate(t *testing.T) {
RangeStart: testIPv4Conf.GatewayIP,
LeaseDuration: 1 * time.Hour,
},
wantErrMsg: "range start " + testGatewayIPv4Str + " should be a valid ipv6",
wantErrMsg: "range start " + testGatewayIPv4Str + " must be a valid ipv6",
}, {
name: "bad_lease_duration",
conf: &dhcpsvc.IPv6Config{
@ -148,7 +148,7 @@ func TestIPv6Config_Validate(t *testing.T) {
RangeStart: netip.MustParseAddr(testRangeStartV6Str),
LeaseDuration: 0,
},
wantErrMsg: "lease duration 0s must be positive",
wantErrMsg: "lease duration: not positive: 0s",
}, {
name: "valid",
conf: &dhcpsvc.IPv6Config{

View file

@ -34,6 +34,8 @@ type dataLeases struct {
}
// dbLease is the structure of stored lease.
//
// TODO(e.burkov): Migrate to add DUID and IAID fields for DHCPv6 leases.
type dbLease struct {
Expiry string `json:"expires"`
IP netip.Addr `json:"ip"`

View file

@ -109,6 +109,9 @@ const (
testAnotherRangeStartV6Str = "2001:db9::1"
)
// testHWIface is the test MAC address of a test network interface.
var testHWIface = net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA}
var (
// testIPv4Conf is a common valid IPv4 part of the interface configuration
// for tests.

View file

@ -21,7 +21,7 @@ func (srv *DHCPServer) serveEther4(ctx context.Context, iface *dhcpInterfaceV4,
src := gopacket.NewPacketSource(nd, nd.LinkType())
for pkt := range src.Packets() {
fd := newFrameData(ctx, srv.logger, pkt, nd)
fd := newFrameData4(ctx, srv.logger, pkt, nd)
if fd == nil {
continue
}
@ -33,15 +33,40 @@ func (srv *DHCPServer) serveEther4(ctx context.Context, iface *dhcpInterfaceV4,
}
}
// newFrameData creates a new frameData with layers extracted from pkt. It
// serveEther6 handles the incoming ethernet packets and dispatches them to the
// appropriate handler. It's used to run in a separate goroutine as it blocks
// until packets channel is closed. iface and nd must not be nil. nd must have
// at least a single address returned by its Addresses method.
//
//lint:ignore U1000 TODO(e.burkov): Use.
func (srv *DHCPServer) serveEther6(ctx context.Context, iface *dhcpInterfaceV6, nd NetworkDevice) {
defer slogutil.RecoverAndLog(ctx, srv.logger)
src := gopacket.NewPacketSource(nd, nd.LinkType())
srvDUID := newServerDUID(nd.HardwareAddr())
for pkt := range src.Packets() {
fd := newFrameData6(ctx, srv.logger, pkt, nd, srvDUID)
if fd == nil {
continue
}
err := srv.serveV6(ctx, iface, pkt, fd)
if err != nil {
srv.logger.ErrorContext(ctx, "serving", slogutil.KeyError, err)
}
}
}
// newFrameData4 creates a new [frameData4] with layers extracted from pkt. It
// returns nil if the packet is not an Ethernet or IPv4 packet, or if the
// network device has no addresses. logger, pkt, and dev must not be nil.
func newFrameData(
func newFrameData4(
ctx context.Context,
logger *slog.Logger,
pkt gopacket.Packet,
dev NetworkDevice,
) (fd *frameData) {
) (fd *frameData4) {
addrs := dev.Addresses()
if len(addrs) == 0 {
logger.ErrorContext(ctx, "no addresses for network device")
@ -70,7 +95,7 @@ func newFrameData(
addr = addrs[0]
}
return &frameData{
return &frameData4{
ether: etherLayer,
ip: ipLayer,
device: dev,
@ -78,4 +103,49 @@ func newFrameData(
}
}
// TODO(e.burkov): Add DHCPServer.serveEther6.
// newFrameData6 creates a new [frameData6] with layers extracted from pkt. It
// returns nil if the packet is not an Ethernet or IPv6 packet, or if the
// network device has no addresses. logger, pkt, and dev must not be nil.
func newFrameData6(
ctx context.Context,
logger *slog.Logger,
pkt gopacket.Packet,
dev NetworkDevice,
duid *layers.DHCPv6DUID,
) (fd *frameData6) {
addrs := dev.Addresses()
if len(addrs) == 0 {
logger.ErrorContext(ctx, "no addresses for network device")
return nil
}
etherLayer, ok := pkt.Layer(layers.LayerTypeEthernet).(*layers.Ethernet)
if !ok {
actual := pkt.Layers()
logger.DebugContext(ctx, "skipping non-ethernet packet", "layers", actual)
return nil
}
ipLayer, ok := pkt.Layer(layers.LayerTypeIPv6).(*layers.IPv6)
if !ok {
actual := pkt.Layers()
logger.DebugContext(ctx, "skipping non-ipv6 packet", "layers", actual)
return nil
}
addr, ok := netip.AddrFromSlice(ipLayer.DstIP)
if !ok || !slices.Contains(addrs, addr) {
addr = addrs[0]
}
return &frameData6{
ether: etherLayer,
ip: ipLayer,
duid: duid,
device: dev,
localAddr: addr,
}
}

View file

@ -12,13 +12,13 @@ import (
"github.com/google/gopacket/layers"
)
// serveV4 handles the ethernet packet of IPv4 type. iface and pkt must not be
// nil. iface and fd must not be nil. pkt must be an IPv4 packet.
// serveV4 handles the ethernet packet of IPv4 type. iface must not be nil, fd
// must be valid, pkt must be an IPv4 packet.
func (srv *DHCPServer) serveV4(
ctx context.Context,
iface *dhcpInterfaceV4,
pkt gopacket.Packet,
fd *frameData,
fd *frameData4,
) (err error) {
defer func() { err = errors.Annotate(err, "serving dhcpv4: %w") }()
@ -55,7 +55,7 @@ func (srv *DHCPServer) serveV4(
// messages are handled by all interfaces concurrently, as those offer addresses
// for the independent networks. The DHCPREQUEST, DHCPRELEASE, and DHCPDECLINE
// messages are handled by the appropriate interface according to the client's
// choice. req and fd must not be nil, typ should be one of:
// choice. req must not be nil, fd must be valid, typ should be one of:
// - [layers.DHCPMsgTypeDiscover]
// - [layers.DHCPMsgTypeRequest]
// - [layers.DHCPMsgTypeRelease]
@ -64,7 +64,7 @@ func (iface *dhcpInterfaceV4) handleDHCPv4(
ctx context.Context,
typ layers.DHCPMsgType,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
) (err error) {
switch typ {
case layers.DHCPMsgTypeDiscover:
@ -84,11 +84,11 @@ func (iface *dhcpInterfaceV4) handleDHCPv4(
}
// handleDiscover handles messages of type DHCPDISCOVER. req must be a
// DHCPDISCOVER message, fd must not be nil.
// DHCPDISCOVER message, fd must be valid.
func (iface *dhcpInterfaceV4) handleDiscover(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
) {
l := iface.common.logger
@ -125,7 +125,7 @@ func (iface *dhcpInterfaceV4) handleDiscover(
}
// handleRequest handles the DHCPv4 message of DHCPREQUEST type. req must be a
// DHCPREQUEST message. req and fd must not be nil.
// DHCPREQUEST message. req must not be nil, fd must be valid.
//
// See https://datatracker.ietf.org/doc/html/rfc2131#section-4.3.2.
//
@ -133,7 +133,7 @@ func (iface *dhcpInterfaceV4) handleDiscover(
func (iface *dhcpInterfaceV4) handleRequest(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
) {
srvID, hasSrvID := serverID4(req)
reqIP, hasReqIP := requestedIPv4(req)
@ -181,12 +181,12 @@ func (iface *dhcpInterfaceV4) handleRequest(
}
// handleSelecting handles messages of type DHCPREQUEST in SELECTING state. req
// must be a DHCPREQUEST message, reqIP must be a valid IPv4 address, fd must
// not be nil.
// must be a DHCPREQUEST message, reqIP must be a valid IPv4 address, fd must be
// valid.
func (iface *dhcpInterfaceV4) handleSelecting(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
reqIP netip.Addr,
) {
l := iface.common.logger
@ -235,11 +235,11 @@ func (iface *dhcpInterfaceV4) handleSelecting(
// handleInitReboot handles messages of type DHCPREQUEST in INIT-REBOOT state.
// req must be a DHCPREQUEST message, reqIP must be a valid IPv4 address, fd
// must not be nil.
// must be valid.
func (iface *dhcpInterfaceV4) handleInitReboot(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
reqIP netip.Addr,
) {
l := iface.common.logger
@ -282,11 +282,11 @@ func (iface *dhcpInterfaceV4) handleInitReboot(
// handleRenew handles messages of type DHCPREQUEST in RENEWING or REBINDING
// state. req must be a DHCPREQUEST message, ip should be a previously leased
// address, fd must not be nil.
// address, fd must be valid.
func (iface *dhcpInterfaceV4) handleRenew(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
ip netip.Addr,
) {
l := iface.common.logger

View file

@ -10,16 +10,21 @@ import (
)
// serveV6 handles the ethernet packet of IPv6 type. iface and pkt must not be
// nil. ctx must contain a [frameData] accessible with [frameDataFromContext].
// nil. iface and fd must be valid. pkt must be an IPv6 packet.
//
//lint:ignore U1000 TODO(e.burkov): Use.
func (srv *DHCPServer) serveV6(
ctx context.Context,
_ *dhcpInterfaceV6,
iface *dhcpInterfaceV6,
pkt gopacket.Packet,
fd *frameData6,
) (err error) {
defer func() { err = errors.Annotate(err, "serving dhcpv6: %w") }()
// TODO(e.burkov): Use the iface and fd parameters.
_ = iface
_ = fd
msg, ok := pkt.Layer(layers.LayerTypeDHCPv6).(*layers.DHCPv6)
if !ok {
// TODO(e.burkov): Consider adding some debug information about the

View file

@ -19,6 +19,8 @@ import (
//
// TODO(e.burkov): Identify the client by the hardware address and the client
// identifier from the DHCP messages.
//
// TODO(e.burkov): Identify IPv6 clients with DUID.
type macKey any
// macToKey converts mac into macKey, which is used as the key for the lease

View file

@ -16,6 +16,8 @@ import (
// [websvc].
//
// TODO(e.burkov): Add validation method.
//
// TODO(e.burkov): Migrate to add DUID and IAID fields for DHCPv6 leases.
type Lease struct {
// IP is the IP address leased to the client. It must not be empty.
IP netip.Addr

View file

@ -3,6 +3,7 @@ package dhcpsvc
import (
"context"
"io"
"net"
"net/netip"
"github.com/AdguardTeam/golibs/errors"
@ -63,10 +64,18 @@ type NetworkDevice interface {
// No methods of a device should be called after Close.
io.Closer
// Addresses returns all IP addresses assigned to the device.
// Addresses returns all IP addresses assigned to the device. It must
// return at least one valid address, unless the implementation documents
// the opposite.
Addresses() (ips []netip.Addr)
// LinkType returns the link type of the network interface.
// HardwareAddr returns the hardware (MAC) address of the device. It must
// return a valid hardware address, unless the implementation documents the
// opposite.
HardwareAddr() (hw net.HardwareAddr)
// LinkType returns the link type of the network interface. It must return
// a valid link type, unless the implementation documents the opposite.
LinkType() (lt layers.LinkType)
// WritePacketData writes a serialized packet to the network interface.
@ -98,6 +107,12 @@ func (EmptyNetworkDevice) Addresses() (ips []netip.Addr) {
return nil
}
// HardwareAddr implements the [NetworkDevice] interface for
// [EmptyNetworkDevice]. It always returns nil.
func (EmptyNetworkDevice) HardwareAddr() (hw net.HardwareAddr) {
return nil
}
// LinkType implements the [NetworkDevice] interface for [EmptyNetworkDevice].
// It always returns [layers.LinkTypeNull].
func (EmptyNetworkDevice) LinkType() (lt layers.LinkType) {
@ -110,11 +125,42 @@ func (EmptyNetworkDevice) WritePacketData(_ []byte) (err error) {
return nil
}
// frameData stores the Ethernet and IPv4 layers of the incoming packet, and
// the network device that the packet was received from.
type frameData struct {
ether *layers.Ethernet
ip *layers.IPv4
device NetworkDevice
// frameData4 stores the Ethernet and IPv4 layers of the incoming packet, as
// well as the network device that the packet was received from and its address.
type frameData4 struct {
// ether is the Ethernet layer of the incoming packet. It must not be nil.
ether *layers.Ethernet
// ip is the IPv4 layer of the incoming packet. It must not be nil.
ip *layers.IPv4
// device is the network device that the packet was received from. It must
// not be nil.
device NetworkDevice
// localAddr is the local IP address that the packet was sent to. It must
// be a valid IPv4 address assigned to the device.
localAddr netip.Addr
}
// frameData6 stores the Ethernet and IPv6 layers of the incoming packet, as
// well as the network device that the packet was received from and its address.
type frameData6 struct {
// ether is the Ethernet layer of the incoming packet. It must not be nil.
ether *layers.Ethernet
// ip is the IPv6 layer of the incoming packet. It must not be nil.
ip *layers.IPv6
// duid is the DHCPv6 DUID constructed of the network device hardware
// address. It must not be nil.
duid *layers.DHCPv6DUID
// device is the network device that the packet was received from. It must
// not be nil.
device NetworkDevice
// localAddr is the local IP address that the packet was sent to. It must
// be a valid IPv6 address assigned to the device.
localAddr netip.Addr
}

View file

@ -3,6 +3,7 @@ package dhcpsvc_test
import (
"context"
"io"
"net"
"net/netip"
"sync/atomic"
"testing"
@ -45,6 +46,7 @@ type testNetworkDevice struct {
onReadPacketData func() (data []byte, ci gopacket.CaptureInfo, err error)
onClose func() (err error)
onAddresses func() (ips []netip.Addr)
onHardwareAddr func() (hw net.HardwareAddr)
onLinkType func() (lt layers.LinkType)
onWritePacketData func(data []byte) (err error)
}
@ -69,6 +71,12 @@ func (nd *testNetworkDevice) Addresses() (ips []netip.Addr) {
return nd.onAddresses()
}
// HardwareAddr implements the [dhcpsvc.NetworkDevice] interface for
// *testNetworkDevice.
func (nd *testNetworkDevice) HardwareAddr() (hw net.HardwareAddr) {
return nd.onHardwareAddr()
}
// WritePacketData implements the [dhcpsvc.NetworkDevice] interface for
// *testNetworkDevice.
func (nd *testNetworkDevice) WritePacketData(data []byte) (err error) {
@ -83,8 +91,9 @@ func (nd *testNetworkDevice) LinkType() (lt layers.LinkType) {
// newTestNetworkDeviceManager creates a network device manager for testing. It
// requires that device opened have a deviceName. The device itself has a link
// type [layers.LinkTypeEthernet]. Incoming packets are received from inCh and
// outgoing packets are sent to outCh.
// type [layers.LinkTypeEthernet] and a hardware address [testHWIface].
// Incoming packets are received from inCh and outgoing packets are sent to
// outCh.
func newTestNetworkDeviceManager(
tb testing.TB,
deviceName string,
@ -116,8 +125,9 @@ func newTestNetworkDeviceManager(
}
// newTestNetworkDevice creates a network device for testing. It has a link
// type [layers.LinkTypeEthernet]. Incoming packets are received from inCh and
// outgoing packets are sent to outCh.
// type [layers.LinkTypeEthernet] and a hardware address [testHWIface].
// Incoming packets are received from inCh and outgoing packets are sent to
// outCh.
func newTestNetworkDevice(
tb testing.TB,
addr netip.Addr,
@ -158,6 +168,10 @@ func newTestNetworkDevice(
return []netip.Addr{addr}
}
onHardwareAddr := func() (hw net.HardwareAddr) {
return testHWIface
}
onLinkType := func() (lt layers.LinkType) {
return layers.LinkTypeEthernet
}
@ -172,6 +186,7 @@ func newTestNetworkDevice(
onReadPacketData: onReadPacketData,
onClose: onClose,
onAddresses: onAddresses,
onHardwareAddr: onHardwareAddr,
onLinkType: onLinkType,
onWritePacketData: onWritePacketData,
}, in, out

View file

@ -313,7 +313,7 @@ func (iface *dhcpInterfaceV4) appendRequestedOptions(
// newRespOptions creates the basic options for a DHCP response. fd must not be
// nil, mt must be a valid DHCP response message type. idOpt is an optional
// client identifier from the corresponding request.
func newRespOptions(mt layers.DHCPMsgType, fd *frameData, idOpt []byte) (opts layers.DHCPOptions) {
func newRespOptions(mt layers.DHCPMsgType, fd *frameData4, idOpt []byte) (opts layers.DHCPOptions) {
opts = layers.DHCPOptions{
msgTypeOptions4[mt],
layers.NewDHCPOption(layers.DHCPOptServerID, fd.localAddr.AsSlice()),

View file

@ -0,0 +1,210 @@
package dhcpsvc
import (
"encoding"
"encoding/binary"
"fmt"
"net"
"net/netip"
"time"
"github.com/AdguardTeam/golibs/validate"
"github.com/google/gopacket/layers"
)
// iaNAMinLen is the minimum length of an IA_NA option data field, in bytes.
//
// See RFC 9915 Section 21.4.
const iaNAMinLen = 12
// iaNAOption represents a parsed IA_NA (Identity Association for Non-temporary
// Addresses) option.
//
// See RFC 9915 Section 21.4.
type iaNAOption struct {
// nested are the IA Address options nested within this IA_NA.
nested []iaAddrOption
// iaid is the Identity Association IDentifier, a 4-octet value uniquely
// identifying this IA within the client.
iaid uint32
// t1 is the time after which the client must contact the same server to
// extend the lifetimes of the addresses in this IA.
t1 time.Duration
// t2 is the time after which the client may contact any available server to
// extend the lifetimes.
t2 time.Duration
}
// type check
var _ encoding.BinaryUnmarshaler = (*iaNAOption)(nil)
// UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface for
// *iaNAOption. data should have the following format:
//
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | IAID (4 octets) |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | T1 |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | T2 |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | |
// . IA_NA-options .
// . .
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
func (opt *iaNAOption) UnmarshalBinary(data []byte) (err error) {
err = validate.NoLessThan("data length", len(data), iaNAMinLen)
if err != nil {
// Don't wrap the error, since it's informative enough as is.
return err
}
opt.iaid = binary.BigEndian.Uint32(data[0:4])
opt.t1 = time.Duration(binary.BigEndian.Uint32(data[4:8])) * time.Second
opt.t2 = time.Duration(binary.BigEndian.Uint32(data[8:12])) * time.Second
// Parse the nested options that follow the fixed fields.
nested := data[iaNAMinLen:]
for i := 0; len(nested) >= 4; i++ {
code := layers.DHCPv6Opt(binary.BigEndian.Uint16(nested[0:2]))
l := int(binary.BigEndian.Uint16(nested[2:4]))
err = validate.NoGreaterThan("nested option length", l, len(nested)-4)
if err != nil {
return fmt.Errorf("nested option at index %d: %w", i, err)
}
if code == layers.DHCPv6OptIAAddr {
addr := iaAddrOption{}
err = addr.UnmarshalBinary(nested[4 : 4+l])
if err != nil {
return fmt.Errorf("nested ia_addr at index %d: %w", i, err)
}
opt.nested = append(opt.nested, addr)
}
nested = nested[4+l:]
}
return nil
}
// Encode serializes ia into a DHCPv6 IA_NA option. Each contained
// [iaAddrOption] is encoded as a nested IA Address option.
//
// TODO(e.burkov): Use.
func (opt iaNAOption) Encode() (iaOpt layers.DHCPv6Option) {
// Each nested IA Address option: code (2) + length (2) + data (24).
const nestedAddrSize = 2 + 2 + iaAddrDataLen
data := make([]byte, 0, iaNAMinLen+len(opt.nested)*nestedAddrSize)
data = binary.BigEndian.AppendUint32(data, opt.iaid)
data = binary.BigEndian.AppendUint32(data, uint32(opt.t1.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(opt.t2.Seconds()))
for _, addr := range opt.nested {
data = addr.append(data)
}
return layers.NewDHCPv6Option(layers.DHCPv6OptIANA, data)
}
// iaAddrDataLen is the minimum length of an IA Address option data field, which
// is encoded [iaAddrOption], in bytes, excluding any nested options. It
// consists of the IPv6 address (16 bytes) and the preferred and valid lifetimes
// (4 bytes each).
const iaAddrDataLen = 24
// iaAddrOption represents a parsed IA Address option.
//
// See RFC 9915 Section 21.6.
type iaAddrOption struct {
// addr is the IPv6 address.
addr netip.Addr
// preferredLifetime is the preferred lifetime of the address. When it is
// zero, the address is deprecated.
preferredLifetime time.Duration
// validLifetime is the valid lifetime of the address. When it is zero, the
// address is no longer valid.
validLifetime time.Duration
}
// type check
var _ encoding.BinaryUnmarshaler = (*iaAddrOption)(nil)
// UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface for
// *iaAddrOption. Nested options within IA Address, if any, are
// ignored. data should have the following format:
//
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | |
// | IPv6-address |
// | |
// | |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | preferred-lifetime |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | valid-lifetime |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// . .
// . IAaddr-options .
// . .
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
func (ia *iaAddrOption) UnmarshalBinary(data []byte) (err error) {
err = validate.NoLessThan("data length", len(data), iaAddrDataLen)
if err != nil {
// Don't wrap the error, since it's informative enough as is.
return err
}
var ok bool
ia.addr, ok = netip.AddrFromSlice(data[0:16])
if !ok {
return fmt.Errorf("ia_addr: invalid ipv6 address bytes")
}
ia.preferredLifetime = time.Duration(binary.BigEndian.Uint32(data[16:20])) * time.Second
ia.validLifetime = time.Duration(binary.BigEndian.Uint32(data[20:24])) * time.Second
return nil
}
// append returns the data portion of the IA Address option encoding,
// suitable for use as a nested option inside an IA_NA.
func (ia iaAddrOption) append(orig []byte) (data []byte) {
data = orig
data = binary.BigEndian.AppendUint16(data, uint16(layers.DHCPv6OptIAAddr))
data = binary.BigEndian.AppendUint16(data, uint16(iaAddrDataLen))
// [netip.Addr.AppendBinary] never returns errors.
data, _ = ia.addr.AppendBinary(data)
data = binary.BigEndian.AppendUint32(data, uint32(ia.preferredLifetime.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(ia.validLifetime.Seconds()))
return data
}
// newServerDUID creates a DUID-LL (Link-Layer Address) from the given MAC
// address per RFC 9915 §11.4. The result is deterministic: the same MAC
// address always produces the same DUID, satisfying the stability requirement
// of §11.
func newServerDUID(mac net.HardwareAddr) (duid *layers.DHCPv6DUID) {
return &layers.DHCPv6DUID{
Type: layers.DHCPv6DUIDTypeLL,
HardwareType: HardwareTypeEthernet,
LinkLayerAddress: mac,
}
}

View file

@ -19,6 +19,27 @@ import (
"github.com/google/gopacket/layers"
)
// Port numbers for DHCPv4.
//
// See RFC 2131 Section 4.1.
const (
// ServerPortV4 is the standard DHCPv4 server port.
ServerPortV4 layers.UDPPort = 67
// ClientPortV4 is the standard DHCPv4 client port.
ClientPortV4 layers.UDPPort = 68
)
const (
// IPv4DefaultTTL is the default Time to Live value in seconds as
// recommended by RFC 1700.
IPv4DefaultTTL = 64
// IPProtoVersion is the IP internetwork general protocol version number as
// defined by RFC 1700.
IPProtoVersion = 4
)
// IPv4Config is the interface-specific configuration for DHCPv4.
type IPv4Config struct {
// Clock is used to get current time. It should not be nil.
@ -176,6 +197,8 @@ func (srv *DHCPServer) newDHCPInterfaceV4(
// TODO(e.burkov): Add a helper for converting [netip.Addr] to subnet mask
// to [netutil].
maskLen, _ := net.IPMask(conf.SubnetMask.AsSlice()).Size()
// Ignore the error since it's already checked in [IPv4Config.Validate].
addrSpace, _ := newIPRange(conf.RangeStart, conf.RangeEnd)
iface = &dhcpInterfaceV4{
@ -207,14 +230,14 @@ func (iface *dhcpInterfaceV4) updateLease(ctx context.Context, lease *Lease) (er
}
// respondOffer sends a DHCPOFFER message to the client. idOpt is expected to
// be the value of the DHCP option Client Identifier, nil if not present. req,
// fd, and lease must not be nil.
// be the value of the DHCP option Client Identifier, nil if not present. req
// and lease must not be nil, fd must be valid
//
// TODO(e.burkov): Consider merging with [respondACK].
func (iface *dhcpInterfaceV4) respondOffer(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
lease *Lease,
idOpt []byte,
) {
@ -231,15 +254,15 @@ func (iface *dhcpInterfaceV4) respondOffer(
}
// respondACK sends a DHCPACK message to the client. idOpt is expected to be
// the value of the DHCP option Client Identifier, nil if not present. req, fd,
// and lease must not be nil.
// the value of the DHCP option Client Identifier, nil if not present. req and
// lease must not be nil, fd must be valid.
//
// TODO(e.burkov): Implement according to RFC, answer to DHCPINFORM
// differently, when it's supported.
func (iface *dhcpInterfaceV4) respondACK(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
lease *Lease,
idOpt []byte,
) {
@ -257,13 +280,13 @@ func (iface *dhcpInterfaceV4) respondACK(
// respondNAK constructs and sends a DHCPNAK message to the client. idOpt is
// expected to be the value of the DHCP option Client Identifier, nil if not
// present. req, fd, and resp must not be nil.
// present. req and resp must not be nil, fd must be valid.
//
// See https://datatracker.ietf.org/doc/html/rfc2131#section-4.3.1.
func (iface *dhcpInterfaceV4) respondNAK(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData,
fd *frameData4,
idOpt []byte,
) {
// TODO(e.burkov): According to RFC 2131 we should add a message.
@ -416,13 +439,13 @@ func (iface *dhcpInterfaceV4) reserveLease(
// updateAndRespond updates the lease and sends a DHCPACK or DHCPNAK response to
// the client according to the update result. idOpt is an expected to be the
// value of the DHCP option Client Identifier, nil if not present. req must be
// a DHCPREQUEST message, lease, l, and fd must not be nil.
// a DHCPREQUEST message, lease, and l must not be nil, fd must be valid.
func (iface *dhcpInterfaceV4) updateAndRespond(
ctx context.Context,
l *slog.Logger,
req *layers.DHCPv4,
lease *Lease,
fd *frameData,
fd *frameData4,
idOpt []byte,
) {
lease.Hostname = cmp.Or(hostname4(req), lease.Hostname)
@ -438,32 +461,12 @@ func (iface *dhcpInterfaceV4) updateAndRespond(
iface.respondACK(ctx, req, fd, lease, idOpt)
}
const (
// IPv4DefaultTTL is the default Time to Live value in seconds as
// recommended by RFC 1700.
IPv4DefaultTTL = 64
// IPProtoVersion is the IP internetwork general protocol version number as
// defined by RFC 1700.
IPProtoVersion = 4
)
// Port numbers for DHCPv4.
//
// See RFC 2131 Section 4.1.
const (
// ServerPortV4 is the standard DHCPv4 server port.
ServerPortV4 layers.UDPPort = 67
// ClientPortV4 is the standard DHCPv4 client port.
ClientPortV4 layers.UDPPort = 68
)
// FlagsBroadcast is the DHCPv4 message flags field with the broadcast bit set.
const FlagsBroadcast uint16 = 1 << 15
// respond4 sends a DHCPv4 response. fd, req, and resp must not be nil.
func respond4(fd *frameData, req, resp *layers.DHCPv4) (err error) {
// respond4 sends a DHCPv4 response. req and resp must not be nil, fd must be
// valid.
func respond4(fd *frameData4, req, resp *layers.DHCPv4) (err error) {
// TODO(e.burkov): Use pools for buffer and layers.
buf := gopacket.NewSerializeBuffer()
@ -488,9 +491,9 @@ func respond4(fd *frameData, req, resp *layers.DHCPv4) (err error) {
return fd.device.WritePacketData(buf.Bytes())
}
// newIPv4UDPLayers creates new UDP and IP layers for DHCPv4 response. fd, req,
// and resp must not be nil.
func newIPv4UDPLayers(fd *frameData, req, resp *layers.DHCPv4) (ip *layers.IPv4, udp *layers.UDP) {
// newIPv4UDPLayers creates new UDP and IP layers for DHCPv4 response. req and
// resp must not be nil, fd must be valid.
func newIPv4UDPLayers(fd *frameData4, req, resp *layers.DHCPv4) (ip *layers.IPv4, udp *layers.UDP) {
var dstIP net.IP
dstPort := ClientPortV4
switch {

View file

@ -2,7 +2,6 @@ package dhcpsvc
import (
"context"
"fmt"
"log/slog"
"net/netip"
"slices"
@ -14,7 +13,50 @@ import (
"github.com/google/gopacket/layers"
)
// Port numbers for DHCPv6.
//
// See RFC 9915 Section 7.2.
const (
// ServerPortV6 is the standard DHCPv6 server port.
ServerPortV6 layers.UDPPort = 547
// ClientPortV6 is the standard DHCPv6 client port.
ClientPortV6 layers.UDPPort = 546
)
// HardwareTypeEthernet is the IANA hardware type number for Ethernet, used in
// DUID-LL and DUID-LLT construction. Its value is 1, encoded as a big-endian
// uint16.
//
// See https://www.iana.org/assignments/arp-parameters/arp-parameters.xhtml#arp-parameters-2.
//
// TODO(e.burkov): Use.
var HardwareTypeEthernet = []byte{0x00, 0x01}
// DHCPv6 multicast addresses.
//
// See RFC 9915 Section 7.1.
var (
// AllDHCPRelayAgentsAndServers is the well-known IPv6 multicast address
// All_DHCP_Relay_Agents_and_Servers. Clients send messages to this address
// to reach all servers on the local link.
AllDHCPRelayAgentsAndServers = netip.MustParseAddr("ff02::1:2")
// AllDHCPServers is the well-known IPv6 multicast address All_DHCP_Servers.
// Relay agents use this to reach all servers.
AllDHCPServers = netip.MustParseAddr("ff05::1:3")
)
// v6PrefLen is the length of prefix to match ip against.
//
// TODO(e.burkov): DHCPv6 inherits the weird behavior of legacy implementation
// where the allocated range constrained by the first address and the first
// address with last byte set to 0xff. Proper prefixes should be used instead.
const v6PrefLen = netutil.IPv6BitLen - 8
// IPv6Config is the interface-specific configuration for DHCPv6.
//
// TODO(e.burkov): Add RangeEnd and SubnetPrefix fields, and validate them.
type IPv6Config struct {
// RangeStart is the first address in the range to assign to DHCP clients.
// It should be a valid IPv6 address.
@ -52,29 +94,38 @@ func (c *IPv6Config) Validate() (err error) {
return nil
}
var errs []error
if !c.RangeStart.Is6() {
err = fmt.Errorf("range start %s should be a valid ipv6", c.RangeStart)
errs = append(errs, err)
errs := []error{
validate.Positive("lease duration", c.LeaseDuration),
}
if c.LeaseDuration <= 0 {
err = fmt.Errorf("lease duration %s must be positive", c.LeaseDuration)
errs = append(errs, err)
}
errs = c.validateSubnet(errs)
return errors.Join(errs...)
}
// validateSubnet validates the subnet configuration.
//
// TODO(e.burkov): Use [validate].
func (c *IPv6Config) validateSubnet(orig []error) (errs []error) {
errs = orig
if !c.RangeStart.Is6() {
err := newMustErr("range start", "be a valid ipv6", c.RangeStart)
errs = append(errs, err)
}
return errs
}
// dhcpInterfaceV6 is a DHCP interface for IPv6 address family.
type dhcpInterfaceV6 struct {
// common is the common part of any network interface within the DHCP
// server.
common *netInterface
// rangeStart is the first IP address in the range.
rangeStart netip.Addr
// subnetPrefix is the network prefix of the interface's IPv6 subnet. It is
// used for on-link address determination.
subnetPrefix netip.Prefix
// implicitOpts are the DHCPv6 options listed in RFC 8415 (and others) and
// initialized with default values. It must not have intersections with
@ -85,6 +136,16 @@ type dhcpInterfaceV6 struct {
// intersections with implicitOpts.
explicitOpts layers.DHCPv6Options
// t1 is the pre-computed T1 value (0.5 × LeaseDuration) per RFC 9915 §21.4.
// It is the time after which the client should contact the same server to
// extend the lease.
t1 time.Duration
// t2 is the pre-computed T2 value (0.8 × LeaseDuration) per RFC 9915 §21.4.
// It is the time after which the client may contact any server to extend
// the lease.
t2 time.Duration
// raSLAACOnly defines if DHCP should send ICMPv6.RA packets without MO
// flags.
raSLAACOnly bool
@ -108,16 +169,29 @@ func (srv *DHCPServer) newDHCPInterfaceV6(
return nil
}
// TODO(e.burkov): Migrate the configuration to use proper range start,
// end, and subnet prefix.
rangeEndData := conf.RangeStart.As16()
rangeEndData[15] = 0xff
// TODO(e.burkov): Validate the range end and subnet prefix against the
// range start during configuration validation.
addrSpace, _ := newIPRange(conf.RangeStart, netip.AddrFrom16(rangeEndData))
iface = &dhcpInterfaceV6{
rangeStart: conf.RangeStart,
common: &netInterface{
logger: l,
leases: map[macKey]*Lease{},
indexMu: srv.leasesMu,
index: srv.leases,
name: name,
leaseTTL: conf.LeaseDuration,
logger: l,
leases: map[macKey]*Lease{},
indexMu: srv.leasesMu,
index: srv.leases,
name: name,
addrSpace: addrSpace,
leasedOffsets: newBitSet(),
leaseTTL: conf.LeaseDuration,
},
subnetPrefix: netip.PrefixFrom(conf.RangeStart, v6PrefLen),
t1: conf.LeaseDuration / 2,
t2: conf.LeaseDuration * 4 / 5,
raSLAACOnly: conf.RASLAACOnly,
raAllowSLAAC: conf.RAAllowSLAAC,
}
@ -129,20 +203,11 @@ func (srv *DHCPServer) newDHCPInterfaceV6(
// dhcpInterfacesV6 is a slice of network interfaces of IPv6 address family.
type dhcpInterfacesV6 []*dhcpInterfaceV6
// find returns the first network interface within ifaces containing ip. It
// returns false if there is no such interface.
// find returns the first network interface within ifaces whose subnet prefix
// contains ip. It returns false if there is no such interface.
func (ifaces dhcpInterfacesV6) find(ip netip.Addr) (iface6 *netInterface, ok bool) {
// prefLen is the length of prefix to match ip against.
//
// TODO(e.burkov): DHCPv6 inherits the weird behavior of legacy
// implementation where the allocated range constrained by the first address
// and the first address with last byte set to 0xff. Proper prefixes should
// be used instead.
const prefLen = netutil.IPv6BitLen - 8
i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV6) (contains bool) {
return !ip.Less(iface.rangeStart) &&
netip.PrefixFrom(iface.rangeStart, prefLen).Contains(ip)
return iface.subnetPrefix.Contains(ip)
})
if i < 0 {
return nil, false