Pull request 2739: AGDNS-3863-gopacket-dhcp-vol.33

Updates #4923.

Squashed commit of the following:

commit a0d7998b4bfa837a8d8c19e3ed7cfc3da16b7181
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Mon Aug 3 14:33:41 2026 +0300

    dhcpsvc: imp code, docs

commit 028c1ff83198e915fd320983eaea5cace5787325
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Fri Jul 31 18:57:36 2026 +0300

    dhcpsvc: handle information request

commit 0d3f2888508eb462ec5ec9f7a29c3a85c917d16d
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Thu Jul 30 15:43:09 2026 +0300

    dhcpsvc: handle rebind
This commit is contained in:
Eugene Burkov committed 2026-08-03 12:16:50 +00:00
1 parent 5f6cb57df9
commit 091d9287fc
3 files changed
+297 -33

No files matched your search

+51 -19
View File
@@ -274,24 +274,27 @@ func (iface *dhcpInterfaceV6) handleRenew(
// handler does.
//
// See RFC 9915 Section 18.3.4.
resp.Options = iface.newRenewRespOpts(fd, req, cliID, layers.DHCPv6Option{})
resp.Options = iface.newUpdateRespOpts(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)
ianaOpt := iface.ianaForUpdate(ctx, req, iana, fd.ether.SrcMAC)
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, ianaOpt)
return respond6(fd, resp)
}
// handleRebind handles messages of type REBIND. req must not be nil and must
// be a valid DHCPv6 message of type REBIND. fd must be valid.
// be a valid DHCPv6 message of type REBIND, fd must be valid.
//
// TODO(e.burkov): Implement. This is a stub for now.
// TODO(e.burkov): The current implementation rebinds 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) handleRebind(
ctx context.Context,
_ *frameData6,
fd *frameData6,
req *layers.DHCPv6,
) (err error) {
cliID, err := clientIDNoServer(req.Options)
@@ -302,31 +305,54 @@ func (iface *dhcpInterfaceV6) handleRebind(
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
// rebind. Respond with no IA options similarly to how the Renew
// handler does.
//
// See RFC 9915 Section 18.3.5.
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, layers.DHCPv6Option{})
return respond6(fd, resp)
}
ianaOpt := iface.ianaForUpdate(ctx, req, iana, fd.ether.SrcMAC)
resp.Options = iface.newUpdateRespOpts(fd, req, cliID, ianaOpt)
return respond6(fd, resp)
}
// handleInfo handles messages of type INFORMATION-REQUEST. req must not be nil
// and must be a valid DHCPv6 message of type INFORMATION-REQUEST. fd must be
// valid.
//
// TODO(e.burkov): Implement. This is a stub for now.
// TODO(e.burkov): The current implementation does not handle relay-forwarded
// INFORMATION-REQUEST messages.
func (iface *dhcpInterfaceV6) handleInfo(
ctx context.Context,
fd *frameData6,
req *layers.DHCPv6,
) (err error) {
if srvID, ok := findOption6(req.Options, layers.DHCPv6OptServerID); ok {
if !bytes.Equal(srvID, fd.duidData) {
return fmt.Errorf(
"dhcpv6: server id: got %v, want %v: %w",
srvID,
fd.duidData,
errors.ErrNotEqual,
)
}
srvID, ok := findOption6(req.Options, layers.DHCPv6OptServerID)
if ok && !bytes.Equal(srvID, fd.duidData) {
return fmt.Errorf(
"dhcpv6: server id: got %v, want %v: %w",
srvID,
fd.duidData,
errors.ErrNotEqual,
)
}
_, ok := findOption6(req.Options, layers.DHCPv6OptIANA)
_, ok = findOption6(req.Options, layers.DHCPv6OptIANA)
if ok {
return fmt.Errorf("dhcpv6: %s: ia option: %w", req.MsgType, errors.ErrUnexpectedValue)
}
@@ -339,7 +365,13 @@ func (iface *dhcpInterfaceV6) handleInfo(
l := iface.common.logger
l.DebugContext(ctx, "handling message", "type", req.MsgType)
return nil
resp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeReply,
TransactionID: req.TransactionID,
Options: iface.newInfoRespOpts(fd, req),
}
return respond6(fd, resp)
}
// handleRelease handles messages of type RELEASE. req must not be nil and must
+212
View File
@@ -524,6 +524,7 @@ func TestDHCPServer_ServeEther6_renew(t *testing.T) {
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeNoBinding),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
@@ -573,6 +574,152 @@ func TestDHCPServer_ServeEther6_renew(t *testing.T) {
}
}
func TestDHCPServer_ServeEther6_rebind(t *testing.T) {
t.Parallel()
testCases := []struct {
in gopacket.Packet
want *dhcpsvc.Lease
name string
wantOpts layers.DHCPv6Options
}{{
in: newDHCPv6Rebind(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: newDHCPv6Rebind(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: newDHCPv6Rebind(t, testHWUnknown, testIPv6Unknown),
name: "no_binding",
want: nil,
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptIANAStatus(t, testIAID, layers.DHCPv6StatusCodeNoBinding),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
in: newDHCPv6Rebind(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)
})
}
}
func TestDHCPServer_ServeEther6_info(t *testing.T) {
t.Parallel()
testCases := []struct {
in gopacket.Packet
name string
wantOpts layers.DHCPv6Options
}{{
in: newDHCPv6Info(t, testHWUnknown, true, true),
name: "cli_and_srv",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
},
}, {
in: newDHCPv6Info(t, testHWUnknown, false, true),
name: "srv_only",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
},
}, {
in: newDHCPv6Info(t, testHWUnknown, true, false),
name: "cli_only",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
},
}, {
in: newDHCPv6Info(t, testHWUnknown, false, false),
name: "no_opts",
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
},
}}
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
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)
})
}
}
// newDHCPv6Solicit creates a new DHCPv6 SOLICIT packet for testing.
func newDHCPv6Solicit(
tb testing.TB,
@@ -797,6 +944,71 @@ func assertValidDHCPv6(
}
}
// newDHCPv6Rebind creates a new DHCPv6 REBIND packet for testing.
func newDHCPv6Rebind(tb testing.TB, mac net.HardwareAddr, reqIP netip.Addr) (pkt gopacket.Packet) {
tb.Helper()
// REBIND is sent to any available server, so the destination is the
// multicast address, not a specific server's unicast.
eth := newEthernetLayer(tb, mac, nil, layers.EthernetTypeIPv6)
ip, udp := newIPv6UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
dhcp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeRebind,
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),
// REBIND must not contain a Server ID option.
},
}
if reqIP.IsValid() && reqIP.Is6() {
dhcp.Options = append(dhcp.Options, newOptIANA(tb, testIAID, reqIP, testLeaseTTL))
}
return newTestPacket(tb, layers.LinkTypeEthernet, eth, ip, udp, dhcp)
}
// newDHCPv6Info creates a new DHCPv6 INFORMATION-REQUEST packet for testing.
// withClientID controls whether the packet includes a Client Identifier option.
func newDHCPv6Info(
tb testing.TB,
mac net.HardwareAddr,
addClientID bool,
addServerID bool,
) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernetLayer(tb, mac, nil, layers.EthernetTypeIPv6)
ip, udp := newIPv6UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
dhcp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeInformationRequest,
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{},
}
if addClientID {
dhcp.Options = append(dhcp.Options, newOptClientDUID(tb, mac))
}
if addServerID {
dhcp.Options = append(dhcp.Options, newOptServerDUID(tb, testIfaceHWAddr))
}
return newTestPacket(tb, layers.LinkTypeEthernet, eth, ip, udp, dhcp)
}
// 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()
+34 -14
View File
@@ -595,14 +595,12 @@ func (iface *dhcpInterfaceV6) newConfirmRespOpts(
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.
// 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.
//
// See RFC 9915 Section 18.3.4.
//
// TODO(e.burkov): DRY with other response option builders.
func (iface *dhcpInterfaceV6) newRenewRespOpts(
func (iface *dhcpInterfaceV6) newUpdateRespOpts(
fd *frameData6,
req *layers.DHCPv6,
cliID *layers.DHCPv6DUID,
@@ -625,6 +623,28 @@ func (iface *dhcpInterfaceV6) newRenewRespOpts(
return iface.appendRequestedOptions(opts, req)
}
// newInfoRespOpts returns the option list for a Reply to an INFORMATION-REQUEST
// message. The Client Identifier option is echoed back only if the request
// contained one. fd and req must not be nil.
//
// See RFC 9915 Section 18.3.6.
func (iface *dhcpInterfaceV6) newInfoRespOpts(
fd *frameData6,
req *layers.DHCPv6,
) (opts layers.DHCPv6Options) {
opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptServerID, fd.duidData))
// Client ID is optional in INFORMATION-REQUEST but must be echoed if
// present.
//
// See RFC 9915 Section 18.3.6.
if cliIDData, ok := clientDUID6(req.Options); ok {
opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptClientID, cliIDData))
}
return iface.appendRequestedOptions(opts, req)
}
// iaNAFromLease returns an IA_NA option with a single IA Address sub-option
// corresponding to lease and with the given iaid. The T1 and T2 values are set
// according to iface.t1 and iface.t2. If lease is nil, it returns an IA_NA
@@ -679,12 +699,12 @@ func (iface *dhcpInterfaceV6) ianaForRequest(
return iface.iaNAFromLease(lease, iaid)
}
// ianaForRenew returns the IANA filled with committed lease data for req. It
// ianaForUpdate 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(
// valid DHCPv6 message of type RENEW or REBIND, 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) ianaForUpdate(
ctx context.Context,
req *layers.DHCPv6,
reqIANA *IANAOption,
@@ -698,7 +718,7 @@ func (iface *dhcpInterfaceV6) ianaForRenew(
// 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.
// See RFC 9915 Section 18.3.4 and 18.3.5.
return layers.DHCPv6Option{}
}
@@ -707,8 +727,8 @@ func (iface *dhcpInterfaceV6) ianaForRenew(
// 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{}
// See RFC 9915 Section 18.3.4 and 18.3.5.
return newIANAWithStatus(reqIANA.ID, layers.DHCPv6StatusCodeNoBinding)
}
err := iface.commit(ctx, req, lease)