diff --git a/README.md b/README.md index e2944c2..71108bc 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ ok github.com/soypat/lneto/x/xnet 2.926s | Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO | | IPv6 | RFC 8200 | ✅ | `ipv6` | — | Frame parsing and stack handling | | ICMPv6 | RFC 4443 | ✅ | `ipv6/icmpv6` | — | Echo+NDP frame parsing and stack handling | -| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Frame parsing and standalone handling | +| DHCPv6 DNS Configuration | RFC 3646 | 🟡 | `dhcp/dhcpv6` | — | Recursive DNS server and domain search options parsed and exposed | +| DHCPv6 NTP Configuration | RFC 5908 | ✅ | `dhcp/dhcpv6` | — | NTP server address, multicast address, and FQDN suboptions parsed and exposed | +| DHCPv6 | RFC 8415 | 🟡 | `dhcp/dhcpv6` | — | Client IA_NA/IA_PD request handling and reconfigure-renew; no relay agent or dynamic server pools | | TLS 1.3 | RFC 8446 | ❌ | — | — | Not implemented | ¹ `BenchmarkARPExchange` — full ARP request/response exchange over Ethernet: **0 B/op, 0 allocs/op** diff --git a/dhcp/dhcpv6/client.go b/dhcp/dhcpv6/client.go index c1f3069..2223ca6 100644 --- a/dhcp/dhcpv6/client.go +++ b/dhcp/dhcpv6/client.go @@ -6,14 +6,77 @@ import ( "net/netip" "github.com/soypat/lneto" + "github.com/soypat/lneto/dns" "github.com/soypat/lneto/internal" ) +// Default caps for the repeated, server-supplied options the client retains. +// A DHCPv6 server may place arbitrarily many of each in a single message +// (RFC 8415), so the client bounds them to prevent a malicious or +// misconfigured server from driving unbounded allocation (an OOM vector). +const ( + defaultMaxDNSServers = 4 + defaultMaxDomainSearch = 6 + defaultMaxNTPServers = 4 + defaultMaxNTPMulticastServers = 2 + defaultMaxNTPServerNames = 4 + defaultMaxDelegatedPrefixes = 4 +) + +// Limits bounds how many entries of each repeated, server-supplied option the +// client retains from a single exchange. The backing arrays are sized to these +// caps once in [Client.BeginRequest] and reused across resets, so parsing a +// server message performs no further allocation and a hostile server cannot +// grow client memory without bound. A zero (or negative) field selects its +// default; see the default* constants. +type Limits struct { + MaxDNSServers int // OptDNSServers addresses. + MaxDomainSearch int // OptDomainList search names. + MaxNTPServers int // OptNTPServer suboption 1 addresses. + MaxNTPMulticastServers int // OptNTPServer suboption 2 addresses. + MaxNTPServerNames int // OptNTPServer suboption 3 FQDNs. + MaxDelegatedPrefixes int // OptIAPD/OptIAPrefix delegated prefixes. +} + +// withDefaults returns a copy of l with every non-positive field replaced by +// its default cap, so the resolved limits are always usable. +func (l Limits) withDefaults() Limits { + if l.MaxDNSServers <= 0 { + l.MaxDNSServers = defaultMaxDNSServers + } + if l.MaxDomainSearch <= 0 { + l.MaxDomainSearch = defaultMaxDomainSearch + } + if l.MaxNTPServers <= 0 { + l.MaxNTPServers = defaultMaxNTPServers + } + if l.MaxNTPMulticastServers <= 0 { + l.MaxNTPMulticastServers = defaultMaxNTPMulticastServers + } + if l.MaxNTPServerNames <= 0 { + l.MaxNTPServerNames = defaultMaxNTPServerNames + } + if l.MaxDelegatedPrefixes <= 0 { + l.MaxDelegatedPrefixes = defaultMaxDelegatedPrefixes + } + return l +} + // RequestConfig holds the parameters for starting a DHCPv6 exchange. type RequestConfig struct { // ClientHardwareAddr is the client's Ethernet MAC address. // It is used to construct the client DUID-LL and IAID. ClientHardwareAddr [6]byte + // Limits bounds how many of each repeated server-supplied option the client + // stores. The zero value selects safe defaults for every field. + Limits Limits +} + +// DelegatedPrefix is an IPv6 prefix delegated by a DHCPv6 server. +type DelegatedPrefix struct { + Prefix netip.Prefix + PreferredLifetime uint32 + ValidLifetime uint32 } // Client is a stateful DHCPv6 client implementing the [lneto.StackNode] interface. @@ -39,6 +102,21 @@ type Client struct { // dns accumulates DNS recursive name server addresses (OptDNSServers). // Client owns the backing array; cleared on reset, capacity reused. dns []netip.Addr + // domainSearch accumulates DNS domain search names (OptDomainList). + // Client owns the backing array; cleared on reset, capacity reused. + domainSearch []dns.Name + // ntps accumulates NTP server addresses (OptNTPServer, suboption 1). + // Client owns the backing array; cleared on reset, capacity reused. + ntps []netip.Addr + // ntpMulticast accumulates NTP multicast addresses (OptNTPServer, suboption 2). + // Client owns the backing array; cleared on reset, capacity reused. + ntpMulticast []netip.Addr + // ntpNames accumulates NTP server FQDNs (OptNTPServer, suboption 3). + // Client owns the backing array; cleared on reset, capacity reused. + ntpNames []dns.Name + // delegatedPrefixes accumulates IA_PD prefixes (OptIAPD/OptIAPrefix). + // Client owns the backing array; cleared on reset, capacity reused. + delegatedPrefixes []DelegatedPrefix assignedAddr [16]byte assignedAddrValid bool @@ -50,9 +128,18 @@ type Client struct { t1, t2 uint32 preferredLifetime uint32 validLifetime uint32 + // IA_PD timers from the server's Advertise/Reply. + pdT1, pdT2 uint32 + reconfigureMsg MsgType + reconfigureOK bool clientMAC [6]byte + // limits is the resolved (defaults-applied) cap on each repeated option. + // It is set in BeginRequest and preserved across resets so the bounded + // backing arrays are reused without reallocation. + limits Limits + // auxbuf is a scratch buffer used during Encapsulate to avoid allocations. auxbuf [128]byte } @@ -70,23 +157,65 @@ func (c *Client) BeginRequest(xid uint32, cfg RequestConfig) error { c.clientMAC = cfg.ClientHardwareAddr c.iaid = [4]byte(cfg.ClientHardwareAddr[:4]) c.xid = xid & 0xFFFFFF + c.limits = cfg.Limits.withDefaults() c.reset() + // Size the per-option backing arrays to their caps once, here, so the + // parse path on the network-facing Demux never allocates and stored + // entries stay bounded across the connection's lifetime. + c.dns = ensureAddrCap(c.dns, c.limits.MaxDNSServers) + c.ntps = ensureAddrCap(c.ntps, c.limits.MaxNTPServers) + c.ntpMulticast = ensureAddrCap(c.ntpMulticast, c.limits.MaxNTPMulticastServers) + c.domainSearch = ensureNameCap(c.domainSearch, c.limits.MaxDomainSearch) + c.ntpNames = ensureNameCap(c.ntpNames, c.limits.MaxNTPServerNames) + c.delegatedPrefixes = ensurePrefixCap(c.delegatedPrefixes, c.limits.MaxDelegatedPrefixes) c.duid = AppendDUIDLL(c.duid[:0], cfg.ClientHardwareAddr) c.state = StateInit return nil } +// ensureAddrCap returns s truncated to length 0 with capacity at least n, +// allocating a new backing array only when the existing one is too small. +func ensureAddrCap(s []netip.Addr, n int) []netip.Addr { + if cap(s) < n { + return make([]netip.Addr, 0, n) + } + return s[:0] +} + +func ensureNameCap(s []dns.Name, n int) []dns.Name { + if cap(s) < n { + return make([]dns.Name, 0, n) + } + return s[:0] +} + +func ensurePrefixCap(s []DelegatedPrefix, n int) []DelegatedPrefix { + if cap(s) < n { + return make([]DelegatedPrefix, 0, n) + } + return s[:0] +} + +// Reset clears the DHCPv6 exchange state and invalidates stack registrations. +func (c *Client) Reset() { c.reset() } + // reset clears exchange state while preserving slice backing arrays and the // connection ID is incremented to invalidate any existing stack registrations. func (c *Client) reset() { *c = Client{ - connID: c.connID + 1, - xid: c.xid, - clientMAC: c.clientMAC, - iaid: c.iaid, - duid: c.duid, - serverDUID: c.serverDUID[:0], - dns: c.dns[:0], + connID: c.connID + 1, + xid: c.xid, + clientMAC: c.clientMAC, + iaid: c.iaid, + limits: c.limits, + duid: c.duid, + serverDUID: c.serverDUID[:0], + dns: c.dns[:0], + domainSearch: c.domainSearch[:0], + ntps: c.ntps[:0], + ntpMulticast: c.ntpMulticast[:0], + ntpNames: c.ntpNames[:0], + delegatedPrefixes: c.delegatedPrefixes[:0], } } @@ -115,6 +244,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err frm.SetTransactionID(c.xid) n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...) numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept) + numOpts += n n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, nil) numOpts += n n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...) @@ -130,6 +261,8 @@ func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, err numOpts += n n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...) numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptReconfAccept) + numOpts += n auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0) n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN]) numOpts += n @@ -185,6 +318,9 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error { if err != nil { return err } + if frm.MsgType() == MsgReconfigure { + return c.handleReconfigure(frm) + } if frm.TransactionID() != c.xid { return lneto.ErrMismatch } @@ -213,6 +349,41 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error { return nil } +func (c *Client) handleReconfigure(frm Frame) error { + if !c.state.HasIP() { + return lneto.ErrPacketDrop + } + var clientOK, serverOK bool + var reconf MsgType + err := frm.ForEachOption(func(_ int, code OptCode, data []byte) error { + switch code { + case OptClientID: + clientOK = internal.BytesEqual(data, c.duid) + case OptServerID: + serverOK = len(c.serverDUID) == 0 || internal.BytesEqual(data, c.serverDUID) + case OptReconfMsg: + if len(data) != 1 { + return lneto.ErrInvalidLengthField + } + reconf = MsgType(data[0]) + } + return nil + }) + if err != nil { + return err + } + if !clientOK || !serverOK { + return lneto.ErrMismatch + } + c.reconfigureMsg = reconf + c.reconfigureOK = true + if reconf != MsgRenew { + return lneto.ErrUnsupported + } + c.state = StateRenewing + return nil +} + // setOptions parses all DHCPv6 options in frm and stores relevant values. func (c *Client) setOptions(frm Frame) error { return frm.ForEachOption(func(_ int, code OptCode, data []byte) error { @@ -221,18 +392,121 @@ func (c *Client) setOptions(frm Frame) error { c.serverDUID = append(c.serverDUID[:0], data...) case OptIANA: c.parseIANA(data) + case OptIAPD: + c.parseIAPD(data) case OptDNSServers: if len(c.dns) > 0 || len(data)%16 != 0 { break // skip if already populated or malformed } - for i := 0; i+16 <= len(data); i += 16 { + for i := 0; i+16 <= len(data) && len(c.dns) < c.limits.MaxDNSServers; i += 16 { c.dns = append(c.dns, netip.AddrFrom16([16]byte(data[i:i+16]))) } + case OptDomainList: + c.parseDomainSearch(data) + case OptNTPServer: + c.parseNTPServer(data) } return nil }) } +func (c *Client) parseIAPD(data []byte) { + if len(c.delegatedPrefixes) > 0 { + return + } + if len(data) < 12 { + return + } + t1 := binary.BigEndian.Uint32(data[4:8]) + t2 := binary.BigEndian.Uint32(data[8:12]) + + for ptr := 12; ptr+4 <= len(data); { + subCode := OptCode(binary.BigEndian.Uint16(data[ptr:])) + subLen := int(binary.BigEndian.Uint16(data[ptr+2:])) + if ptr+4+subLen > len(data) { + break + } + if subCode == OptIAPrefix && subLen >= 25 && len(c.delegatedPrefixes) < c.limits.MaxDelegatedPrefixes { + sub := data[ptr+4 : ptr+4+subLen] + bits := int(sub[8]) + prefix := netip.PrefixFrom(netip.AddrFrom16([16]byte(sub[9:25])), bits) + if prefix.IsValid() { + c.delegatedPrefixes = append(c.delegatedPrefixes, DelegatedPrefix{ + Prefix: prefix.Masked(), + PreferredLifetime: binary.BigEndian.Uint32(sub[:4]), + ValidLifetime: binary.BigEndian.Uint32(sub[4:8]), + }) + } + } + ptr += 4 + subLen + } + if len(c.delegatedPrefixes) > 0 { + c.pdT1 = t1 + c.pdT2 = t2 + } +} + +func (c *Client) parseNTPServer(data []byte) { + if len(c.ntps) > 0 || len(c.ntpMulticast) > 0 || len(c.ntpNames) > 0 { + return + } + for ptr := 0; ptr+4 <= len(data); { + subCode := binary.BigEndian.Uint16(data[ptr:]) + subLen := int(binary.BigEndian.Uint16(data[ptr+2:])) + if ptr+4+subLen > len(data) { + break + } + subData := data[ptr+4 : ptr+4+subLen] + switch subCode { + case 1: + if subLen == 16 && len(c.ntps) < c.limits.MaxNTPServers { + c.ntps = append(c.ntps, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20]))) + } + case 2: + if subLen == 16 && len(c.ntpMulticast) < c.limits.MaxNTPMulticastServers { + c.ntpMulticast = append(c.ntpMulticast, netip.AddrFrom16([16]byte(data[ptr+4:ptr+20]))) + } + case 3: + idx := len(c.ntpNames) + if idx >= c.limits.MaxNTPServerNames { + break // suboption switch: cap reached, drop further names. + } + if idx < cap(c.ntpNames) { + c.ntpNames = c.ntpNames[:idx+1] + } else { + c.ntpNames = append(c.ntpNames, dns.Name{}) + } + next, err := c.ntpNames[idx].Decode(subData, 0) + if err != nil || next != uint16(len(subData)) { + c.ntpNames[idx].Reset() + c.ntpNames = c.ntpNames[:idx] + } + } + ptr += 4 + subLen + } +} + +func (c *Client) parseDomainSearch(data []byte) { + if len(c.domainSearch) > 0 { + return + } + for off := uint16(0); off < uint16(len(data)) && len(c.domainSearch) < c.limits.MaxDomainSearch; { + idx := len(c.domainSearch) + if idx < cap(c.domainSearch) { + c.domainSearch = c.domainSearch[:idx+1] + } else { + c.domainSearch = append(c.domainSearch, dns.Name{}) + } + next, err := c.domainSearch[idx].Decode(data, off) + if err != nil || next <= off { + c.domainSearch[idx].Reset() + c.domainSearch = c.domainSearch[:idx] + return + } + off = next + } +} + // parseIANA processes the payload of an OptIANA option, extracting the // assigned address and lease timers from any embedded OptIAAddr sub-option. func (c *Client) parseIANA(data []byte) { @@ -273,15 +547,70 @@ func (c *Client) isClosed() bool { return c.state == 0 || c.xid == 0 } // State returns the current client state. func (c *Client) State() ClientState { return c.state } +// LastReconfigure returns the last accepted Reconfigure message target. +func (c *Client) LastReconfigure() (MsgType, bool) { return c.reconfigureMsg, c.reconfigureOK } + // AssignedAddr returns the IPv6 address assigned by the server and whether it is valid. func (c *Client) AssignedAddr() ([16]byte, bool) { return c.assignedAddr, c.assignedAddrValid } +// RebindingSeconds returns the IA_NA T2 timer from the server. +func (c *Client) RebindingSeconds() uint32 { return c.t2 } + +// RenewalSeconds returns the IA_NA T1 timer from the server. +func (c *Client) RenewalSeconds() uint32 { return c.t1 } + +// PreferredLifetimeSeconds returns the preferred lifetime of the assigned address. +func (c *Client) PreferredLifetimeSeconds() uint32 { return c.preferredLifetime } + +// ValidLifetimeSeconds returns the valid lifetime of the assigned address. +func (c *Client) ValidLifetimeSeconds() uint32 { return c.validLifetime } + +// PrefixDelegationRebindingSeconds returns the IA_PD T2 timer from the server. +func (c *Client) PrefixDelegationRebindingSeconds() uint32 { return c.pdT2 } + +// PrefixDelegationRenewalSeconds returns the IA_PD T1 timer from the server. +func (c *Client) PrefixDelegationRenewalSeconds() uint32 { return c.pdT1 } + +// AppendDelegatedPrefixes appends delegated IPv6 prefixes received from the server to dst. +func (c *Client) AppendDelegatedPrefixes(dst []DelegatedPrefix) []DelegatedPrefix { + return append(dst, c.delegatedPrefixes...) +} + +// NumDelegatedPrefixes returns the number of delegated IPv6 prefixes received. +func (c *Client) NumDelegatedPrefixes() int { return len(c.delegatedPrefixes) } + // AppendDNSServers appends the DNS server addresses received from the server to dst. func (c *Client) AppendDNSServers(dst []netip.Addr) []netip.Addr { return append(dst, c.dns...) } // NumDNSServers returns the number of DNS server addresses received. func (c *Client) NumDNSServers() int { return len(c.dns) } +// AppendDomainSearch appends the DNS domain search names received from the server to dst. +func (c *Client) AppendDomainSearch(dst []dns.Name) []dns.Name { return append(dst, c.domainSearch...) } + +// NumDomainSearch returns the number of DNS domain search names received. +func (c *Client) NumDomainSearch() int { return len(c.domainSearch) } + +// AppendNTPServers appends the NTP server addresses received from the server to dst. +func (c *Client) AppendNTPServers(dst []netip.Addr) []netip.Addr { return append(dst, c.ntps...) } + +// NumNTPServers returns the number of NTP server addresses received. +func (c *Client) NumNTPServers() int { return len(c.ntps) } + +// AppendNTPMulticastServers appends NTP multicast addresses received from the server to dst. +func (c *Client) AppendNTPMulticastServers(dst []netip.Addr) []netip.Addr { + return append(dst, c.ntpMulticast...) +} + +// NumNTPMulticastServers returns the number of NTP multicast addresses received. +func (c *Client) NumNTPMulticastServers() int { return len(c.ntpMulticast) } + +// AppendNTPServerNames appends NTP server FQDNs received from the server to dst. +func (c *Client) AppendNTPServerNames(dst []dns.Name) []dns.Name { return append(dst, c.ntpNames...) } + +// NumNTPServerNames returns the number of NTP server FQDNs received. +func (c *Client) NumNTPServerNames() int { return len(c.ntpNames) } + // ConnectionID returns a pointer to the client's connection ID. // The value increments on each reset; callers should discard registrations when it changes. // Implements [lneto.StackNode]. diff --git a/dhcp/dhcpv6/client_test.go b/dhcp/dhcpv6/client_test.go index 20383b9..1802a06 100644 --- a/dhcp/dhcpv6/client_test.go +++ b/dhcp/dhcpv6/client_test.go @@ -2,7 +2,10 @@ package dhcpv6 import ( "encoding/binary" + "net/netip" "testing" + + "github.com/soypat/lneto/dns" ) // writeOpt6 encodes a single DHCPv6 option into dst using the 4-byte TLV header @@ -124,6 +127,9 @@ func TestClientSolicitRequest(t *testing.T) { if n == 0 { t.Fatal("Encapsulate (Solicit): wrote 0 bytes") } + if !frameHasOption(t, buf[:n], OptReconfAccept) { + t.Fatal("Solicit missing Reconfigure Accept option") + } if cl.State() != StateSoliciting { t.Fatalf("after Solicit: want StateSoliciting, got %v", cl.State()) } @@ -164,6 +170,291 @@ func TestClientSolicitRequest(t *testing.T) { } } +func TestClientReconfigureRenew(t *testing.T) { + const xid = 0x112233 + clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]} + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil { + t.Fatal(err) + } + var buf [1024]byte + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal(err) + } + if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr), 0); err != nil { + t.Fatal(err) + } + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal(err) + } + if err := cl.Demux(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), 0); err != nil { + t.Fatal(err) + } + if cl.State() != StateBound { + t.Fatalf("state before reconfigure = %v, want %v", cl.State(), StateBound) + } + reconf := buildReconfigureFrame(0x445566, serverDUID, cl.duid, MsgRenew) + if err := cl.Demux(reconf, 0); err != nil { + t.Fatal(err) + } + if cl.State() != StateRenewing { + t.Fatalf("state after reconfigure = %v, want %v", cl.State(), StateRenewing) + } + msg, ok := cl.LastReconfigure() + if !ok || msg != MsgRenew { + t.Fatalf("LastReconfigure = %v, %v, want %v, true", msg, ok, MsgRenew) + } + n, err := cl.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Fatal(err) + } + frm, err := NewFrame(buf[:n]) + if err != nil { + t.Fatal(err) + } + if frm.MsgType() != MsgRenew { + t.Fatalf("Encapsulate after Reconfigure type = %v, want %v", frm.MsgType(), MsgRenew) + } +} + +func buildReconfigureFrame(xid uint32, serverDUID, clientDUID []byte, msg MsgType) []byte { + buf := make([]byte, 128) + buf[0] = byte(MsgReconfigure) + buf[1] = byte(xid >> 16) + buf[2] = byte(xid >> 8) + buf[3] = byte(xid) + n := OptionsOffset + n += writeOpt6(buf[n:], OptServerID, serverDUID...) + n += writeOpt6(buf[n:], OptClientID, clientDUID...) + n += writeOpt6(buf[n:], OptReconfMsg, byte(msg)) + return buf[:n] +} + +func frameHasOption(t testing.TB, b []byte, want OptCode) bool { + t.Helper() + frm, err := NewFrame(b) + if err != nil { + t.Fatal(err) + } + found := false + if err := frm.ForEachOption(func(_ int, code OptCode, _ []byte) error { + if code == want { + found = true + } + return nil + }); err != nil { + t.Fatal(err) + } + return found +} + +func TestClientParsesNTPServerOption(t *testing.T) { + const xid = 0x102030 + clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + ntpAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x7b} + ntpMulticast := [16]byte{0xff, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0x01} + iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]} + + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil { + t.Fatal("BeginRequest:", err) + } + var buf [1024]byte + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Solicit):", err) + } + advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr) + if err := cl.Demux(advFrame, 0); err != nil { + t.Fatal("Demux (Advertise):", err) + } + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Request):", err) + } + + replyFrame := appendNTPServerOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), ntpAddr, ntpMulticast, "time.example.com") + if err := cl.Demux(replyFrame, 0); err != nil { + t.Fatal("Demux (Reply):", err) + } + var ntps []netip.Addr + ntps = cl.AppendNTPServers(ntps) + if len(ntps) != 1 || ntps[0] != netip.AddrFrom16(ntpAddr) { + t.Fatalf("AppendNTPServers = %v, want %v", ntps, netip.AddrFrom16(ntpAddr)) + } + if n := cl.NumNTPServers(); n != 1 { + t.Fatalf("NumNTPServers = %d, want 1", n) + } + var multicasts []netip.Addr + multicasts = cl.AppendNTPMulticastServers(multicasts) + if len(multicasts) != 1 || multicasts[0] != netip.AddrFrom16(ntpMulticast) { + t.Fatalf("AppendNTPMulticastServers = %v, want %v", multicasts, netip.AddrFrom16(ntpMulticast)) + } + if n := cl.NumNTPMulticastServers(); n != 1 { + t.Fatalf("NumNTPMulticastServers = %d, want 1", n) + } + var names []dns.Name + names = cl.AppendNTPServerNames(names) + if len(names) != 1 || !names[0].EqualString("time.example.com") { + t.Fatalf("AppendNTPServerNames = %v, want time.example.com", names) + } + if n := cl.NumNTPServerNames(); n != 1 { + t.Fatalf("NumNTPServerNames = %d, want 1", n) + } +} + +func TestClientParsesDomainSearchOption(t *testing.T) { + const xid = 0x203040 + clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]} + + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil { + t.Fatal("BeginRequest:", err) + } + var buf [1024]byte + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Solicit):", err) + } + advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr) + if err := cl.Demux(advFrame, 0); err != nil { + t.Fatal("Demux (Advertise):", err) + } + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Request):", err) + } + + replyFrame := appendDomainSearchOption(t, buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), "example.com", "corp.example.com") + if err := cl.Demux(replyFrame, 0); err != nil { + t.Fatal("Demux (Reply):", err) + } + var domains []dns.Name + domains = cl.AppendDomainSearch(domains) + if len(domains) != 2 { + t.Fatalf("AppendDomainSearch len = %d, want 2", len(domains)) + } + if !domains[0].EqualString("example.com") || !domains[1].EqualString("corp.example.com") { + t.Fatalf("AppendDomainSearch = %q, %q", domains[0].String(), domains[1].String()) + } + if n := cl.NumDomainSearch(); n != 2 { + t.Fatalf("NumDomainSearch = %d, want 2", n) + } +} + +func TestClientParsesDelegatedPrefix(t *testing.T) { + const xid = 0x304050 + clientMAC := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + assignedAddr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + delegated := netip.MustParsePrefix("2001:db8:abcd::/48") + iaid := [4]byte{clientMAC[0], clientMAC[1], clientMAC[2], clientMAC[3]} + + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: clientMAC}); err != nil { + t.Fatal("BeginRequest:", err) + } + var buf [1024]byte + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Solicit):", err) + } + advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr) + if err := cl.Demux(advFrame, 0); err != nil { + t.Fatal("Demux (Advertise):", err) + } + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Request):", err) + } + + replyFrame := appendIAPDOption(buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr), iaid, delegated) + if err := cl.Demux(replyFrame, 0); err != nil { + t.Fatal("Demux (Reply):", err) + } + var prefixes []DelegatedPrefix + prefixes = cl.AppendDelegatedPrefixes(prefixes) + if len(prefixes) != 1 { + t.Fatalf("AppendDelegatedPrefixes len = %d, want 1", len(prefixes)) + } + if prefixes[0].Prefix != delegated || prefixes[0].PreferredLifetime != 1800 || prefixes[0].ValidLifetime != 3600 { + t.Fatalf("delegated prefix = %+v, want %s preferred=1800 valid=3600", prefixes[0], delegated) + } + if n := cl.NumDelegatedPrefixes(); n != 1 { + t.Fatalf("NumDelegatedPrefixes = %d, want 1", n) + } + if cl.PrefixDelegationRenewalSeconds() != 900 || cl.PrefixDelegationRebindingSeconds() != 1800 { + t.Fatalf("IA_PD timers = renewal:%d rebind:%d, want 900/1800", cl.PrefixDelegationRenewalSeconds(), cl.PrefixDelegationRebindingSeconds()) + } +} + +func appendNTPServerOption(t testing.TB, frame []byte, addr, multicast [16]byte, fqdn string) []byte { + t.Helper() + var ntpPayload []byte + ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 1, addr) + ntpPayload = appendNTPServerAddrSuboption(ntpPayload, 2, multicast) + name, err := dns.NewName(fqdn) + if err != nil { + t.Fatal(err) + } + nameStart := len(ntpPayload) + 4 + ntpPayload = append(ntpPayload, 0, 3, 0, 0) + ntpPayload, err = name.AppendTo(ntpPayload) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint16(ntpPayload[nameStart-2:nameStart], uint16(len(ntpPayload)-nameStart)) + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(ntpPayload))...) + writeOpt6(buf[len(frame):], OptNTPServer, ntpPayload...) + return buf +} + +func appendNTPServerAddrSuboption(dst []byte, code uint16, addr [16]byte) []byte { + start := len(dst) + dst = append(dst, 0, 0, 0, 16) + binary.BigEndian.PutUint16(dst[start:start+2], code) + return append(dst, addr[:]...) +} + +func appendDomainSearchOption(t testing.TB, frame []byte, domains ...string) []byte { + t.Helper() + var payload []byte + for _, domain := range domains { + name, err := dns.NewName(domain) + if err != nil { + t.Fatal(err) + } + payload, err = name.AppendTo(payload) + if err != nil { + t.Fatal(err) + } + } + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...) + writeOpt6(buf[len(frame):], OptDomainList, payload...) + return buf +} + +func appendIAPDOption(frame []byte, iaid [4]byte, prefix netip.Prefix) []byte { + var iaPrefix [29]byte + binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix)) + binary.BigEndian.PutUint16(iaPrefix[2:4], 25) + binary.BigEndian.PutUint32(iaPrefix[4:8], 1800) + binary.BigEndian.PutUint32(iaPrefix[8:12], 3600) + iaPrefix[12] = byte(prefix.Bits()) + prefixAddr := prefix.Addr().As16() + copy(iaPrefix[13:], prefixAddr[:]) + var iapd [41]byte + copy(iapd[:4], iaid[:]) + binary.BigEndian.PutUint32(iapd[4:8], 900) + binary.BigEndian.PutUint32(iapd[8:12], 1800) + copy(iapd[12:], iaPrefix[:]) + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(iapd))...) + writeOpt6(buf[len(frame):], OptIAPD, iapd[:]...) + return buf +} + // TestClientEncapsulateSolicit verifies that the first Encapsulate call // writes a Solicit message with at least OptClientID and OptIANA. func TestClientEncapsulateSolicit(t *testing.T) { @@ -242,3 +533,188 @@ func TestClientDoubleTapEncapsulate(t *testing.T) { t.Errorf("second Encapsulate: want 0 bytes (idempotent), got %d", n2) } } + +// driveToRequesting advances a fresh client through Solicit→Advertise→Request so +// the next Demux of a Reply runs the option-parsing path. +func driveToRequesting(t testing.TB, cl *Client, xid uint32, serverDUID []byte, iaid [4]byte, addr [16]byte) { + t.Helper() + var buf [1024]byte + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Solicit):", err) + } + if err := cl.Demux(buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, addr), 0); err != nil { + t.Fatal("Demux (Advertise):", err) + } + if _, err := cl.Encapsulate(buf[:], -1, 0); err != nil { + t.Fatal("Encapsulate (Request):", err) + } +} + +// floodDNSServersOption appends an OptDNSServers carrying n distinct addresses. +func floodDNSServersOption(frame []byte, n int) []byte { + payload := make([]byte, 0, n*16) + for i := range n { + var a [16]byte + a[0], a[1], a[15] = 0x20, 0x01, byte(i+1) + payload = append(payload, a[:]...) + } + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...) + writeOpt6(buf[len(frame):], OptDNSServers, payload...) + return buf +} + +// floodIAPDOption appends an OptIAPD carrying n OptIAPrefix sub-options. +func floodIAPDOption(frame []byte, iaid [4]byte, n int) []byte { + payload := make([]byte, 12) + copy(payload[:4], iaid[:]) + binary.BigEndian.PutUint32(payload[4:8], 900) + binary.BigEndian.PutUint32(payload[8:12], 1800) + for i := range n { + var iaPrefix [29]byte + binary.BigEndian.PutUint16(iaPrefix[0:2], uint16(OptIAPrefix)) + binary.BigEndian.PutUint16(iaPrefix[2:4], 25) + binary.BigEndian.PutUint32(iaPrefix[4:8], 1800) + binary.BigEndian.PutUint32(iaPrefix[8:12], 3600) + iaPrefix[12] = 48 + iaPrefix[13], iaPrefix[14], iaPrefix[15] = 0x20, 0x01, byte(i+1) + payload = append(payload, iaPrefix[:]...) + } + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...) + writeOpt6(buf[len(frame):], OptIAPD, payload...) + return buf +} + +// floodNTPOption appends an OptNTPServer carrying nAddr unicast (suboption 1), +// nMulticast (suboption 2) and nNames FQDN (suboption 3) entries. +func floodNTPOption(t testing.TB, frame []byte, nAddr, nMulticast, nNames int) []byte { + t.Helper() + var payload []byte + for i := range nAddr { + var a [16]byte + a[0], a[15] = 0x20, byte(i+1) + payload = appendNTPServerAddrSuboption(payload, 1, a) + } + for i := range nMulticast { + var a [16]byte + a[0], a[1], a[15] = 0xff, 0x05, byte(i+1) + payload = appendNTPServerAddrSuboption(payload, 2, a) + } + name, err := dns.NewName("ntp.example.com") + if err != nil { + t.Fatal(err) + } + for range nNames { + start := len(payload) + 4 + payload = append(payload, 0, 3, 0, 0) + payload, err = name.AppendTo(payload) + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint16(payload[start-2:start], uint16(len(payload)-start)) + } + buf := append(frame[:len(frame):len(frame)], make([]byte, 4+len(payload))...) + writeOpt6(buf[len(frame):], OptNTPServer, payload...) + return buf +} + +func repeatStrings(s string, n int) []string { + out := make([]string, n) + for i := range out { + out[i] = s + } + return out +} + +// TestClientLimitsCapServerData verifies that the client stores no more than +// the configured number of each repeated option, so a server flooding a Reply +// cannot drive unbounded allocation (the OOM concern raised in the PR review). +func TestClientLimitsCapServerData(t *testing.T) { + const xid = 0x515151 + mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]} + addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + limits := Limits{ + MaxDNSServers: 2, MaxDomainSearch: 2, MaxNTPServers: 2, + MaxNTPMulticastServers: 1, MaxNTPServerNames: 2, MaxDelegatedPrefixes: 2, + } + + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac, Limits: limits}); err != nil { + t.Fatal("BeginRequest:", err) + } + driveToRequesting(t, &cl, xid, serverDUID, iaid, addr) + + // A Reply far exceeding every configured cap. + reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr) + reply = floodDNSServersOption(reply, 8) + reply = floodIAPDOption(reply, iaid, 8) + reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", 8)...) + reply = floodNTPOption(t, reply, 8, 8, 8) + if err := cl.Demux(reply, 0); err != nil { + t.Fatal("Demux (Reply):", err) + } + + for _, tc := range []struct { + name string + got int + want int + }{ + {"DNS servers", cl.NumDNSServers(), limits.MaxDNSServers}, + {"domain search", cl.NumDomainSearch(), limits.MaxDomainSearch}, + {"NTP servers", cl.NumNTPServers(), limits.MaxNTPServers}, + {"NTP multicast", cl.NumNTPMulticastServers(), limits.MaxNTPMulticastServers}, + {"NTP names", cl.NumNTPServerNames(), limits.MaxNTPServerNames}, + {"delegated prefixes", cl.NumDelegatedPrefixes(), limits.MaxDelegatedPrefixes}, + } { + if tc.got != tc.want { + t.Errorf("%s stored = %d, want capped at %d", tc.name, tc.got, tc.want) + } + } +} + +// TestClientParseNoAllocs verifies that once BeginRequest has sized the option +// backing arrays, parsing a server message performs no further allocation: the +// bounded slices are reused, keeping the network-facing path off the heap. +func TestClientParseNoAllocs(t *testing.T) { + const xid = 0x123456 + mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + serverDUID := []byte{0, 3, 0, 1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66} + iaid := [4]byte{mac[0], mac[1], mac[2], mac[3]} + addr := [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + + var cl Client + if err := cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac}); err != nil { + t.Fatal("BeginRequest:", err) + } + reply := buildServerFrame(MsgReply, xid, serverDUID, iaid, addr) + reply = floodDNSServersOption(reply, cl.limits.MaxDNSServers) + reply = floodIAPDOption(reply, iaid, cl.limits.MaxDelegatedPrefixes) + reply = appendDomainSearchOption(t, reply, repeatStrings("example.com", cl.limits.MaxDomainSearch)...) + reply = floodNTPOption(t, reply, cl.limits.MaxNTPServers, cl.limits.MaxNTPMulticastServers, cl.limits.MaxNTPServerNames) + frm, err := NewFrame(reply) + if err != nil { + t.Fatal("NewFrame:", err) + } + + clearStores := func() { + cl.serverDUID = cl.serverDUID[:0] + cl.dns = cl.dns[:0] + cl.domainSearch = cl.domainSearch[:0] + cl.ntps = cl.ntps[:0] + cl.ntpMulticast = cl.ntpMulticast[:0] + cl.ntpNames = cl.ntpNames[:0] + cl.delegatedPrefixes = cl.delegatedPrefixes[:0] + } + clearStores() + if err := cl.setOptions(frm); err != nil { // warm up the dns.Name backing arrays. + t.Fatal("setOptions:", err) + } + allocs := testing.AllocsPerRun(50, func() { + clearStores() + _ = cl.setOptions(frm) + }) + if allocs != 0 { + t.Errorf("setOptions must not allocate after BeginRequest sizing, got %v allocs/op", allocs) + } +}