diff --git a/internal/dhcpsvc/config.go b/internal/dhcpsvc/config.go index 573b1df3..9b0fb350 100644 --- a/internal/dhcpsvc/config.go +++ b/internal/dhcpsvc/config.go @@ -10,31 +10,77 @@ import ( "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/netutil" + "github.com/AdguardTeam/golibs/validate" ) // Config is the configuration for the DHCP service. type Config struct { // Interfaces stores configurations of DHCP server specific for the network - // interface identified by its name. + // interface identified by its name. It must not be empty and must only + // contain valid interface names and configurations. Interfaces map[string]*InterfaceConfig - // Logger will be used to log the DHCP events. + // Logger will be used to log the DHCP events. It must not be nil. Logger *slog.Logger // LocalDomainName is the top-level domain name to use for resolving DHCP - // clients' hostnames. + // clients' hostnames. It must be a valid domain name. LocalDomainName string // DBFilePath is the path to the database file containing the DHCP leases. + // It must not be empty. DBFilePath string // ICMPTimeout is the timeout for checking another DHCP server's presence. + // It must be non-negative. If it is zero, the check will be skipped. ICMPTimeout time.Duration // Enabled is the state of the service, whether it is enabled or not. Enabled bool } +// type check +var _ validate.Interface = (*Config)(nil) + +// Validate implements the [validate.Interface] for *Config. +func (conf *Config) Validate() (err error) { + switch { + case conf == nil: + return errors.ErrNoValue + case !conf.Enabled: + return nil + } + + errs := []error{ + validate.NotNegative("ICMPTimeout", conf.ICMPTimeout), + } + + err = netutil.ValidateDomainName(conf.LocalDomainName) + if err != nil { + errs = append(errs, fmt.Errorf("LocalDomainName: %w", err)) + } + + // This is a best-effort check for the file accessibility. The file will be + // checked again when it is opened later. + if _, err = os.Stat(conf.DBFilePath); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("DBFilePath %q: %w", conf.DBFilePath, err)) + } + + if len(conf.Interfaces) == 0 { + err = fmt.Errorf("interfaces: %w", errors.ErrEmptyValue) + errs = append(errs, err) + + return errors.Join(errs...) + } + + for _, iface := range slices.Sorted(maps.Keys(conf.Interfaces)) { + ifaceConf := conf.Interfaces[iface] + errs = validate.Append(errs, iface, ifaceConf) + } + + return errors.Join(errs...) +} + // InterfaceConfig is the configuration of a single DHCP interface. type InterfaceConfig struct { // IPv4 is the configuration of DHCP protocol for IPv4. @@ -44,65 +90,17 @@ type InterfaceConfig struct { IPv6 *IPv6Config } -// Validate returns an error in conf if any. -// -// TODO(e.burkov): Unexport and rewrite the test. -func (conf *Config) Validate() (err error) { - switch { - case conf == nil: - return errNilConfig - case !conf.Enabled: - return nil - } +// type check +var _ validate.Interface = (*InterfaceConfig)(nil) - var errs []error - if conf.ICMPTimeout < 0 { - err = newMustErr("icmp timeout", "be non-negative", conf.ICMPTimeout) - errs = append(errs, err) - } - - err = netutil.ValidateDomainName(conf.LocalDomainName) - if err != nil { - // Don't wrap the error since it's informative enough as is. - errs = append(errs, err) - } - - // This is a best-effort check for the file accessibility. The file will be - // checked again when it is opened later. - if _, err = os.Stat(conf.DBFilePath); err != nil && !errors.Is(err, os.ErrNotExist) { - errs = append(errs, fmt.Errorf("db file path %q: %w", conf.DBFilePath, err)) - } - - if len(conf.Interfaces) == 0 { - errs = append(errs, errNoInterfaces) - - return errors.Join(errs...) - } - - for _, iface := range slices.Sorted(maps.Keys(conf.Interfaces)) { - ic := conf.Interfaces[iface] - err = ic.validate() - if err != nil { - errs = append(errs, fmt.Errorf("interface %q: %w", iface, err)) - } - } - - return errors.Join(errs...) -} - -// validate returns an error in ic, if any. -func (ic *InterfaceConfig) validate() (err error) { +// Validate implements the [validate.Interface] interface for *InterfaceConfig. +func (ic *InterfaceConfig) Validate() (err error) { if ic == nil { - return errNilConfig + return errors.ErrNoValue } - 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 + return errors.Join( + errors.Annotate(ic.IPv4.Validate(), "ipv4: %w"), + errors.Annotate(ic.IPv6.Validate(), "ipv6: %w"), + ) } diff --git a/internal/dhcpsvc/config_test.go b/internal/dhcpsvc/config_test.go index 85dab4a9..db5dfef3 100644 --- a/internal/dhcpsvc/config_test.go +++ b/internal/dhcpsvc/config_test.go @@ -1,14 +1,49 @@ package dhcpsvc_test import ( + "net/netip" "path/filepath" "testing" + "time" "github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc" "github.com/AdguardTeam/golibs/testutil" ) func TestConfig_Validate(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, + } + leasesPath := filepath.Join(t.TempDir(), "leases.json") testCases := []struct { @@ -18,7 +53,7 @@ func TestConfig_Validate(t *testing.T) { }{{ name: "nil_config", conf: nil, - wantErrMsg: "config is nil", + wantErrMsg: "no value", }, { name: "disabled", conf: &dhcpsvc.Config{}, @@ -30,7 +65,7 @@ func TestConfig_Validate(t *testing.T) { Interfaces: testInterfaceConf, DBFilePath: leasesPath, }, - wantErrMsg: `bad domain name "": domain name is empty`, + wantErrMsg: `LocalDomainName: bad domain name "": domain name is empty`, }, { conf: &dhcpsvc.Config{ Enabled: true, @@ -39,16 +74,7 @@ func TestConfig_Validate(t *testing.T) { DBFilePath: leasesPath, }, name: "no_interfaces", - wantErrMsg: "no interfaces specified", - }, { - conf: &dhcpsvc.Config{ - Enabled: true, - LocalDomainName: testLocalTLD, - Interfaces: nil, - DBFilePath: leasesPath, - }, - name: "no_interfaces", - wantErrMsg: "no interfaces specified", + wantErrMsg: "interfaces: empty value", }, { conf: &dhcpsvc.Config{ Enabled: true, @@ -59,7 +85,7 @@ func TestConfig_Validate(t *testing.T) { DBFilePath: leasesPath, }, name: "nil_interface", - wantErrMsg: `interface "eth0": config is nil`, + wantErrMsg: `eth0: no value`, }, { conf: &dhcpsvc.Config{ Enabled: true, @@ -73,7 +99,7 @@ func TestConfig_Validate(t *testing.T) { DBFilePath: leasesPath, }, name: "nil_ipv4", - wantErrMsg: `interface "eth0": ipv4: config is nil`, + wantErrMsg: `eth0: ipv4: no value`, }, { conf: &dhcpsvc.Config{ Enabled: true, @@ -87,7 +113,69 @@ func TestConfig_Validate(t *testing.T) { DBFilePath: leasesPath, }, name: "nil_ipv6", - wantErrMsg: `interface "eth0": ipv6: config is nil`, + wantErrMsg: `eth0: ipv6: no value`, + }, { + conf: &dhcpsvc.Config{ + Enabled: true, + Logger: discardLog, + LocalDomainName: testLocalTLD, + Interfaces: map[string]*dhcpsvc.InterfaceConfig{ + "eth0": { + IPv4: validIPv4Conf, + IPv6: validIPv6Conf, + }, + }, + DBFilePath: leasesPath, + }, + name: "valid", + wantErrMsg: "", + }, { + conf: &dhcpsvc.Config{ + Enabled: true, + Logger: discardLog, + LocalDomainName: testLocalTLD, + Interfaces: map[string]*dhcpsvc.InterfaceConfig{ + "eth0": { + IPv4: &dhcpsvc.IPv4Config{Enabled: false}, + IPv6: &dhcpsvc.IPv6Config{Enabled: false}, + }, + }, + DBFilePath: leasesPath, + }, + name: "disabled_interfaces", + wantErrMsg: "", + }, { + conf: &dhcpsvc.Config{ + Enabled: true, + Logger: discardLog, + LocalDomainName: testLocalTLD, + Interfaces: map[string]*dhcpsvc.InterfaceConfig{ + "eth0": { + IPv4: gwInRangeConf, + IPv6: validIPv6Conf, + }, + }, + DBFilePath: leasesPath, + }, + name: "gateway_within_range", + wantErrMsg: "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, + Logger: discardLog, + LocalDomainName: testLocalTLD, + Interfaces: map[string]*dhcpsvc.InterfaceConfig{ + "eth0": { + IPv4: badStartConf, + IPv6: validIPv6Conf, + }, + }, + DBFilePath: leasesPath, + }, + name: "bad_start", + wantErrMsg: "eth0: ipv4: range start 127.0.0.1 is not within 192.168.0.1/24" + "\n" + + "gateway ip 192.168.0.1 in the ip range 127.0.0.1-192.168.0.254", }} for _, tc := range testCases { diff --git a/internal/dhcpsvc/errors.go b/internal/dhcpsvc/errors.go index fdb97b76..1b840495 100644 --- a/internal/dhcpsvc/errors.go +++ b/internal/dhcpsvc/errors.go @@ -2,20 +2,12 @@ package dhcpsvc import ( "fmt" - - "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" ) // newMustErr returns an error that indicates that valName must be as must // describes. +// +// TODO(e.burkov): Use [validate] and remove this function. 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/handle.go b/internal/dhcpsvc/handle.go new file mode 100644 index 00000000..75033c91 --- /dev/null +++ b/internal/dhcpsvc/handle.go @@ -0,0 +1,61 @@ +package dhcpsvc + +import ( + "context" + + "github.com/AdguardTeam/golibs/logutil/slogutil" + "github.com/google/gopacket/layers" +) + +// responseWriter4 writes DHCPv4 response to the client. +type responseWriter4 interface { + // write writes the DHCPv4 response to the client. + write(ctx context.Context, pkt *layers.DHCPv4) (err error) +} + +// responseWriter6 writes DHCPv6 response to the client. +type responseWriter6 interface { + // write writes the DHCPv6 response to the client. + write(ctx context.Context, pkt *layers.DHCPv6) (err error) +} + +// serve handles the incoming packets and dispatches them to the appropriate +// handler based on the Ethernet type. It's used to run in a separate goroutine +// as it blocks until packets channel is closed. +func (srv *DHCPServer) serve(ctx context.Context) { + defer slogutil.RecoverAndLog(ctx, srv.logger) + + for pkt := range srv.packetSource.Packets() { + etherLayer, ok := pkt.Layer(layers.LayerTypeEthernet).(*layers.Ethernet) + if !ok { + actual := pkt.Layers() + srv.logger.DebugContext(ctx, "skipping non-ethernet packet", "layers", actual) + + continue + } + + var err error + + switch typ := etherLayer.EthernetType; typ { + case layers.EthernetTypeIPv4: + // TODO(e.burkov): Set the response writer. + var rw responseWriter4 + err = srv.serveV4(ctx, rw, pkt) + case layers.EthernetTypeIPv6: + // TODO(e.burkov): Set the response writer. + var rw responseWriter6 + err = srv.serveV6(ctx, rw, pkt) + default: + // TODO(e.burkov): It seems, there is another standard for Ethernet + // header, which uses the Length field instead of the EthernetType, + // so handle it properly. + srv.logger.DebugContext(ctx, "skipping ethernet packet", "type", typ) + + continue + } + + if err != nil { + srv.logger.ErrorContext(ctx, "serving", slogutil.KeyError, err) + } + } +} diff --git a/internal/dhcpsvc/handler4.go b/internal/dhcpsvc/handler4.go new file mode 100644 index 00000000..eafd26b6 --- /dev/null +++ b/internal/dhcpsvc/handler4.go @@ -0,0 +1,130 @@ +package dhcpsvc + +import ( + "context" + "fmt" + "net/netip" + + "github.com/AdguardTeam/golibs/errors" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" +) + +// serveV4 handles the ethernet packet of IPv4 type. rw and pkt must not be +// nil. +func (srv *DHCPServer) serveV4( + ctx context.Context, + rw responseWriter4, + pkt gopacket.Packet, +) (err error) { + defer func() { err = errors.Annotate(err, "serving dhcpv4: %w") }() + + req, ok := pkt.Layer(layers.LayerTypeDHCPv4).(*layers.DHCPv4) + if !ok { + // TODO(e.burkov): Consider adding some debug information about the + // actual received packet. + srv.logger.DebugContext(ctx, "skipping non-dhcpv4 packet") + + return nil + } + + // TODO(e.burkov): Handle duplicate Xid. + + if req.Operation != layers.DHCPOpRequest { + srv.logger.DebugContext(ctx, "skipping non-request dhcpv4 packet", "op", req.Operation) + + return nil + } + + typ, ok := msg4Type(req) + if !ok { + // The "DHCP message type" option - must be included in every DHCP + // message. + // + // See https://datatracker.ietf.org/doc/html/rfc2131#section-3. + return fmt.Errorf("dhcpv4: message type: %w", errors.ErrNoValue) + } + + return srv.handleDHCPv4(ctx, rw, typ, req) +} + +// handleDHCPv4 handles the DHCPv4 message of the given type. +func (srv *DHCPServer) handleDHCPv4( + ctx context.Context, + rw responseWriter4, + typ layers.DHCPMsgType, + req *layers.DHCPv4, +) (err error) { + // Each interface should handle the DISCOVER and REQUEST messages offer and + // allocate the available leases. The RELEASE and DECLINE messages should + // be handled by the server itself as it should remove the lease. + switch typ { + case layers.DHCPMsgTypeDiscover: + srv.handleDiscover(ctx, rw, req) + case layers.DHCPMsgTypeRequest: + srv.handleRequest(ctx, rw, req) + case layers.DHCPMsgTypeRelease: + // TODO(e.burkov): Remove the lease, either allocated or offered. + case layers.DHCPMsgTypeDecline: + // TODO(e.burkov): Remove the allocated lease. RFC tells it only + // possible if the client found the address already in use. + default: + // TODO(e.burkov): Handle DHCPINFORM. + return fmt.Errorf("dhcpv4: request type: %w: %v", errors.ErrBadEnumValue, typ) + } + + return nil +} + +// handleDiscover handles the DHCPv4 message of discover type. +func (srv *DHCPServer) handleDiscover(ctx context.Context, rw responseWriter4, req *layers.DHCPv4) { + // TODO(e.burkov): Check existing leases, either allocated or offered. + + for _, iface := range srv.interfaces4 { + go iface.handleDiscover(ctx, rw, req) + } +} + +// handleRequest handles the DHCPv4 message of request type. +func (srv *DHCPServer) handleRequest(ctx context.Context, rw responseWriter4, req *layers.DHCPv4) { + srvID, hasSrvID := serverID4(req) + reqIP, hasReqIP := requestedIPv4(req) + + switch { + case hasSrvID && !srvID.IsUnspecified(): + // If the DHCPREQUEST message contains a server identifier option, the + // message is in response to a DHCPOFFER message. Otherwise, the + // message is a request to verify or extend an existing lease. + iface, hasIface := srv.interfaces4.findInterface(srvID) + if !hasIface { + srv.logger.DebugContext(ctx, "skipping selecting request", "serverid", srvID) + + return + } + + iface.handleSelecting(ctx, rw, req, reqIP) + case hasReqIP && !reqIP.IsUnspecified(): + // Requested IP address option MUST be filled in with client's notion of + // its previously assigned address. + iface, hasIface := srv.interfaces4.findInterface(reqIP) + if !hasIface { + srv.logger.DebugContext(ctx, "skipping init-reboot request", "requestedip", reqIP) + + return + } + + iface.handleInitReboot(ctx, rw, req, reqIP) + default: + // Server identifier MUST NOT be filled in, requested IP address option + // MUST NOT be filled in. + ip, _ := netip.AddrFromSlice(req.ClientIP.To4()) + iface, hasIface := srv.interfaces4.findInterface(ip) + if !hasIface { + srv.logger.DebugContext(ctx, "skipping init-reboot request", "clientip", ip) + + return + } + + iface.handleRenew(ctx, rw, req) + } +} diff --git a/internal/dhcpsvc/handler6.go b/internal/dhcpsvc/handler6.go new file mode 100644 index 00000000..7c0946f6 --- /dev/null +++ b/internal/dhcpsvc/handler6.go @@ -0,0 +1,58 @@ +package dhcpsvc + +import ( + "context" + "fmt" + + "github.com/AdguardTeam/golibs/errors" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" +) + +// serveV6 handles the ethernet packet of IPv6 type. rw and pkt must not be +// nil. +func (srv *DHCPServer) serveV6( + ctx context.Context, + rw responseWriter6, + pkt gopacket.Packet, +) (err error) { + defer func() { err = errors.Annotate(err, "serving dhcpv6: %w") }() + + msg, ok := pkt.Layer(layers.LayerTypeDHCPv6).(*layers.DHCPv6) + if !ok { + // TODO(e.burkov): Consider adding some debug information about the + // actual received packet. + srv.logger.DebugContext(ctx, "skipping non-dhcpv6 packet") + + return nil + } + + // TODO(e.burkov): Handle duplicate TransactionID. + + return srv.handleDHCPv6(ctx, rw, msg.MsgType, msg) +} + +// handleDHCPv6 handles the DHCPv6 message of the given type. +func (srv *DHCPServer) handleDHCPv6( + _ context.Context, + _ responseWriter6, + typ layers.DHCPv6MsgType, + _ *layers.DHCPv6, +) (err error) { + switch typ { + case + layers.DHCPv6MsgTypeSolicit, + layers.DHCPv6MsgTypeRequest, + layers.DHCPv6MsgTypeConfirm, + layers.DHCPv6MsgTypeRenew, + layers.DHCPv6MsgTypeRebind, + layers.DHCPv6MsgTypeInformationRequest, + layers.DHCPv6MsgTypeRelease, + layers.DHCPv6MsgTypeDecline: + // TODO(e.burkov): Handle messages. + default: + return fmt.Errorf("dhcpv6: request type: %w: %v", errors.ErrBadEnumValue, typ) + } + + return nil +} diff --git a/internal/dhcpsvc/interface.go b/internal/dhcpsvc/interface.go index 87c3de4d..e0785c2a 100644 --- a/internal/dhcpsvc/interface.go +++ b/internal/dhcpsvc/interface.go @@ -45,17 +45,6 @@ type netInterface struct { leaseTTL time.Duration } -// newNetInterface creates a new netInterface with the given name, leaseTTL, and -// logger. -func newNetInterface(name string, l *slog.Logger, leaseTTL time.Duration) (iface *netInterface) { - return &netInterface{ - logger: l, - leases: map[macKey]*Lease{}, - name: name, - leaseTTL: leaseTTL, - } -} - // reset clears all the slices in iface for reuse. func (iface *netInterface) reset() { clear(iface.leases) diff --git a/internal/dhcpsvc/server.go b/internal/dhcpsvc/server.go index 194935c7..fcd492b1 100644 --- a/internal/dhcpsvc/server.go +++ b/internal/dhcpsvc/server.go @@ -13,9 +13,13 @@ import ( "time" "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/netutil" + "github.com/google/gopacket" ) // DHCPServer is a DHCP server for both IPv4 and IPv6 address families. +// +// TODO(e.burkov): Rename to Default. type DHCPServer struct { // enabled indicates whether the DHCP server is enabled and can provide // information about its clients. @@ -24,6 +28,11 @@ type DHCPServer struct { // logger logs common DHCP events. logger *slog.Logger + // packetSource is the source of DHCP packets to process. + // + // TODO(e.burkov): Implement and set. + packetSource gopacket.PacketSource + // localTLD is the top-level domain name to use for resolving DHCP clients' // hostnames. localTLD string @@ -51,8 +60,8 @@ type DHCPServer struct { 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. +// New creates a new DHCP server with the given configuration. conf must be +// valid. // // TODO(e.burkov): Use. func New(ctx context.Context, conf *Config) (srv *DHCPServer, err error) { @@ -64,11 +73,7 @@ func New(ctx context.Context, conf *Config) (srv *DHCPServer, err error) { return nil, nil } - ifaces4, ifaces6, err := newInterfaces(ctx, l, conf.Interfaces) - if err != nil { - // Don't wrap the error since it's informative enough as is. - return nil, err - } + ifaces4, ifaces6 := newInterfaces(ctx, l, conf.Interfaces) enabled := &atomic.Bool{} enabled.Store(conf.Enabled) @@ -95,40 +100,42 @@ func New(ctx context.Context, conf *Config) (srv *DHCPServer, err error) { } // newInterfaces creates interfaces for the given map of interface names to -// their configurations. +// their configurations. ifaces must be valid, baseLogger must not be nil. func newInterfaces( ctx context.Context, - l *slog.Logger, + baseLogger *slog.Logger, ifaces map[string]*InterfaceConfig, -) (v4 dhcpInterfacesV4, v6 dhcpInterfacesV6, err error) { - defer func() { err = errors.Annotate(err, "creating interfaces: %w") }() - +) (v4 dhcpInterfacesV4, v6 dhcpInterfacesV6) { // TODO(e.burkov): Add validations scoped to the network interfaces set. v4 = make(dhcpInterfacesV4, 0, len(ifaces)) v6 = make(dhcpInterfacesV6, 0, len(ifaces)) - var errs []error for _, name := range slices.Sorted(maps.Keys(ifaces)) { iface := ifaces[name] - var i4 *dhcpInterfaceV4 - i4, err = newDHCPInterfaceV4(ctx, l, name, iface.IPv4) - if err != nil { - errs = append(errs, fmt.Errorf("interface %q: ipv4: %w", name, err)) - } else if i4 != nil { - v4 = append(v4, i4) + ifaceLogger := baseLogger.With(keyInterface, name) + + iface4 := newDHCPInterfaceV4( + ctx, + ifaceLogger.With(keyFamily, netutil.AddrFamilyIPv4), + name, + iface.IPv4, + ) + if iface4 != nil { + v4 = append(v4, iface4) } - i6 := newDHCPInterfaceV6(ctx, l, name, iface.IPv6) - if i6 != nil { - v6 = append(v6, i6) + iface6 := newDHCPInterfaceV6( + ctx, + ifaceLogger.With(keyFamily, netutil.AddrFamilyIPv6), + name, + iface.IPv6, + ) + if iface6 != nil { + v6 = append(v6, iface6) } } - if err = errors.Join(errs...); err != nil { - return nil, nil, err - } - - return v4, v6, nil + return v4, v6 } // type check @@ -136,6 +143,25 @@ func newInterfaces( // TODO(e.burkov): Uncomment when the [Interface] interface is implemented. // var _ Interface = (*DHCPServer)(nil) +// Start implements the [Interface] interface for *DHCPServer. +func (srv *DHCPServer) Start(ctx context.Context) (err error) { + srv.logger.DebugContext(ctx, "starting dhcp server") + + // TODO(e.burkov): Listen to configured interfaces. + + go srv.serve(context.WithoutCancel(ctx)) + + return nil +} + +func (srv *DHCPServer) Shutdown(ctx context.Context) (err error) { + srv.logger.DebugContext(ctx, "shutting down dhcp server") + + // TODO(e.burkov): Close the packet source. + + return nil +} + // Enabled implements the [Interface] interface for *DHCPServer. func (srv *DHCPServer) Enabled() (ok bool) { return srv.enabled.Load() @@ -146,11 +172,9 @@ func (srv *DHCPServer) Leases() (leases []*Lease) { srv.leasesMu.RLock() defer srv.leasesMu.RUnlock() - srv.leases.rangeLeases(func(l *Lease) (cont bool) { - leases = append(leases, l.Clone()) - - return true - }) + for l := range srv.leases.rangeLeases { + leases = append(leases, l) + } return leases } @@ -253,7 +277,7 @@ func (srv *DHCPServer) AddLease(ctx context.Context, l *Lease) (err error) { "hostname", l.Hostname, "ip", l.IP, "mac", l.HWAddr, - "static", l.IsStatic, + "is_static", l.IsStatic, ) return nil @@ -292,7 +316,7 @@ func (srv *DHCPServer) UpdateStaticLease(ctx context.Context, l *Lease) (err err "hostname", l.Hostname, "ip", l.IP, "mac", l.HWAddr, - "static", l.IsStatic, + "is_static", l.IsStatic, ) return nil @@ -329,7 +353,51 @@ func (srv *DHCPServer) RemoveLease(ctx context.Context, l *Lease) (err error) { "hostname", l.Hostname, "ip", l.IP, "mac", l.HWAddr, - "static", l.IsStatic, + "is_static", l.IsStatic, + ) + + return nil +} + +// removeLeaseByAddr removes the lease with the given IP address from the +// server. It returns an error if the lease can't be removed. +// +//lint:ignore U1000 TODO(e.burkov): Use +func (srv *DHCPServer) removeLeaseByAddr(ctx context.Context, addr netip.Addr) (err error) { + defer func() { err = errors.Annotate(err, "removing lease by address: %w") }() + + iface, err := srv.ifaceForAddr(addr) + if err != nil { + // Don't wrap the error since it's already informative enough as is. + return err + } + + srv.leasesMu.Lock() + defer srv.leasesMu.Unlock() + + l, ok := srv.leases.leaseByAddr(addr) + if !ok { + return fmt.Errorf("no lease for ip %s", addr) + } + + err = srv.leases.remove(l, iface) + if err != nil { + // Don't wrap the error since there is already an annotation deferred. + return err + } + + err = srv.dbStore(ctx) + if err != nil { + // Don't wrap the error since it's already informative enough as is. + return err + } + + iface.logger.DebugContext( + ctx, "removed lease", + "hostname", l.Hostname, + "ip", l.IP, + "mac", l.HWAddr, + "is_static", l.IsStatic, ) return nil diff --git a/internal/dhcpsvc/server_test.go b/internal/dhcpsvc/server_test.go index 88123e17..598f2d00 100644 --- a/internal/dhcpsvc/server_test.go +++ b/internal/dhcpsvc/server_test.go @@ -40,120 +40,6 @@ func newTempDB(tb testing.TB) (dst string) { return dst } -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, - } - - leasesPath := filepath.Join(t.TempDir(), "leases.json") - - testCases := []struct { - conf *dhcpsvc.Config - name string - wantErrMsg string - }{{ - conf: &dhcpsvc.Config{ - Enabled: true, - Logger: discardLog, - LocalDomainName: testLocalTLD, - Interfaces: map[string]*dhcpsvc.InterfaceConfig{ - "eth0": { - IPv4: validIPv4Conf, - IPv6: validIPv6Conf, - }, - }, - DBFilePath: leasesPath, - }, - name: "valid", - wantErrMsg: "", - }, { - conf: &dhcpsvc.Config{ - Enabled: true, - Logger: discardLog, - LocalDomainName: testLocalTLD, - Interfaces: map[string]*dhcpsvc.InterfaceConfig{ - "eth0": { - IPv4: &dhcpsvc.IPv4Config{Enabled: false}, - IPv6: &dhcpsvc.IPv6Config{Enabled: false}, - }, - }, - DBFilePath: leasesPath, - }, - name: "disabled_interfaces", - wantErrMsg: "", - }, { - conf: &dhcpsvc.Config{ - Enabled: true, - Logger: discardLog, - LocalDomainName: testLocalTLD, - Interfaces: map[string]*dhcpsvc.InterfaceConfig{ - "eth0": { - IPv4: gwInRangeConf, - IPv6: validIPv6Conf, - }, - }, - DBFilePath: leasesPath, - }, - name: "gateway_within_range", - wantErrMsg: `creating interfaces: 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, - Logger: discardLog, - LocalDomainName: testLocalTLD, - Interfaces: map[string]*dhcpsvc.InterfaceConfig{ - "eth0": { - IPv4: badStartConf, - IPv6: validIPv6Conf, - }, - }, - DBFilePath: leasesPath, - }, - name: "bad_start", - wantErrMsg: `creating interfaces: interface "eth0": ipv4: ` + - `range start 127.0.0.1 is not within 192.168.0.1/24`, - }} - - ctx := testutil.ContextWithTimeout(t, testTimeout) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - _, err := dhcpsvc.New(ctx, tc.conf) - testutil.AssertErrorMsg(t, tc.wantErrMsg, err) - }) - } -} - func TestDHCPServer_AddLease(t *testing.T) { ctx := testutil.ContextWithTimeout(t, testTimeout) diff --git a/internal/dhcpsvc/v4.go b/internal/dhcpsvc/v4.go index b5194a9f..f43e112a 100644 --- a/internal/dhcpsvc/v4.go +++ b/internal/dhcpsvc/v4.go @@ -11,14 +11,16 @@ import ( "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/netutil" + "github.com/AdguardTeam/golibs/validate" "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. + // the default gateway for DHCP clients and also used for calculating the + // network-specific broadcast address. It should be a valid IPv4 address, + // should be within the subnet, and should be outside the address range. GatewayIP netip.Addr // SubnetMask is the IPv4 subnet mask of the network. It should be a valid @@ -26,17 +28,22 @@ type IPv4Config struct { SubnetMask netip.Addr // RangeStart is the first address in the range to assign to DHCP clients. + // It should be a valid IPv4 address, should be within the subnet, and + // should be less or equal to RangeEnd. RangeStart netip.Addr - // RangeEnd is the last address in the range to assign to DHCP clients. + // RangeEnd is the last address in the range to assign to DHCP clients. It + // should be a valid IPv4 address, should be within the subnet, and should + // be greater or equal to RangeStart. RangeEnd netip.Addr - // Options is the list of DHCP options to send to DHCP clients. The options - // having a zero value within the Length field are treated as deletions of - // the corresponding options, either implicit or explicit. + // Options is the list of explicitly configured DHCP options to send to + // clients. The options having a zero value within the Length field are + // treated as deletions of the corresponding options, either implicit or + // explicit. Options layers.DHCPOptions - // LeaseDuration is the TTL of a DHCP lease. + // LeaseDuration is the TTL of a DHCP lease. It should be positive. LeaseDuration time.Duration // Enabled is the state of the DHCPv4 service, whether it is enabled or not @@ -44,35 +51,23 @@ type IPv4Config struct { Enabled bool } -// validate returns an error in conf if any. -func (c *IPv4Config) validate() (err error) { +// type check +var _ validate.Interface = (*IPv4Config)(nil) + +// Validate implements the [validate.Interface] interface for *IPv4Config. +// +// TODO(e.burkov): Use [validate]. +func (c *IPv4Config) Validate() (err error) { if c == nil { - return errNilConfig + return errors.ErrNoValue } else if !c.Enabled { + // Don't validate the configuration for disabled interface. return nil } var errs []error - if !c.GatewayIP.Is4() { - err = newMustErr("gateway ip", "be a valid ipv4", c.GatewayIP) - errs = append(errs, err) - } - - if !c.SubnetMask.Is4() { - err = newMustErr("subnet mask", "be a valid ipv4 cidr mask", c.SubnetMask) - errs = append(errs, err) - } - - if !c.RangeStart.Is4() { - err = newMustErr("range start", "be a valid ipv4", c.RangeStart) - errs = append(errs, err) - } - - if !c.RangeEnd.Is4() { - err = newMustErr("range end", "be a valid ipv4", c.RangeEnd) - errs = append(errs, err) - } + errs = c.validateSubnet(errs) if c.LeaseDuration <= 0 { err = newMustErr("icmp timeout", "be positive", c.LeaseDuration) @@ -82,6 +77,50 @@ func (c *IPv4Config) validate() (err error) { return errors.Join(errs...) } +// validateSubnet validates the subnet configuration. +func (c *IPv4Config) validateSubnet(errs []error) (res []error) { + res = errs + + if !c.GatewayIP.Is4() { + err := newMustErr("gateway ip", "be a valid ipv4", c.GatewayIP) + res = append(res, err) + } + + if !c.SubnetMask.Is4() { + err := newMustErr("subnet mask", "be a valid ipv4 cidr mask", c.SubnetMask) + res = append(res, err) + } + + if !c.RangeStart.Is4() { + err := newMustErr("range start", "be a valid ipv4", c.RangeStart) + res = append(res, err) + } + + if !c.RangeEnd.Is4() { + err := newMustErr("range end", "be a valid ipv4", c.RangeEnd) + res = append(res, err) + } + + maskLen, _ := net.IPMask(c.SubnetMask.AsSlice()).Size() + subnet := netip.PrefixFrom(c.GatewayIP, maskLen) + + switch { + case !subnet.Contains(c.RangeStart): + res = append(res, fmt.Errorf("range start %s is not within %s", c.RangeStart, subnet)) + case !subnet.Contains(c.RangeEnd): + res = append(res, fmt.Errorf("range end %s is not within %s", c.RangeEnd, subnet)) + } + + addrSpace, err := newIPRange(c.RangeStart, c.RangeEnd) + if err != nil { + res = append(res, err) + } else if addrSpace.contains(c.GatewayIP) { + res = append(res, fmt.Errorf("gateway ip %s in the ip range %s", c.GatewayIP, addrSpace)) + } + + return res +} + // dhcpInterfaceV4 is a DHCP interface for IPv4 address family. type dhcpInterfaceV4 struct { // common is the common part of any network interface within the DHCP @@ -91,7 +130,7 @@ type dhcpInterfaceV4 struct { // gateway is the IP address of the network gateway. gateway netip.Addr - // subnet is the network subnet. + // subnet is the network subnet of the interface. subnet netip.Prefix // addrSpace is the IPv4 address space allocated for leasing. @@ -108,67 +147,39 @@ type dhcpInterfaceV4 struct { } // newDHCPInterfaceV4 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. +// the given configuration. If the interface is disabled, it returns nil. conf +// must be valid. func newDHCPInterfaceV4( ctx context.Context, l *slog.Logger, name string, conf *IPv4Config, -) (i *dhcpInterfaceV4, err error) { - l = l.With( - keyInterface, name, - keyFamily, netutil.AddrFamilyIPv4, - ) - +) (iface *dhcpInterfaceV4) { if !conf.Enabled { l.DebugContext(ctx, "disabled") - return nil, nil + return nil } + // TODO(e.burkov): Add a helper for converting [netip.Addr] to subnet mask + // to [netutil]. maskLen, _ := net.IPMask(conf.SubnetMask.AsSlice()).Size() - subnet := netip.PrefixFrom(conf.GatewayIP, maskLen) + addrSpace, _ := newIPRange(conf.RangeStart, conf.RangeEnd) - 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 = &dhcpInterfaceV4{ + iface = &dhcpInterfaceV4{ gateway: conf.GatewayIP, - subnet: subnet, + subnet: netip.PrefixFrom(conf.GatewayIP, maskLen), addrSpace: addrSpace, - common: newNetInterface(name, l, conf.LeaseDuration), + common: &netInterface{ + logger: l, + leases: map[macKey]*Lease{}, + name: name, + leaseTTL: conf.LeaseDuration, + }, } - i.implicitOpts, i.explicitOpts = conf.options(ctx, l) + iface.implicitOpts, iface.explicitOpts = conf.options(ctx, l) - return i, nil -} - -// dhcpInterfacesV4 is a slice of network interfaces of IPv4 address family. -type dhcpInterfacesV4 []*dhcpInterfaceV4 - -// find returns the first network interface within ifaces containing ip. It -// returns false if there is no such interface. -func (ifaces dhcpInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok bool) { - i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV4) (contains bool) { - return iface.subnet.Contains(ip) - }) - if i < 0 { - return nil, false - } - - return ifaces[i].common, true + return iface } // options returns the implicit and explicit options for the interface. The two @@ -361,3 +372,106 @@ func (c *IPv4Config) options(ctx context.Context, l *slog.Logger) (imp, exp laye func compareV4OptionCodes(a, b layers.DHCPOption) (res int) { return int(a.Type) - int(b.Type) } + +// msg4Type returns the message type of msg, if it's present within the options. +func msg4Type(msg *layers.DHCPv4) (typ layers.DHCPMsgType, ok bool) { + for _, opt := range msg.Options { + if opt.Type == layers.DHCPOptMessageType && len(opt.Data) > 0 { + return layers.DHCPMsgType(opt.Data[0]), true + } + } + + return 0, false +} + +// requestedIPv4 returns the IPv4 address, requested by client in the DHCP +// message, if any. +// +// TODO(e.burkov): DRY with other IP-from-option helpers. +func requestedIPv4(msg *layers.DHCPv4) (ip netip.Addr, ok bool) { + for _, opt := range msg.Options { + if opt.Type == layers.DHCPOptRequestIP && len(opt.Data) == net.IPv4len { + return netip.AddrFromSlice(opt.Data) + } + } + + return netip.Addr{}, false +} + +// serverID4 returns the server ID of the DHCP message, if any. +func serverID4(msg *layers.DHCPv4) (ip netip.Addr, ok bool) { + for _, opt := range msg.Options { + if opt.Type == layers.DHCPOptServerID && len(opt.Data) == net.IPv4len { + return netip.AddrFromSlice(opt.Data) + } + } + + return netip.Addr{}, false +} + +// handleDiscover handles messages of type discover. +func (iface *dhcpInterfaceV4) handleDiscover( + ctx context.Context, + rw responseWriter4, + msg *layers.DHCPv4, +) { + // TODO(e.burkov): Implement. +} + +// handleSelecting handles messages of type request in SELECTING state. +func (iface *dhcpInterfaceV4) handleSelecting( + ctx context.Context, + rw responseWriter4, + msg *layers.DHCPv4, + reqIP netip.Addr, +) { + // TODO(e.burkov): Implement. +} + +// handleSelecting handles messages of type request in INIT-REBOOT state. +func (iface *dhcpInterfaceV4) handleInitReboot( + ctx context.Context, + rw responseWriter4, + msg *layers.DHCPv4, + reqIP netip.Addr, +) { + // TODO(e.burkov): Implement. +} + +// handleRenew handles messages of type request in RENEWING or REBINDING state. +func (iface *dhcpInterfaceV4) handleRenew( + ctx context.Context, + rw responseWriter4, + req *layers.DHCPv4, +) { + // TODO(e.burkov): Implement. +} + +// dhcpInterfacesV4 is a slice of network interfaces of IPv4 address family. +type dhcpInterfacesV4 []*dhcpInterfaceV4 + +// find returns the first network interface within ifaces containing ip. It +// returns false if there is no such interface. +func (ifaces dhcpInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok bool) { + i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV4) (contains bool) { + return iface.subnet.Contains(ip) + }) + if i < 0 { + return nil, false + } + + return ifaces[i].common, true +} + +// findInterface returns the first DHCPv4 interface within ifaces containing +// ip. It returns false if there is no such interface. +func (ifaces dhcpInterfacesV4) findInterface(ip netip.Addr) (iface *dhcpInterfaceV4, ok bool) { + i := slices.IndexFunc(ifaces, func(iface *dhcpInterfaceV4) (contains bool) { + return iface.subnet.Contains(ip) + }) + if i < 0 { + return nil, false + } + + return ifaces[i], true +} diff --git a/internal/dhcpsvc/v6.go b/internal/dhcpsvc/v6.go index dd75184e..a9d892fe 100644 --- a/internal/dhcpsvc/v6.go +++ b/internal/dhcpsvc/v6.go @@ -10,20 +10,22 @@ import ( "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/netutil" + "github.com/AdguardTeam/golibs/validate" "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. + // It should be a valid IPv6 address. RangeStart netip.Addr - // Options is the list of DHCP options to send to DHCP clients. The options - // with zero length are treated as deletions of the corresponding options, - // either implicit or explicit. + // Options is the list of explicit DHCP options to send to clients. The + // options with zero length are treated as deletions of the corresponding + // options, either implicit or explicit. Options layers.DHCPv6Options - // LeaseDuration is the TTL of a DHCP lease. + // LeaseDuration is the TTL of a DHCP lease. It should be positive. LeaseDuration time.Duration // RASlaacOnly defines whether the DHCP clients should only use SLAAC for @@ -39,10 +41,13 @@ type IPv6Config struct { Enabled bool } -// validate returns an error in conf if any. -func (c *IPv6Config) validate() (err error) { +// type check +var _ validate.Interface = (*IPv6Config)(nil) + +// Validate implements the [validate.Interface] interface for *IPv6Config. +func (c *IPv6Config) Validate() (err error) { if c == nil { - return errNilConfig + return errors.ErrNoValue } else if !c.Enabled { return nil } @@ -89,31 +94,34 @@ type dhcpInterfaceV6 struct { } // newDHCPInterfaceV6 creates a new DHCP interface for IPv6 address family with -// the given configuration. -// -// TODO(e.burkov): Validate properly. +// the given configuration. If the interface is disabled, it returns nil. conf +// must be valid. func newDHCPInterfaceV6( ctx context.Context, l *slog.Logger, name string, conf *IPv6Config, -) (i *dhcpInterfaceV6) { - l = l.With(keyInterface, name, keyFamily, netutil.AddrFamilyIPv6) +) (iface *dhcpInterfaceV6) { if !conf.Enabled { l.DebugContext(ctx, "disabled") return nil } - i = &dhcpInterfaceV6{ - rangeStart: conf.RangeStart, - common: newNetInterface(name, l, conf.LeaseDuration), + iface = &dhcpInterfaceV6{ + rangeStart: conf.RangeStart, + common: &netInterface{ + logger: l, + leases: map[macKey]*Lease{}, + name: name, + leaseTTL: conf.LeaseDuration, + }, raSLAACOnly: conf.RASLAACOnly, raAllowSLAAC: conf.RAAllowSLAAC, } - i.implicitOpts, i.explicitOpts = conf.options(ctx, l) + iface.implicitOpts, iface.explicitOpts = conf.options(ctx, l) - return i + return iface } // dhcpInterfacesV6 is a slice of network interfaces of IPv6 address family.