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