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
This commit is contained in:
Eugene Burkov 2023-10-02 13:21:16 +03:00
parent c3f141a0a8
commit 39aeaf8910
12 changed files with 862 additions and 60 deletions

View file

@ -148,6 +148,9 @@ func (s *v4Server) ResetLeases(leases []*Lease) (err error) {
return nil return nil
} }
s.leasesLock.Lock()
defer s.leasesLock.Unlock()
s.leasedOffsets = newBitSet() s.leasedOffsets = newBitSet()
s.hostsIndex = make(map[string]*Lease, len(leases)) s.hostsIndex = make(map[string]*Lease, len(leases))
s.ipIndex = make(map[netip.Addr]*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 return false
} }
ok = true
for _, b := range l.HWAddr { for _, b := range l.HWAddr {
if b != 0 { if b != 0 {
ok = false return false
break
} }
} }
return ok return true
} }
// GetLeases returns the list of current DHCP leases. It is safe for concurrent // GetLeases returns the list of current DHCP leases. It is safe for concurrent

View file

@ -90,6 +90,9 @@ func (s *v6Server) IPByHost(host string) (ip netip.Addr) {
func (s *v6Server) ResetLeases(leases []*Lease) (err error) { func (s *v6Server) ResetLeases(leases []*Lease) (err error) {
defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() defer func() { err = errors.Annotate(err, "dhcpv6: %w") }()
s.leasesLock.Lock()
defer s.leasesLock.Unlock()
s.leases = nil s.leases = nil
for _, l := range leases { for _, l := range leases {
ip := net.IP(l.IP.AsSlice()) ip := net.IP(l.IP.AsSlice())

View file

@ -1,10 +1,12 @@
package dhcpsvc package dhcpsvc
import ( import (
"net/netip" "fmt"
"time" "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. // Config is the configuration for the DHCP service.
@ -33,54 +35,58 @@ type InterfaceConfig struct {
IPv6 *IPv6Config IPv6 *IPv6Config
} }
// IPv4Config is the interface-specific configuration for DHCPv4. // Validate returns an error in conf if any.
type IPv4Config struct { func (conf *Config) Validate() (err error) {
// GatewayIP is the IPv4 address of the network's gateway. It is used as switch {
// the default gateway for DHCP clients and also used in calculating the case conf == nil:
// network-specific broadcast address. return errNilConfig
GatewayIP netip.Addr 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 err = netutil.ValidateDomainName(conf.LocalDomainName)
// IPv4 subnet mask (i.e. all 1s followed by all 0s). if err != nil {
SubnetMask netip.Addr // 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. if len(conf.Interfaces) == 0 {
RangeStart netip.Addr return errNoInterfaces
}
// RangeEnd is the last address in the range to assign to DHCP clients. ifaces := maps.Keys(conf.Interfaces)
RangeEnd netip.Addr slices.Sort(ifaces)
// Options is the list of DHCP options to send to DHCP clients. for _, iface := range ifaces {
Options layers.DHCPOptions if err = conf.Interfaces[iface].validate(); err != nil {
return fmt.Errorf("interface %q: %w", iface, err)
}
}
// LeaseDuration is the TTL of a DHCP lease. return nil
LeaseDuration time.Duration
// Enabled is the state of the DHCPv4 service, whether it is enabled or not
// on the specific interface.
Enabled bool
} }
// IPv6Config is the interface-specific configuration for DHCPv6. // mustBeErr returns an error that indicates that valName must be as must
type IPv6Config struct { // describes.
// RangeStart is the first address in the range to assign to DHCP clients. func mustBeErr(valName, must string, val fmt.Stringer) (err error) {
RangeStart netip.Addr return fmt.Errorf("%s %s must %s", valName, val, must)
}
// Options is the list of DHCP options to send to DHCP clients.
Options layers.DHCPOptions // validate returns an error in ic, if any.
func (ic *InterfaceConfig) validate() (err error) {
// LeaseDuration is the TTL of a DHCP lease. if ic == nil {
LeaseDuration time.Duration return errNilConfig
}
// RASlaacOnly defines whether the DHCP clients should only use SLAAC for
// address assignment. if err = ic.IPv4.validate(); err != nil {
RASLAACOnly bool return fmt.Errorf("ipv4: %w", err)
}
// RAAllowSlaac defines whether the DHCP clients may use SLAAC for address
// assignment. if err = ic.IPv6.validate(); err != nil {
RAAllowSLAAC bool return fmt.Errorf("ipv6: %w", err)
}
// Enabled is the state of the DHCPv6 service, whether it is enabled or not
// on the specific interface. return nil
Enabled bool
} }

View file

@ -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())
})
}
}

View file

@ -56,16 +56,17 @@ type Interface interface {
// hostname, either set or generated. // hostname, either set or generated.
IPByHost(host string) (ip netip.Addr) IPByHost(host string) (ip netip.Addr)
// Leases returns all the DHCP leases. // Leases returns all the active DHCP leases.
Leases() (leases []*Lease) Leases() (ls []*Lease)
// AddLease adds a new DHCP lease. It returns an error if the lease is // AddLease adds a new DHCP lease. It returns an error if the lease is
// invalid or already exists. // invalid or already exists.
AddLease(l *Lease) (err error) AddLease(l *Lease) (err error)
// EditLease changes an existing DHCP lease. It returns an error if there // UpdateStaticLease changes an existing DHCP lease. It returns an error if
// is no lease equal to old or if new is invalid or already exists. // there is no lease with such hardware addressor if new values are invalid
EditLease(old, new *Lease) (err error) // or already exist.
UpdateStaticLease(l *Lease) (err error)
// RemoveLease removes an existing DHCP lease. It returns an error if there // RemoveLease removes an existing DHCP lease. It returns an error if there
// is no lease equal to l. // is no lease equal to l.
@ -79,7 +80,7 @@ type Interface interface {
type Empty struct{} type Empty struct{}
// type check // type check
var _ Interface = Empty{} var _ agh.ServiceWithConfig[*Config] = Empty{}
// Start implements the [Service] interface for Empty. // Start implements the [Service] interface for Empty.
func (Empty) Start() (err error) { return nil } 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. // Shutdown implements the [Service] interface for Empty.
func (Empty) Shutdown(_ context.Context) (err error) { return nil } func (Empty) Shutdown(_ context.Context) (err error) { return nil }
var _ agh.ServiceWithConfig[*Config] = Empty{}
// Config implements the [ServiceWithConfig] interface for Empty. // Config implements the [ServiceWithConfig] interface for Empty.
func (Empty) Config() (conf *Config) { return nil } 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. // AddLease implements the [Interface] interface for Empty.
func (Empty) AddLease(_ *Lease) (err error) { return nil } func (Empty) AddLease(_ *Lease) (err error) { return nil }
// EditLease implements the [Interface] interface for Empty. // UpdateStaticLease implements the [Interface] interface for Empty.
func (Empty) EditLease(_, _ *Lease) (err error) { return nil } func (Empty) UpdateStaticLease(_ *Lease) (err error) { return nil }
// RemoveLease implements the [Interface] interface for Empty. // RemoveLease implements the [Interface] interface for Empty.
func (Empty) RemoveLease(_ *Lease) (err error) { return nil } func (Empty) RemoveLease(_ *Lease) (err error) { return nil }

View file

@ -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"
)

View file

@ -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)
}

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -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)
})
}
}

113
internal/dhcpsvc/v4.go Normal file
View file

@ -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
}

88
internal/dhcpsvc/v6.go Normal file
View file

@ -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,
}
}