Pull request 2676: AGDNS-3863-gopacket-dhcp-vol.26

Updates #4923.

Squashed commit of the following:

commit 072006739b
Merge: 763b23a37 54e6e3002
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Wed Jun 17 14:40:42 2026 +0300

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

commit 763b23a37b
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Jun 16 17:28:05 2026 +0300

    dhcpsvc: fix lease expiry logic

commit 84cb6fde32
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Mon Jun 15 19:13:42 2026 +0300

    dhcpsvc: imp tests

commit cbe23b49f2
Author: Eugene Burkov <e.burkov@adguard.com>
Date:   Tue Jun 9 20:45:59 2026 +0300

    dhcpsvc: add v6 tests
This commit is contained in:
Eugene Burkov 2026-06-17 11:50:34 +00:00
parent 54e6e30022
commit 798cd4d2fd
10 changed files with 755 additions and 249 deletions

View file

@ -41,9 +41,6 @@ const testTimeout = 10 * time.Second
// testLeaseTTL is the lease duration used in tests.
const testLeaseTTL = 24 * time.Hour
// testXid is a common transaction ID for DHCPv4 tests.
const testXid = 1
// testLogger is a common logger for tests.
var testLogger = slogutil.NewDiscardLogger()
@ -102,11 +99,15 @@ const (
const (
// testRangeStartV6Str is the string representation of the range start of
// the IPv6 interface used in tests.
testRangeStartV6Str = "2001:db8::1"
testRangeStartV6Str = "2001:db8::2"
// testAnotherRangeStartV6Str is the string representation of the range
// start of the second IPv6 interface used in tests.
testAnotherRangeStartV6Str = "2001:db9::1"
// testIfaceAddrV6Str is the string representation of the interface's IPv6
// address used in tests.
testIfaceAddrV6Str = "2001:db8::1"
)
var (
@ -133,10 +134,23 @@ var (
RASLAACOnly: true,
}
// testIfaceAddr is a common valid IPv4 address of the test network
// disabledIPv4Conf is a configuration of IPv4 part of the interfaces
// configuration that is disabled.
disabledIPv4Conf = &dhcpsvc.IPv4Config{Enabled: false}
// disabledIPv6Conf is a configuration of IPv6 part of the interfaces
// configuration that is disabled.
disabledIPv6Conf = &dhcpsvc.IPv6Config{Enabled: false}
// testIfaceAddrV4 is a common valid IPv4 address of the test network
// interface, compliant with [testIPv4Conf], i.e. outside of the range,
// within the subnet, not equal to the gateway.
testIfaceAddr = netip.MustParseAddr(testIfaceAddrV4Str)
testIfaceAddrV4 = netip.MustParseAddr(testIfaceAddrV4Str)
// testIfaceAddrV6 is a common valid IPv6 address of the test network
// interface, compliant with [testIPv6Conf], i.e. outside of the range,
// within the subnet, not equal to the gateway.
testIfaceAddrV6 = netip.MustParseAddr(testIfaceAddrV6Str)
// testIfaceHWAddr is a common valid hardware address of the test network
// interface.
@ -171,19 +185,44 @@ var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{
},
}
// disabledIPv6Config is a configuration of IPv6 part of the interfaces
// configuration that is disabled.
var disabledIPv6Config = &dhcpsvc.IPv6Config{Enabled: false}
// Hardware addresses for test cases.
//
// 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}
// fullLayersStack is the complete stack of layers expected to appear in the
// testHWStatic is the test MAC address for a known static lease.
testHWStatic = net.HardwareAddr{0x1, 0x2, 0x3, 0x4, 0x5, 0x6}
// testHWDynamic is the test MAC address for a known dynamic lease.
testHWDynamic = net.HardwareAddr{0x2, 0x3, 0x4, 0x5, 0x6, 0x7}
// testHWExpired is the test MAC address for a known expired lease.
testHWExpired = net.HardwareAddr{0x3, 0x4, 0x5, 0x6, 0x7, 0x8}
// testHWAnother is the test MAC address for a lease with another IP.
testHWAnother = net.HardwareAddr{0x4, 0x5, 0x6, 0x7, 0x8, 0x9}
)
// fullLayersStack4 is the complete stack of layers expected to appear in the
// DHCP response packets.
var fullLayersStack = []gopacket.LayerType{
var fullLayersStack4 = []gopacket.LayerType{
layers.LayerTypeEthernet,
layers.LayerTypeIPv4,
layers.LayerTypeUDP,
layers.LayerTypeDHCPv4,
}
// fullLayersStack6 is the complete stack of layers expected to appear in the
// DHCPv6 response packets.
var fullLayersStack6 = []gopacket.LayerType{
layers.LayerTypeEthernet,
layers.LayerTypeIPv6,
layers.LayerTypeUDP,
layers.LayerTypeDHCPv6,
}
// newTempDB copies the leases database file located in the testdata FS, under
// tb.Name()/leases.json, to a temporary directory and returns the path to the
// copied file.
@ -235,3 +274,39 @@ func newTestDHCPServer(tb testing.TB, conf *dhcpsvc.Config) (srv *dhcpsvc.DHCPSe
func startTestDHCPServer(tb testing.TB, conf *dhcpsvc.Config) {
servicetest.RequireRun(tb, newTestDHCPServer(tb, conf), testTimeout)
}
// newTestPacket creates a valid packet from ls using first as first layer
// decoder.
func newTestPacket(
tb testing.TB,
first gopacket.Decoder,
ls ...gopacket.SerializableLayer,
) (pkg gopacket.Packet) {
tb.Helper()
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{
FixLengths: true,
ComputeChecksums: true,
}
err := gopacket.SerializeLayers(buf, opts, ls...)
require.NoError(tb, err)
return gopacket.NewPacket(buf.Bytes(), first, gopacket.Default)
}
// assertNoResponse asserts that no response is received on the channel within
// the timeout.
//
// TODO(e.burkov): Improve the helper to not rely on timeout.
func assertNoResponse(tb testing.TB, outCh <-chan []byte, timeout time.Duration) {
tb.Helper()
var resp []byte
require.Panics(tb, func() {
resp, _ = testutil.RequireReceive(testutil.NewPanicT(tb), outCh, timeout)
})
require.Nil(tb, resp)
}

View file

@ -25,7 +25,7 @@ import (
var testIPv4InterfacesConf = map[string]*dhcpsvc.InterfaceConfig{
testIfaceName: {
IPv4: testIPv4Conf,
IPv6: disabledIPv6Config,
IPv6: disabledIPv6Conf,
},
}
@ -33,54 +33,42 @@ var testIPv4InterfacesConf = map[string]*dhcpsvc.InterfaceConfig{
//
// NOTE: Keep in sync with testdata.
const (
// testLeaseHostnameStatic is the test hostname for the static lease.
testLeaseHostnameStatic = "static4"
// testLease4HostnameStatic is the test hostname for a static DHCPv4 lease.
testLease4HostnameStatic = "static4"
// testLeaseHostnameDynamic is the test hostname for the dynamic lease.
testLeaseHostnameDynamic = "dynamic4"
// testLease4HostnameDynamic is the test hostname for a dynamic DHCPv4
// lease.
testLease4HostnameDynamic = "dynamic4"
// testLeaseHostnameExpired is the test hostname for the expired lease.
testLeaseHostnameExpired = "expired4"
// testLease4HostnameExpired is the test hostname for an expired DHCPv4
// lease.
testLease4HostnameExpired = "expired4"
)
// Hardware addresses for test cases.
// testXid is a common transaction ID for DHCPv4 tests.
//
// 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}
// testHWStatic is the test MAC address for a known static lease.
testHWStatic = net.HardwareAddr{0x1, 0x2, 0x3, 0x4, 0x5, 0x6}
// testHWDynamic is the test MAC address for a known dynamic lease.
testHWDynamic = net.HardwareAddr{0x2, 0x3, 0x4, 0x5, 0x6, 0x7}
// testHWExpired is the test MAC address for a known expired lease.
testHWExpired = net.HardwareAddr{0x3, 0x4, 0x5, 0x6, 0x7, 0x8}
// testHWAnother is the test MAC address for a lease with another IP.
testHWAnother = net.HardwareAddr{0x4, 0x5, 0x6, 0x7, 0x8, 0x9}
)
// TODO(e.burkov): Generate unique IDs when they will be actually used.
const testXid = 1
// IP addresses for test cases.
//
// NOTE: Keep in sync with testdata.
var (
// testIPUnknown is the test IP address for an unknown client.
testIPUnknown = netip.MustParseAddr("192.0.2.142")
// testIPv4Unknown is the test IP address for an unknown client.
testIPv4Unknown = netip.MustParseAddr("192.0.2.142")
// testIPStatic is the test IP address for a known static lease.
testIPStatic = netip.MustParseAddr("192.0.2.101")
// testIPv4Static is the test IP address for a known static lease.
testIPv4Static = netip.MustParseAddr("192.0.2.101")
// testIPDynamic is the test IP address for a known dynamic lease.
testIPDynamic = netip.MustParseAddr("192.0.2.102")
// testIPv4Dynamic is the test IP address for a known dynamic lease.
testIPv4Dynamic = netip.MustParseAddr("192.0.2.102")
// testIPOtherSubnet is the test IP address for a client on another subnet.
testIPOtherSubnet = netip.MustParseAddr(testAnotherGatewayIPv4Str)
// testIPv4OtherSubnet is the test IP address for a client on another
// subnet.
testIPv4OtherSubnet = netip.MustParseAddr(testAnotherGatewayIPv4Str)
// testIPRelayAgent is the test IP address of the relay agent.
testIPRelayAgent = netip.MustParseAddr("10.0.0.1")
// testIPv4RelayAgent is the test IP address of the relay agent.
testIPv4RelayAgent = netip.MustParseAddr("10.0.0.1")
)
// Time-related variables for test cases.
@ -106,7 +94,7 @@ func TestDHCPServer_ServeEther4_discover(t *testing.T) {
in: newDHCPDISCOVER(t, testHWUnknown),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeOffer),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
},
}, {
@ -114,27 +102,27 @@ func TestDHCPServer_ServeEther4_discover(t *testing.T) {
in: newDHCPDISCOVER(t, testHWStatic),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeOffer),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
newOptHostname(t, testLeaseHostnameStatic),
newOptHostname(t, testLease4HostnameStatic),
},
}, {
name: "existing_dynamic",
in: newDHCPDISCOVER(t, testHWDynamic),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeOffer),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testTTLDynamicLease),
newOptHostname(t, testLeaseHostnameDynamic),
newOptHostname(t, testLease4HostnameDynamic),
},
}, {
name: "existing_dynamic_expired",
in: newDHCPDISCOVER(t, testHWExpired),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeOffer),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
newOptHostname(t, testLeaseHostnameExpired),
newOptHostname(t, testLease4HostnameExpired),
},
}}
@ -145,7 +133,7 @@ func TestDHCPServer_ServeEther4_discover(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -155,7 +143,7 @@ func TestDHCPServer_ServeEther4_discover(t *testing.T) {
testutil.RequireSend(t, inCh, tc.in, testTimeout)
assertValidResponse(t, req, outCh, tc.wantOpts)
assertValidResponse4(t, req, outCh, tc.wantOpts)
})
}
}
@ -166,7 +154,7 @@ func TestDHCPServer_ServeEther4_discoverExpired(t *testing.T) {
pkt := newDHCPDISCOVER(t, testHWUnknown)
req := testutil.RequireTypeAssert[*layers.DHCPv4](t, pkt.Layer(layers.LayerTypeDHCPv4))
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
@ -177,9 +165,9 @@ func TestDHCPServer_ServeEther4_discoverExpired(t *testing.T) {
testutil.RequireSend(t, inCh, pkt, testTimeout)
assertValidResponse(t, req, outCh, layers.DHCPOptions{
assertValidResponse4(t, req, outCh, layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeOffer),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
})
}
@ -187,18 +175,18 @@ func TestDHCPServer_ServeEther4_discoverExpired(t *testing.T) {
func TestDHCPServer_ServeEther4_release(t *testing.T) {
t.Parallel()
ipMismatch := testIPDynamic.Next().Next()
ipMismatch := testIPv4Dynamic.Next().Next()
testCases := []struct {
req gopacket.Packet
name string
wantChange bool
}{{
req: newDHCPRELEASE(t, testHWDynamic, testIPDynamic),
req: newDHCPRELEASE(t, testHWDynamic, testIPv4Dynamic),
name: "success",
wantChange: true,
}, {
req: newDHCPRELEASE(t, testHWUnknown, testIPDynamic),
req: newDHCPRELEASE(t, testHWUnknown, testIPv4Dynamic),
name: "not_found",
wantChange: false,
}, {
@ -206,7 +194,7 @@ func TestDHCPServer_ServeEther4_release(t *testing.T) {
name: "mismatch_ip",
wantChange: false,
}, {
req: newDHCPRELEASE(t, testHWDynamic, testIPOtherSubnet),
req: newDHCPRELEASE(t, testHWDynamic, testIPv4OtherSubnet),
name: "bad_subnet",
wantChange: false,
}}
@ -217,7 +205,7 @@ func TestDHCPServer_ServeEther4_release(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, _ := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, _ := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
srv := newTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -259,7 +247,7 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
request: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{
newOptRequestIP(t, testIPv4Conf.RangeStart),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
clientHWAddr: testHWUnknown,
flags: dhcpsvc.FlagsBroadcast,
@ -267,15 +255,15 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
name: "success",
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
},
}, {
discover: newDHCPDISCOVER(t, testHWStatic),
request: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{
newOptRequestIP(t, testIPStatic),
newOptServerID(t, testIPOtherSubnet),
newOptRequestIP(t, testIPv4Static),
newOptServerID(t, testIPv4OtherSubnet),
},
clientHWAddr: testHWStatic,
flags: dhcpsvc.FlagsBroadcast,
@ -287,7 +275,7 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
request: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{
newOptRequestIP(t, testIPv4Conf.RangeEnd.Next()),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
clientHWAddr: testHWUnknown,
flags: dhcpsvc.FlagsBroadcast,
@ -295,14 +283,14 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
name: "no_lease",
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}, {
discover: newDHCPDISCOVER(t, testHWStatic),
request: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{
newOptRequestIP(t, testIPv4Conf.RangeEnd.Next()),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
clientHWAddr: testHWStatic,
flags: dhcpsvc.FlagsBroadcast,
@ -310,17 +298,17 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
name: "wrong_ip",
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}, {
discover: newDHCPDISCOVER(t, testHWStatic),
request: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{
newOptRequestIP(t, testIPStatic),
newOptServerID(t, testIfaceAddr),
newOptRequestIP(t, testIPv4Static),
newOptServerID(t, testIfaceAddrV4),
},
clientHWAddr: testHWStatic,
clientIP: testIPStatic,
clientIP: testIPv4Static,
flags: dhcpsvc.FlagsBroadcast,
}),
name: "nonzero_ciaddr",
@ -333,7 +321,7 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Logger: slogutil.NewDiscardLogger(),
Interfaces: testIPv4InterfacesConf,
@ -351,7 +339,7 @@ func TestDHCPServer_ServeEther4_requestSelecting(t *testing.T) {
testutil.RequireSend(t, inCh, tc.request, testTimeout)
assertValidResponse(t, dhcpv4FromPacket(t, tc.request), outCh, tc.wantOpts)
assertValidResponse4(t, dhcpv4FromPacket(t, tc.request), outCh, tc.wantOpts)
})
}
}
@ -366,31 +354,31 @@ func TestDHCPServer_ServeEther4_requestInitReboot(t *testing.T) {
}{{
name: "success",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPStatic)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4Static)},
clientHWAddr: testHWStatic,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
newOptHostname(t, testLeaseHostnameStatic),
newOptHostname(t, testLease4HostnameStatic),
},
}, {
name: "wrong_subnet",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPOtherSubnet)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4OtherSubnet)},
clientHWAddr: testHWStatic,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}, {
name: "no_lease",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPStatic)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4Static)},
clientHWAddr: testHWUnknown,
flags: dhcpsvc.FlagsBroadcast,
}),
@ -398,30 +386,30 @@ func TestDHCPServer_ServeEther4_requestInitReboot(t *testing.T) {
}, {
name: "wrong_ip",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPDynamic)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4Dynamic)},
clientHWAddr: testHWStatic,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}, {
name: "wrong_ip_no_broadcast",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPDynamic)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4Dynamic)},
clientHWAddr: testHWStatic,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}, {
name: "nonzero_ciaddr",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
options: layers.DHCPOptions{newOptRequestIP(t, testIPStatic)},
options: layers.DHCPOptions{newOptRequestIP(t, testIPv4Static)},
clientHWAddr: testHWStatic,
clientIP: testIPStatic,
clientIP: testIPv4Static,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: nil,
@ -433,7 +421,7 @@ func TestDHCPServer_ServeEther4_requestInitReboot(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -443,7 +431,7 @@ func TestDHCPServer_ServeEther4_requestInitReboot(t *testing.T) {
testutil.RequireSend(t, inCh, tc.req, testTimeout)
assertValidResponse(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
assertValidResponse4(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
})
}
}
@ -459,52 +447,52 @@ func TestDHCPServer_ServeEther4_requestRenewSuccess(t *testing.T) {
name: "success",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWDynamic,
clientIP: testIPDynamic,
clientIP: testIPv4Dynamic,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testTTLDynamicLease),
newOptHostname(t, testLeaseHostnameDynamic),
newOptHostname(t, testLease4HostnameDynamic),
},
}, {
name: "static",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWStatic,
clientIP: testIPStatic,
clientIP: testIPv4Static,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testLeaseTTL),
newOptHostname(t, testLeaseHostnameStatic),
newOptHostname(t, testLease4HostnameStatic),
},
}, {
name: "relay_agent",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWDynamic,
clientIP: testIPDynamic,
relayAgentIP: testIPRelayAgent,
clientIP: testIPv4Dynamic,
relayAgentIP: testIPv4RelayAgent,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testTTLDynamicLease),
newOptHostname(t, testLeaseHostnameDynamic),
newOptHostname(t, testLease4HostnameDynamic),
},
}, {
name: "ciaddr_unicast",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWDynamic,
clientIP: testIPDynamic,
clientIP: testIPv4Dynamic,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeAck),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
newOptLeaseTime(t, testTTLDynamicLease),
newOptHostname(t, testLeaseHostnameDynamic),
newOptHostname(t, testLease4HostnameDynamic),
},
}}
@ -514,7 +502,7 @@ func TestDHCPServer_ServeEther4_requestRenewSuccess(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -524,7 +512,7 @@ func TestDHCPServer_ServeEther4_requestRenewSuccess(t *testing.T) {
testutil.RequireSend(t, inCh, tc.req, testTimeout)
assertValidResponse(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
assertValidResponse4(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
})
}
}
@ -540,7 +528,7 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
name: "wrong_subnet",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWStatic,
clientIP: testIPOtherSubnet,
clientIP: testIPv4OtherSubnet,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: nil,
@ -548,7 +536,7 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
name: "no_lease",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWUnknown,
clientIP: testIPStatic,
clientIP: testIPv4Static,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: nil,
@ -556,12 +544,12 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
name: "wrong_ip",
req: newDHCPREQUEST(t, &dhcpRequestConfig{
clientHWAddr: testHWStatic,
clientIP: testIPDynamic,
clientIP: testIPv4Dynamic,
flags: dhcpsvc.FlagsBroadcast,
}),
wantOpts: layers.DHCPOptions{
newOptMessageType(t, layers.DHCPMsgTypeNak),
newOptServerID(t, testIfaceAddr),
newOptServerID(t, testIfaceAddrV4),
},
}}
@ -571,7 +559,7 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -581,7 +569,7 @@ func TestDHCPServer_ServeEther4_requestRenewFail(t *testing.T) {
testutil.RequireSend(t, inCh, tc.req, testTimeout)
assertValidResponse(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
assertValidResponse4(t, dhcpv4FromPacket(t, tc.req), outCh, tc.wantOpts)
})
}
}
@ -594,19 +582,19 @@ func TestDHCPServer_ServeEther4_decline(t *testing.T) {
name string
wantChange bool
}{{
req: newDHCPDECLINE(t, testHWDynamic, testIPDynamic),
req: newDHCPDECLINE(t, testHWDynamic, testIPv4Dynamic),
name: "success",
wantChange: true,
}, {
req: newDHCPDECLINE(t, testHWUnknown, testIPDynamic),
req: newDHCPDECLINE(t, testHWUnknown, testIPv4Dynamic),
name: "not_found",
wantChange: false,
}, {
req: newDHCPDECLINE(t, testHWAnother, testIPUnknown),
req: newDHCPDECLINE(t, testHWAnother, testIPv4Unknown),
name: "mismatch_ip",
wantChange: false,
}, {
req: newDHCPDECLINE(t, testHWDynamic, testIPOtherSubnet),
req: newDHCPDECLINE(t, testHWDynamic, testIPv4OtherSubnet),
name: "bad_subnet",
wantChange: false,
}, {
@ -621,7 +609,7 @@ func TestDHCPServer_ServeEther4_decline(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, _ := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddr)
ndMgr, inCh, _ := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV4)
srv := newTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv4InterfacesConf,
NetworkDeviceManager: ndMgr,
@ -675,7 +663,7 @@ type dhcpRequestConfig struct {
func newDHCPREQUEST(tb testing.TB, conf *dhcpRequestConfig) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernet4Layer(tb, conf.clientHWAddr, nil)
eth := newEthernetLayer(tb, conf.clientHWAddr, nil, layers.EthernetTypeIPv4)
ip, udp := newIPv4UDPLayer(
tb,
@ -711,7 +699,7 @@ func newDHCPREQUEST(tb testing.TB, conf *dhcpRequestConfig) (pkt gopacket.Packet
func newDHCPDISCOVER(tb testing.TB, clientHWAddr net.HardwareAddr) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernet4Layer(tb, clientHWAddr, nil)
eth := newEthernetLayer(tb, clientHWAddr, nil, layers.EthernetTypeIPv4)
ip, udp := newIPv4UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
@ -737,12 +725,12 @@ func newDHCPRELEASE(
) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernet4Layer(tb, clientHWAddr, testIfaceHWAddr)
eth := newEthernetLayer(tb, clientHWAddr, testIfaceHWAddr, layers.EthernetTypeIPv4)
ip, udp := newIPv4UDPLayer(
tb,
netip.AddrPortFrom(clientIP, uint16(dhcpsvc.ClientPortV4)),
netip.AddrPortFrom(testIfaceAddr, uint16(dhcpsvc.ServerPortV4)),
netip.AddrPortFrom(testIfaceAddrV4, uint16(dhcpsvc.ServerPortV4)),
)
dhcp := &layers.DHCPv4{
@ -768,7 +756,7 @@ func newDHCPDECLINE(
) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernet4Layer(tb, clientHWAddr, nil)
eth := newEthernetLayer(tb, clientHWAddr, nil, layers.EthernetTypeIPv4)
ip, udp := newIPv4UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
@ -828,47 +816,6 @@ func newIPv4UDPLayer(tb testing.TB, src, dst netip.AddrPort) (ip *layers.IPv4, u
return ip, udp
}
// newEthernet4Layer creates a new Ethernet layer for IPv4 packets. Nil src is
// replaced with an unspecified MAC address, nil dst is replaced with a
// broadcast MAC address.
func newEthernet4Layer(tb testing.TB, src, dst net.HardwareAddr) (eth *layers.Ethernet) {
tb.Helper()
if src == nil {
src = net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
}
if dst == nil {
dst = net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
}
return &layers.Ethernet{
SrcMAC: src,
DstMAC: dst,
EthernetType: layers.EthernetTypeIPv4,
}
}
// newTestPacket creates a valid packet from ls using first as first layer
// decoder.
func newTestPacket(
tb testing.TB,
first gopacket.Decoder,
ls ...gopacket.SerializableLayer,
) (pkg gopacket.Packet) {
tb.Helper()
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{
FixLengths: true,
ComputeChecksums: true,
}
err := gopacket.SerializeLayers(buf, opts, ls...)
require.NoError(tb, err)
return gopacket.NewPacket(buf.Bytes(), first, gopacket.Default)
}
// requireEthernet requires data to contain an Ethernet layer and all layers
// from ls. First of ls must be of type [layers.LayerTypeEthernet].
func requireEthernet(
@ -886,10 +833,10 @@ func requireEthernet(
return types
}
// assertValidResponse asserts that recvCh eventually gets the response with
// assertValidResponse4 asserts that recvCh eventually gets the response with
// wantOpts for request. If wantOpts is nil, asserts that no response is sent.
// request and recvCh must not be nil.
func assertValidResponse(
func assertValidResponse4(
tb testing.TB,
request *layers.DHCPv4,
recvCh <-chan []byte,
@ -910,7 +857,7 @@ func assertValidResponse(
udp := &layers.UDP{}
resp := &layers.DHCPv4{}
types := requireEthernet(tb, respData, &layers.Ethernet{}, ip, udp, resp)
require.Equal(tb, fullLayersStack, types)
require.Equal(tb, fullLayersStack4, types)
assertValidDHCPv4(tb, request, resp, ip, udp)
@ -927,6 +874,8 @@ func assertValidResponse(
// assertValidDHCPv4 asserts that the response is valid for the given request
// according to RFC 2131.
func assertValidDHCPv4(tb testing.TB, req, resp *layers.DHCPv4, ip *layers.IPv4, udp *layers.UDP) {
tb.Helper()
switch {
case !req.RelayAgentIP.IsUnspecified():
assert.Equal(tb, req.RelayAgentIP.To4(), ip.DstIP)
@ -945,21 +894,6 @@ func assertValidDHCPv4(tb testing.TB, req, resp *layers.DHCPv4, ip *layers.IPv4,
}
}
// assertNoResponse asserts that no response is received on the channel within
// the timeout.
//
// TODO(e.burkov): Improve the helper to not rely on timeout.
func assertNoResponse(tb testing.TB, outCh <-chan []byte, timeout time.Duration) {
tb.Helper()
var resp []byte
require.Panics(tb, func() {
resp, _ = testutil.RequireReceive(testutil.NewPanicT(tb), outCh, timeout)
})
require.Nil(tb, resp)
}
// dhcpv4FromPacket extracts the DHCPv4 layer from pkt, which is required to
// contain one.
func dhcpv4FromPacket(tb testing.TB, pkt gopacket.Packet) (msg *layers.DHCPv4) {

View file

@ -92,7 +92,6 @@ func (iface *dhcpInterfaceV6) handleSolicit(
}
if lease == nil {
l.DebugContext(ctx, "no ia_na in solicit or no addresses available")
resp.Options = iface.newSolicitRespOpts(fd, req, cliID, iaid, nil, false)
return respond6(fd, resp)
@ -110,6 +109,12 @@ func (iface *dhcpInterfaceV6) handleSolicit(
if err != nil {
l.WarnContext(ctx, "committing rapid leases", slogutil.KeyError, err)
isRapidCommit = false
} else {
// The server will also send a Reply in response to a Solicit with a
// Rapid Commit option.
//
// See RFC 9915 Section 18.3.
resp.MsgType = layers.DHCPv6MsgTypeReply
}
resp.Options = iface.newSolicitRespOpts(fd, req, cliID, iaid, lease, isRapidCommit)

View file

@ -0,0 +1,337 @@
package dhcpsvc_test
import (
"net"
"net/netip"
"slices"
"testing"
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/testutil"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TODO(e.burkov): Add tests for wrong packets.
// testIPv6InterfacesConf is the test interfaces configuration for the DHCPv6
// part of the [DHCPServer].
var testIPv6InterfacesConf = map[string]*dhcpsvc.InterfaceConfig{
testIfaceName: {
IPv4: disabledIPv4Conf,
IPv6: testIPv6Conf,
},
}
// testIAID is a common IAID for IANA options in tests.
const testIAID = 1
// testTransactionID is a sample transaction ID for testing.
//
// TODO(e.burkov): Generate unique IDs when they will be actually used.
var testTransactionID = []byte{0x01, 0x02, 0x03}
// IP addresses for test cases.
//
// NOTE: Keep in sync with testdata.
var (
// testIPv6Unknown is the test IP address for an unknown client.
testIPv6Unknown = netip.MustParseAddr("2001:db8::64")
// testIPv6Dynamic is the test IP address for a known dynamic lease.
testIPv6Dynamic = netip.MustParseAddr("2001:db8::66")
// testIPv6Expired is the test IP address for a known expired lease.
testIPv6Expired = netip.MustParseAddr("2001:db8::67")
// testIPv6Static is the test IP address for a known static lease.
testIPv6Static = netip.MustParseAddr("2001:db8::65")
)
func TestDHCPServer_ServeEther6_solicit(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
in gopacket.Packet
wantOpts layers.DHCPv6Options
}{{
name: "new",
in: newDHCPv6SOLICIT(t, testHWUnknown, testIPv6Unknown, false),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptIANA(t, testIAID, testIPv6Conf.RangeStart),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
name: "existing_static",
in: newDHCPv6SOLICIT(t, testHWStatic, testIPv6Static, false),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWStatic),
newOptIANA(t, testIAID, testIPv6Static),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
name: "existing_dynamic",
in: newDHCPv6SOLICIT(t, testHWDynamic, testIPv6Dynamic, false),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptIANA(t, testIAID, testIPv6Dynamic),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
name: "existing_expired",
in: newDHCPv6SOLICIT(t, testHWExpired, testIPv6Expired, false),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWExpired),
newOptIANA(t, testIAID, testIPv6Expired),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
},
}, {
name: "new_rapid_commit",
in: newDHCPv6SOLICIT(t, testHWUnknown, testIPv6Unknown, true),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWUnknown),
newOptIANA(t, testIAID, testIPv6Conf.RangeStart),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, []byte{}),
},
}, {
name: "existing_rapid_commit",
in: newDHCPv6SOLICIT(t, testHWStatic, testIPv6Static, true),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWStatic),
newOptIANA(t, testIAID, testIPv6Static),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, []byte{}),
},
}, {
name: "existing_dynamic_rapid_commit",
in: newDHCPv6SOLICIT(t, testHWDynamic, testIPv6Dynamic, true),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWDynamic),
newOptIANA(t, testIAID, testIPv6Dynamic),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, []byte{}),
},
}, {
name: "existing_expired_rapid_commit",
in: newDHCPv6SOLICIT(t, testHWExpired, testIPv6Expired, true),
wantOpts: layers.DHCPv6Options{
newOptServerDUID(t, testIfaceHWAddr),
newOptClientDUID(t, testHWExpired),
newOptIANA(t, testIAID, testIPv6Expired),
newOptPreference(t, 0),
newOptSolMaxRT(t, dhcpsvc.DefaultSolMaxRT),
layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, []byte{}),
},
}}
for _, tc := range testCases {
req := testutil.RequireTypeAssert[*layers.DHCPv6](t, tc.in.Layer(layers.LayerTypeDHCPv6))
dbFilePath := newTempDB(t)
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ndMgr, inCh, outCh := newTestNetworkDeviceManager(t, testIfaceName, testIfaceAddrV6)
startTestDHCPServer(t, &dhcpsvc.Config{
Interfaces: testIPv6InterfacesConf,
NetworkDeviceManager: ndMgr,
Logger: testLogger,
DBFilePath: dbFilePath,
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,
hwAddr net.HardwareAddr,
reqIP netip.Addr,
rapidCommit bool,
) (pkt gopacket.Packet) {
tb.Helper()
eth := newEthernetLayer(tb, hwAddr, nil, layers.EthernetTypeIPv6)
ip, udp := newIPv6UDPLayer(tb, netip.AddrPort{}, netip.AddrPort{})
dhcp := &layers.DHCPv6{
MsgType: layers.DHCPv6MsgTypeSolicit,
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, hwAddr),
},
}
if reqIP.IsValid() && reqIP.Is6() {
dhcp.Options = append(dhcp.Options, newOptIANA(tb, testIAID, reqIP))
}
if rapidCommit {
o := layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, nil)
dhcp.Options = append(dhcp.Options, o)
}
return newTestPacket(tb, layers.LinkTypeEthernet, eth, ip, udp, dhcp)
}
// newIPv6UDPLayer creates IPv6 and UDP layers for testing. Invalid src is
// replaced with an unspecified address and client DHCPv6 port, invalid dst is
// replaced with the broadcast address and server DHCPv6 port.
func newIPv6UDPLayer(tb testing.TB, src, dst netip.AddrPort) (ip *layers.IPv6, udp *layers.UDP) {
tb.Helper()
if !src.IsValid() {
src = netip.AddrPortFrom(netip.IPv6Unspecified(), uint16(dhcpsvc.ClientPortV6))
}
if !dst.IsValid() {
bcastAddr, ok := netip.AddrFromSlice(net.IPv6linklocalallnodes)
require.True(tb, ok)
dst = netip.AddrPortFrom(bcastAddr, uint16(dhcpsvc.ServerPortV6))
}
ip = &layers.IPv6{
Version: 6,
HopLimit: dhcpsvc.IPv6DefaultHopLimit,
SrcIP: src.Addr().AsSlice(),
DstIP: dst.Addr().AsSlice(),
NextHeader: layers.IPProtocolUDP,
}
udp = &layers.UDP{
SrcPort: layers.UDPPort(src.Port()),
DstPort: layers.UDPPort(dst.Port()),
}
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
return ip, udp
}
// newEthernetLayer creates a new Ethernet layer for IP packets of the specified
// type. Nil src is replaced with an unspecified MAC address, nil dst is
// replaced with a broadcast MAC address, typ must be [layers.EthernetTypeIPv4]
// or [layers.EthernetTypeIPv6].
func newEthernetLayer(
tb testing.TB,
src net.HardwareAddr,
dst net.HardwareAddr,
typ layers.EthernetType,
) (eth *layers.Ethernet) {
tb.Helper()
if src == nil {
src = net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
}
if dst == nil {
dst = net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}
}
return &layers.Ethernet{
SrcMAC: src,
DstMAC: dst,
EthernetType: typ,
}
}
// assertValidResponse6 asserts that the response received on recvCh is a valid
// DHCPv6 response for the given request and contains the expected options. If
// wantOpts is nil, it asserts that no response is received.
func assertValidResponse6(
tb testing.TB,
req *layers.DHCPv6,
recvCh <-chan []byte,
wantOpts layers.DHCPv6Options,
) {
tb.Helper()
if wantOpts == nil {
assertNoResponse(tb, recvCh, testTimeout/10)
return
}
respData, ok := testutil.RequireReceive(tb, recvCh, testTimeout)
require.True(tb, ok)
ip := &layers.IPv6{}
udp := &layers.UDP{}
resp := &layers.DHCPv6{}
types := requireEthernet(tb, respData, &layers.Ethernet{}, ip, udp, resp)
require.Equal(tb, fullLayersStack6, types)
assertValidDHCPv6(tb, req, resp)
// TODO(e.burkov): Consider comparing the whole message instead of separate
// fields.
assert.Equal(tb, req.LinkAddr, resp.LinkAddr, "link address")
assert.Equal(tb, req.PeerAddr, resp.PeerAddr, "peer address")
assert.Equal(tb, req.TransactionID, resp.TransactionID, "transaction id")
assert.Equal(tb, wantOpts, resp.Options, "options")
}
// assertValidDHCPv6 asserts that the response is valid for the given request
// according to RFC 9915.
//
// TODO(e.burkov): Add more checks involving other network layers.
func assertValidDHCPv6(
tb testing.TB,
req *layers.DHCPv6,
resp *layers.DHCPv6,
) {
tb.Helper()
switch req.MsgType {
case
layers.DHCPv6MsgTypeRequest,
layers.DHCPv6MsgTypeConfirm,
layers.DHCPv6MsgTypeRenew,
layers.DHCPv6MsgTypeRebind,
layers.DHCPv6MsgTypeRelease,
layers.DHCPv6MsgTypeDecline,
layers.DHCPv6MsgTypeInformationRequest:
assert.Equal(tb, layers.DHCPv6MsgTypeReply, resp.MsgType)
case layers.DHCPv6MsgTypeSolicit:
isRapidCommit := slices.ContainsFunc(resp.Options, func(o layers.DHCPv6Option) (ok bool) {
return o.Code == layers.DHCPv6OptRapidCommit
})
if isRapidCommit {
assert.Equal(tb, layers.DHCPv6MsgTypeReply, resp.MsgType)
} else {
assert.Equal(tb, layers.DHCPv6MsgTypeAdverstise, resp.MsgType)
}
default:
tb.Errorf("request message type: %v: %s", errors.ErrUnexpectedValue, req.MsgType)
}
}

