Pull request 2734: AGDNS-3863-gopacket-dhcp-vol.32

Updates #4923.

Squashed commit of the following:

commit d423443053c188101efc0234709c9b899446e684
Merge: aed226338 6f4120483
Author: Eugene Burkov <[email protected]>
Date:   Fri Jul 31 18:22:21 2026 +0300

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

commit aed226338259c0360141db94d87595a8295cbeed
Author: Eugene Burkov <[email protected]>
Date:   Fri Jul 31 16:47:21 2026 +0300

    dhcpsvc: imp docs

commit 7c13ad6ca7229b01895f8e81a449ec9b71922325
Author: Eugene Burkov <[email protected]>
Date:   Thu Jul 30 13:17:22 2026 +0300

    dhcpsvc: imp code

commit 54fa1eeb4c163f84d204aeeccbf5bb4033b67cdb
Author: Eugene Burkov <[email protected]>
Date:   Tue Jul 28 20:03:19 2026 +0300

    dhcpsvc: imp code, resolve few todos

commit 4b81d0945e6d11d50b5f3da7cdaa4390d4255453
Author: Eugene Burkov <[email protected]>
Date:   Tue Jul 28 15:41:31 2026 +0300

    all: imp gitattrs

commit ce0a5fefee564578674b003955d5a38b6f4d0faf
Author: Eugene Burkov <[email protected]>
Date:   Tue Jul 28 15:29:29 2026 +0300

    dhcpsvc: fix windows line breaks

commit ceed0784247908c3993ccc3968061f0780679780
Author: Eugene Burkov <[email protected]>
Date:   Tue Jul 28 15:03:19 2026 +0300

    dhcpsvc: handle renew
