diff --git a/internal/dhcpsvc/dhcpsvc_test.go b/internal/dhcpsvc/dhcpsvc_test.go index f2ec21fc7..7d0cf912b 100644 --- a/internal/dhcpsvc/dhcpsvc_test.go +++ b/internal/dhcpsvc/dhcpsvc_test.go @@ -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) +} diff --git a/internal/dhcpsvc/handler4_test.go b/internal/dhcpsvc/handler4_test.go index 8e06c31f4..f4b401547 100644 --- a/internal/dhcpsvc/handler4_test.go +++ b/internal/dhcpsvc/handler4_test.go @@ -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) { diff --git a/internal/dhcpsvc/handler6.go b/internal/dhcpsvc/handler6.go index 12b376b3e..4c12b2f08 100644 --- a/internal/dhcpsvc/handler6.go +++ b/internal/dhcpsvc/handler6.go @@ -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) diff --git a/internal/dhcpsvc/handler6_test.go b/internal/dhcpsvc/handler6_test.go new file mode 100644 index 000000000..a491f9c9b --- /dev/null +++ b/internal/dhcpsvc/handler6_test.go @@ -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) + } +} diff --git a/internal/dhcpsvc/interface.go b/internal/dhcpsvc/interface.go index ad5949004..25b08684f 100644 --- a/internal/dhcpsvc/interface.go +++ b/internal/dhcpsvc/interface.go @@ -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, diff --git a/internal/dhcpsvc/options6.go b/internal/dhcpsvc/options6.go index 1031292a9..c7cafdf83 100644 --- a/internal/dhcpsvc/options6.go +++ b/internal/dhcpsvc/options6.go @@ -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. // diff --git a/internal/dhcpsvc/options6_test.go b/internal/dhcpsvc/options6_test.go new file mode 100644 index 000000000..011102df6 --- /dev/null +++ b/internal/dhcpsvc/options6_test.go @@ -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()) +} diff --git a/internal/dhcpsvc/server.go b/internal/dhcpsvc/server.go index d4709d3ba..00dfb3646 100644 --- a/internal/dhcpsvc/server.go +++ b/internal/dhcpsvc/server.go @@ -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...) } diff --git a/internal/dhcpsvc/testdata/TestDHCPServer_ServeEther6_solicit/leases.json b/internal/dhcpsvc/testdata/TestDHCPServer_ServeEther6_solicit/leases.json new file mode 100644 index 000000000..0c1536592 --- /dev/null +++ b/internal/dhcpsvc/testdata/TestDHCPServer_ServeEther6_solicit/leases.json @@ -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 +} diff --git a/internal/dhcpsvc/v6.go b/internal/dhcpsvc/v6.go index f7d077e5a..2113cf22c 100644 --- a/internal/dhcpsvc/v6.go +++ b/internal/dhcpsvc/v6.go @@ -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)