View file

@ -226,6 +226,8 @@ func (iface *netInterface) allocateLease(
// reserveLease reserves a lease for a client by its MAC-address. lease is nil
// if a new lease can't be allocated. mac must be a valid according to
// [netutil.ValidateMAC]. iface.indexMu mutex must be locked.
//
// TODO(e.burkov): Pass the time moment instead of clock.
func (iface *netInterface) reserveLease(
ctx context.Context,
mac net.HardwareAddr,

View file

@ -18,34 +18,34 @@ import (
// See RFC 9915 Section 21.4.
const iaNAMinLen = 12
// iaNAOption represents a parsed IA_NA (Identity Association for Non-temporary
// 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
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
// ID is the Identity Association Identifier, a 4-octet value uniquely
// identifying this IA within the client.
//
// TODO(e.burkov): Add new type.
iaid uint32
ID uint32
// t1 is the time after which the client must contact the same server to
// 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
T1 time.Duration
// t2 is the time after which the client may contact any available server to
// T2 is the time after which the client may contact any available server to
// extend the lifetimes.
t2 time.Duration
T2 time.Duration
}
// type check
var _ encoding.BinaryUnmarshaler = (*iaNAOption)(nil)
var _ encoding.BinaryUnmarshaler = (*IANAOption)(nil)
// UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface for
// *iaNAOption. data should have the following format:
// *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
@ -60,16 +60,16 @@ var _ encoding.BinaryUnmarshaler = (*iaNAOption)(nil)
// . IA_NA-options .
// . .
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
func (opt *iaNAOption) UnmarshalBinary(data []byte) (err error) {
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
opt.ID = 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:]
@ -83,13 +83,13 @@ func (opt *iaNAOption) UnmarshalBinary(data []byte) (err error) {
}
if code == layers.DHCPv6OptIAAddr {
addr := iaAddrOption{}
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)
opt.Nested = append(opt.Nested, addr)
}
nested = nested[4+l:]
@ -98,21 +98,21 @@ func (opt *iaNAOption) UnmarshalBinary(data []byte) (err error) {
return nil
}
// Encode serializes ia into a DHCPv6 IA_NA option. Each contained
// [iaAddrOption] is encoded as a nested IA Address option.
// 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) {
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 := 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()))
data = binary.BigEndian.AppendUint32(data, opt.ID)
data = binary.BigEndian.AppendUint32(data, uint32(opt.T1.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(opt.T2.Seconds()))
for _, addr := range opt.nested {
for _, addr := range opt.Nested {
data = addr.appendTo(data)
}
@ -125,27 +125,27 @@ func (opt iaNAOption) Encode() (iaOpt layers.DHCPv6Option) {
// (4 bytes each).
const iaAddrDataLen = 24
// iaAddrOption represents a parsed IA Address option.
// IAAddrOption represents a parsed IA Address option.
//
// See RFC 9915 Section 21.6.
type iaAddrOption struct {
// addr is the IPv6 address.
addr netip.Addr
type IAAddrOption struct {
// Addr is the IPv6 address.
Addr netip.Addr
// preferredLifetime is the preferred lifetime of the address. When it is
// PreferredLifetime is the preferred lifetime of the address. When it is
// zero, the address is deprecated.
preferredLifetime time.Duration
PreferredLifetime time.Duration
// validLifetime is the valid lifetime of the address. When it is zero, the
// ValidLifetime is the valid lifetime of the address. When it is zero, the
// address is no longer valid.
validLifetime time.Duration
ValidLifetime time.Duration
}
// type check
var _ encoding.BinaryUnmarshaler = (*iaAddrOption)(nil)
var _ encoding.BinaryUnmarshaler = (*IAAddrOption)(nil)
// UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface for
// *iaAddrOption. Nested options within IA Address, if any, are
// *IAAddrOption. Nested options within IA Address, if any, are
// ignored. data should have the following format:
//
// 0 1 2 3
@ -164,7 +164,7 @@ var _ encoding.BinaryUnmarshaler = (*iaAddrOption)(nil)
// . IAaddr-options .
// . .
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
func (ia *iaAddrOption) UnmarshalBinary(data []byte) (err error) {
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.
@ -172,30 +172,30 @@ func (ia *iaAddrOption) UnmarshalBinary(data []byte) (err error) {
}
var ok bool
ia.addr, ok = netip.AddrFromSlice(data[0:16])
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
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
}
// appendTo returns the data portion of the IA Address option encoding, suitable
// for use as a nested option inside an IA_NA.
func (ia iaAddrOption) appendTo(orig []byte) (data []byte) {
func (ia IAAddrOption) appendTo(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, _ = ia.Addr.AppendBinary(data)
data = binary.BigEndian.AppendUint32(data, uint32(ia.preferredLifetime.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(ia.validLifetime.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(ia.PreferredLifetime.Seconds()))
data = binary.BigEndian.AppendUint32(data, uint32(ia.ValidLifetime.Seconds()))
return data
}
@ -236,11 +236,11 @@ func serverDUID6(opts layers.DHCPv6Options) (duid []byte, ok bool) {
return findOption6(opts, layers.DHCPv6OptServerID)
}
// solMaxRT is the recommended SOL_MAX_RT value sent to clients. It caps the
// client's solicit retransmission interval.
// DefaultSolMaxRT is the recommended SOL_MAX_RT value sent to clients. It caps
// the client's solicit retransmission interval.
//
// See RFC 9915 Section 21.24.
const solMaxRT = 1 * time.Hour
const DefaultSolMaxRT = 1 * time.Hour
// newPreferenceOption returns a DHCPv6 Preference option with the given value.
//

View file

@ -0,0 +1,88 @@
package dhcpsvc_test
import (
"encoding/binary"
"net"
"net/netip"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
"github.com/google/gopacket/layers"
)
// newOptIANA creates a DHCPv6 Identity Association for Non-temporary Address
// (3) option containing an IA Address with the specified IAID and requested IP
// address. reqIP must be a valid IPv6 address. The option will have the T1
// and T2 values set to the recommended values based on the [testLeaseTTL]
// constant, see the RFC reference in the
// [dhcpsvc.DHCPServer.newDHCPInterfaceV6].
func newOptIANA(tb testing.TB, iaid uint32, reqIP netip.Addr) (opt layers.DHCPv6Option) {
tb.Helper()
iana := &dhcpsvc.IANAOption{
ID: iaid,
Nested: []dhcpsvc.IAAddrOption{{
PreferredLifetime: testLeaseTTL,
ValidLifetime: testLeaseTTL,
Addr: reqIP,
}},
T1: testLeaseTTL / 2,
T2: testLeaseTTL * 4 / 5,
}
return iana.Encode()
}
// newOptPreference creates a DHCPv6 Preference (7) option with the specified
// preference value.
func newOptPreference(tb testing.TB, pref uint8) (opt layers.DHCPv6Option) {
tb.Helper()
return layers.NewDHCPv6Option(layers.DHCPv6OptPreference, []byte{pref})
}
// newOptSolMaxRT creates a DHCPv6 Solicit Message Maximum Retransmission Time
// (80) option with the specified maxRT value.
func newOptSolMaxRT(tb testing.TB, maxRT time.Duration) (opt layers.DHCPv6Option) {
tb.Helper()
return layers.NewDHCPv6Option(
layers.DHCPv6OptSolMaxRt,
binary.BigEndian.AppendUint32(nil, uint32(maxRT.Seconds())),
)
}
// newOptClientDUID creates a DHCPv6 Client Identifier (1) option containing a
// DUID-LL made of cliHWAddr.
func newOptClientDUID(tb testing.TB, cliHWAddr net.HardwareAddr) (opt layers.DHCPv6Option) {
tb.Helper()
return newOptDUIDLL(tb, layers.DHCPv6OptClientID, cliHWAddr)
}
// newOptServerID creates a DHCPv6 Server Identifier (2) option containing a
// DUID-LL made of srvHWAddr.
func newOptServerDUID(tb testing.TB, srvHWAddr net.HardwareAddr) (opt layers.DHCPv6Option) {
tb.Helper()
return newOptDUIDLL(tb, layers.DHCPv6OptServerID, srvHWAddr)
}
// newOptDUIDLL creates a DHCPv6 option with the specified code containing a
// DUID-LL made of hwAddr and Ethernet hardware type.
func newOptDUIDLL(
tb testing.TB,
code layers.DHCPv6Opt,
hwAddr net.HardwareAddr,
) (opt layers.DHCPv6Option) {
tb.Helper()
duid := &layers.DHCPv6DUID{
Type: layers.DHCPv6DUIDTypeLL,
HardwareType: binary.BigEndian.AppendUint16(nil, uint16(layers.LinkTypeEthernet)),
LinkLayerAddress: hwAddr,
}
return layers.NewDHCPv6Option(code, duid.Encode())
}

View file

@ -144,6 +144,9 @@ func (srv *DHCPServer) newInterfaces(
func (srv *DHCPServer) Start(ctx context.Context) (err error) {
srv.logger.DebugContext(ctx, "starting dhcp server")
// TODO(e.burkov): Create a single device for each network interface with
// dual-stack support when possible.
var errs []error
for _, iface := range srv.interfaces4 {
netDevName := iface.common.name
@ -153,7 +156,7 @@ func (srv *DHCPServer) Start(ctx context.Context) (err error) {
Name: netDevName,
})
if err != nil {
errs = append(errs, err)
errs = append(errs, fmt.Errorf("opening ipv4 device %q: %w", netDevName, err))
continue
}
@ -166,7 +169,26 @@ func (srv *DHCPServer) Start(ctx context.Context) (err error) {
go srv.serveEther4(context.WithoutCancel(ctx), iface, netDev)
}
// TODO(e.burkov): Serve EthernetTypeIPv6.
for _, iface := range srv.interfaces6 {
netDevName := iface.common.name
var netDev NetworkDevice
netDev, err = srv.deviceManager.Open(ctx, &NetworkDeviceConfig{
Name: netDevName,
})
if err != nil {
errs = append(errs, fmt.Errorf("opening ipv6 device %q: %w", netDevName, err))
continue
}
srv.devices = append(srv.devices, container.KeyValue[string, NetworkDevice]{
Key: netDevName,
Value: netDev,
})
go srv.serveEther6(context.WithoutCancel(ctx), iface, netDev)
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,26 @@
{
"leases": [
{
"expires": "2025-01-01T10:01:01Z",
"ip": "2001:db8::66",
"hostname": "dynamic6",
"mac": "02:03:04:05:06:07",
"static": false
},
{
"expires": "2025-01-01T01:01:00Z",
"ip": "2001:db8::67",
"hostname": "expired6",
"mac": "03:04:05:06:07:08",
"static": false
},
{
"expires": "",
"ip": "2001:db8::65",
"hostname": "static6",
"mac": "01:02:03:04:05:06",
"static": true
}
],
"version": 1
}

View file

@ -210,6 +210,13 @@ func (srv *DHCPServer) newDHCPInterfaceV6(
// TODO(e.burkov): Use an ICMP implementation.
addrChecker: noopAddressChecker{},
subnetPrefix: netip.PrefixFrom(conf.RangeStart, v6PrefLen),
// Recommended values for T1 and T2 are 0.5 and 0.8 times the shortest
// preferred lifetime of the addresses in the IA that the server is
// willing to extend, respectively.
//
// See RFC 9915 Section 21.4.
//
// TODO(e.burkov): Consider making configurable.
t1: conf.LeaseDuration / 2,
t2: conf.LeaseDuration * 4 / 5,
raSLAACOnly: conf.RASLAACOnly,
@ -360,11 +367,11 @@ func clientIDMatchingServer(
return cliID, nil
}
// defaultHopLimit is the default hop limit for relaying DHCPv6 response
// IPv6DefaultHopLimit is the default hop limit for relaying DHCPv6 response
// packets.
//
// See RFC 9915 Section 7.6.
const defaultHopLimit = 8
const IPv6DefaultHopLimit = 8
// respond6 constructs and sends a DHCPv6 response to the client.
func respond6(fd *frameData6, resp *layers.DHCPv6) (err error) {
@ -377,7 +384,7 @@ func respond6(fd *frameData6, resp *layers.DHCPv6) (err error) {
ip := &layers.IPv6{
Version: 6,
NextHeader: layers.IPProtocolUDP,
HopLimit: defaultHopLimit,
HopLimit: IPv6DefaultHopLimit,
SrcIP: fd.localAddr.AsSlice(),
// If the original message was received directly by the server, the
// server unicasts the Advertise or Reply message directly to the client
@ -412,7 +419,7 @@ func respond6(fd *frameData6, resp *layers.DHCPv6) (err error) {
// option is malformed. lease is nil if there is no address available for
// leasing. mac must be a valid MAC address according to [netutil.ValidateMAC],
// req must be a valid DHCPv6 message of SOLICIT type, iface.common.indexMu
// mutex must be locked.
// must be locked.
//
// TODO(e.burkov): Support allocating several leases at a time when the
// database will migrate, see the BUG at [Lease]'s documentation.
@ -422,13 +429,14 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
req *layers.DHCPv6,
) (lease *Lease, iaid uint32) {
l := iface.common.logger
key := macToKey(mac)
for _, reqOpt := range req.Options {
if reqOpt.Code != layers.DHCPv6OptIANA {
continue
}
var iana iaNAOption
var iana IANAOption
err := iana.UnmarshalBinary(reqOpt.Data)
if err != nil {
// TODO(e.burkov): Recheck the logic on malformed IA_NA options.
@ -437,21 +445,25 @@ func (iface *dhcpInterfaceV6) allocateForSolicit(
continue
}
// TODO(e.burkov): Test the case, where the lease exists and is
// expired.
//
var ok bool
if lease, ok = iface.common.leases[key]; ok {
return lease, iana.ID
}
// TODO(e.burkov): Support allocating the exact requested address if it
// is available.
lease, err = iface.common.allocateLease(ctx, mac, iface.addrChecker, iface.clock)
if err != nil {
l.DebugContext(ctx, "no address available", "iaid", iana.iaid, slogutil.KeyError, err)
l.DebugContext(ctx, "no address available", "iaid", iana.ID, slogutil.KeyError, err)
continue
}
return lease, iana.iaid
return lease, iana.ID
}
l.DebugContext(ctx, "no valid ia_na in solicit")
return nil, 0
}
@ -483,7 +495,7 @@ func (iface *dhcpInterfaceV6) newSolicitRespOpts(
//
// See RFC 9915 Section 18.3.9.
opts = append(opts, newPreferenceOption(0))
opts = append(opts, newSOLMaxRTOption(solMaxRT))
opts = append(opts, newSOLMaxRTOption(DefaultSolMaxRT))
if rapidCommit {
opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, nil))
@ -501,15 +513,15 @@ func (iface *dhcpInterfaceV6) iaNAFromLease(lease *Lease, iaid uint32) (iana lay
return newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoAddrsAvail)
}
return iaNAOption{
nested: []iaAddrOption{{
addr: lease.IP,
preferredLifetime: iface.common.leaseTTL,
validLifetime: iface.common.leaseTTL,
return IANAOption{
Nested: []IAAddrOption{{
Addr: lease.IP,
PreferredLifetime: iface.common.leaseTTL,
ValidLifetime: iface.common.leaseTTL,
}},
iaid: iaid,
t1: iface.t1,
t2: iface.t2,
ID: iaid,
T1: iface.t1,
T2: iface.t2,
}.Encode()
}
@ -532,6 +544,11 @@ func (iface *dhcpInterfaceV6) commit(
lease.Hostname = aghnet.GenerateHostname(lease.IP)
}
// TODO(e.burkov): Add the Lease.isExpired. method.
if lease.Expiry.Before(iface.clock.Now()) {
lease.updateExpiry(iface.clock, iface.common.leaseTTL)
}
err = iface.common.index.update(ctx, iface.common.logger, lease, iface.common)
if err != nil {
rmErr := iface.common.removeLease(lease)