This commit is contained in:
Eugene Burkov
2026-07-31 15:30:36 +00:00
parent 6f4120483b
commit 5f6cb57df9
20 changed files with 307 additions and 119 deletions
+1
View File
@@ -3,3 +3,4 @@ client/* linguist-vendored
# grepping. Technically, this file must be reviewed when new icons appear, but
# that happens fairly rarely.
client/src/components/ui/Icons.js -diff
* text=auto eol=lf
+1 -1
View File
@@ -25,7 +25,7 @@ type Config struct {
// NetworkDeviceManager is the manager of network devices. It must not be
// nil.
//
// TODO(e.burkov): Set.
// TODO(e.burkov): Implement and set.
NetworkDeviceManager NetworkDeviceManager
// Logger will be used to log the DHCP events. It must not be nil.
+4 -4
View File
@@ -62,7 +62,7 @@ func TestIPv4Config_Validate(t *testing.T) {
RangeEnd: testIPv4Conf.RangeEnd,
LeaseDuration: testIPv4Conf.LeaseDuration,
},
wantErrMsg: "gateway ip " + testRangeStartV6Str + " must be a valid ipv4" + "\n" +
wantErrMsg: "gateway ip: " + testRangeStartV6Str + ": must be a valid ipv4" + "\n" +
"range start " + testRangeStartV4Str + " is not within " + testRangeStartV6Str + "/24",
}, {
name: "bad_subnet_mask",
@@ -75,7 +75,7 @@ func TestIPv4Config_Validate(t *testing.T) {
RangeEnd: testIPv4Conf.RangeEnd,
LeaseDuration: testIPv4Conf.LeaseDuration,
},
wantErrMsg: "subnet mask " + testRangeStartV6Str + " must be a valid ipv4 cidr mask",
wantErrMsg: "subnet mask: " + testRangeStartV6Str + ": must be a valid ipv4 cidr mask",
}, {
name: "bad_range_start",
conf: &dhcpsvc.IPv4Config{
@@ -87,7 +87,7 @@ func TestIPv4Config_Validate(t *testing.T) {
RangeEnd: testIPv4Conf.RangeEnd,
LeaseDuration: testIPv4Conf.LeaseDuration,
},
wantErrMsg: "range start " + testRangeStartV6Str + " must be a valid ipv4" + "\n" +
wantErrMsg: "range start: " + testRangeStartV6Str + ": must be a valid ipv4" + "\n" +
"range start " + testRangeStartV6Str + " is not within " +
testGatewayIPv4Str + "/24" + "\n" + "invalid ip range: " + testRangeStartV6Str +
" and " + testRangeEndV4Str + " must be within the same address family",
@@ -102,7 +102,7 @@ func TestIPv4Config_Validate(t *testing.T) {
RangeEnd: netip.MustParseAddr(testRangeStartV6Str),
LeaseDuration: testIPv4Conf.LeaseDuration,
},
wantErrMsg: "range end " + testRangeStartV6Str + " must be a valid ipv4" + "\n" +
wantErrMsg: "range end: " + testRangeStartV6Str + ": must be a valid ipv4" + "\n" +
"range end " + testRangeStartV6Str + " is not within " + testGatewayIPv4Str + "/24" +
"\n" + "invalid ip range: " + testRangeStartV4Str + " and " + testRangeStartV6Str +
" must be within the same address family",
+5 -5
View File
@@ -38,15 +38,17 @@ type Interface interface {
// MACByIP returns the MAC address for the given IP address leased. It
// returns nil if there is no such client, due to an assumption that a DHCP
// client must always have a MAC address.
//
// TODO(e.burkov): Think of a contract for the returned value.
// client must always have a MAC address. mac must be a valid MAC address
// according to [netutil.ValidateMAC].
MACByIP(ip netip.Addr) (mac net.HardwareAddr)
// IPByHost returns the IP address of the DHCP client with the given
// hostname. The hostname will be an empty string if there is no such
// client, due to an assumption that a DHCP client must always have a
// hostname, either set or generated.
//
// TODO(e.burkov): Support several IP addresses for a single hostname.
// This is possible if the client has several network interfaces.
IPByHost(host string) (ip netip.Addr)
// Leases returns all the active DHCP leases. The returned slice should be
@@ -70,8 +72,6 @@ type Interface interface {
RemoveLease(ctx context.Context, l *Lease) (err error)
// Reset removes all the DHCP leases.
//
// TODO(e.burkov): If it's really needed?
Reset(ctx context.Context) (err error)
}
+6 -10
View File
@@ -20,10 +20,6 @@ import (
"github.com/stretchr/testify/require"
)
// TODO(e.burkov): Use addresses and prefixes from [RFC 5737].
//
// [RFC 5737]: https://datatracker.ietf.org/doc/html/rfc5737
// testLocalTLD is a common local TLD for tests.
const testLocalTLD = "local"
@@ -180,7 +176,7 @@ var (
// testIfaceHWAddr is a common valid hardware address of the test network
// interface.
testIfaceHWAddr = net.HardwareAddr{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
testIfaceHWAddr = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0xFF}
)
// testInterfaceConf is a common valid set of interface configurations for
@@ -216,19 +212,19 @@ var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{
// NOTE: Keep in sync with testdata.
var (
// testHWUnknown is the test MAC address for an unknown client.
testHWUnknown = net.HardwareAddr{0x0, 0x1, 0x2, 0x3, 0x4, 0x5}
testHWUnknown = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0x01}
// testHWStatic is the test MAC address for a known static lease.
testHWStatic = net.HardwareAddr{0x1, 0x2, 0x3, 0x4, 0x5, 0x6}
testHWStatic = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0x02}
// testHWDynamic is the test MAC address for a known dynamic lease.
testHWDynamic = net.HardwareAddr{0x2, 0x3, 0x4, 0x5, 0x6, 0x7}
testHWDynamic = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0x03}
// testHWExpired is the test MAC address for a known expired lease.
testHWExpired = net.HardwareAddr{0x3, 0x4, 0x5, 0x6, 0x7, 0x8}
testHWExpired = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0x04}
// testHWAnother is the test MAC address for a lease with another IP.
testHWAnother = net.HardwareAddr{0x4, 0x5, 0x6, 0x7, 0x8, 0x9}
testHWAnother = net.HardwareAddr{0x00, 0x00, 0x5E, 0x00, 0x53, 0x05}
)
// IPv4 addresses for tests.
-13
View File
@@ -1,13 +0,0 @@
package dhcpsvc
import (
"fmt"
)
// newMustErr returns an error that indicates that valName must be as must
// describes.
//
// TODO(e.burkov): Use [validate] and remove this function.
func newMustErr(valName, must string, val fmt.Stringer) (err error) {
return fmt.Errorf("%s %s must %s", valName, val, must)
}
+1 -1
View File
@@ -146,7 +146,7 @@ func (iface *dhcpInterfaceV4) handleDiscover(
return
}
lease, err := iface.common.allocateLease(ctx, mac, now)
lease, err := iface.common.allocateLease(ctx, mac, mk, now)
if err != nil {
l.ErrorContext(ctx, "allocating a lease", slogutil.KeyError, err)
+29 -11
View File
@@ -187,14 +187,7 @@ func (iface *dhcpInterfaceV6) handleRequest(
return respond6(fd, resp)
}
var ianaOpt layers.DHCPv6Option
lease, err := iface.leaseForRequest(ctx, req, fd.ether.SrcMAC)
if err != nil {
ianaOpt = newIANAWithStatus(iana.ID, layers.DHCPv6StatusCodeNoAddrsAvail)
} else {
ianaOpt = iface.iaNAFromLease(lease, iana.ID)
}
ianaOpt := iface.ianaForRequest(ctx, req, iana.ID, fd.ether.SrcMAC)
resp.Options = iface.newRequestRespOpts(fd, req, cliID, ianaOpt)
@@ -241,7 +234,7 @@ func (iface *dhcpInterfaceV6) handleConfirm(
resp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeReply,
TransactionID: req.TransactionID,
Options: iface.newConfirmRespOpts(fd, cliID, status),
Options: iface.newConfirmRespOpts(fd, req, cliID, status),
}
return respond6(fd, resp)
@@ -250,7 +243,9 @@ func (iface *dhcpInterfaceV6) handleConfirm(
// handleRenew handles messages of type RENEW. req must not be nil and must be
// a valid DHCPv6 message of type RENEW. fd must be valid.
//
// TODO(e.burkov): Implement. This is a stub for now.
// TODO(e.burkov): The current implementation renews only the first valid IA_NA
// option. It does not verify that the addresses in the IA match the stored
// lease, since clients are identified by MAC address rather than DUID+IAID.
func (iface *dhcpInterfaceV6) handleRenew(
ctx context.Context,
fd *frameData6,
@@ -264,7 +259,30 @@ func (iface *dhcpInterfaceV6) handleRenew(
l := iface.common.logger
l.DebugContext(ctx, "handling message", "type", req.MsgType, "cli_id", cliID)
return nil
iface.common.indexMu.Lock()
defer iface.common.indexMu.Unlock()
resp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeReply,
TransactionID: req.TransactionID,
}
iana, ok := iface.firstIANA(ctx, req)
if !ok {
// With no IA_NA options and no requested addresses there's nothing to
// renew. Respond with no IA options similarly to how the Request
// handler does.
//
// See RFC 9915 Section 18.3.4.
resp.Options = iface.newRenewRespOpts(fd, req, cliID, layers.DHCPv6Option{})
return respond6(fd, resp)
}
ianaOpt := iface.ianaForRenew(ctx, req, iana, fd.ether.SrcMAC)
resp.Options = iface.newRenewRespOpts(fd, req, cliID, ianaOpt)
return respond6(fd, resp)
}
// handleRebind handles messages of type REBIND. req must not be nil and must
+114
View File
@@ -487,6 +487,92 @@ func TestDHCPServer_ServeEther6_confirm(t *testing.T) {
}
}
func TestDHCPServer_ServeEther6_renew(t *testing.T) {
t.Parallel()
testCases := []struct {
in gopacket.Packet
want *dhcpsvc.Lease
name string
wantOpts layers.DHCPv6Options
}{{
in: newDHCPv6Renew(t, testHWDynamic, testIPv6Dynamic),
name: "success",
want: testLease6Dynamic,
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptIANA(t, testIAID, testIPv6Dynamic, testLeaseTTL),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Renew(t, testHWStatic, testIPv6Static),
name: "success_static",
want: testLease6Static,
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWStatic),
newOptIANA(t, testIAID, testIPv6Static, testLeaseTTL),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Renew(t, testHWUnknown, testIPv6Unknown),
name: "no_binding",
want: nil,
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Renew(t, testHWUnknown, netip.Addr{}),
name: "no_iana",
want: nil,
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}}
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
db := newTestDatabase(t, testLeases)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
return nil
}
if tc.want != nil {
db.onStore = onStore
}
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV6)
startTestDHCPServer(t, &dhcpsvc.Config{
Database: db,
Interfaces: testIPv6InterfacesConf,
Logger: testLogger,
NetworkDeviceManager: ndMgr,
Enabled: true,
})
testutil.RequireSend(t, inCh, tc.in, testTimeout)
assertValidResponse6(t, req, outCh, tc.wantOpts)
})
}
}
// newDHCPv6Solicit creates a new DHCPv6 SOLICIT packet for testing.
func newDHCPv6Solicit(
tb testing.TB,
@@ -710,3 +796,31 @@ func assertValidDHCPv6(
tb.Errorf("request message type: %v: %s", errors.ErrUnexpectedValue, req.MsgType)
}
}
// newDHCPv6Renew creates a new DHCPv6 RENEW packet for testing.
func newDHCPv6Renew(tb testing.TB, mac net.HardwareAddr, reqIP netip.Addr) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernetLayer(tb, mac, testIfaceHWAddr, layers.EthernetTypeIPv6)
ip, udp := newIPv6UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
dhcp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeRenew,
HopCount: 0,
// Don't specify link and peer addresses, as they are intended for relay
// messages.
LinkAddr: nil,
PeerAddr: nil,
TransactionID: testTransactionID,
Options: layers.DHCPv6Options{
newOptClientDUID(tb, mac),
newOptServerDUID(tb, testIfaceHWAddr),
},
}
if reqIP.IsValid() && reqIP.Is6() {
dhcp.Options = append(dhcp.Options, newOptIANA(tb, testIAID, reqIP, testLeaseTTL))
}
return newTestPacket(tb, layers.LinkTypeEthernet, eth, ip, udp, dhcp)
}
+6 -10
View File
@@ -181,20 +181,18 @@ func (iface *netInterface) findExpiredLease(now time.Time) (l *Lease) {
}
// allocateLease allocates a new lease for the MAC address. If there are no IP
// addresses left, both lease and err are nil. mac must be a valid according to
// [netutil.ValidateMAC].
//
// TODO(e.burkov): Pass the precalculated macKey.
// addresses left, both lease and err are nil. mac must be a valid hardware
// address according to [netutil.ValidateMAC], macKey must be calculated from
// mac.
//
// TODO(e.burkov): Support allocating the exact requested address if it is
// available.
func (iface *netInterface) allocateLease(
ctx context.Context,
mac net.HardwareAddr,
macKey macKey,
now time.Time,
) (lease *Lease, err error) {
key := macToKey(mac)
for {
lease, err = iface.reserveLease(ctx, mac, now)
if err != nil {
@@ -208,7 +206,7 @@ func (iface *netInterface) allocateLease(
}
if ok {
iface.leases[key] = lease
iface.leases[macKey] = lease
off, _ := iface.addrSpace.offset(lease.IP)
iface.leasedOffsets.set(off, true)
@@ -252,9 +250,7 @@ func (iface *netInterface) reserveLease(
err = iface.index.remove(ctx, lease, iface)
if err != nil {
// TODO(e.burkov): Reconsider the severity of this error, it actually
// seems impossible to get the error about the existing lease from the
// method.
// The lease may have been allocated but not yet committed.
iface.logger.DebugContext(ctx, "deleting expired lease", slogutil.KeyError, err)
}
-2
View File
@@ -65,8 +65,6 @@ type ipPredicate func(ip netip.Addr) (ok bool)
// find finds the first IP address in r for which p returns true. It returns an
// empty [netip.Addr] if there are no addresses that satisfy p.
//
// TODO(e.burkov): Use.
func (r ipRange) find(p ipPredicate) (ip netip.Addr) {
for ip = r.start; !r.end.Less(ip); ip = ip.Next() {
if p(ip) {
-2
View File
@@ -132,8 +132,6 @@ func (c *JSONDatabaseConfig) Validate() (err error) {
// JSONDatabase is a [Database] implementation that stores leases in a JSON
// file.
//
// TODO(e.burkov): Test.
type JSONDatabase struct {
// mu protects the database file from concurrent access.
mu *sync.RWMutex
+3 -6
View File
@@ -97,9 +97,6 @@ func (idx *leaseIndex) add(l *Lease, iface *netInterface) (err error) {
// remove removes l from idx and from iface. l must be valid, iface should
// contain the same lease or the lease itself. It returns an error if the lease
// not found.
//
// TODO(e.burkov): Consider using the iface's logger after simplifying
// relations between index and interfaces.
func (idx *leaseIndex) remove(
ctx context.Context,
l *Lease,
@@ -189,13 +186,13 @@ func (idx *leaseIndex) dbLoad(
return fmt.Errorf("loading leases: %w", err)
}
idx.addDBLeases(ctx, logger, leases, ifaces4, ifaces6)
idx.indexLeases(ctx, logger, leases, ifaces4, ifaces6)
return nil
}
// addDBLeases adds leases to the server. logger must not be nil.
func (idx *leaseIndex) addDBLeases(
// indexLeases adds leases to idx. logger must not be nil.
func (idx *leaseIndex) indexLeases(
ctx context.Context,
logger *slog.Logger,
leases []*Lease,
+18 -10
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/binary"
"log/slog"
"net"
"net/netip"
"slices"
"time"
@@ -353,9 +352,6 @@ func (iface *dhcpInterfaceV4) appendTimeOptions(
// appendHostnameOption appends the hostname option to the response, if it's
// provided.
//
// TODO(e.burkov): Lease should always has a hostname, investigate when it
// isn't the case.
func appendHostnameOption(opts layers.DHCPOptions, hostname string) (res layers.DHCPOptions) {
if hostname == "" {
return opts
@@ -377,26 +373,38 @@ func msg4Type(msg *layers.DHCPv4) (typ layers.DHCPMsgType, ok bool) {
// requestedIPv4 returns the IPv4 address, requested by client in the DHCP
// message, if any.
//
// TODO(e.burkov): DRY with other IP-from-option helpers.
func requestedIPv4(msg *layers.DHCPv4) (ip netip.Addr, ok bool) {
for _, opt := range msg.Options {
if opt.Type == layers.DHCPOptRequestIP && len(opt.Data) == net.IPv4len {
return netip.AddrFromSlice(opt.Data)
if opt.Type == layers.DHCPOptRequestIP {
ip, ok = netip.AddrFromSlice(opt.Data)
break
}
}
ip = ip.Unmap()
if ok && ip.Is4() {
return ip, true
}
return netip.Addr{}, false
}
// serverID4 returns the server ID of the DHCP message, if any.
func serverID4(msg *layers.DHCPv4) (ip netip.Addr, ok bool) {
for _, opt := range msg.Options {
if opt.Type == layers.DHCPOptServerID && len(opt.Data) == net.IPv4len {
return netip.AddrFromSlice(opt.Data)
if opt.Type == layers.DHCPOptServerID {
ip, ok = netip.AddrFromSlice(opt.Data)
break
}
}
ip = ip.Unmap()
if ok && ip.Is4() {
return ip, true
}
return netip.Addr{}, false
}
-2
View File
@@ -100,8 +100,6 @@ func (opt *IANAOption) UnmarshalBinary(data []byte) (err error) {
// Encode serializes opt 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
+4 -2
View File
@@ -16,6 +16,8 @@ import (
func TestDHCPServer_AddLease(t *testing.T) {
t.Parallel()
outOfRangeAddr := testIPv4Conf.RangeEnd.Next()
testCases := []struct {
name string
want *dhcpsvc.Lease
@@ -24,10 +26,10 @@ func TestDHCPServer_AddLease(t *testing.T) {
name: "outside_range",
want: &dhcpsvc.Lease{
Hostname: testLease4HostnameUnknown,
IP: netip.MustParseAddr("1.2.3.4"),
IP: outOfRangeAddr,
HWAddr: testHWUnknown,
},
wantErrMsg: "adding lease: no interface for ip 1.2.3.4",
wantErrMsg: "adding lease: no interface for ip " + outOfRangeAddr.String(),
}, {
name: "duplicate_ip",
want: &dhcpsvc.Lease{
@@ -4,42 +4,42 @@
"expires": "",
"ip": "192.0.2.101",
"hostname": "static4",
"mac": "01:02:03:04:05:06",
"mac": "00:00:5e:00:53:02",
"static": true
},
{
"expires": "2025-01-02T01:01:01Z",
"ip": "192.0.2.102",
"hostname": "dynamic4",
"mac": "02:03:04:05:06:07",
"mac": "00:00:5e:00:53:03",
"static": false
},
{
"expires": "2025-01-01T00:01:01Z",
"ip": "192.0.2.103",
"hostname": "expired4",
"mac": "03:04:05:06:07:08",
"mac": "00:00:5e:00:53:04",
"static": false
},
{
"expires": "",
"ip": "2001:db8::65",
"hostname": "static6",
"mac": "01:02:03:04:05:06",
"mac": "00:00:5e:00:53:02",
"static": true
},
{
"expires": "2025-01-02T01:01:01Z",
"ip": "2001:db8::66",
"hostname": "dynamic6",
"mac": "02:03:04:05:06:07",
"mac": "00:00:5e:00:53:03",
"static": false
},
{
"expires": "2025-01-01T00:01:01Z",
"ip": "2001:db8::67",
"hostname": "expired6",
"mac": "03:04:05:06:07:08",
"mac": "00:00:5e:00:53:04",
"static": false
}
],
@@ -4,42 +4,42 @@
"expires": "2025-01-02T01:01:01Z",
"ip": "192.0.2.102",
"hostname": "dynamic4",
"mac": "02:03:04:05:06:07",
"mac": "00:00:5e:00:53:03",
"static": false
},
{
"expires": "2025-01-02T01:01:01Z",
"ip": "2001:db8::66",
"hostname": "dynamic6",
"mac": "02:03:04:05:06:07",
"mac": "00:00:5e:00:53:03",
"static": false
},
{
"expires": "2025-01-01T00:01:01Z",
"ip": "192.0.2.103",
"hostname": "expired4",
"mac": "03:04:05:06:07:08",
"mac": "00:00:5e:00:53:04",
"static": false
},
{
"expires": "2025-01-01T00:01:01Z",
"ip": "2001:db8::67",
"hostname": "expired6",
"mac": "03:04:05:06:07:08",
"mac": "00:00:5e:00:53:04",
"static": false
},
{
"expires": "",
"ip": "192.0.2.101",
"hostname": "static4",
"mac": "01:02:03:04:05:06",
"mac": "00:00:5e:00:53:02",
"static": true
},
{
"expires": "",
"ip": "2001:db8::65",
"hostname": "static6",
"mac": "01:02:03:04:05:06",
"mac": "00:00:5e:00:53:02",
"static": true
}
],
+9 -9
View File
@@ -49,6 +49,9 @@ type IPv4Config struct {
// SubnetMask is the IPv4 subnet mask of the network. It should be a valid
// IPv4 CIDR (i.e. all 1s followed by all 0s).
//
// TODO(e.burkov): Use the a unsinged integer type for the mask length
// instead of [netip.Addr] to reduce the chance of misconfiguration.
SubnetMask netip.Addr
// RangeStart is the first address in the range to assign to DHCP clients.
@@ -98,28 +101,26 @@ func (c *IPv4Config) Validate() (err error) {
}
// validateSubnet validates the subnet configuration.
//
// TODO(e.burkov): Use [validate].
func (c *IPv4Config) validateSubnet(orig []error) (errs []error) {
errs = orig
if !c.GatewayIP.Is4() {
err := newMustErr("gateway ip", "be a valid ipv4", c.GatewayIP)
err := fmt.Errorf("gateway ip: %s: must be a valid ipv4", c.GatewayIP)
errs = append(errs, err)
}
if !c.SubnetMask.Is4() {
err := newMustErr("subnet mask", "be a valid ipv4 cidr mask", c.SubnetMask)
err := fmt.Errorf("subnet mask: %s: must be a valid ipv4 cidr mask", c.SubnetMask)
errs = append(errs, err)
}
if !c.RangeStart.Is4() {
err := newMustErr("range start", "be a valid ipv4", c.RangeStart)
err := fmt.Errorf("range start: %s: must be a valid ipv4", c.RangeStart)
errs = append(errs, err)
}
if !c.RangeEnd.Is4() {
err := newMustErr("range end", "be a valid ipv4", c.RangeEnd)
err := fmt.Errorf("range end: %s: must be a valid ipv4", c.RangeEnd)
errs = append(errs, err)
}
@@ -185,8 +186,6 @@ func (srv *DHCPServer) newDHCPInterfaceV4(
return nil
}
// 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].
@@ -275,13 +274,14 @@ func (iface *dhcpInterfaceV4) respondACK(
// present. req and resp must not be nil, fd must be valid.
//
// See https://datatracker.ietf.org/doc/html/rfc2131#section-4.3.1.
//
// TODO(e.burkov): Add a message according to RFC 2131.
func (iface *dhcpInterfaceV4) respondNAK(
ctx context.Context,
req *layers.DHCPv4,
fd *frameData4,
idOpt []byte,
) {
// TODO(e.burkov): According to RFC 2131 we should add a message.
opts := newRespOptions(layers.DHCPMsgTypeNak, fd, idOpt)
// If 'giaddr' is set in the DHCPREQUEST message, the client is on a
+94 -19
View File
@@ -422,7 +422,7 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
return lease, iana.ID
}
lease, err = iface.common.allocateLease(ctx, mac, iface.clock.Now())
lease, err = iface.common.allocateLease(ctx, mac, key, iface.clock.Now())
if err != nil {
l.DebugContext(ctx, "no address available", "iaid", iana.ID, slogutil.KeyError, err)
@@ -541,7 +541,7 @@ func (iface *dhcpInterfaceV6) newSolicitRespOpts(
// newRequestRespOpts returns the common option list for Reply responses to a
// Request message. fd, req, and cliID must not be nil. iana must be a valid
// IA_NA option.
// IA_NA option, or be empty if the response should not contain an IA_NA option.
//
// TODO(e.burkov): Keep the Reply option set aligned with the current Advertise
// response shape until the wider DHCPv6 implementation is completed.
@@ -575,14 +575,14 @@ func (iface *dhcpInterfaceV6) newRequestRespOpts(
// See RFC 9915 Section 18.3.3.
func (iface *dhcpInterfaceV6) newConfirmRespOpts(
fd *frameData6,
req *layers.DHCPv6,
cliID *layers.DHCPv6DUID,
status layers.DHCPv6StatusCode,
) (opts layers.DHCPv6Options) {
opts = append(
opts,
opts = layers.DHCPv6Options{
layers.NewDHCPv6Option(layers.DHCPv6OptServerID, fd.duidData),
layers.NewDHCPv6Option(layers.DHCPv6OptClientID, cliID.Encode()),
)
}
// If the Status Code option does not appear in a message in which the
// option could appear, the status of the message is assumed to be Success.
@@ -592,7 +592,37 @@ func (iface *dhcpInterfaceV6) newConfirmRespOpts(
opts = append(opts, newStatusCodeOption(status))
}
return opts
return iface.appendRequestedOptions(opts, req)
}
// newRenewRespOpts returns the common option list for Reply responses to a
// Renew message. fd, req, and cliID must not be nil. iana must be a valid
// IA_NA option, or be empty if the response should not contain an IA_NA option.
//
// See RFC 9915 Section 18.3.4.
//
// TODO(e.burkov): DRY with other response option builders.
func (iface *dhcpInterfaceV6) newRenewRespOpts(
fd *frameData6,
req *layers.DHCPv6,
cliID *layers.DHCPv6DUID,
iana layers.DHCPv6Option,
) (opts layers.DHCPv6Options) {
opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptServerID, fd.duidData))
opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptClientID, cliID.Encode()))
if iana.Code != 0 {
opts = append(opts, iana)
}
// The server preference value MUST default to 0 unless otherwise configured
// by the server administrator.
//
// See RFC 9915 Section 18.3.9.
opts = append(opts, newPreferenceOption(0))
opts = append(opts, newSOLMaxRTOption(DefaultSolMaxRT))
return iface.appendRequestedOptions(opts, req)
}
// iaNAFromLease returns an IA_NA option with a single IA Address sub-option
@@ -616,34 +646,79 @@ func (iface *dhcpInterfaceV6) iaNAFromLease(lease *Lease, iaid uint32) (iana lay
}.Encode()
}
// leaseForRequest returns the committed lease for req. It reuses an already
// reserved lease for the client when possible, or allocates and commits the new
// address. iface.common.indexMu must be locked.
func (iface *dhcpInterfaceV6) leaseForRequest(
// ianaForRequest returns the IANA filled with committed lease data for req. It
// reuses an already reserved lease for the client when possible, or allocates
// and commits the new address. req must be a valid DHCPv6 message of type
// REQUEST, iaid must not be zero, and mac must be a valid MAC address according
// to [netutil.ValidateMAC]. iface.common.indexMu must be locked.
func (iface *dhcpInterfaceV6) ianaForRequest(
ctx context.Context,
req *layers.DHCPv6,
iaid uint32,
mac net.HardwareAddr,
) (lease *Lease, err error) {
) (iana layers.DHCPv6Option) {
key := macToKey(mac)
l := iface.common.logger
lease, ok := iface.common.leases[key]
if !ok {
lease, err = iface.common.allocateLease(ctx, mac, iface.clock.Now())
var err error
lease, err = iface.common.allocateLease(ctx, mac, key, iface.clock.Now())
if err != nil {
return nil, fmt.Errorf("allocating lease for mac %s: %w", mac, err)
}
}
l.ErrorContext(ctx, "allocating lease", slogutil.KeyError, err)
err = iface.commit(ctx, req, lease)
if err != nil {
return newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoAddrsAvail)
}
} else if err := iface.commit(ctx, req, lease); err != nil {
l.WarnContext(ctx, "committing lease", slogutil.KeyError, err)
// Don't wrap the error, because it's informative enough as is.
return nil, err
return newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoAddrsAvail)
}
return lease, nil
return iface.iaNAFromLease(lease, iaid)
}
// ianaForRenew returns the IANA filled with committed lease data for req. It
// reuses an already reserved lease for the client, if it exists. req must be a
// valid DHCPv6 message of type RENEW, iaid must not be zero, and mac must be a
// valid MAC address according to [netutil.ValidateMAC]. iface.common.indexMu
// must be locked.
func (iface *dhcpInterfaceV6) ianaForRenew(
ctx context.Context,
req *layers.DHCPv6,
reqIANA *IANAOption,
mac net.HardwareAddr,
) (iana layers.DHCPv6Option) {
key := macToKey(mac)
l := iface.common.logger
reqIP, hasReqIP := reqIANA.requestedAddr()
if !hasReqIP {
// With no requested addresses there's nothing to renew. Respond with
// no IA options similarly to how the Request handler does.
//
// See RFC 9915 Section 18.3.4.
return layers.DHCPv6Option{}
}
lease, hasLease := iface.common.leases[key]
if !hasLease || lease.IP != reqIP {
// No binding found for this client. The server returns the IA with a
// NoBinding status code.
//
// See RFC 9915 Section 18.3.4.
return layers.DHCPv6Option{}
}
err := iface.commit(ctx, req, lease)
if err != nil {
l.WarnContext(ctx, "committing lease", slogutil.KeyError, err)
return newIANAWithStatus(reqIANA.ID, layers.DHCPv6StatusCodeNoAddrsAvail)
}
return iface.iaNAFromLease(lease, reqIANA.ID)
}
// commit updates the lease allocated previously via a SOLICIT, or during