diff --git a/internal/dhcpsvc/config_test.go b/internal/dhcpsvc/config_test.go index 96a997da4a6..5815fd8819f 100644 --- a/internal/dhcpsvc/config_test.go +++ b/internal/dhcpsvc/config_test.go @@ -133,10 +133,20 @@ func TestIPv6Config_Validate(t *testing.T) { name: "disabled", conf: &dhcpsvc.IPv6Config{Enabled: false}, wantErrMsg: "", + }, { + name: "nil_clock", + conf: &dhcpsvc.IPv6Config{ + Enabled: true, + Clock: nil, + RangeStart: testIPv6Conf.RangeStart, + LeaseDuration: testIPv6Conf.LeaseDuration, + }, + wantErrMsg: "clock: no value", }, { name: "bad_range_start", conf: &dhcpsvc.IPv6Config{ Enabled: true, + Clock: testIPv6Conf.Clock, RangeStart: testIPv4Conf.GatewayIP, LeaseDuration: 1 * time.Hour, }, @@ -145,6 +155,7 @@ func TestIPv6Config_Validate(t *testing.T) { name: "bad_lease_duration", conf: &dhcpsvc.IPv6Config{ Enabled: true, + Clock: testIPv6Conf.Clock, RangeStart: netip.MustParseAddr(testRangeStartV6Str), LeaseDuration: 0, }, @@ -153,6 +164,7 @@ func TestIPv6Config_Validate(t *testing.T) { name: "valid", conf: &dhcpsvc.IPv6Config{ Enabled: true, + Clock: testIPv6Conf.Clock, RangeStart: netip.MustParseAddr(testRangeStartV6Str), LeaseDuration: 1 * time.Hour, }, diff --git a/internal/dhcpsvc/dhcpsvc_test.go b/internal/dhcpsvc/dhcpsvc_test.go index 915f32f5ada..f2ec21fc7c8 100644 --- a/internal/dhcpsvc/dhcpsvc_test.go +++ b/internal/dhcpsvc/dhcpsvc_test.go @@ -122,6 +122,17 @@ var ( Enabled: true, } + // testIPv6Conf is a common valid IPv6 part of the interface configuration + // for tests. + testIPv6Conf = &dhcpsvc.IPv6Config{ + Enabled: true, + Clock: testClock, + RangeStart: netip.MustParseAddr(testRangeStartV6Str), + LeaseDuration: testLeaseTTL, + RAAllowSLAAC: true, + RASLAACOnly: true, + } + // testIfaceAddr is a common valid IPv4 address of the test network // interface, compliant with [testIPv4Conf], i.e. outside of the range, // within the subnet, not equal to the gateway. @@ -132,16 +143,6 @@ var ( testIfaceHWAddr = net.HardwareAddr{0x01, 0x01, 0x01, 0x01, 0x01, 0x01} ) -// testIPv6Conf is a common valid IPv6 part of the interface configuration for -// tests. -var testIPv6Conf = &dhcpsvc.IPv6Config{ - Enabled: true, - RangeStart: netip.MustParseAddr(testRangeStartV6Str), - LeaseDuration: testLeaseTTL, - RAAllowSLAAC: true, - RASLAACOnly: true, -} - // testInterfaceConf is a common valid set of interface configurations for // tests. var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{ @@ -161,6 +162,7 @@ var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{ }, IPv6: &dhcpsvc.IPv6Config{ Enabled: true, + Clock: timeutil.SystemClock{}, RangeStart: netip.MustParseAddr(testAnotherRangeStartV6Str), LeaseDuration: 1 * time.Hour, RAAllowSLAAC: true, diff --git a/internal/dhcpsvc/handler4.go b/internal/dhcpsvc/handler4.go index 3cfabf5b48c..f3e239f4530 100644 --- a/internal/dhcpsvc/handler4.go +++ b/internal/dhcpsvc/handler4.go @@ -113,7 +113,7 @@ func (iface *dhcpInterfaceV4) handleDiscover( return } - lease, err := iface.allocateLease(ctx, mac) + lease, err := iface.common.allocateLease(ctx, mac, iface.addrChecker, iface.clock) if err != nil { l.ErrorContext(ctx, "allocating a lease", slogutil.KeyError, err) diff --git a/internal/dhcpsvc/handler6.go b/internal/dhcpsvc/handler6.go index 8e79ca23b0e..12b376b3ed2 100644 --- a/internal/dhcpsvc/handler6.go +++ b/internal/dhcpsvc/handler6.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/google/gopacket" "github.com/google/gopacket/layers" ) @@ -67,11 +68,9 @@ func (iface *dhcpInterfaceV6) handleDHCPv6( // handleSolicit handles messages of type SOLICIT. req must not be nil and must // be a valid DHCPv6 message of type SOLICIT. fd must be valid. -// -// TODO(e.burkov): Implement. This is a stub for now. func (iface *dhcpInterfaceV6) handleSolicit( ctx context.Context, - _ *frameData6, + fd *frameData6, req *layers.DHCPv6, ) (err error) { cliID, err := clientIDNoServer(req.Options) @@ -82,7 +81,40 @@ func (iface *dhcpInterfaceV6) handleSolicit( l := iface.common.logger l.DebugContext(ctx, "handling message", "type", req.MsgType, "cli_id", cliID) - return nil + iface.common.indexMu.Lock() + defer iface.common.indexMu.Unlock() + + lease, iaid := iface.allocateForSolicit(ctx, fd.ether.SrcMAC, req) + + resp := &layers.DHCPv6{ + MsgType: layers.DHCPv6MsgTypeAdverstise, + TransactionID: req.TransactionID, + } + + if lease == nil { + l.DebugContext(ctx, "no ia_na in solicit or no addresses available") + resp.Options = iface.newSolicitRespOpts(fd, req, cliID, iaid, nil, false) + + return respond6(fd, resp) + } + + _, isRapidCommit := findOption6(req.Options, layers.DHCPv6OptRapidCommit) + + if !isRapidCommit { + resp.Options = iface.newSolicitRespOpts(fd, req, cliID, iaid, lease, false) + + return respond6(fd, resp) + } + + err = iface.commit(ctx, req, lease) + if err != nil { + l.WarnContext(ctx, "committing rapid leases", slogutil.KeyError, err) + isRapidCommit = false + } + + resp.Options = iface.newSolicitRespOpts(fd, req, cliID, iaid, lease, isRapidCommit) + + return respond6(fd, resp) } // handleRequest handles messages of type REQUEST. req must not be nil and must diff --git a/internal/dhcpsvc/interface.go b/internal/dhcpsvc/interface.go index da45ccb10e7..ad594900410 100644 --- a/internal/dhcpsvc/interface.go +++ b/internal/dhcpsvc/interface.go @@ -6,10 +6,12 @@ import ( "log/slog" "net" "net/netip" + "slices" "sync" "time" "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/timeutil" ) @@ -125,13 +127,18 @@ func (iface *netInterface) removeLease(l *Lease) (err error) { } // blockLease marks l as blocked for a configured TTL, as reported by -// [Lease.IsBlocked]. indexMu must be locked, It also removes the lease from -// iface. l must not be nil. +// [Lease.IsBlocked]. It also removes the lease from iface, but leaves it in +// the index. iface.indexMu must be locked, l and clock must not be nil. func (iface *netInterface) blockLease( ctx context.Context, l *Lease, clock timeutil.Clock, ) (err error) { + err = iface.removeLease(l) + if err != nil { + return fmt.Errorf("removing lease: %w", err) + } + l.HWAddr = blockedHardwareAddr l.Hostname = "" l.Expiry = clock.Now().Add(iface.leaseTTL) @@ -145,7 +152,8 @@ func (iface *netInterface) blockLease( return nil } -// nextIP generates a new free IP. +// nextIP generates a new free IP. It returns netip.Addr{} if there are no free +// IPs in the address space. iface.indexMu must be locked. func (iface *netInterface) nextIP() (ip netip.Addr) { r := iface.addrSpace ip = r.find(func(next netip.Addr) (ok bool) { @@ -160,8 +168,8 @@ func (iface *netInterface) nextIP() (ip netip.Addr) { return ip } -// findExpiredLease returns the first found lease that has expired. indexMu -// must be locked. +// findExpiredLease returns the first found lease that has expired. +// iface.indexMu must be locked. func (iface *netInterface) findExpiredLease(now time.Time) (l *Lease) { for _, lease := range iface.leases { if !lease.IsStatic && lease.Expiry.Before(now) { @@ -171,3 +179,89 @@ func (iface *netInterface) findExpiredLease(now time.Time) (l *Lease) { return nil } + +// allocateLease allocates a new lease for the MAC address. If there are no IP +// addresses left, both lease and err are nil. mac must be a valid according to +// [netutil.ValidateMAC]. +// +// TODO(e.burkov): Pass the precalculated macKey. +func (iface *netInterface) allocateLease( + ctx context.Context, + mac net.HardwareAddr, + checker addressChecker, + clock timeutil.Clock, +) (lease *Lease, err error) { + key := macToKey(mac) + + for { + lease, err = iface.reserveLease(ctx, mac, clock) + if err != nil { + return nil, err + } + + var ok bool + ok, err = checker.IsAvailable(lease.IP) + if err != nil { + return nil, fmt.Errorf("checking address availability: %w", err) + } + + if ok { + iface.leases[key] = lease + + off, _ := iface.addrSpace.offset(lease.IP) + iface.leasedOffsets.set(off, true) + + return lease, nil + } + + iface.logger.DebugContext(ctx, "address not available", "ip", lease.IP) + + err = iface.blockLease(ctx, lease, clock) + if err != nil { + return nil, fmt.Errorf("blocking unavailable address: %w", err) + } + } +} + +// reserveLease reserves a lease for a client by its MAC-address. lease is nil +// if a new lease can't be allocated. mac must be a valid according to +// [netutil.ValidateMAC]. iface.indexMu mutex must be locked. +func (iface *netInterface) reserveLease( + ctx context.Context, + mac net.HardwareAddr, + clock timeutil.Clock, +) (lease *Lease, err error) { + // TODO(e.burkov): Limit the number of attempts. + nextIP := iface.nextIP() + if nextIP != (netip.Addr{}) { + lease = &Lease{ + HWAddr: slices.Clone(mac), + IP: nextIP, + Expiry: clock.Now().Add(iface.leaseTTL), + } + + return lease, nil + } + + lease = iface.findExpiredLease(clock.Now()) + if lease == nil { + return nil, errors.Error("no addresses available to lease") + } + + err = iface.index.remove(ctx, iface.logger, lease, iface) + if err != nil { + // TODO(e.burkov): Reconsider the severity of this error, it actually + // seems impossible to get the error about the existing lease from the + // method. + iface.logger.DebugContext(ctx, "deleting expired lease", slogutil.KeyError, err) + } + + lease.HWAddr = slices.Clone(mac) + lease.Hostname = "" + lease.IsStatic = false + lease.updateExpiry(clock, iface.leaseTTL) + + iface.leases[macToKey(mac)] = lease + + return lease, nil +} diff --git a/internal/dhcpsvc/lease.go b/internal/dhcpsvc/lease.go index 5332c80ae5c..cb2d87ec421 100644 --- a/internal/dhcpsvc/lease.go +++ b/internal/dhcpsvc/lease.go @@ -17,7 +17,10 @@ import ( // // TODO(e.burkov): Add validation method. // -// TODO(e.burkov): Migrate to add DUID and IAID fields for DHCPv6 leases. +// BUG(e.burkov): The implementation currently relies on the client's hardware +// address for client identification. This approach is not recommended by RFC +// 9915, so the database should be migrated to use the client's DUID and IAID +// for lease identification. type Lease struct { // IP is the IP address leased to the client. It must not be empty. IP netip.Addr @@ -29,7 +32,8 @@ type Lease struct { // Hostname of the client. It may be empty if the lease is blocked. Hostname string - // HWAddr is the physical hardware (MAC) address. It must not be nil. + // HWAddr is the physical hardware (MAC) address. It must be a valid + // hardware address of length 6, 8, or 20 bytes, see [netutil.ValidateMAC]. HWAddr net.HardwareAddr // IsStatic defines if the lease is static. diff --git a/internal/dhcpsvc/leaseindex.go b/internal/dhcpsvc/leaseindex.go index 9addc45fa70..d76476fc0e9 100644 --- a/internal/dhcpsvc/leaseindex.go +++ b/internal/dhcpsvc/leaseindex.go @@ -183,7 +183,7 @@ func (idx *leaseIndex) update( } // rangeLeases calls f for each lease in idx in an unspecified order until f -// returns false. +// returns false. It must not be called concurrently, f must not modify leases. func (idx *leaseIndex) rangeLeases(f func(l *Lease) (cont bool)) { for _, l := range idx.byName { if !f(l) { diff --git a/internal/dhcpsvc/options4.go b/internal/dhcpsvc/options4.go index 69e7084d9ce..3945c3133b5 100644 --- a/internal/dhcpsvc/options4.go +++ b/internal/dhcpsvc/options4.go @@ -276,7 +276,7 @@ func (iface *dhcpInterfaceV4) appendRequestedOptions( // Requirements Document, the server MUST include the default value for that // parameter. optWithCode := layers.DHCPOption{} - for _, code := range requestedOptions(req) { + for _, code := range requestedOptions4(req) { optWithCode.Type = code i, has := slices.BinarySearchFunc(iface.implicitOpts, optWithCode, compareV4OptionCodes) if has { @@ -422,11 +422,11 @@ func clientIdentifier4(msg *layers.DHCPv4) (id []byte) { return nil } -// requestedOptions returns the list of options requested in DHCPv4 message, if +// requestedOptions4 returns the list of options requested in DHCPv4 message, if // any. // -// TODO(e.burkov): Use [iter.Seq1]. -func requestedOptions(msg *layers.DHCPv4) (opts []layers.DHCPOpt) { +// TODO(e.burkov): Use [iter.Seq]. +func requestedOptions4(msg *layers.DHCPv4) (opts []layers.DHCPOpt) { for _, opt := range msg.Options { l := len(opt.Data) if opt.Type != layers.DHCPOptParamsRequest || l == 0 { diff --git a/internal/dhcpsvc/options6.go b/internal/dhcpsvc/options6.go index 8525e7ab8e6..1031292a9f9 100644 --- a/internal/dhcpsvc/options6.go +++ b/internal/dhcpsvc/options6.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "slices" "time" "github.com/AdguardTeam/golibs/validate" @@ -27,6 +28,8 @@ type iaNAOption struct { // iaid is the Identity Association IDentifier, a 4-octet value uniquely // identifying this IA within the client. + // + // TODO(e.burkov): Add new type. iaid uint32 // t1 is the time after which the client must contact the same server to @@ -110,7 +113,7 @@ func (opt iaNAOption) Encode() (iaOpt layers.DHCPv6Option) { data = binary.BigEndian.AppendUint32(data, uint32(opt.t2.Seconds())) for _, addr := range opt.nested { - data = addr.append(data) + data = addr.appendTo(data) } return layers.NewDHCPv6Option(layers.DHCPv6OptIANA, data) @@ -180,9 +183,9 @@ func (ia *iaAddrOption) UnmarshalBinary(data []byte) (err error) { return nil } -// append returns the data portion of the IA Address option encoding, -// suitable for use as a nested option inside an IA_NA. -func (ia iaAddrOption) append(orig []byte) (data []byte) { +// appendTo returns the data portion of the IA Address option encoding, suitable +// for use as a nested option inside an IA_NA. +func (ia iaAddrOption) appendTo(orig []byte) (data []byte) { data = orig data = binary.BigEndian.AppendUint16(data, uint16(layers.DHCPv6OptIAAddr)) @@ -200,7 +203,7 @@ func (ia iaAddrOption) append(orig []byte) (data []byte) { // newServerDUID creates a DUID-LL (Link-Layer Address) from the given MAC // address per RFC 9915 §11.4. The result is deterministic: the same MAC // address always produces the same DUID, satisfying the stability requirement -// of §11. +// of §11. mac must be a valid MAC address according to [netutil.ValidateMAC]. func newServerDUID(mac net.HardwareAddr) (duid *layers.DHCPv6DUID) { return &layers.DHCPv6DUID{ Type: layers.DHCPv6DUIDTypeLL, @@ -232,3 +235,100 @@ func clientDUID6(opts layers.DHCPv6Options) (duid []byte, ok bool) { func serverDUID6(opts layers.DHCPv6Options) (duid []byte, ok bool) { return findOption6(opts, layers.DHCPv6OptServerID) } + +// solMaxRT is the recommended SOL_MAX_RT value sent to clients. It caps the +// client's solicit retransmission interval. +// +// See RFC 9915 Section 21.24. +const solMaxRT = 1 * time.Hour + +// newPreferenceOption returns a DHCPv6 Preference option with the given value. +// +// See RFC 9915 Section 21.8. +func newPreferenceOption(pref byte) (opt layers.DHCPv6Option) { + return layers.NewDHCPv6Option(layers.DHCPv6OptPreference, []byte{pref}) +} + +// newSOLMaxRTOption returns a DHCPv6 SOL_MAX_RT option with the given value. +// +// See RFC 9915 Section 21.24. +func newSOLMaxRTOption(rtt time.Duration) (opt layers.DHCPv6Option) { + data := binary.BigEndian.AppendUint32(nil, uint32(rtt.Seconds())) + + return layers.NewDHCPv6Option(layers.DHCPv6OptSolMaxRt, data) +} + +// newStatusCodeOption returns a DHCPv6 Status Code option with the given +// status. +// +// See RFC 9915 Section 21.13. +func newStatusCodeOption(status layers.DHCPv6StatusCode) (opt layers.DHCPv6Option) { + data := binary.BigEndian.AppendUint16(nil, uint16(status)) + + return layers.NewDHCPv6Option(layers.DHCPv6OptStatusCode, data) +} + +// newIANAWithStatus returns a DHCPv6 IA_NA option carrying only a Status Code +// nested option, with T1 and T2 set to zero. It is used when the server can't +// assign an address to the requested IA. +// +// See RFC 9915 Sections 21.4 and 21.13. +func newIANAWithStatus(iaid uint32, status layers.DHCPv6StatusCode) (opt layers.DHCPv6Option) { + // Nested Status Code option: code (2) + length (2) + status (2) = 6 bytes. + const statusOptLen = 6 + + data := make([]byte, 0, iaNAMinLen+statusOptLen) + + data = binary.BigEndian.AppendUint32(data, iaid) + // T1 and T2 are set to zero. + data = binary.BigEndian.AppendUint32(data, 0) + data = binary.BigEndian.AppendUint32(data, 0) + + // Nested Status Code option. + data = binary.BigEndian.AppendUint16(data, uint16(layers.DHCPv6OptStatusCode)) + + // The length of the Status Code option data is 2 bytes. + data = binary.BigEndian.AppendUint16(data, 2) + data = binary.BigEndian.AppendUint16(data, uint16(status)) + + return layers.NewDHCPv6Option(layers.DHCPv6OptIANA, data) +} + +// requestedOptions6 returns the list of option codes in the Option Request +// option of msg, if any. msg must not be nil. +// +// TODO(e.burkov): Use [iter.Seq]. +func requestedOptions6(msg *layers.DHCPv6) (codes []layers.DHCPv6Opt) { + data, ok := findOption6(msg.Options, layers.DHCPv6OptOro) + if !ok { + return nil + } + + for codeData := range slices.Chunk(data, 2) { + if len(codeData) != 2 { + return codes + } + + code := binary.BigEndian.Uint16(codeData) + codes = append(codes, layers.DHCPv6Opt(code)) + } + + return codes +} + +// clientFQDN6 returns the client's fully qualified domain name from the Client +// FQDN option of msg, if any. +// +// See RFC 4704. +func clientFQDN6(msg *layers.DHCPv6) (fqdn string) { + data, ok := findOption6(msg.Options, layers.DHCPv6OptClientFQDN) + if !ok || len(data) < 1 { + return "" + } + + // The first byte of the FQDN option data is the flags field, which we + // intentionally ignore. + // + // See RFC 4704 Section 4.1. + return string(data[1:]) +} diff --git a/internal/dhcpsvc/server.go b/internal/dhcpsvc/server.go index 4f67641f524..d4709d3bac7 100644 --- a/internal/dhcpsvc/server.go +++ b/internal/dhcpsvc/server.go @@ -15,6 +15,8 @@ import ( "github.com/AdguardTeam/golibs/container" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/netutil" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" ) // DHCPServer is a DHCP server for both IPv4 and IPv6 address families. @@ -421,3 +423,33 @@ func ifaceForAddr( return iface, nil } + +// respond constructs a response packet with eth, udp, ip and resp layers, and +// writes it. All arguments must not be nil, ip must be either [*layers.IPv4] +// or [*layers.IPv6], and resp must be either [*layers.DHCPv4] or +// [*layers.DHCPv6]. Additionally, udp's checksum must use ip. +// +// TODO(e.burkov): Pool buffers. +// +// TODO(e.burkov): Consider adding context. +func respond( + nd NetworkDevice, + eth *layers.Ethernet, + udp *layers.UDP, + ip gopacket.SerializableLayer, + resp gopacket.SerializableLayer, +) (err error) { + buf := gopacket.NewSerializeBuffer() + + opts := gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + } + + err = gopacket.SerializeLayers(buf, opts, eth, ip, udp, resp) + if err != nil { + return fmt.Errorf("serializing layers: %w", err) + } + + return nd.WritePacketData(buf.Bytes()) +} diff --git a/internal/dhcpsvc/v4.go b/internal/dhcpsvc/v4.go index 327d8dfb0ba..65833d399d1 100644 --- a/internal/dhcpsvc/v4.go +++ b/internal/dhcpsvc/v4.go @@ -8,14 +8,12 @@ import ( "net" "net/netip" "slices" - "strings" "time" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/timeutil" "github.com/AdguardTeam/golibs/validate" - "github.com/google/gopacket" "github.com/google/gopacket/layers" ) @@ -350,92 +348,6 @@ func (ifaces dhcpInterfacesV4) find(ip netip.Addr) (iface4 *netInterface, ok boo return ifaces[i].common, true } -// allocateLease allocates a new lease for the MAC address. If there are no IP -// addresses left, both lease and err are nil. mac must be a valid according to -// [netutil.ValidateMAC]. -// -// TODO(e.burkov): Pass the precalculated macKey. -func (iface *dhcpInterfaceV4) allocateLease( - ctx context.Context, - mac net.HardwareAddr, -) (lease *Lease, err error) { - for { - lease, err = iface.reserveLease(ctx, mac) - if err != nil { - return nil, err - } - - var ok bool - ok, err = iface.addrChecker.IsAvailable(lease.IP) - if err != nil { - return nil, fmt.Errorf("checking address availability: %w", err) - } - - if ok { - iface.common.leases[macToKey(mac)] = lease - - off, _ := iface.common.addrSpace.offset(lease.IP) - iface.common.leasedOffsets.set(off, true) - - return lease, nil - } - - iface.common.logger.DebugContext(ctx, "address not available", "ip", lease.IP) - - err = iface.common.blockLease(ctx, lease, iface.clock) - if err != nil { - return nil, fmt.Errorf("blocking unavailable address: %w", err) - } - } -} - -// reserveLease reserves a lease for a client by its MAC-address. lease is nil -// if a new lease can't be allocated. mac must be a valid according to -// [netutil.ValidateMAC]. index mutex must be locked. -func (iface *dhcpInterfaceV4) reserveLease( - ctx context.Context, - mac net.HardwareAddr, -) (lease *Lease, err error) { - nextIP := iface.common.nextIP() - if nextIP != (netip.Addr{}) { - lease = &Lease{ - HWAddr: slices.Clone(mac), - IP: nextIP, - Expiry: iface.clock.Now().Add(iface.common.leaseTTL), - } - - return lease, nil - } - - lease = iface.common.findExpiredLease(iface.clock.Now()) - if lease == nil { - return nil, errors.Error("no addresses available to lease") - } - - // TODO(e.burkov): Move validation from index methods into server's - // methods and use index here. - delete(iface.common.leases, macToKey(lease.HWAddr)) - - idx := iface.common.index - delete(idx.byAddr, lease.IP) - delete(idx.byName, strings.ToLower(lease.Hostname)) - - err = idx.dbStore(ctx, iface.common.logger) - if err != nil { - // Don't wrap the error since it's informative enough as is. - return nil, err - } - - lease.HWAddr = slices.Clone(mac) - lease.Hostname = "" - lease.IsStatic = false - lease.updateExpiry(iface.clock, iface.common.leaseTTL) - - iface.common.leases[macToKey(mac)] = lease - - return lease, nil -} - // updateAndRespond updates the lease and sends a DHCPACK or DHCPNAK response to // the client according to the update result. idOpt is an expected to be the // value of the DHCP option Client Identifier, nil if not present. req must be @@ -467,9 +379,6 @@ const FlagsBroadcast uint16 = 1 << 15 // respond4 sends a DHCPv4 response. req and resp must not be nil, fd must be // valid. func respond4(fd *frameData4, req, resp *layers.DHCPv4) (err error) { - // TODO(e.burkov): Use pools for buffer and layers. - buf := gopacket.NewSerializeBuffer() - eth := &layers.Ethernet{ SrcMAC: fd.ether.DstMAC, DstMAC: fd.ether.SrcMAC, @@ -478,17 +387,12 @@ func respond4(fd *frameData4, req, resp *layers.DHCPv4) (err error) { ip, udp := newIPv4UDPLayers(fd, req, resp) - opts := gopacket.SerializeOptions{ - FixLengths: true, - ComputeChecksums: true, - } - - err = gopacket.SerializeLayers(buf, opts, eth, ip, udp, resp) + err = respond(fd.device, eth, udp, ip, resp) if err != nil { - return fmt.Errorf("constructing dhcp v4 response: %w", err) + return fmt.Errorf("writing dhcpv4 response: %w", err) } - return fd.device.WritePacketData(buf.Bytes()) + return nil } // newIPv4UDPLayers creates new UDP and IP layers for DHCPv4 response. req and @@ -535,7 +439,10 @@ func newIPv4UDPLayers(fd *frameData4, req, resp *layers.DHCPv4) (ip *layers.IPv4 } // It only returns an error if the network layer is not an IP layer. - _ = udp.SetNetworkLayerForChecksum(ip) + err := udp.SetNetworkLayerForChecksum(ip) + if err != nil { + panic(err) + } return ip, udp } diff --git a/internal/dhcpsvc/v6.go b/internal/dhcpsvc/v6.go index 8adac7c4810..f7d077e5a6e 100644 --- a/internal/dhcpsvc/v6.go +++ b/internal/dhcpsvc/v6.go @@ -5,12 +5,16 @@ import ( "context" "fmt" "log/slog" + "net" "net/netip" "slices" "time" + "github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/logutil/slogutil" "github.com/AdguardTeam/golibs/netutil" + "github.com/AdguardTeam/golibs/timeutil" "github.com/AdguardTeam/golibs/validate" "github.com/google/gopacket/layers" ) @@ -60,6 +64,9 @@ const v6PrefLen = netutil.IPv6BitLen - 8 // // TODO(e.burkov): Add RangeEnd and SubnetPrefix fields, and validate them. type IPv6Config struct { + // Clock is used to get the current time. It should not be nil. + Clock timeutil.Clock + // RangeStart is the first address in the range to assign to DHCP clients. // It should be a valid IPv6 address. RangeStart netip.Addr @@ -97,6 +104,7 @@ func (c *IPv6Config) Validate() (err error) { } errs := []error{ + validate.NotNilInterface("clock", c.Clock), validate.Positive("lease duration", c.LeaseDuration), } @@ -125,6 +133,13 @@ type dhcpInterfaceV6 struct { // server. common *netInterface + // clock is used to get the current time. + clock timeutil.Clock + + // addrChecker checks if an address is available for leasing in current + // network. + addrChecker addressChecker + // subnetPrefix is the network prefix of the interface's IPv6 subnet. It is // used for on-link address determination. subnetPrefix netip.Prefix @@ -191,6 +206,9 @@ func (srv *DHCPServer) newDHCPInterfaceV6( leasedOffsets: newBitSet(), leaseTTL: conf.LeaseDuration, }, + clock: conf.Clock, + // TODO(e.burkov): Use an ICMP implementation. + addrChecker: noopAddressChecker{}, subnetPrefix: netip.PrefixFrom(conf.RangeStart, v6PrefLen), t1: conf.LeaseDuration / 2, t2: conf.LeaseDuration * 4 / 5, @@ -248,6 +266,40 @@ func compareV6OptionCodes(a, b layers.DHCPv6Option) (res int) { return int(a.Code) - int(b.Code) } +// appendRequestedOptions adds the options to opts in accordance with the +// requested parameters. req must not be nil. +// +// See RFC 9915 Section 21.7. +func (iface *dhcpInterfaceV6) appendRequestedOptions( + opts layers.DHCPv6Options, + req *layers.DHCPv6, +) (res layers.DHCPv6Options) { + optWithCode := layers.DHCPv6Option{} + for _, code := range requestedOptions6(req) { + optWithCode.Code = code + i, has := slices.BinarySearchFunc(iface.implicitOpts, optWithCode, compareV6OptionCodes) + if has { + opts = append(opts, iface.implicitOpts[i]) + } + } + + for _, opt := range iface.explicitOpts { + if len(opt.Data) > 0 { + opts = append(opts, opt) + + continue + } + + // Remove options explicitly configured to be removed, in case they are + // already set. + opts = slices.DeleteFunc(opts, func(o layers.DHCPv6Option) (ok bool) { + return o.Code == opt.Code + }) + } + + return opts +} + // clientIDNoServer extracts the client identifier from opts and checks that // there is no server identifier. It returns an error if the client identifier // is not found or if the server identifier is found. @@ -307,3 +359,187 @@ func clientIDMatchingServer( return cliID, nil } + +// defaultHopLimit is the default hop limit for relaying DHCPv6 response +// packets. +// +// See RFC 9915 Section 7.6. +const defaultHopLimit = 8 + +// respond6 constructs and sends a DHCPv6 response to the client. +func respond6(fd *frameData6, resp *layers.DHCPv6) (err error) { + eth := &layers.Ethernet{ + SrcMAC: fd.ether.DstMAC, + DstMAC: fd.ether.SrcMAC, + EthernetType: layers.EthernetTypeIPv6, + } + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolUDP, + HopLimit: defaultHopLimit, + SrcIP: fd.localAddr.AsSlice(), + // If the original message was received directly by the server, the + // server unicasts the Advertise or Reply message directly to the client + // using the address in the source address field from the IP datagram in + // which the original message was received. + // + // See RFC 9915 Section 18.3.10. + DstIP: fd.ip.SrcIP, + } + + udp := &layers.UDP{ + SrcPort: ServerPortV6, + DstPort: ClientPortV6, + } + + // It only returns an error if the network layer is not an IP layer. + err = udp.SetNetworkLayerForChecksum(ip) + if err != nil { + panic(err) + } + + err = respond(fd.device, eth, udp, ip, resp) + if err != nil { + return fmt.Errorf("writing dhcpv6 response: %w", err) + } + + return nil +} + +// allocateForSolicit allocates a lease for the first IA_NA option in req and +// returns it. It returns a zero iaid if there is no IA_NA option, if the +// option is malformed. lease is nil if there is no address available for +// leasing. mac must be a valid MAC address according to [netutil.ValidateMAC], +// req must be a valid DHCPv6 message of SOLICIT type, iface.common.indexMu +// mutex must be locked. +// +// TODO(e.burkov): Support allocating several leases at a time when the +// database will migrate, see the BUG at [Lease]'s documentation. +func (iface *dhcpInterfaceV6) allocateForSolicit( + ctx context.Context, + mac net.HardwareAddr, + req *layers.DHCPv6, +) (lease *Lease, iaid uint32) { + l := iface.common.logger + + for _, reqOpt := range req.Options { + if reqOpt.Code != layers.DHCPv6OptIANA { + continue + } + + var iana iaNAOption + err := iana.UnmarshalBinary(reqOpt.Data) + if err != nil { + // TODO(e.burkov): Recheck the logic on malformed IA_NA options. + l.DebugContext(ctx, "malformed ia_na in solicit", slogutil.KeyError, err) + + continue + } + + // TODO(e.burkov): Test the case, where the lease exists and is + // expired. + // + // TODO(e.burkov): Support allocating the exact requested address if it + // is available. + lease, err = iface.common.allocateLease(ctx, mac, iface.addrChecker, iface.clock) + if err != nil { + l.DebugContext(ctx, "no address available", "iaid", iana.iaid, slogutil.KeyError, err) + + continue + } + + return lease, iana.iaid + } + + return nil, 0 +} + +// newSolicitRespOpts returns the common option list for Advertise and +// rapid-commit Reply responses to a Solicit request. cliID must not be nil. +func (iface *dhcpInterfaceV6) newSolicitRespOpts( + fd *frameData6, + req *layers.DHCPv6, + cliID *layers.DHCPv6DUID, + iaid uint32, + lease *Lease, + rapidCommit bool, +) (opts layers.DHCPv6Options) { + cliIDData := cliID.Encode() + + opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptServerID, fd.duidData)) + opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptClientID, cliIDData)) + + // For Solicit without IA_NA options, respond with safe Advertise with no + // IA_NA options and Status Code NoAddrsAvail. + if iaid == 0 { + opts = append(opts, newStatusCodeOption(layers.DHCPv6StatusCodeNoAddrsAvail)) + } else { + opts = append(opts, iface.iaNAFromLease(lease, iaid)) + } + + // The server preference value MUST default to 0 unless otherwise configured + // by the server administrator. + // + // See RFC 9915 Section 18.3.9. + opts = append(opts, newPreferenceOption(0)) + opts = append(opts, newSOLMaxRTOption(solMaxRT)) + + if rapidCommit { + opts = append(opts, layers.NewDHCPv6Option(layers.DHCPv6OptRapidCommit, nil)) + } + + return iface.appendRequestedOptions(opts, req) +} + +// iaNAFromLease returns an IA_NA option with a single IA Address sub-option +// corresponding to lease and with the given iaid. The T1 and T2 values are set +// according to iface.t1 and iface.t2. If lease is nil, it returns an IA_NA +// option with the Status Code [layers.NoAddrsAvail]. iaid must not be zero. +func (iface *dhcpInterfaceV6) iaNAFromLease(lease *Lease, iaid uint32) (iana layers.DHCPv6Option) { + if lease == nil { + return newIANAWithStatus(iaid, layers.DHCPv6StatusCodeNoAddrsAvail) + } + + return iaNAOption{ + nested: []iaAddrOption{{ + addr: lease.IP, + preferredLifetime: iface.common.leaseTTL, + validLifetime: iface.common.leaseTTL, + }}, + iaid: iaid, + t1: iface.t1, + t2: iface.t2, + }.Encode() +} + +// commit updates the lease allocated previously via a SOLICIT, or during +// handling the Rapid Commit option, assigning a hostname according to req. It +// deallocates the lease if the one fails to be committed. lease must be +// non-nil and allocated for the client corresponding to req, +// iface.common.indexMu mutex must be locked. +// +// TODO(e.burkov): Support committing several leases at a time when the +// database will migrate, see the BUG at [Lease]'s documentation. +func (iface *dhcpInterfaceV6) commit( + ctx context.Context, + req *layers.DHCPv6, + lease *Lease, +) (err error) { + if hostname := clientFQDN6(req); hostname != "" { + lease.Hostname = hostname + } else { + lease.Hostname = aghnet.GenerateHostname(lease.IP) + } + + err = iface.common.index.update(ctx, iface.common.logger, lease, iface.common) + if err != nil { + rmErr := iface.common.removeLease(lease) + err = errors.WithDeferred(err, rmErr) + + return fmt.Errorf("committing rapid lease for ip %s: %w", lease.IP, err) + + } + + return nil +}