From aa872dfe988540154daacd305c0b02d5546e50fd Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Wed, 31 Jan 2024 14:50:27 +0300
Subject: [PATCH] Pull request 2130: 4923 gopacket dhcp vol.6

Updates #4923.

Squashed commit of the following:

commit 14ae8dc3680eae7d3ecb9e37a44c2e68221c5085
Merge: 280a4dbc7 713901c2a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Jan 31 13:52:52 2024 +0300

    Merge branch 'master' into 4923-gopacket-dhcp-vol.6

commit 280a4dbc728ff67c7659f91734a74c87bf0bda43
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Jan 30 20:20:04 2024 +0300

    dhcpsvc: imp docs

commit 310ed67b9bf22f88c4414095bfbfc1a29c6db6d5
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Jan 30 18:51:49 2024 +0300

    dhcpsvc: generalize

commit e4c2cae73a729be4db244d3042d93fcc9742bb34
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Jan 30 12:37:38 2024 +0300

    dhcpsvc: imp code

commit 9a60d3529293ce1f0e8da70da05958f81e1d0092
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Jan 26 16:28:04 2024 +0300

    dhcpsvc: imp code

commit 120c0472f3a3df2ebc0495a40936c8f94156db4b
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Jan 25 20:44:09 2024 +0300

    dhcpsvc: imp code, names, docs

commit a92f44c75279868d8e07fe7d468278025a245d13
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Jan 24 16:01:35 2024 +0300

    dhcpsvc: imp code, docs

commit 18b3f237b7523f649b49563e852c298fe02fa8ae
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Jan 18 15:29:36 2024 +0300

    dhcpsvc: add some lease-related methods
---
 internal/dhcpsvc/config.go      |   8 +-
 internal/dhcpsvc/dhcpsvc.go     |  45 ++--------
 internal/dhcpsvc/errors.go      |  12 ++-
 internal/dhcpsvc/interface.go   |  41 +++++++++
 internal/dhcpsvc/lease.go       |  53 ++++++++++++
 internal/dhcpsvc/server.go      | 149 ++++++++++++++++++++++++++++----
 internal/dhcpsvc/server_test.go | 113 ++++++++++++++++++++++++
 internal/dhcpsvc/v4.go          | 143 ++++++++++++++++--------------
 internal/dhcpsvc/v6.go          | 128 ++++++++++++++++-----------
 9 files changed, 514 insertions(+), 178 deletions(-)
 create mode 100644 internal/dhcpsvc/interface.go
 create mode 100644 internal/dhcpsvc/lease.go

