From 39aeaf8910379177199bc01cda184598530279c3 Mon Sep 17 00:00:00 2001
From: Eugene Burkov <e.burkov@adguard.com>
Date: Mon, 2 Oct 2023 13:21:16 +0300
Subject: [PATCH] Pull request 2014: 4923 gopacket dhcp vol.3

Merge in DNS/adguard-home from 4923-gopacket-dhcp-vol.3 to master

Updates #4923.

Squashed commit of the following:

commit 1a09c436e5666a515084cd5e76cfccd67991ae5e
Merge: 95bcf0720 c3f141a0a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 28 19:38:57 2023 +0300

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

commit 95bcf07206434fd451632e819926871ba8c14f08
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 28 13:19:42 2023 +0300

    dhcpsvc: fix interface to match legacy version

commit 5da513ce177319f19698c5a8e1d10affaaf5e85c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 28 12:32:21 2023 +0300

    dhcpsvc: make it build on 32bit

commit 37a935514b1cebdc817cdcd5ec3562baeafbc42d
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 27 19:39:35 2023 +0300

    dhcpd: fix v6 as well

commit 03b5454b04c4fdb3fe928d661562883dc3e09d81
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 27 19:34:17 2023 +0300

    dhcpsvc: imp code, docs

commit 91a0e451f78fba64578cc541f7ba66579c31d388
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Sep 22 15:25:58 2023 +0300

    dhcpsvc: imp filing

commit 57c91e1194caa00a69e62b6655b1b4e38b69b89f
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Sep 22 15:23:02 2023 +0300

    dhcpsvc: imp code

commit d86be56efbfc121c9fe2c5ecef992b4523e04d57
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 14 12:24:39 2023 +0300

    dhcpsvc: imp code, docs

commit c9ef29057e9e378779d1a7938ad13b6eebda8f50
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 13 17:53:55 2023 +0300

    dhcpsvc: add constructor, validations, tests

commit f2533ed64e4ef439603b9cdf9596f8b0c4a87cf1
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 12 23:05:42 2023 +0500

    WIP
---
 internal/dhcpd/v4_unix.go                 |  10 +-
 internal/dhcpd/v6_unix.go                 |   3 +
 internal/dhcpsvc/config.go                |  96 +++++-----
 internal/dhcpsvc/config_test.go           |  88 ++++++++++
 internal/dhcpsvc/dhcpsvc.go               |  19 +-
 internal/dhcpsvc/errors.go                |  11 ++
 internal/dhcpsvc/iprange.go               |  98 +++++++++++
 internal/dhcpsvc/iprange_internal_test.go | 204 ++++++++++++++++++++++
 internal/dhcpsvc/server.go                |  77 ++++++++
 internal/dhcpsvc/server_test.go           | 115 ++++++++++++
 internal/dhcpsvc/v4.go                    | 113 ++++++++++++
 internal/dhcpsvc/v6.go                    |  88 ++++++++++
 12 files changed, 862 insertions(+), 60 deletions(-)
 create mode 100644 internal/dhcpsvc/config_test.go
 create mode 100644 internal/dhcpsvc/errors.go
 create mode 100644 internal/dhcpsvc/iprange.go
 create mode 100644 internal/dhcpsvc/iprange_internal_test.go
 create mode 100644 internal/dhcpsvc/server.go
 create mode 100644 internal/dhcpsvc/server_test.go
 create mode 100644 internal/dhcpsvc/v4.go
 create mode 100644 internal/dhcpsvc/v6.go

diff --git a/internal/dhcpd/v4_unix.go b/internal/dhcpd/v4_unix.go
index 270a6072..70e34974 100644
--- a/internal/dhcpd/v4_unix.go
+++ b/internal/dhcpd/v4_unix.go
@@ -148,6 +148,9 @@ func (s *v4Server) ResetLeases(leases []*Lease) (err error) {
 		return nil
 	}
 
+	s.leasesLock.Lock()
+	defer s.leasesLock.Unlock()
+
 	s.leasedOffsets = newBitSet()
 	s.hostsIndex = make(map[string]*Lease, len(leases))
 	s.ipIndex = make(map[netip.Addr]*Lease, len(leases))
@@ -182,16 +185,13 @@ func (s *v4Server) isBlocklisted(l *Lease) (ok bool) {
 		return false
 	}
 
