Pull request 2741: AGDNS-3863-gopacket-dhcp-vol.34

Updates #4923.

Squashed commit of the following:

commit ac803e041ccc2d635031479a143fc1afd93921eb
Merge: 7c4263837 b0e930c6b
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Aug 4 19:35:57 2026 +0300

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

commit 7c4263837388c51bdf33acabbca7ab0d7ae7d8ac
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Aug 4 19:09:44 2026 +0300

    dhcpsvc: don't consider zero iaid invalid

commit d883e64b91089dd0a2369e6e537b89c984c0d106
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Aug 4 17:35:56 2026 +0300

    dhcpsvc: imp maint

commit 947a9b34f9f0e07c6a895f55163757a85eb684b6
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Aug 4 15:35:14 2026 +0300

    dhcpsvc: impl dhcpv6 release
This commit is contained in:
Eugene Burkov committed 2026-08-04 16:45:03 +00:00
1 parent b0e930c6bc
commit 369d476163
7 files changed
+249 -53

No files matched your search

+6 -14
View File
@@ -393,27 +393,19 @@ func (db *testDatabase) Store(ctx context.Context, leases []*dhcpsvc.Lease) (err
return db.onStore(ctx, leases)
}
// newTestDatabase creates a new *testDatabase for testing. If initial is not
// nil, db.Load is set to return it. By default, db.Store panics on any call.
func newTestDatabase(tb testing.TB, initial []*dhcpsvc.Lease) (db *testDatabase) {
// newTestDatabase creates a new *testDatabase for testing. The Load method
// returns [testLeases], and the Store method panics.
func newTestDatabase(tb testing.TB) (db *testDatabase) {
tb.Helper()
db = &testDatabase{
onLoad: func(ctx context.Context) (_ []*dhcpsvc.Lease, _ error) {
panic(testutil.UnexpectedCall(ctx))
return &testDatabase{
onLoad: func(_ context.Context) (leases []*dhcpsvc.Lease, err error) {
return testLeases, nil
},
onStore: func(ctx context.Context, leases []*dhcpsvc.Lease) (_ error) {
panic(testutil.UnexpectedCall(ctx, leases))
},
}
if initial != nil {
db.onLoad = func(ctx context.Context) (leases []*dhcpsvc.Lease, err error) {
return initial, nil
}
}
return db
}
// newTestDHCPServer creates a new DHCPServer for testing. It uses the default
+9 -9
View File
@@ -83,7 +83,7 @@ func TestDHCPServer_ServeEther4_discover(t *testing.T) {
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Database: newTestDatabase(t, testLeases),
Database: newTestDatabase(t),
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
Enabled: true,
@@ -105,7 +105,7 @@ func TestDHCPServer_ServeEther4_discoverExpired(t *testing.T) {
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Database: newTestDatabase(t, testLeases),
Database: newTestDatabase(t),
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
Enabled: true,
@@ -156,7 +156,7 @@ func TestDHCPServer_ServeEther4_release(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.NotContains(testutil.NewPanicT(t), leases, tc.want)
@@ -262,7 +262,7 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -311,7 +311,7 @@ func TestDHCPServer_ServeEther4_requestSelectingNoLease(t *testing.T) {
flags: dhcpsvc.FlagsBroadcast,
})
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
@@ -413,7 +413,7 @@ func TestDHCPServer_ServeEther4_requestInitReboot(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -513,7 +513,7 @@ func TestDHCPServer_ServeEther4_requestRenewSuccess(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -586,7 +586,7 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
}
startTestDHCPServer(t, &dhcpsvc.Config{
Database: newTestDatabase(t, testLeases),
Database: newTestDatabase(t),
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
Enabled: true,
@@ -641,7 +641,7 @@ func TestDHCPServer_ServeEther4_decline(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, wantLease)
+40 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"net/netip"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/logutil/slogutil"
@@ -377,7 +378,8 @@ func (iface *dhcpInterfaceV6) handleInfo(
// handleRelease handles messages of type RELEASE. req must not be nil and must
// be a valid DHCPv6 message of type RELEASE. fd must be valid.
//
// TODO(e.burkov): Implement. This is a stub for now.
// TODO(e.burkov): Verify all IA_NA options instead of handling only the first
// one.
func (iface *dhcpInterfaceV6) handleRelease(
ctx context.Context,
fd *frameData6,
@@ -391,7 +393,43 @@ func (iface *dhcpInterfaceV6) handleRelease(
l := iface.common.logger
l.DebugContext(ctx, "handling message", "type", req.MsgType, "cli_id", cliID)
return nil
resp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeReply,
TransactionID: req.TransactionID,
}
iaid, ip := iface.ipForRelease(ctx, req)
if ip == (netip.Addr{}) {
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, layers.DHCPv6Option{})
return respond6(fd, resp)
}
key := macToKey(fd.ether.SrcMAC)
iface.common.indexMu.Lock()
defer iface.common.indexMu.Unlock()
lease, hasLease := iface.common.leases[key]
if !hasLease || lease.IP != ip {
respIANA := newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoBinding)
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, respIANA)
return respond6(fd, resp)
}
err = iface.common.index.remove(ctx, lease, iface.common)
if err != nil {
l.ErrorContext(ctx, "removing lease", slogutil.KeyError, err)
respIANA := newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoBinding)
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, respIANA)
} else {
respIANA := &IANAOption{ID: iaid}
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, respIANA.Encode())
}
return respond6(fd, resp)
}
// handleDecline handles messages of type DECLINE. req must not be nil and must
+134 -8
View File
@@ -91,7 +91,7 @@ func TestDHCPServer_ServeEther6_solicit(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV6)
startTestDHCPServer(t, &dhcpsvc.Config{
@@ -185,7 +185,7 @@ func TestDHCPServer_ServeEther6_solicitRapidCommit(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -280,7 +280,7 @@ func TestDHCPServer_ServeEther6_request(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -361,7 +361,7 @@ func TestDHCPServer_ServeEther6_requestWithSolicit(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -469,7 +469,7 @@ func TestDHCPServer_ServeEther6_confirm(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceAddrV6)
startTestDHCPServer(t, &dhcpsvc.Config{
@@ -543,7 +543,7 @@ func TestDHCPServer_ServeEther6_renew(t *testing.T) {
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -630,7 +630,7 @@ func TestDHCPServer_ServeEther6_rebind(t *testing.T) {
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Contains(t, leases, tc.want)
@@ -699,10 +699,108 @@ func TestDHCPServer_ServeEther6_info(t *testing.T) {
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
db := newTestDatabase(t)
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
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)
})
}
}
func TestDHCPServer_ServeEther6_release(t *testing.T) {
t.Parallel()
testCases := []struct {
in gopacket.Packet
want *dhcpsvc.Lease
name string
wantOpts layers.DHCPv6Options
}{{
in: newDHCPv6Release(t, testHWDynamic, testIPv6Dynamic),
want: testLease6Dynamic,
name: "success",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeSuccess),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Release(t, testHWStatic, testIPv6Static),
want: testLease6Static,
name: "success_static",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWStatic),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeSuccess),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Release(t, testHWUnknown, testIPv6Unknown),
want: nil,
name: "no_binding",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeNoBinding),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Release(t, testHWDynamic, netip.Addr{}),
want: nil,
name: "no_iana",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Release(t, testHWDynamic, testIPv6Unknown),
want: nil,
name: "ip_mismatch",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeNoBinding),
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)
onStore := func(ctx context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.NotContains(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{
@@ -1036,3 +1134,31 @@ func newDHCPv6Renew(tb testing.TB, mac net.HardwareAddr, reqIP netip.Addr) (pkt
return newTestPacket(tb, layers.LinkTypeEthernet, eth, ip, udp, dhcp)
}
// newDHCPv6Release creates a new DHCPv6 RELEASE packet for testing.
func newDHCPv6Release(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.DHCPv6MsgTypeRelease,
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)
}
+1 -5
View File
@@ -97,11 +97,7 @@ 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.
func (idx *leaseIndex) remove(
ctx context.Context,
l *Lease,
iface *netInterface,
) (err error) {
func (idx *leaseIndex) remove(ctx context.Context, l *Lease, iface *netInterface) (err error) {
loweredName := strings.ToLower(l.Hostname)
if _, ok := idx.byAddr[l.IP]; !ok {
+6 -6
View File
@@ -94,7 +94,7 @@ func TestDHCPServer_AddLease(t *testing.T) {
return nil
}
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
if tc.wantErrMsg == "" {
db.onStore = onStore
}
@@ -116,7 +116,7 @@ func TestDHCPServer_index(t *testing.T) {
t.Parallel()
srv := newTestDHCPServer(t, &dhcpsvc.Config{
Database: newTestDatabase(t, testLeases),
Database: newTestDatabase(t),
Enabled: true,
})
@@ -244,7 +244,7 @@ func TestDHCPServer_UpdateStaticLease(t *testing.T) {
return nil
}
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
if tc.wantErrMsg == "" {
db.onStore = onStore
}
@@ -319,7 +319,7 @@ func TestDHCPServer_RemoveLease(t *testing.T) {
return nil
}
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
if tc.wantErrMsg == "" {
db.onStore = onStore
}
@@ -340,7 +340,7 @@ func TestDHCPServer_RemoveLease(t *testing.T) {
func TestDHCPServer_Reset(t *testing.T) {
t.Parallel()
db := newTestDatabase(t, testLeases)
db := newTestDatabase(t)
db.onStore = func(_ context.Context, leases []*dhcpsvc.Lease) (err error) {
assert.Empty(t, leases)
@@ -364,7 +364,7 @@ func TestServer_Leases(t *testing.T) {
t.Parallel()
srv := newTestDHCPServer(t, &dhcpsvc.Config{
Database: newTestDatabase(t, testLeases),
Database: newTestDatabase(t),
Enabled: true,
})
+53 -9
View File
@@ -403,6 +403,7 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
) (lease *Lease, iaid uint32) {
l := iface.common.logger
key := macToKey(mac)
now := iface.clock.Now()
for i, reqOpt := range req.Options {
if reqOpt.Code != layers.DHCPv6OptIANA {
@@ -417,12 +418,14 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
continue
}
// TODO(e.burkov): Check if the IA actually requests any address.
var ok bool
if lease, ok = iface.common.leases[key]; ok {
return lease, iana.ID
}
lease, err = iface.common.allocateLease(ctx, mac, key, iface.clock.Now())
lease, err = iface.common.allocateLease(ctx, mac, key, now)
if err != nil {
l.DebugContext(ctx, "no address available", "iaid", iana.ID, slogutil.KeyError, err)
@@ -437,6 +440,41 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
return nil, 0
}
// ipForRelease returns the IAID and IP address requested for release in req.
// It returns zero values if there is no IA_NA option or if all options are
// malformed. req must be a valid DHCPv6 message of RELEASE type.
func (iface *dhcpInterfaceV6) ipForRelease(
ctx context.Context,
req *layers.DHCPv6,
) (iaid uint32, ip netip.Addr) {
l := iface.common.logger
for i, reqOpt := range req.Options {
if reqOpt.Code != layers.DHCPv6OptIANA {
continue
}
iana := &IANAOption{}
err := iana.UnmarshalBinary(reqOpt.Data)
if err != nil {
l.DebugContext(ctx, "malformed ia_na", "idx", i, slogutil.KeyError, err)
continue
}
reqIP, hasReqIP := iana.requestedAddr()
if !hasReqIP {
l.DebugContext(ctx, "no ip in ia_na for release", "iaid", iana.ID)
continue
}
return iana.ID, reqIP
}
return 0, netip.Addr{}
}
// firstIANA returns the first valid IA_NA option in req. It returns false if
// there is no such option. req must not be nil.
func (iface *dhcpInterfaceV6) firstIANA(
@@ -502,10 +540,10 @@ func (iface *dhcpInterfaceV6) confirmAddrsOnLink(
}
// newSolicitRespOpts returns the common option list for Advertise and
// rapid-commit Reply responses to a Solicit request. Zero iaid creates an
// rapid-commit Reply responses to a Solicit request. Nil lease creates an
// option with Status Code NoAddrsAvail. rapidCommit defines whether the
// response should include the Rapid Commit option. fd, req, cliID, and lease
// must not be nil.
// response should include the Rapid Commit option. fd, req, and cliID must not
// be nil.
func (iface *dhcpInterfaceV6) newSolicitRespOpts(
fd *frameData6,
req *layers.DHCPv6,
@@ -519,7 +557,7 @@ func (iface *dhcpInterfaceV6) newSolicitRespOpts(
// For Solicit without IA_NA options, respond with safe Advertise with no
// IA_NA options and Status Code NoAddrsAvail.
if iaid == 0 {
if lease == nil {
opts = append(opts, newStatusCodeOption(layers.DHCPv6StatusCodeNoAddrsAvail))
} else {
opts = append(opts, iface.iaNAFromLease(lease, iaid))
@@ -541,7 +579,8 @@ 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, or be empty if the response should not contain an IA_NA option.
// IA_NA option, or have a zero code 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.
@@ -595,11 +634,16 @@ func (iface *dhcpInterfaceV6) newConfirmRespOpts(
return iface.appendRequestedOptions(opts, req)
}
// newUpdateRespOpts returns the common option list for Reply responses to a
// RENEW or REBIND messages. fd, req, and cliID must not be nil. iana must be
// a valid IA_NA option, or empty if the response should not include one.
// newUpdateRespOpts returns the common option list for Reply responses to
// RENEW, REBIND, and RELEASE messages. fd, req, and cliID must not be nil.
// iana must be a valid IA_NA option, or have a zero code if the response should
// not include one.
//
// See RFC 9915 Section 18.3.4.
//
// TODO(e.burkov): DRY with other options builders
//
// TODO(e.burkov): Use internal types for options.
func (iface *dhcpInterfaceV6) newUpdateRespOpts(
fd *frameData6,
req *layers.DHCPv6,