diff --git a/internal/dhcpsvc/config.go b/internal/dhcpsvc/config.go
index 52d28a33..d1683be8 100644
--- a/internal/dhcpsvc/config.go
+++ b/internal/dhcpsvc/config.go
@@ -19,6 +19,8 @@ type Config struct {
 	// clients' hostnames.
 	LocalDomainName string
 
+	// TODO(e.burkov):  Add DB path.
+
 	// ICMPTimeout is the timeout for checking another DHCP server's presence.
 	ICMPTimeout time.Duration
 
@@ -68,12 +70,6 @@ func (conf *Config) Validate() (err error) {
 	return nil
 }
 
-// newMustErr returns an error that indicates that valName must be as must
-// describes.
-func newMustErr(valName, must string, val fmt.Stringer) (err error) {
-	return fmt.Errorf("%s %s must %s", valName, val, must)
-}
-
 // validate returns an error in ic, if any.
 func (ic *InterfaceConfig) validate() (err error) {
 	if ic == nil {
diff --git a/internal/dhcpsvc/dhcpsvc.go b/internal/dhcpsvc/dhcpsvc.go
index efa7404f..8ab2cab7 100644
--- a/internal/dhcpsvc/dhcpsvc.go
+++ b/internal/dhcpsvc/dhcpsvc.go
@@ -7,48 +7,14 @@ import (
 	"context"
 	"net"
 	"net/netip"
-	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/next/agh"
-	"golang.org/x/exp/slices"
 )
 
-// Lease is a DHCP lease.
+// Interface is a DHCP service.
 //
-// TODO(e.burkov):  Consider moving it to [agh], since it also may be needed in
-// [websvc].
-type Lease struct {
-	// IP is the IP address leased to the client.
-	IP netip.Addr
-
-	// Expiry is the expiration time of the lease.
-	Expiry time.Time
-
-	// Hostname of the client.
-	Hostname string
-
-	// HWAddr is the physical hardware address (MAC address).
-	HWAddr net.HardwareAddr
-
-	// IsStatic defines if the lease is static.
-	IsStatic bool
-}
-
-// Clone returns a deep copy of l.
-func (l *Lease) Clone() (clone *Lease) {
-	if l == nil {
-		return nil
-	}
-
-	return &Lease{
-		Expiry:   l.Expiry,
-		Hostname: l.Hostname,
-		HWAddr:   slices.Clone(l.HWAddr),
-		IP:       l.IP,
-		IsStatic: l.IsStatic,
-	}
-}
-
+// TODO(e.burkov):  Separate HostByIP, MACByIP, IPByHost into a separate
+// interface.  This is also valid for Enabled method.
 type Interface interface {
 	agh.ServiceWithConfig[*Config]
 
@@ -71,7 +37,8 @@ type Interface interface {
 	// hostname, either set or generated.
 	IPByHost(host string) (ip netip.Addr)
 
-	// Leases returns all the active DHCP leases.
+	// Leases returns all the active DHCP leases.  The returned slice should be
+	// a clone.
 	//
 	// TODO(e.burkov):  Consider implementing iterating methods with appropriate
 	// signatures instead of cloning the whole list.
@@ -91,6 +58,8 @@ type Interface interface {
 	RemoveLease(l *Lease) (err error)
 
 	// Reset removes all the DHCP leases.
+	//
+	// TODO(e.burkov):  If it's really needed?
 	Reset() (err error)
 }
 
diff --git a/internal/dhcpsvc/errors.go b/internal/dhcpsvc/errors.go
index a7cc8931..fdb97b76 100644
--- a/internal/dhcpsvc/errors.go
+++ b/internal/dhcpsvc/errors.go
@@ -1,6 +1,10 @@
 package dhcpsvc
 
-import "github.com/AdguardTeam/golibs/errors"
+import (
+	"fmt"
+
+	"github.com/AdguardTeam/golibs/errors"
+)
 
 const (
 	// errNilConfig is returned when a nil config met.
@@ -9,3 +13,9 @@ const (
 	// errNoInterfaces is returned when no interfaces found in configuration.
 	errNoInterfaces errors.Error = "no interfaces specified"
 )
+
+// newMustErr returns an error that indicates that valName must be as must
+// describes.
+func newMustErr(valName, must string, val fmt.Stringer) (err error) {
+	return fmt.Errorf("%s %s must %s", valName, val, must)
+}
diff --git a/internal/dhcpsvc/interface.go b/internal/dhcpsvc/interface.go
new file mode 100644
index 00000000..56b52382
--- /dev/null
+++ b/internal/dhcpsvc/interface.go
@@ -0,0 +1,41 @@
+package dhcpsvc
+
+import (
+	"fmt"
+	"time"
+
+	"golang.org/x/exp/slices"
+)
+
+// netInterface is a common part of any network interface within the DHCP
+// server.
+//
+// TODO(e.burkov):  Add other methods as [DHCPServer] evolves.
+type netInterface struct {
+	// name is the name of the network interface.
+	name string
+
+	// leases is a set of leases sorted by hardware address.
+	leases []*Lease
+
+	// leaseTTL is the default Time-To-Live value for leases.
+	leaseTTL time.Duration
+}
+
+// reset clears all the slices in iface for reuse.
+func (iface *netInterface) reset() {
+	iface.leases = iface.leases[:0]
+}
+
+// insertLease inserts the given lease into iface.  It returns an error if the
+// lease can't be inserted.
+func (iface *netInterface) insertLease(l *Lease) (err error) {
+	i, found := slices.BinarySearchFunc(iface.leases, l, compareLeaseMAC)
+	if found {
+		return fmt.Errorf("lease for mac %s already exists", l.HWAddr)
+	}
+
+	iface.leases = slices.Insert(iface.leases, i, l)
+
+	return nil
+}
diff --git a/internal/dhcpsvc/lease.go b/internal/dhcpsvc/lease.go
new file mode 100644
index 00000000..6f7006a1
--- /dev/null
+++ b/internal/dhcpsvc/lease.go
@@ -0,0 +1,53 @@
+package dhcpsvc
+
+import (
+	"bytes"
+	"net"
+	"net/netip"
+	"time"
+
+	"golang.org/x/exp/slices"
+)
+
+// Lease is a DHCP lease.
+//
+// TODO(e.burkov):  Consider moving it to [agh], since it also may be needed in
+// [websvc].
+//
+// TODO(e.burkov):  Add validation method.
+type Lease struct {
+	// IP is the IP address leased to the client.
+	IP netip.Addr
+
+	// Expiry is the expiration time of the lease.
+	Expiry time.Time
+
+	// Hostname of the client.
+	Hostname string
+
+	// HWAddr is the physical hardware address (MAC address).
+	HWAddr net.HardwareAddr
+
+	// IsStatic defines if the lease is static.
+	IsStatic bool
+}
+
+// Clone returns a deep copy of l.
+func (l *Lease) Clone() (clone *Lease) {
+	if l == nil {
+		return nil
+	}
+
+	return &Lease{
+		Expiry:   l.Expiry,
+		Hostname: l.Hostname,
+		HWAddr:   slices.Clone(l.HWAddr),
+		IP:       l.IP,
+		IsStatic: l.IsStatic,
+	}
+}
+
+// compareLeaseMAC compares two [Lease]s by hardware address.
+func compareLeaseMAC(a, b *Lease) (res int) {
+	return bytes.Compare(a.HWAddr, b.HWAddr)
+}
diff --git a/internal/dhcpsvc/server.go b/internal/dhcpsvc/server.go
index 2d24ef27..d4dbeb9f 100644
--- a/internal/dhcpsvc/server.go
+++ b/internal/dhcpsvc/server.go
@@ -2,6 +2,10 @@ package dhcpsvc
 
 import (
 	"fmt"
+	"net"
+	"net/netip"
+	"strings"
+	"sync"
 	"sync/atomic"
 	"time"
 
@@ -15,18 +19,27 @@ type DHCPServer struct {
 	// information about its clients.
 	enabled *atomic.Bool
 
-	// localTLD is the top-level domain name to use for resolving DHCP
-	// clients' hostnames.
+	// localTLD is the top-level domain name to use for resolving DHCP clients'
+	// hostnames.
 	localTLD string
 
+	// leasesMu protects the ipIndex and nameIndex fields against concurrent
+	// access, as well as leaseHandlers within the interfaces.
+	leasesMu *sync.RWMutex
+
+	// leaseByIP is a lookup shortcut for leases by their IP addresses.
+	leaseByIP map[netip.Addr]*Lease
+
+	// leaseByName is a lookup shortcut for leases by their hostnames.
+	//
+	// TODO(e.burkov):  Use a slice of leases with the same hostname?
+	leaseByName map[string]*Lease
+
 	// interfaces4 is the set of IPv4 interfaces sorted by interface name.
-	interfaces4 []*iface4
+	interfaces4 netInterfacesV4
 
 	// interfaces6 is the set of IPv6 interfaces sorted by interface name.
-	interfaces6 []*iface6
-
-	// leases is the set of active DHCP leases.
-	leases []*Lease
+	interfaces6 netInterfacesV6
 
 	// icmpTimeout is the timeout for checking another DHCP server's presence.
 	icmpTimeout time.Duration
@@ -42,26 +55,27 @@ func New(conf *Config) (srv *DHCPServer, err error) {
 		return nil, nil
 	}
 
-	ifaces4 := make([]*iface4, len(conf.Interfaces))
-	ifaces6 := make([]*iface6, len(conf.Interfaces))
+	// TODO(e.burkov):  Add validations scoped to the network interfaces set.
+	ifaces4 := make(netInterfacesV4, 0, len(conf.Interfaces))
+	ifaces6 := make(netInterfacesV6, 0, len(conf.Interfaces))
 
 	ifaceNames := maps.Keys(conf.Interfaces)
 	slices.Sort(ifaceNames)
 
-	var i4 *iface4
-	var i6 *iface6
+	var i4 *netInterfaceV4
+	var i6 *netInterfaceV6
 
 	for _, ifaceName := range ifaceNames {
 		iface := conf.Interfaces[ifaceName]
 
-		i4, err = newIface4(ifaceName, iface.IPv4)
+		i4, err = newNetInterfaceV4(ifaceName, iface.IPv4)
 		if err != nil {
 			return nil, fmt.Errorf("interface %q: ipv4: %w", ifaceName, err)
 		} else if i4 != nil {
 			ifaces4 = append(ifaces4, i4)
 		}
 
-		i6 = newIface6(ifaceName, iface.IPv6)
+		i6 = newNetInterfaceV6(ifaceName, iface.IPv6)
 		if i6 != nil {
 			ifaces6 = append(ifaces6, i6)
 		}
@@ -70,13 +84,20 @@ func New(conf *Config) (srv *DHCPServer, err error) {
 	enabled := &atomic.Bool{}
 	enabled.Store(conf.Enabled)
 
-	return &DHCPServer{
+	srv = &DHCPServer{
 		enabled:     enabled,
+		localTLD:    conf.LocalDomainName,
+		leasesMu:    &sync.RWMutex{},
+		leaseByIP:   map[netip.Addr]*Lease{},
+		leaseByName: map[string]*Lease{},
 		interfaces4: ifaces4,
 		interfaces6: ifaces6,
-		localTLD:    conf.LocalDomainName,
 		icmpTimeout: conf.ICMPTimeout,
-	}, nil
+	}
+
+	// TODO(e.burkov):  Load leases.
+
+	return srv, nil
 }
 
 // type check
@@ -91,10 +112,100 @@ func (srv *DHCPServer) Enabled() (ok bool) {
 
 // Leases implements the [Interface] interface for *DHCPServer.
 func (srv *DHCPServer) Leases() (leases []*Lease) {
-	leases = make([]*Lease, 0, len(srv.leases))
-	for _, lease := range srv.leases {
-		leases = append(leases, lease.Clone())
+	srv.leasesMu.RLock()
+	defer srv.leasesMu.RUnlock()
+
+	for _, iface := range srv.interfaces4 {
+		for _, lease := range iface.leases {
+			leases = append(leases, lease.Clone())
+		}
 	}
 
 	return leases
 }
+
+// HostByIP implements the [Interface] interface for *DHCPServer.
+func (srv *DHCPServer) HostByIP(ip netip.Addr) (host string) {
+	srv.leasesMu.RLock()
+	defer srv.leasesMu.RUnlock()
+
+	if l, ok := srv.leaseByIP[ip]; ok {
+		return l.Hostname
+	}
+
+	return ""
+}
+
+// MACByIP implements the [Interface] interface for *DHCPServer.
+func (srv *DHCPServer) MACByIP(ip netip.Addr) (mac net.HardwareAddr) {
+	srv.leasesMu.RLock()
+	defer srv.leasesMu.RUnlock()
+
+	if l, ok := srv.leaseByIP[ip]; ok {
+		return l.HWAddr
+	}
+
+	return nil
+}
+
+// IPByHost implements the [Interface] interface for *DHCPServer.
+func (srv *DHCPServer) IPByHost(host string) (ip netip.Addr) {
+	lowered := strings.ToLower(host)
+
+	srv.leasesMu.RLock()
+	defer srv.leasesMu.RUnlock()
+
+	if l, ok := srv.leaseByName[lowered]; ok {
+		return l.IP
+	}
+
+	return netip.Addr{}
+}
+
+// Reset implements the [Interface] interface for *DHCPServer.
+func (srv *DHCPServer) Reset() (err error) {
+	srv.leasesMu.Lock()
+	defer srv.leasesMu.Unlock()
+
+	for _, iface := range srv.interfaces4 {
+		iface.reset()
+	}
+	for _, iface := range srv.interfaces6 {
+		iface.reset()
+	}
+
+	maps.Clear(srv.leaseByIP)
+	maps.Clear(srv.leaseByName)
+
+	return nil
+}
+
+// AddLease implements the [Interface] interface for *DHCPServer.
+func (srv *DHCPServer) AddLease(l *Lease) (err error) {
+	var ok bool
+	var iface *netInterface
+
+	addr := l.IP
+
+	if addr.Is4() {
+		iface, ok = srv.interfaces4.find(addr)
+	} else {
+		iface, ok = srv.interfaces6.find(addr)
+	}
+	if !ok {
+		return fmt.Errorf("no interface for IP address %s", addr)
+	}
+
+	srv.leasesMu.Lock()
+	defer srv.leasesMu.Unlock()
+
+	err = iface.insertLease(l)
+	if err != nil {
+		return err
+	}
+
+	srv.leaseByIP[l.IP] = l
+	srv.leaseByName[strings.ToLower(l.Hostname)] = l
+
+	return nil
+}
diff --git a/internal/dhcpsvc/server_test.go b/internal/dhcpsvc/server_test.go
index 6475dfa4..3db16000 100644
--- a/internal/dhcpsvc/server_test.go
+++ b/internal/dhcpsvc/server_test.go
@@ -1,12 +1,15 @@
 package dhcpsvc_test
 
 import (
+	"net"
 	"net/netip"
 	"testing"
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
 	"github.com/AdguardTeam/golibs/testutil"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 // testLocalTLD is a common local TLD for tests.
@@ -113,3 +116,113 @@ func TestNew(t *testing.T) {
 		})
 	}
 }
+
+func TestDHCPServer_index(t *testing.T) {
+	srv, err := dhcpsvc.New(&dhcpsvc.Config{
+		Enabled:         true,
+		LocalDomainName: testLocalTLD,
+		Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+			"eth0": {
+				IPv4: &dhcpsvc.IPv4Config{
+					Enabled:       true,
+					GatewayIP:     netip.MustParseAddr("192.168.0.1"),
+					SubnetMask:    netip.MustParseAddr("255.255.255.0"),
+					RangeStart:    netip.MustParseAddr("192.168.0.2"),
+					RangeEnd:      netip.MustParseAddr("192.168.0.254"),
+					LeaseDuration: 1 * time.Hour,
+				},
+				IPv6: &dhcpsvc.IPv6Config{
+					Enabled:       true,
+					RangeStart:    netip.MustParseAddr("2001:db8::1"),
+					LeaseDuration: 1 * time.Hour,
+					RAAllowSLAAC:  true,
+					RASLAACOnly:   true,
+				},
+			},
+			"eth1": {
+				IPv4: &dhcpsvc.IPv4Config{
+					Enabled:       true,
+					GatewayIP:     netip.MustParseAddr("172.16.0.1"),
+					SubnetMask:    netip.MustParseAddr("255.255.255.0"),
+					RangeStart:    netip.MustParseAddr("172.16.0.2"),
+					RangeEnd:      netip.MustParseAddr("172.16.0.255"),
+					LeaseDuration: 1 * time.Hour,
+				},
+				IPv6: &dhcpsvc.IPv6Config{
+					Enabled:       true,
+					RangeStart:    netip.MustParseAddr("2001:db9::1"),
+					LeaseDuration: 1 * time.Hour,
+					RAAllowSLAAC:  true,
+					RASLAACOnly:   true,
+				},
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	const (
+		host1 = "host1"
+		host2 = "host2"
+		host3 = "host3"
+		host4 = "host4"
+		host5 = "host5"
+	)
+
+	ip1 := netip.MustParseAddr("192.168.0.2")
+	ip2 := netip.MustParseAddr("192.168.0.3")
+	ip3 := netip.MustParseAddr("172.16.0.3")
+	ip4 := netip.MustParseAddr("172.16.0.4")
+
+	mac1 := net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}
+	mac2 := net.HardwareAddr{0x06, 0x05, 0x04, 0x03, 0x02, 0x01}
+	mac3 := net.HardwareAddr{0x05, 0x04, 0x03, 0x02, 0x01, 0x00}
+
+	leases := []*dhcpsvc.Lease{{
+		Hostname: host1,
+		IP:       ip1,
+		HWAddr:   mac1,
+		IsStatic: true,
+	}, {
+		Hostname: host2,
+		IP:       ip2,
+		HWAddr:   mac2,
+		IsStatic: true,
+	}, {
+		Hostname: host3,
+		IP:       ip3,
+		HWAddr:   mac3,
+		IsStatic: true,
+	}, {
+		Hostname: host4,
+		IP:       ip4,
+		HWAddr:   mac1,
+		IsStatic: true,
+	}}
+	for _, l := range leases {
+		require.NoError(t, srv.AddLease(l))
+	}
+
+	t.Run("ip_idx", func(t *testing.T) {
+		assert.Equal(t, ip1, srv.IPByHost(host1))
+		assert.Equal(t, ip2, srv.IPByHost(host2))
+		assert.Equal(t, ip3, srv.IPByHost(host3))
+		assert.Equal(t, ip4, srv.IPByHost(host4))
+		assert.Equal(t, netip.Addr{}, srv.IPByHost(host5))
+	})
+
+	t.Run("name_idx", func(t *testing.T) {
+		assert.Equal(t, host1, srv.HostByIP(ip1))
+		assert.Equal(t, host2, srv.HostByIP(ip2))
+		assert.Equal(t, host3, srv.HostByIP(ip3))
+		assert.Equal(t, host4, srv.HostByIP(ip4))
+		assert.Equal(t, "", srv.HostByIP(netip.Addr{}))
+	})
+
+	t.Run("mac_idx", func(t *testing.T) {
+		assert.Equal(t, mac1, srv.MACByIP(ip1))
+		assert.Equal(t, mac2, srv.MACByIP(ip2))
+		assert.Equal(t, mac3, srv.MACByIP(ip3))
+		assert.Equal(t, mac1, srv.MACByIP(ip4))
+		assert.Nil(t, srv.MACByIP(netip.Addr{}))
+	})
+}
diff --git a/internal/dhcpsvc/v4.go b/internal/dhcpsvc/v4.go
index e74c06b3..e28e3845 100644
--- a/internal/dhcpsvc/v4.go
+++ b/internal/dhcpsvc/v4.go
@@ -64,69 +64,6 @@ func (conf *IPv4Config) validate() (err error) {
 	}
 }
 
-// iface4 is a DHCP interface for IPv4 address family.
-type iface4 struct {
-	// gateway is the IP address of the network gateway.
-	gateway netip.Addr
-
-	// subnet is the network subnet.
-	subnet netip.Prefix
-
-	// addrSpace is the IPv4 address space allocated for leasing.
-	addrSpace ipRange
-
-	// name is the name of the interface.
-	name string
-
-	// implicitOpts are the options listed in Appendix A of RFC 2131 and
-	// initialized with default values.  It must not have intersections with
-	// explicitOpts.
-	implicitOpts layers.DHCPOptions
-
-	// explicitOpts are the user-configured options.  It must not have
-	// intersections with implicitOpts.
-	explicitOpts layers.DHCPOptions
-
-	// leaseTTL is the time-to-live of dynamic leases on this interface.
-	leaseTTL time.Duration
-}
-
-// newIface4 creates a new DHCP interface for IPv4 address family with the given
-// configuration.  It returns an error if the given configuration can't be used.
-func newIface4(name string, conf *IPv4Config) (i *iface4, err error) {
-	if !conf.Enabled {
-		return nil, nil
-	}
-
-	maskLen, _ := net.IPMask(conf.SubnetMask.AsSlice()).Size()
-	subnet := netip.PrefixFrom(conf.GatewayIP, maskLen)
-
-	switch {
-	case !subnet.Contains(conf.RangeStart):
-		return nil, fmt.Errorf("range start %s is not within %s", conf.RangeStart, subnet)
-	case !subnet.Contains(conf.RangeEnd):
-		return nil, fmt.Errorf("range end %s is not within %s", conf.RangeEnd, subnet)
-	}
-
-	addrSpace, err := newIPRange(conf.RangeStart, conf.RangeEnd)
-	if err != nil {
-		return nil, err
-	} else if addrSpace.contains(conf.GatewayIP) {
-		return nil, fmt.Errorf("gateway ip %s in the ip range %s", conf.GatewayIP, addrSpace)
-	}
-
-	i = &iface4{
-		name:      name,
-		gateway:   conf.GatewayIP,
-		subnet:    subnet,
-		addrSpace: addrSpace,
-		leaseTTL:  conf.LeaseDuration,
-	}
-	i.implicitOpts, i.explicitOpts = conf.options()
-
-	return i, nil
-}
-
 // options returns the implicit and explicit options for the interface.  The two
 // lists are disjoint and the implicit options are initialized with default
 // values.
@@ -318,3 +255,83 @@ func (conf *IPv4Config) options() (implicit, explicit layers.DHCPOptions) {
 func compareV4OptionCodes(a, b layers.DHCPOption) (res int) {
 	return int(a.Type) - int(b.Type)
 }
+
+// netInterfaceV4 is a DHCP interface for IPv4 address family.
+type netInterfaceV4 struct {
+	// gateway is the IP address of the network gateway.
+	gateway netip.Addr
+
+	// subnet is the network subnet.
+	subnet netip.Prefix
+
+	// addrSpace is the IPv4 address space allocated for leasing.
+	addrSpace ipRange
+
+	// implicitOpts are the options listed in Appendix A of RFC 2131 and
+	// initialized with default values.  It must not have intersections with
+	// explicitOpts.
+	implicitOpts layers.DHCPOptions
+
+	// explicitOpts are the user-configured options.  It must not have
+	// intersections with implicitOpts.
+	explicitOpts layers.DHCPOptions
+
+	// netInterface is embedded here to provide some common network interface
+	// logic.
+	netInterface
+}
+
+// newNetInterfaceV4 creates a new DHCP interface for IPv4 address family with
+// the given configuration.  It returns an error if the given configuration
+// can't be used.
+func newNetInterfaceV4(name string, conf *IPv4Config) (i *netInterfaceV4, err error) {
+	if !conf.Enabled {
+		return nil, nil
+	}
+
+	maskLen, _ := net.IPMask(conf.SubnetMask.AsSlice()).Size()
+	subnet := netip.PrefixFrom(conf.GatewayIP, maskLen)
+
+	switch {
+	case !subnet.Contains(conf.RangeStart):
+		return nil, fmt.Errorf("range start %s is not within %s", conf.RangeStart, subnet)
+	case !subnet.Contains(conf.RangeEnd):
+		return nil, fmt.Errorf("range end %s is not within %s", conf.RangeEnd, subnet)
+	}
+
+	addrSpace, err := newIPRange(conf.RangeStart, conf.RangeEnd)
+	if err != nil {
+		return nil, err
+	} else if addrSpace.contains(conf.GatewayIP) {
+		return nil, fmt.Errorf("gateway ip %s in the ip range %s", conf.GatewayIP, addrSpace)
+	}
+
+	i = &netInterfaceV4{
+		gateway:   conf.GatewayIP,
+		subnet:    subnet,
+		addrSpace: addrSpace,
+		netInterface: netInterface{
+			name:     name,
+			leaseTTL: conf.LeaseDuration,
+		},
+	}
+	i.implicitOpts, i.explicitOpts = conf.options()
+
+	return i, nil
+}
+
+// netInterfacesV4 is a slice of network interfaces of IPv4 address family.
+type netInterfacesV4 []*netInterfaceV4
+
+// find returns the first network interface within ifaces containing ip.  It
+// returns false if there is no such interface.
+func (ifaces netInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok bool) {
+	i := slices.IndexFunc(ifaces, func(iface *netInterfaceV4) (contains bool) {
+		return iface.subnet.Contains(ip)
+	})
+	if i < 0 {
+		return nil, false
+	}
+
+	return &ifaces[i].netInterface, true
+}
diff --git a/internal/dhcpsvc/v6.go b/internal/dhcpsvc/v6.go
index 2dc832b6..9387c3be 100644
--- a/internal/dhcpsvc/v6.go
+++ b/internal/dhcpsvc/v6.go
@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/AdguardTeam/golibs/log"
+	"github.com/AdguardTeam/golibs/netutil"
 	"github.com/google/gopacket/layers"
 	"golang.org/x/exp/slices"
 )
@@ -52,57 +53,6 @@ func (conf *IPv6Config) validate() (err error) {
 	}
 }
 
-// iface6 is a DHCP interface for IPv6 address family.
-//
-// TODO(e.burkov):  Add options.
-type iface6 struct {
-	// rangeStart is the first IP address in the range.
-	rangeStart netip.Addr
-
-	// name is the name of the interface.
-	name string
-
-	// implicitOpts are the DHCPv6 options listed in RFC 8415 (and others) and
-	// initialized with default values.  It must not have intersections with
-	// explicitOpts.
-	implicitOpts layers.DHCPv6Options
-
-	// explicitOpts are the user-configured options.  It must not have
-	// intersections with implicitOpts.
-	explicitOpts layers.DHCPv6Options
-
-	// leaseTTL is the time-to-live of dynamic leases on this interface.
-	leaseTTL time.Duration
-
-	// raSLAACOnly defines if DHCP should send ICMPv6.RA packets without MO
-	// flags.
-	raSLAACOnly bool
-
-	// raAllowSLAAC defines if DHCP should send ICMPv6.RA packets with MO flags.
-	raAllowSLAAC bool
-}
-
-// newIface6 creates a new DHCP interface for IPv6 address family with the given
-// configuration.
-//
-// TODO(e.burkov):  Validate properly.
-func newIface6(name string, conf *IPv6Config) (i *iface6) {
-	if !conf.Enabled {
-		return nil
-	}
-
-	i = &iface6{
-		name:         name,
-		rangeStart:   conf.RangeStart,
-		leaseTTL:     conf.LeaseDuration,
-		raSLAACOnly:  conf.RASLAACOnly,
-		raAllowSLAAC: conf.RAAllowSLAAC,
-	}
-	i.implicitOpts, i.explicitOpts = conf.options()
-
-	return i
-}
-
 // options returns the implicit and explicit options for the interface.  The two
 // lists are disjoint and the implicit options are initialized with default
 // values.
@@ -133,3 +83,79 @@ func (conf *IPv6Config) options() (implicit, explicit layers.DHCPv6Options) {
 func compareV6OptionCodes(a, b layers.DHCPv6Option) (res int) {
 	return int(a.Code) - int(b.Code)
 }
+
+// netInterfaceV6 is a DHCP interface for IPv6 address family.
+//
+// TODO(e.burkov):  Add options.
+type netInterfaceV6 struct {
+	// rangeStart is the first IP address in the range.
+	rangeStart netip.Addr
+
+	// implicitOpts are the DHCPv6 options listed in RFC 8415 (and others) and
+	// initialized with default values.  It must not have intersections with
+	// explicitOpts.
+	implicitOpts layers.DHCPv6Options
+
+	// explicitOpts are the user-configured options.  It must not have
+	// intersections with implicitOpts.
+	explicitOpts layers.DHCPv6Options
+
+	// netInterface is embedded here to provide some common network interface
+	// logic.
+	netInterface
+
+	// raSLAACOnly defines if DHCP should send ICMPv6.RA packets without MO
+	// flags.
+	raSLAACOnly bool
+
+	// raAllowSLAAC defines if DHCP should send ICMPv6.RA packets with MO flags.
+	raAllowSLAAC bool
+}
+
+// newNetInterfaceV6 creates a new DHCP interface for IPv6 address family with
+// the given configuration.
+//
+// TODO(e.burkov):  Validate properly.
+func newNetInterfaceV6(name string, conf *IPv6Config) (i *netInterfaceV6) {
+	if !conf.Enabled {
+		return nil
+	}
+
+	i = &netInterfaceV6{
+		rangeStart: conf.RangeStart,
+		netInterface: netInterface{
+			name:     name,
+			leaseTTL: conf.LeaseDuration,
+		},
+		raSLAACOnly:  conf.RASLAACOnly,
+		raAllowSLAAC: conf.RAAllowSLAAC,
+	}
+	i.implicitOpts, i.explicitOpts = conf.options()
+
+	return i
+}
+
+// netInterfacesV4 is a slice of network interfaces of IPv4 address family.
+type netInterfacesV6 []*netInterfaceV6
+
+// find returns the first network interface within ifaces containing ip.  It
+// returns false if there is no such interface.
+func (ifaces netInterfacesV6) find(ip netip.Addr) (iface6 *netInterface, ok bool) {
+	// prefLen is the length of prefix to match ip against.
+	//
+	// TODO(e.burkov):  DHCPv6 inherits the weird behavior of legacy
+	// implementation where the allocated range constrained by the first address
+	// and the first address with last byte set to 0xff.  Proper prefixes should
+	// be used instead.
+	const prefLen = netutil.IPv6BitLen - 8
+
+	i := slices.IndexFunc(ifaces, func(iface *netInterfaceV6) (contains bool) {
+		return !iface.rangeStart.Less(ip) &&
+			netip.PrefixFrom(iface.rangeStart, prefLen).Contains(ip)
+	})
+	if i < 0 {
+		return nil, false
+	}
+
+	return &ifaces[i].netInterface, true
+}