-	ok = true
 	for _, b := range l.HWAddr {
 		if b != 0 {
-			ok = false
-
-			break
+			return false
 		}
 	}
 
-	return ok
+	return true
 }
 
 // GetLeases returns the list of current DHCP leases.  It is safe for concurrent
diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go
index f08ea19e..6a01e553 100644
--- a/internal/dhcpd/v6_unix.go
+++ b/internal/dhcpd/v6_unix.go
@@ -90,6 +90,9 @@ func (s *v6Server) IPByHost(host string) (ip netip.Addr) {
 func (s *v6Server) ResetLeases(leases []*Lease) (err error) {
 	defer func() { err = errors.Annotate(err, "dhcpv6: %w") }()
 
+	s.leasesLock.Lock()
+	defer s.leasesLock.Unlock()
+
 	s.leases = nil
 	for _, l := range leases {
 		ip := net.IP(l.IP.AsSlice())
diff --git a/internal/dhcpsvc/config.go b/internal/dhcpsvc/config.go
index b01dc263..cd4bb148 100644
--- a/internal/dhcpsvc/config.go
+++ b/internal/dhcpsvc/config.go
@@ -1,10 +1,12 @@
 package dhcpsvc
 
 import (
-	"net/netip"
+	"fmt"
 	"time"
 
-	"github.com/google/gopacket/layers"
+	"github.com/AdguardTeam/golibs/netutil"
+	"golang.org/x/exp/maps"
+	"golang.org/x/exp/slices"
 )
 
 // Config is the configuration for the DHCP service.
@@ -33,54 +35,58 @@ type InterfaceConfig struct {
 	IPv6 *IPv6Config
 }
 
-// IPv4Config is the interface-specific configuration for DHCPv4.
-type IPv4Config struct {
-	// GatewayIP is the IPv4 address of the network's gateway.  It is used as
-	// the default gateway for DHCP clients and also used in calculating the
-	// network-specific broadcast address.
-	GatewayIP netip.Addr
+// Validate returns an error in conf if any.
+func (conf *Config) Validate() (err error) {
+	switch {
+	case conf == nil:
+		return errNilConfig
+	case !conf.Enabled:
+		return nil
+	case conf.ICMPTimeout < 0:
+		return fmt.Errorf("icmp timeout %s must be non-negative", conf.ICMPTimeout)
+	}
 
-	// SubnetMask is the IPv4 subnet mask of the network.  It should be a valid
-	// IPv4 subnet mask (i.e. all 1s followed by all 0s).
-	SubnetMask netip.Addr
+	err = netutil.ValidateDomainName(conf.LocalDomainName)
+	if err != nil {
+		// Don't wrap the error since it's informative enough as is.
+		return err
+	}
 
-	// RangeStart is the first address in the range to assign to DHCP clients.
-	RangeStart netip.Addr
+	if len(conf.Interfaces) == 0 {
+		return errNoInterfaces
+	}
 
-	// RangeEnd is the last address in the range to assign to DHCP clients.
-	RangeEnd netip.Addr
+	ifaces := maps.Keys(conf.Interfaces)
+	slices.Sort(ifaces)
 
-	// Options is the list of DHCP options to send to DHCP clients.
-	Options layers.DHCPOptions
+	for _, iface := range ifaces {
+		if err = conf.Interfaces[iface].validate(); err != nil {
+			return fmt.Errorf("interface %q: %w", iface, err)
+		}
+	}
 
-	// LeaseDuration is the TTL of a DHCP lease.
-	LeaseDuration time.Duration
-
-	// Enabled is the state of the DHCPv4 service, whether it is enabled or not
-	// on the specific interface.
-	Enabled bool
+	return nil
 }
 
-// IPv6Config is the interface-specific configuration for DHCPv6.
-type IPv6Config struct {
-	// RangeStart is the first address in the range to assign to DHCP clients.
-	RangeStart netip.Addr
-
-	// Options is the list of DHCP options to send to DHCP clients.
-	Options layers.DHCPOptions
-
-	// LeaseDuration is the TTL of a DHCP lease.
-	LeaseDuration time.Duration
-
-	// RASlaacOnly defines whether the DHCP clients should only use SLAAC for
-	// address assignment.
-	RASLAACOnly bool
-
-	// RAAllowSlaac defines whether the DHCP clients may use SLAAC for address
-	// assignment.
-	RAAllowSLAAC bool
-
-	// Enabled is the state of the DHCPv6 service, whether it is enabled or not
-	// on the specific interface.
-	Enabled bool
+// mustBeErr returns an error that indicates that valName must be as must
+// describes.
+func mustBeErr(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 {
+		return errNilConfig
+	}
+
+	if err = ic.IPv4.validate(); err != nil {
+		return fmt.Errorf("ipv4: %w", err)
+	}
+
+	if err = ic.IPv6.validate(); err != nil {
+		return fmt.Errorf("ipv6: %w", err)
+	}
+
+	return nil
 }
diff --git a/internal/dhcpsvc/config_test.go b/internal/dhcpsvc/config_test.go
new file mode 100644
index 00000000..6663d378
--- /dev/null
+++ b/internal/dhcpsvc/config_test.go
@@ -0,0 +1,88 @@
+package dhcpsvc_test
+
+import (
+	"testing"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
+	"github.com/AdguardTeam/golibs/testutil"
+)
+
+func TestConfig_Validate(t *testing.T) {
+	testCases := []struct {
+		name       string
+		conf       *dhcpsvc.Config
+		wantErrMsg string
+	}{{
+		name:       "nil_config",
+		conf:       nil,
+		wantErrMsg: "config is nil",
+	}, {
+		name:       "disabled",
+		conf:       &dhcpsvc.Config{},
+		wantErrMsg: "",
+	}, {
+		name: "empty",
+		conf: &dhcpsvc.Config{
+			Enabled: true,
+		},
+		wantErrMsg: `bad domain name "": domain name is empty`,
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces:      nil,
+		},
+		name:       "no_interfaces",
+		wantErrMsg: "no interfaces specified",
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces:      nil,
+		},
+		name:       "no_interfaces",
+		wantErrMsg: "no interfaces specified",
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": nil,
+			},
+		},
+		name:       "nil_interface",
+		wantErrMsg: `interface "eth0": config is nil`,
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: nil,
+					IPv6: &dhcpsvc.IPv6Config{Enabled: false},
+				},
+			},
+		},
+		name:       "nil_ipv4",
+		wantErrMsg: `interface "eth0": ipv4: config is nil`,
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: &dhcpsvc.IPv4Config{Enabled: false},
+					IPv6: nil,
+				},
+			},
+		},
+		name:       "nil_ipv6",
+		wantErrMsg: `interface "eth0": ipv6: config is nil`,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			testutil.AssertErrorMsg(t, tc.wantErrMsg, tc.conf.Validate())
+		})
+	}
+}
diff --git a/internal/dhcpsvc/dhcpsvc.go b/internal/dhcpsvc/dhcpsvc.go
index 4b3f5c21..b0d83fb5 100644
--- a/internal/dhcpsvc/dhcpsvc.go
+++ b/internal/dhcpsvc/dhcpsvc.go
@@ -56,16 +56,17 @@ type Interface interface {
 	// hostname, either set or generated.
 	IPByHost(host string) (ip netip.Addr)
 
-	// Leases returns all the DHCP leases.
-	Leases() (leases []*Lease)
+	// Leases returns all the active DHCP leases.
+	Leases() (ls []*Lease)
 
 	// AddLease adds a new DHCP lease.  It returns an error if the lease is
 	// invalid or already exists.
 	AddLease(l *Lease) (err error)
 
-	// EditLease changes an existing DHCP lease.  It returns an error if there
-	// is no lease equal to old or if new is invalid or already exists.
-	EditLease(old, new *Lease) (err error)
+	// UpdateStaticLease changes an existing DHCP lease.  It returns an error if
+	// there is no lease with such hardware addressor if new values are invalid
+	// or already exist.
+	UpdateStaticLease(l *Lease) (err error)
 
 	// RemoveLease removes an existing DHCP lease.  It returns an error if there
 	// is no lease equal to l.
@@ -79,7 +80,7 @@ type Interface interface {
 type Empty struct{}
 
 // type check
-var _ Interface = Empty{}
+var _ agh.ServiceWithConfig[*Config] = Empty{}
 
 // Start implements the [Service] interface for Empty.
 func (Empty) Start() (err error) { return nil }
@@ -87,8 +88,6 @@ func (Empty) Start() (err error) { return nil }
 // Shutdown implements the [Service] interface for Empty.
 func (Empty) Shutdown(_ context.Context) (err error) { return nil }
 
-var _ agh.ServiceWithConfig[*Config] = Empty{}
-
 // Config implements the [ServiceWithConfig] interface for Empty.
 func (Empty) Config() (conf *Config) { return nil }
 
@@ -113,8 +112,8 @@ func (Empty) Leases() (leases []*Lease) { return nil }
 // AddLease implements the [Interface] interface for Empty.
 func (Empty) AddLease(_ *Lease) (err error) { return nil }
 
-// EditLease implements the [Interface] interface for Empty.
-func (Empty) EditLease(_, _ *Lease) (err error) { return nil }
+// UpdateStaticLease implements the [Interface] interface for Empty.
+func (Empty) UpdateStaticLease(_ *Lease) (err error) { return nil }
 
 // RemoveLease implements the [Interface] interface for Empty.
 func (Empty) RemoveLease(_ *Lease) (err error) { return nil }
diff --git a/internal/dhcpsvc/errors.go b/internal/dhcpsvc/errors.go
new file mode 100644
index 00000000..a7cc8931
--- /dev/null
+++ b/internal/dhcpsvc/errors.go
@@ -0,0 +1,11 @@
+package dhcpsvc
+
+import "github.com/AdguardTeam/golibs/errors"
+
+const (
+	// errNilConfig is returned when a nil config met.
+	errNilConfig errors.Error = "config is nil"
+
+	// errNoInterfaces is returned when no interfaces found in configuration.
+	errNoInterfaces errors.Error = "no interfaces specified"
+)
diff --git a/internal/dhcpsvc/iprange.go b/internal/dhcpsvc/iprange.go
new file mode 100644
index 00000000..0f922bba
--- /dev/null
+++ b/internal/dhcpsvc/iprange.go
@@ -0,0 +1,98 @@
+package dhcpsvc
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+	"math/big"
+	"net/netip"
+
+	"github.com/AdguardTeam/golibs/errors"
+)
+
+// ipRange is an inclusive range of IP addresses.  A zero range doesn't contain
+// any IP addresses.
+//
+// It is safe for concurrent use.
+type ipRange struct {
+	start netip.Addr
+	end   netip.Addr
+}
+
+// maxRangeLen is the maximum IP range length.  The bitsets used in servers only
+// accept uints, which can have the size of 32 bit.
+//
+// TODO(a.garipov, e.burkov):  Reconsider the value for IPv6.
+const maxRangeLen = math.MaxUint32
+
+// newIPRange creates a new IP address range.  start must be less than end.  The
+// resulting range must not be greater than maxRangeLen.
+func newIPRange(start, end netip.Addr) (r ipRange, err error) {
+	defer func() { err = errors.Annotate(err, "invalid ip range: %w") }()
+
+	switch false {
+	case start.Is4() == end.Is4():
+		return ipRange{}, fmt.Errorf("%s and %s must be within the same address family", start, end)
+	case start.Less(end):
+		return ipRange{}, fmt.Errorf("start %s is greater than or equal to end %s", start, end)
+	default:
+		diff := (&big.Int{}).Sub(
+			(&big.Int{}).SetBytes(end.AsSlice()),
+			(&big.Int{}).SetBytes(start.AsSlice()),
+		)
+
+		if !diff.IsUint64() || diff.Uint64() > maxRangeLen {
+			return ipRange{}, fmt.Errorf("range length must be within %d", uint32(maxRangeLen))
+		}
+	}
+
+	return ipRange{
+		start: start,
+		end:   end,
+	}, nil
+}
+
+// contains returns true if r contains ip.
+func (r ipRange) contains(ip netip.Addr) (ok bool) {
+	// Assume that the end was checked to be within the same address family as
+	// the start during construction.
+	return r.start.Is4() == ip.Is4() && !ip.Less(r.start) && !r.end.Less(ip)
+}
+
+// ipPredicate is a function that is called on every IP address in
+// [ipRange.find].
+type ipPredicate func(ip netip.Addr) (ok bool)
+
+// find finds the first IP address in r for which p returns true.  It returns an
+// empty [netip.Addr] if there are no addresses that satisfy p.
+//
+// TODO(e.burkov):  Use.
+func (r ipRange) find(p ipPredicate) (ip netip.Addr) {
+	for ip = r.start; !r.end.Less(ip); ip = ip.Next() {
+		if p(ip) {
+			return ip
+		}
+	}
+
+	return netip.Addr{}
+}
+
+// offset returns the offset of ip from the beginning of r.  It returns 0 and
+// false if ip is not in r.
+func (r ipRange) offset(ip netip.Addr) (offset uint64, ok bool) {
+	if !r.contains(ip) {
+		return 0, false
+	}
+
+	startData, ipData := r.start.As16(), ip.As16()
+	be := binary.BigEndian
+
+	// Assume that the range length was checked against maxRangeLen during
+	// construction.
+	return be.Uint64(ipData[8:]) - be.Uint64(startData[8:]), true
+}
+
+// String implements the fmt.Stringer interface for *ipRange.
+func (r ipRange) String() (s string) {
+	return fmt.Sprintf("%s-%s", r.start, r.end)
+}
diff --git a/internal/dhcpsvc/iprange_internal_test.go b/internal/dhcpsvc/iprange_internal_test.go
new file mode 100644
index 00000000..1cb12688
--- /dev/null
+++ b/internal/dhcpsvc/iprange_internal_test.go
@@ -0,0 +1,204 @@
+package dhcpsvc
+
+import (
+	"net/netip"
+	"strconv"
+	"testing"
+
+	"github.com/AdguardTeam/golibs/testutil"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewIPRange(t *testing.T) {
+	start4 := netip.MustParseAddr("0.0.0.1")
+	end4 := netip.MustParseAddr("0.0.0.3")
+	start6 := netip.MustParseAddr("1::1")
+	end6 := netip.MustParseAddr("1::3")
+	end6Large := netip.MustParseAddr("2::3")
+
+	testCases := []struct {
+		start      netip.Addr
+		end        netip.Addr
+		name       string
+		wantErrMsg string
+	}{{
+		start:      start4,
+		end:        end4,
+		name:       "success_ipv4",
+		wantErrMsg: "",
+	}, {
+		start:      start6,
+		end:        end6,
+		name:       "success_ipv6",
+		wantErrMsg: "",
+	}, {
+		start:      end4,
+		end:        start4,
+		name:       "start_gt_end",
+		wantErrMsg: "invalid ip range: start 0.0.0.3 is greater than or equal to end 0.0.0.1",
+	}, {
+		start:      start4,
+		end:        start4,
+		name:       "start_eq_end",
+		wantErrMsg: "invalid ip range: start 0.0.0.1 is greater than or equal to end 0.0.0.1",
+	}, {
+		start: start6,
+		end:   end6Large,
+		name:  "too_large",
+		wantErrMsg: "invalid ip range: range length must be within " +
+			strconv.FormatUint(maxRangeLen, 10),
+	}, {
+		start:      start4,
+		end:        end6,
+		name:       "different_family",
+		wantErrMsg: "invalid ip range: 0.0.0.1 and 1::3 must be within the same address family",
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := newIPRange(tc.start, tc.end)
+			testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
+		})
+	}
+}
+
+func TestIPRange_Contains(t *testing.T) {
+	start, end := netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.3")
+	r, err := newIPRange(start, end)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		in   netip.Addr
+		want assert.BoolAssertionFunc
+		name string
+	}{{
+		in:   start,
+		want: assert.True,
+		name: "start",
+	}, {
+		in:   end,
+		want: assert.True,
+		name: "end",
+	}, {
+		in:   start.Next(),
+		want: assert.True,
+		name: "within",
+	}, {
+		in:   netip.MustParseAddr("0.0.0.0"),
+		want: assert.False,
+		name: "before",
+	}, {
+		in:   netip.MustParseAddr("0.0.0.4"),
+		want: assert.False,
+		name: "after",
+	}, {
+		in:   netip.MustParseAddr("::"),
+		want: assert.False,
+		name: "another_family",
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc.want(t, r.contains(tc.in))
+		})
+	}
+}
+
+func TestIPRange_Find(t *testing.T) {
+	start, end := netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.5")
+	r, err := newIPRange(start, end)
+	require.NoError(t, err)
+
+	num, ok := r.offset(end)
+	require.True(t, ok)
+
+	testCases := []struct {
+		predicate ipPredicate
+		want      netip.Addr
+		name      string
+	}{{
+		predicate: func(ip netip.Addr) (ok bool) {
+			ipData := ip.AsSlice()
+
+			return ipData[len(ipData)-1]%2 == 0
+		},
+		want: netip.MustParseAddr("0.0.0.2"),
+		name: "even",
+	}, {
+		predicate: func(ip netip.Addr) (ok bool) {
+			ipData := ip.AsSlice()
+
+			return ipData[len(ipData)-1]%10 == 0
+		},
+		want: netip.Addr{},
+		name: "none",
+	}, {
+		predicate: func(ip netip.Addr) (ok bool) {
+			return true
+		},
+		want: start,
+		name: "first",
+	}, {
+		predicate: func(ip netip.Addr) (ok bool) {
+			off, _ := r.offset(ip)
+
+			return off == num
+		},
+		want: end,
+		name: "last",
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := r.find(tc.predicate)
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestIPRange_Offset(t *testing.T) {
+	start, end := netip.MustParseAddr("0.0.0.1"), netip.MustParseAddr("0.0.0.5")
+	r, err := newIPRange(start, end)
+	require.NoError(t, err)
+
+	testCases := []struct {
+		in         netip.Addr
+		name       string
+		wantOffset uint64
+		wantOK     bool
+	}{{
+		in:         netip.MustParseAddr("0.0.0.2"),
+		name:       "in",
+		wantOffset: 1,
+		wantOK:     true,
+	}, {
+		in:         start,
+		name:       "in_start",
+		wantOffset: 0,
+		wantOK:     true,
+	}, {
+		in:         end,
+		name:       "in_end",
+		wantOffset: 4,
+		wantOK:     true,
+	}, {
+		in:         netip.MustParseAddr("0.0.0.6"),
+		name:       "out_after",
+		wantOffset: 0,
+		wantOK:     false,
+	}, {
+		in:         netip.MustParseAddr("0.0.0.0"),
+		name:       "out_before",
+		wantOffset: 0,
+		wantOK:     false,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			offset, ok := r.offset(tc.in)
+			assert.Equal(t, tc.wantOffset, offset)
+			assert.Equal(t, tc.wantOK, ok)
+		})
+	}
+}
diff --git a/internal/dhcpsvc/server.go b/internal/dhcpsvc/server.go
new file mode 100644
index 00000000..bd0571fd
--- /dev/null
+++ b/internal/dhcpsvc/server.go
@@ -0,0 +1,77 @@
+package dhcpsvc
+
+import (
+	"fmt"
+	"sync/atomic"
+	"time"
+
+	"golang.org/x/exp/maps"
+	"golang.org/x/exp/slices"
+)
+
+// DHCPServer is a DHCP server for both IPv4 and IPv6 address families.
+type DHCPServer struct {
+	// enabled indicates whether the DHCP server is enabled and can provide
+	// information about its clients.
+	enabled *atomic.Bool
+
+	// localTLD is the top-level domain name to use for resolving DHCP
+	// clients' hostnames.
+	localTLD string
+
+	// interfaces4 is the set of IPv4 interfaces sorted by interface name.
+	interfaces4 []*iface4
+
+	// interfaces6 is the set of IPv6 interfaces sorted by interface name.
+	interfaces6 []*iface6
+
+	// icmpTimeout is the timeout for checking another DHCP server's presence.
+	icmpTimeout time.Duration
+}
+
+// New creates a new DHCP server with the given configuration.  It returns an
+// error if the given configuration can't be used.
+//
+// TODO(e.burkov):  Use.
+func New(conf *Config) (srv *DHCPServer, err error) {
+	if !conf.Enabled {
+		// TODO(e.burkov):  Perhaps return [Empty]?
+		return nil, nil
+	}
+
+	ifaces4 := make([]*iface4, len(conf.Interfaces))
+	ifaces6 := make([]*iface6, len(conf.Interfaces))
+
+	ifaceNames := maps.Keys(conf.Interfaces)
+	slices.Sort(ifaceNames)
+
+	var i4 *iface4
+	var i6 *iface6
+
+	for _, ifaceName := range ifaceNames {
+		iface := conf.Interfaces[ifaceName]
+
+		i4, err = newIface4(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)
+		if i6 != nil {
+			ifaces6 = append(ifaces6, i6)
+		}
+	}
+
+	enabled := &atomic.Bool{}
+	enabled.Store(conf.Enabled)
+
+	return &DHCPServer{
+		enabled:     enabled,
+		interfaces4: ifaces4,
+		interfaces6: ifaces6,
+		localTLD:    conf.LocalDomainName,
+		icmpTimeout: conf.ICMPTimeout,
+	}, nil
+}
diff --git a/internal/dhcpsvc/server_test.go b/internal/dhcpsvc/server_test.go
new file mode 100644
index 00000000..6475dfa4
--- /dev/null
+++ b/internal/dhcpsvc/server_test.go
@@ -0,0 +1,115 @@
+package dhcpsvc_test
+
+import (
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
+	"github.com/AdguardTeam/golibs/testutil"
+)
+
+// testLocalTLD is a common local TLD for tests.
+const testLocalTLD = "local"
+
+func TestNew(t *testing.T) {
+	validIPv4Conf := &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,
+	}
+	gwInRangeConf := &dhcpsvc.IPv4Config{
+		Enabled:       true,
+		GatewayIP:     netip.MustParseAddr("192.168.0.100"),
+		SubnetMask:    netip.MustParseAddr("255.255.255.0"),
+		RangeStart:    netip.MustParseAddr("192.168.0.1"),
+		RangeEnd:      netip.MustParseAddr("192.168.0.254"),
+		LeaseDuration: 1 * time.Hour,
+	}
+	badStartConf := &dhcpsvc.IPv4Config{
+		Enabled:       true,
+		GatewayIP:     netip.MustParseAddr("192.168.0.1"),
+		SubnetMask:    netip.MustParseAddr("255.255.255.0"),
+		RangeStart:    netip.MustParseAddr("127.0.0.1"),
+		RangeEnd:      netip.MustParseAddr("192.168.0.254"),
+		LeaseDuration: 1 * time.Hour,
+	}
+
+	validIPv6Conf := &dhcpsvc.IPv6Config{
+		Enabled:       true,
+		RangeStart:    netip.MustParseAddr("2001:db8::1"),
+		LeaseDuration: 1 * time.Hour,
+		RAAllowSLAAC:  true,
+		RASLAACOnly:   true,
+	}
+
+	testCases := []struct {
+		conf       *dhcpsvc.Config
+		name       string
+		wantErrMsg string
+	}{{
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: validIPv4Conf,
+					IPv6: validIPv6Conf,
+				},
+			},
+		},
+		name:       "valid",
+		wantErrMsg: "",
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: &dhcpsvc.IPv4Config{Enabled: false},
+					IPv6: &dhcpsvc.IPv6Config{Enabled: false},
+				},
+			},
+		},
+		name:       "disabled_interfaces",
+		wantErrMsg: "",
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: gwInRangeConf,
+					IPv6: validIPv6Conf,
+				},
+			},
+		},
+		name: "gateway_within_range",
+		wantErrMsg: `interface "eth0": ipv4: ` +
+			`gateway ip 192.168.0.100 in the ip range 192.168.0.1-192.168.0.254`,
+	}, {
+		conf: &dhcpsvc.Config{
+			Enabled:         true,
+			LocalDomainName: testLocalTLD,
+			Interfaces: map[string]*dhcpsvc.InterfaceConfig{
+				"eth0": {
+					IPv4: badStartConf,
+					IPv6: validIPv6Conf,
+				},
+			},
+		},
+		name: "bad_start",
+		wantErrMsg: `interface "eth0": ipv4: ` +
+			`range start 127.0.0.1 is not within 192.168.0.1/24`,
+	}}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := dhcpsvc.New(tc.conf)
+			testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
+		})
+	}
+}
diff --git a/internal/dhcpsvc/v4.go b/internal/dhcpsvc/v4.go
new file mode 100644
index 00000000..f53e7bba
--- /dev/null
+++ b/internal/dhcpsvc/v4.go
@@ -0,0 +1,113 @@
+package dhcpsvc
+
+import (
+	"fmt"
+	"net"
+	"net/netip"
+	"time"
+
+	"github.com/google/gopacket/layers"
+)
+
+// IPv4Config is the interface-specific configuration for DHCPv4.
+type IPv4Config struct {
+	// GatewayIP is the IPv4 address of the network's gateway.  It is used as
+	// the default gateway for DHCP clients and also used in calculating the
+	// network-specific broadcast address.
+	GatewayIP netip.Addr
+
+	// SubnetMask is the IPv4 subnet mask of the network.  It should be a valid
+	// IPv4 CIDR (i.e. all 1s followed by all 0s).
+	SubnetMask netip.Addr
+
+	// RangeStart is the first address in the range to assign to DHCP clients.
+	RangeStart netip.Addr
+
+	// RangeEnd is the last address in the range to assign to DHCP clients.
+	RangeEnd netip.Addr
+
+	// Options is the list of DHCP options to send to DHCP clients.
+	Options layers.DHCPOptions
+
+	// LeaseDuration is the TTL of a DHCP lease.
+	LeaseDuration time.Duration
+
+	// Enabled is the state of the DHCPv4 service, whether it is enabled or not
+	// on the specific interface.
+	Enabled bool
+}
+
+// validate returns an error in conf if any.
+func (conf *IPv4Config) validate() (err error) {
+	switch {
+	case conf == nil:
+		return errNilConfig
+	case !conf.Enabled:
+		return nil
+	case !conf.GatewayIP.Is4():
+		return mustBeErr("gateway ip", "be a valid ipv4", conf.GatewayIP)
+	case !conf.SubnetMask.Is4():
+		return mustBeErr("subnet mask", "be a valid ipv4 cidr mask", conf.SubnetMask)
+	case !conf.RangeStart.Is4():
+		return mustBeErr("range start", "be a valid ipv4", conf.RangeStart)
+	case !conf.RangeEnd.Is4():
+		return mustBeErr("range end", "be a valid ipv4", conf.RangeEnd)
+	case conf.LeaseDuration <= 0:
+		return mustBeErr("lease duration", "be less than %d", conf.LeaseDuration)
+	default:
+		return nil
+	}
+}
+
+// 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
+
+	// TODO(e.burkov):  Add options.
+
+	// 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)
+	}
+
+	return &iface4{
+		name:      name,
+		gateway:   conf.GatewayIP,
+		subnet:    subnet,
+		addrSpace: addrSpace,
+		leaseTTL:  conf.LeaseDuration,
+	}, nil
+}
diff --git a/internal/dhcpsvc/v6.go b/internal/dhcpsvc/v6.go
new file mode 100644
index 00000000..8bdc1637
--- /dev/null
+++ b/internal/dhcpsvc/v6.go
@@ -0,0 +1,88 @@
+package dhcpsvc
+
+import (
+	"fmt"
+	"net/netip"
+	"time"
+
+	"github.com/google/gopacket/layers"
+)
+
+// IPv6Config is the interface-specific configuration for DHCPv6.
+type IPv6Config struct {
+	// RangeStart is the first address in the range to assign to DHCP clients.
+	RangeStart netip.Addr
+
+	// Options is the list of DHCP options to send to DHCP clients.
+	Options layers.DHCPOptions
+
+	// LeaseDuration is the TTL of a DHCP lease.
+	LeaseDuration time.Duration
+
+	// RASlaacOnly defines whether the DHCP clients should only use SLAAC for
+	// address assignment.
+	RASLAACOnly bool
+
+	// RAAllowSlaac defines whether the DHCP clients may use SLAAC for address
+	// assignment.
+	RAAllowSLAAC bool
+
+	// Enabled is the state of the DHCPv6 service, whether it is enabled or not
+	// on the specific interface.
+	Enabled bool
+}
+
+// validate returns an error in conf if any.
+func (conf *IPv6Config) validate() (err error) {
+	switch {
+	case conf == nil:
+		return errNilConfig
+	case !conf.Enabled:
+		return nil
+	case !conf.RangeStart.Is6():
+		return fmt.Errorf("range start %s should be a valid ipv6", conf.RangeStart)
+	case conf.LeaseDuration <= 0:
+		return fmt.Errorf("lease duration %s must be positive", conf.LeaseDuration)
+	default:
+		return nil
+	}
+}
+
+// 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
+
+	// 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
+	}
+
+	return &iface6{
+		name:         name,
+		rangeStart:   conf.RangeStart,
+		leaseTTL:     conf.LeaseDuration,
+		raSLAACOnly:  conf.RASLAACOnly,
+		raAllowSLAAC: conf.RAAllowSLAAC,
+	}
+}