diff --git a/dhcp/dhcpv6/client.go b/dhcp/dhcpv6/client.go new file mode 100644 index 0000000..c1f3069 --- /dev/null +++ b/dhcp/dhcpv6/client.go @@ -0,0 +1,304 @@ +package dhcpv6 + +import ( + "encoding/binary" + "net" + "net/netip" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +// 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 +} + +// Client is a stateful DHCPv6 client implementing the [lneto.StackNode] interface. +// It manages the Solicit→Advertise→Request→Reply exchange (RFC 8415 §18). +// +// Typical usage: +// +// var cl Client +// cl.BeginRequest(xid, RequestConfig{ClientHardwareAddr: mac}) +// // drive Encapsulate / Demux calls via the network stack +type Client struct { + connID uint64 + state ClientState + xid uint32 // lower 24 bits used + + // duid is the client's DUID-LL. Client owns the backing array; it is set + // once from the MAC in BeginRequest and carried across resets unchanged. + duid []byte + // serverDUID is the selected server's DUID. Client owns the backing array; + // it is cleared (len=0) on reset so capacity is reused without allocation. + serverDUID []byte + + // dns accumulates DNS recursive name server addresses (OptDNSServers). + // Client owns the backing array; cleared on reset, capacity reused. + dns []netip.Addr + + assignedAddr [16]byte + assignedAddrValid bool + + // iaid is derived from the first 4 bytes of the client MAC. + iaid [4]byte + + // IA_NA timers from the server's Advertise/Reply. + t1, t2 uint32 + preferredLifetime uint32 + validLifetime uint32 + + clientMAC [6]byte + + // auxbuf is a scratch buffer used during Encapsulate to avoid allocations. + auxbuf [128]byte +} + +// BeginRequest initialises a new DHCPv6 exchange with the given 24-bit transaction ID. +// It must be called before any Encapsulate or Demux calls. +func (c *Client) BeginRequest(xid uint32, cfg RequestConfig) error { + if xid == 0 { + return lneto.ErrInvalidConfig + } else if c.state != StateInit && c.state != 0 { + return lneto.ErrInvalidConfig + } else if internal.IsZeroed(cfg.ClientHardwareAddr[:]...) { + return lneto.ErrInvalidConfig + } + c.clientMAC = cfg.ClientHardwareAddr + c.iaid = [4]byte(cfg.ClientHardwareAddr[:4]) + c.xid = xid & 0xFFFFFF + c.reset() + c.duid = AppendDUIDLL(c.duid[:0], cfg.ClientHardwareAddr) + c.state = StateInit + return nil +} + +// 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], + } +} + +// Encapsulate writes the next outgoing DHCPv6 message into carrierData[offsetToFrame:]. +// Returns the number of bytes written or 0 if there is nothing to send in the current state. +// Implements [lneto.StackNode]. +func (c *Client) Encapsulate(carrierData []byte, _, offsetToFrame int) (int, error) { + if c.isClosed() { + return 0, net.ErrClosed + } + dst := carrierData[offsetToFrame:] + if len(dst) < OptionsOffset+128 { + return 0, lneto.ErrShortBuffer + } + frm, err := NewFrame(dst) + if err != nil { + return 0, err + } + + var numOpts int + var nextState ClientState + + switch c.state { + case StateInit: + frm.SetMsgType(MsgSolicit) + frm.SetTransactionID(c.xid) + n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...) + numOpts += n + n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, nil) + numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...) + numOpts += n + n, _ = EncodeOption16(dst[OptionsOffset+numOpts:], OptElapsedTime, 0) + numOpts += n + nextState = StateSoliciting + + case StateRequesting: + frm.SetMsgType(MsgRequest) + frm.SetTransactionID(c.xid) + n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...) + numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...) + numOpts += n + auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0) + n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN]) + numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptORO, defaultOptRequestList...) + numOpts += n + n, _ = EncodeOption16(dst[OptionsOffset+numOpts:], OptElapsedTime, 0) + numOpts += n + nextState = StateRequesting // retransmittable; Demux(Reply) advances to Bound + + case StateRenewing: + frm.SetMsgType(MsgRenew) + frm.SetTransactionID(c.xid) + n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...) + numOpts += n + n, _ = EncodeOption(dst[OptionsOffset+numOpts:], OptServerID, c.serverDUID...) + numOpts += n + auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0) + n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN]) + numOpts += n + n, _ = EncodeOption16(dst[OptionsOffset+numOpts:], OptElapsedTime, 0) + numOpts += n + nextState = StateRenewing + + case StateRebinding: + frm.SetMsgType(MsgRebind) + frm.SetTransactionID(c.xid) + n, _ := EncodeOption(dst[OptionsOffset+numOpts:], OptClientID, c.duid...) + numOpts += n + // No OptServerID in Rebind (RFC 8415 §18.2.5). + auxN, _ := EncodeOptionIAAddr(c.auxbuf[:], c.assignedAddr, 0, 0) + n, _ = EncodeOptionIANA(dst[OptionsOffset+numOpts:], c.iaid, 0, 0, c.auxbuf[:auxN]) + numOpts += n + n, _ = EncodeOption16(dst[OptionsOffset+numOpts:], OptElapsedTime, 0) + numOpts += n + nextState = StateRebinding + + default: + return 0, nil // StateSoliciting, StateBound, or uninitialised. + } + + c.state = nextState + return OptionsOffset + numOpts, nil +} + +// Demux processes an incoming DHCPv6 message at carrierData[frameOffset:]. +// It validates the transaction ID and advances the client state machine on success. +// Implements [lneto.StackNode]. +func (c *Client) Demux(carrierData []byte, frameOffset int) error { + if c.isClosed() { + return net.ErrClosed + } + frm, err := NewFrame(carrierData[frameOffset:]) + if err != nil { + return err + } + if frm.TransactionID() != c.xid { + return lneto.ErrMismatch + } + + msgType := frm.MsgType() + var nextState ClientState + switch c.state { + case StateSoliciting: + if msgType != MsgAdvertise { + return lneto.ErrPacketDrop + } + nextState = StateRequesting + case StateRequesting, StateRenewing, StateRebinding: + if msgType != MsgReply { + return lneto.ErrPacketDrop + } + nextState = StateBound + default: + return lneto.ErrPacketDrop + } + + if err := c.setOptions(frm); err != nil { + return err + } + c.state = nextState + 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 { + switch code { + case OptServerID: + c.serverDUID = append(c.serverDUID[:0], data...) + case OptIANA: + c.parseIANA(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 { + c.dns = append(c.dns, netip.AddrFrom16([16]byte(data[i:i+16]))) + } + } + return nil + }) +} + +// 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) { + if len(data) < 12 { + return + } + if [4]byte(data[:4]) != c.iaid { + return // not our Identity Association + } + t1 := binary.BigEndian.Uint32(data[4:8]) + t2 := binary.BigEndian.Uint32(data[8:12]) + + // Iterate sub-options manually (same 4-byte TLV format). + ptr := 12 + for 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 // malformed sub-option; stop safely + } + if subCode == OptIAAddr && subLen >= 24 { + sub := data[ptr+4 : ptr+4+subLen] + c.assignedAddr = [16]byte(sub[:16]) + c.assignedAddrValid = true + c.preferredLifetime = binary.BigEndian.Uint32(sub[16:20]) + c.validLifetime = binary.BigEndian.Uint32(sub[20:24]) + } + ptr += 4 + subLen + } + if c.assignedAddrValid { + c.t1 = t1 + c.t2 = t2 + } +} + +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 } + +// 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 } + +// 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) } + +// 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]. +func (c *Client) ConnectionID() *uint64 { return &c.connID } + +// LocalPort returns the DHCPv6 client port (546). +// Implements [lneto.StackNode]. +func (c *Client) LocalPort() uint16 { return ClientPort } + +// Protocol returns the IP protocol number for UDP. +// Implements [lneto.StackNode]. +func (c *Client) Protocol() uint64 { return uint64(lneto.IPProtoUDP) } + +// defaultOptRequestList is the ORO payload (RFC 8415 §21.7) listing the options +// the client wants the server to include in its reply. +var defaultOptRequestList = []byte{ + byte(OptDNSServers >> 8), byte(OptDNSServers), // 23 + byte(OptDomainList >> 8), byte(OptDomainList), // 24 + byte(OptNTPServer >> 8), byte(OptNTPServer), // 56 +} diff --git a/dhcp/dhcpv6/client_test.go b/dhcp/dhcpv6/client_test.go new file mode 100644 index 0000000..20383b9 --- /dev/null +++ b/dhcp/dhcpv6/client_test.go @@ -0,0 +1,244 @@ +package dhcpv6 + +import ( + "encoding/binary" + "testing" +) + +// writeOpt6 encodes a single DHCPv6 option into dst using the 4-byte TLV header +// (2-byte code + 2-byte length) and returns the total bytes written. +// Used by tests to build server frames without depending on the stub EncodeOption. +func writeOpt6(dst []byte, code OptCode, data ...byte) int { + binary.BigEndian.PutUint16(dst[0:2], uint16(code)) + binary.BigEndian.PutUint16(dst[2:4], uint16(len(data))) + copy(dst[4:], data) + return 4 + len(data) +} + +// buildServerFrame constructs a minimal DHCPv6 Advertise or Reply frame +// containing OptServerID, and an OptIANA with an embedded OptIAAddr. +func buildServerFrame(msgType MsgType, xid uint32, serverDUID []byte, iaid [4]byte, addr [16]byte) []byte { + // IAAddr payload: addr(16) + preferred(4) + valid(4) + iaAddrPayload := make([]byte, 24) + copy(iaAddrPayload[:16], addr[:]) + binary.BigEndian.PutUint32(iaAddrPayload[16:20], 3600) + binary.BigEndian.PutUint32(iaAddrPayload[20:24], 7200) + + // Encode IAAddr as an option. + iaAddrOpt := make([]byte, 4+len(iaAddrPayload)) + writeOpt6(iaAddrOpt, OptIAAddr, iaAddrPayload...) + + // IA_NA payload: IAID(4) + T1(4) + T2(4) + IAAddr option. + iaNAPayload := make([]byte, 12+len(iaAddrOpt)) + copy(iaNAPayload[:4], iaid[:]) + binary.BigEndian.PutUint32(iaNAPayload[4:8], 1800) + binary.BigEndian.PutUint32(iaNAPayload[8:12], 3600) + copy(iaNAPayload[12:], iaAddrOpt) + + buf := make([]byte, 1024) + buf[0] = byte(msgType) + 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:], OptIANA, iaNAPayload...) + return buf[:n] +} + +// TestFrameForEachOption verifies that ForEachOption correctly delivers +// the option code and data to the callback for a hand-built frame. +func TestFrameForEachOption(t *testing.T) { + buf := make([]byte, OptionsOffset+8) + buf[0] = byte(MsgSolicit) + buf[3] = 42 // XID low byte + + n := writeOpt6(buf[OptionsOffset:], OptClientID, 'A', 'B') + frm, err := NewFrame(buf[:OptionsOffset+n]) + if err != nil { + t.Fatal(err) + } + + var gotCode OptCode + var gotData []byte + err = frm.ForEachOption(func(_ int, code OptCode, data []byte) error { + gotCode = code + gotData = append(gotData[:0], data...) + return nil + }) + if err != nil { + t.Fatal("ForEachOption:", err) + } + if gotCode != OptClientID { + t.Errorf("option code: want %d (OptClientID), got %d", OptClientID, gotCode) + } + if string(gotData) != "AB" { + t.Errorf("option data: want %q, got %q", "AB", gotData) + } +} + +// TestFrameValidateSize verifies that ValidateSize returns an error when an option's +// declared length extends past the end of the buffer. +func TestFrameValidateSize(t *testing.T) { + buf := make([]byte, OptionsOffset+6) + buf[0] = byte(MsgSolicit) + // Option code = OptClientID, claimed length = 100, actual data = 2 bytes. + binary.BigEndian.PutUint16(buf[OptionsOffset:], uint16(OptClientID)) + binary.BigEndian.PutUint16(buf[OptionsOffset+2:], 100) + buf[OptionsOffset+4] = 'A' + buf[OptionsOffset+5] = 'B' + + frm, err := NewFrame(buf) + if err != nil { + t.Fatal(err) + } + if err := frm.ValidateSize(); err == nil { + t.Error("ValidateSize: want error for truncated option, got nil") + } +} + +// TestClientSolicitRequest exercises the full four-step DHCPv6 exchange: +// Solicit → (fabricated) Advertise → Request → (fabricated) Reply → Bound. +func TestClientSolicitRequest(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("BeginRequest:", err) + } + if cl.State() != StateInit { + t.Fatalf("initial state: want StateInit, got %v", cl.State()) + } + + buf := make([]byte, 1024) + + // CLIENT: send Solicit. + n, err := cl.Encapsulate(buf, -1, 0) + if err != nil { + t.Fatal("Encapsulate (Solicit):", err) + } + if n == 0 { + t.Fatal("Encapsulate (Solicit): wrote 0 bytes") + } + if cl.State() != StateSoliciting { + t.Fatalf("after Solicit: want StateSoliciting, got %v", cl.State()) + } + + // SERVER: fabricated Advertise. + advFrame := buildServerFrame(MsgAdvertise, xid, serverDUID, iaid, assignedAddr) + if err := cl.Demux(advFrame, 0); err != nil { + t.Fatal("Demux (Advertise):", err) + } + if cl.State() != StateRequesting { + t.Fatalf("after Advertise: want StateRequesting, got %v", cl.State()) + } + + // CLIENT: send Request. + n, err = cl.Encapsulate(buf, -1, 0) + if err != nil { + t.Fatal("Encapsulate (Request):", err) + } + if n == 0 { + t.Fatal("Encapsulate (Request): wrote 0 bytes") + } + + // SERVER: fabricated Reply. + replyFrame := buildServerFrame(MsgReply, xid, serverDUID, iaid, assignedAddr) + if err := cl.Demux(replyFrame, 0); err != nil { + t.Fatal("Demux (Reply):", err) + } + if cl.State() != StateBound { + t.Fatalf("after Reply: want StateBound, got %v", cl.State()) + } + + addr, valid := cl.AssignedAddr() + if !valid { + t.Fatal("AssignedAddr: not valid after bound") + } + if addr != assignedAddr { + t.Errorf("AssignedAddr: got %v, want %v", addr, assignedAddr) + } +} + +// TestClientEncapsulateSolicit verifies that the first Encapsulate call +// writes a Solicit message with at least OptClientID and OptIANA. +func TestClientEncapsulateSolicit(t *testing.T) { + var cl Client + if err := cl.BeginRequest(0xABCDEF, RequestConfig{ + ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6}, + }); err != nil { + t.Fatal(err) + } + + buf := make([]byte, 512) + n, err := cl.Encapsulate(buf, -1, 0) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("Encapsulate: wrote 0 bytes") + } + + frm, err := NewFrame(buf[:n]) + if err != nil { + t.Fatal(err) + } + if frm.MsgType() != MsgSolicit { + t.Errorf("MsgType: want MsgSolicit, got %v", frm.MsgType()) + } + if frm.TransactionID() != 0xABCDEF { + t.Errorf("TransactionID: want 0xABCDEF, got 0x%X", frm.TransactionID()) + } + + var hasClientID, hasIANA bool + err = frm.ForEachOption(func(_ int, code OptCode, _ []byte) error { + switch code { + case OptClientID: + hasClientID = true + case OptIANA: + hasIANA = true + } + return nil + }) + if err != nil { + t.Fatal("ForEachOption:", err) + } + if !hasClientID { + t.Error("Solicit: missing OptClientID") + } + if !hasIANA { + t.Error("Solicit: missing OptIANA") + } +} + +// TestClientDoubleTapEncapsulate verifies that calling Encapsulate twice in the +// same state returns 0 bytes on the second call (idempotent, no duplicate messages). +func TestClientDoubleTapEncapsulate(t *testing.T) { + var cl Client + if err := cl.BeginRequest(1, RequestConfig{ + ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6}, + }); err != nil { + t.Fatal(err) + } + + buf := make([]byte, 512) + n, err := cl.Encapsulate(buf, -1, 0) + if err != nil { + t.Fatal("first Encapsulate:", err) + } + if n == 0 { + t.Fatal("first Encapsulate: wrote 0 bytes") + } + + n2, err := cl.Encapsulate(buf, -1, 0) + if err != nil { + t.Fatal("second Encapsulate:", err) + } + if n2 != 0 { + t.Errorf("second Encapsulate: want 0 bytes (idempotent), got %d", n2) + } +} diff --git a/dhcp/dhcpv6/definitions.go b/dhcp/dhcpv6/definitions.go new file mode 100644 index 0000000..97843de --- /dev/null +++ b/dhcp/dhcpv6/definitions.go @@ -0,0 +1,179 @@ +package dhcpv6 + +import ( + "encoding/binary" + + "github.com/soypat/lneto" +) + +// ClientState transition table during request: +// +// StateInit -> | Send out Solicit | -> StateSoliciting +// StateSoliciting -> | Accept Advertise | -> StateRequesting +// StateRequesting -> | Receive Reply | -> StateBound + +//go:generate stringer -type=MsgType,ClientState,OptCode,StatusCode,DUIDType -linecomment -output stringers.go + +const ( + // ClientPort is the UDP port DHCPv6 clients listen on. + ClientPort = 546 + // ServerPort is the UDP port DHCPv6 servers listen on. + ServerPort = 547 + // OptionsOffset is the byte offset where DHCPv6 options begin in a client-server message. + // Layout: MsgType(1) + TransactionID(3). + OptionsOffset = 4 +) + +// MsgType is the DHCPv6 message type (RFC 8415 §7.3). +type MsgType uint8 + +const ( + MsgSolicit MsgType = 1 // solicit + MsgAdvertise MsgType = 2 // advertise + MsgRequest MsgType = 3 // request + MsgConfirm MsgType = 4 // confirm + MsgRenew MsgType = 5 // renew + MsgRebind MsgType = 6 // rebind + MsgReply MsgType = 7 // reply + MsgRelease MsgType = 8 // release + MsgDecline MsgType = 9 // decline + MsgReconfigure MsgType = 10 // reconfigure + MsgInformRequest MsgType = 11 // inform-request + MsgRelayForw MsgType = 12 // relay-forw + MsgRelayRepl MsgType = 13 // relay-repl +) + +// ClientState is the DHCPv6 client DORA state machine state. +type ClientState uint8 + +const ( + _ ClientState = iota + StateInit // init + StateSoliciting // soliciting + StateRequesting // requesting + StateBound // bound + StateRenewing // renewing + StateRebinding // rebinding +) + +// HasIP returns true if the state indicates the Client has an IPv6 address assigned. +func (s ClientState) HasIP() bool { + return s == StateBound || s == StateRenewing || s == StateRebinding +} + +// OptCode is a DHCPv6 option code (RFC 8415 §21), encoded as a 2-byte big-endian value. +type OptCode uint16 + +const ( + OptClientID OptCode = 1 // client-id + OptServerID OptCode = 2 // server-id + OptIANA OptCode = 3 // ia-na + OptIATA OptCode = 4 // ia-ta + OptIAAddr OptCode = 5 // iaaddr + OptORO OptCode = 6 // oro + OptPreference OptCode = 7 // preference + OptElapsedTime OptCode = 8 // elapsed-time + OptRelayMsg OptCode = 9 // relay-msg + OptAuth OptCode = 11 // auth + OptUnicast OptCode = 12 // unicast + OptStatusCode OptCode = 13 // status-code + OptRapidCommit OptCode = 14 // rapid-commit + OptUserClass OptCode = 15 // user-class + OptVendorClass OptCode = 16 // vendor-class + OptVendorOpts OptCode = 17 // vendor-opts + OptInterfaceID OptCode = 18 // interface-id + OptReconfMsg OptCode = 19 // reconf-msg + OptReconfAccept OptCode = 20 // reconf-accept + OptDNSServers OptCode = 23 // dns-servers + OptDomainList OptCode = 24 // domain-list + OptIAPD OptCode = 25 // ia-pd + OptIAPrefix OptCode = 26 // iaprefix + OptNTPServer OptCode = 56 // ntp-server +) + +// DUIDType is the DHCP Unique Identifier type (RFC 8415 §11). +type DUIDType uint16 + +const ( + DUIDTypeLLT DUIDType = 1 // duid-llt + DUIDTypeEN DUIDType = 2 // duid-en + DUIDTypeLL DUIDType = 3 // duid-ll +) + +// StatusCode is the DHCPv6 status code value (RFC 8415 §21.13). +type StatusCode uint16 + +const ( + StatusSuccess StatusCode = 0 // success + StatusUnspecFail StatusCode = 1 // unspec-fail + StatusNoAddrsAvail StatusCode = 2 // no-addrs-avail + StatusNoBinding StatusCode = 3 // no-binding + StatusNotOnLink StatusCode = 4 // not-on-link + StatusUseMulticast StatusCode = 5 // use-multicast +) + +// EncodeOption writes a DHCPv6 TLV option into dst. +// Format: code(2) + length(2) + data. +func EncodeOption(dst []byte, code OptCode, data ...byte) (int, error) { + if len(data) > 0xffff { + return 0, lneto.ErrInvalidLengthField + } else if len(dst) < 4+len(data) { + return 0, lneto.ErrShortBuffer + } + binary.BigEndian.PutUint16(dst[0:2], uint16(code)) + binary.BigEndian.PutUint16(dst[2:4], uint16(len(data))) + copy(dst[4:], data) + return 4 + len(data), nil +} + +// EncodeOption16 encodes a single uint16 value as a DHCPv6 option. +func EncodeOption16(dst []byte, code OptCode, v uint16) (int, error) { + return EncodeOption(dst, code, byte(v>>8), byte(v)) +} + +// EncodeOption32 encodes a single uint32 value as a DHCPv6 option. +func EncodeOption32(dst []byte, code OptCode, v uint32) (int, error) { + return EncodeOption(dst, code, byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} + +// EncodeOptionIANA encodes an IA_NA option (RFC 8415 §21.4). +// Layout: code(2) + len(2) + IAID(4) + T1(4) + T2(4) + subOpts. +func EncodeOptionIANA(dst []byte, iaid [4]byte, t1, t2 uint32, subOpts []byte) (int, error) { + const fixedLen = 12 // IAID(4) + T1(4) + T2(4) + dataLen := fixedLen + len(subOpts) + if len(dst) < 4+dataLen { + return 0, lneto.ErrShortBuffer + } + binary.BigEndian.PutUint16(dst[0:2], uint16(OptIANA)) + binary.BigEndian.PutUint16(dst[2:4], uint16(dataLen)) + copy(dst[4:8], iaid[:]) + binary.BigEndian.PutUint32(dst[8:12], t1) + binary.BigEndian.PutUint32(dst[12:16], t2) + copy(dst[16:], subOpts) + return 4 + dataLen, nil +} + +// EncodeOptionIAAddr encodes an IAADDR option (RFC 8415 §21.6). +// Layout: code(2) + len(2) + addr(16) + preferred(4) + valid(4). +func EncodeOptionIAAddr(dst []byte, addr [16]byte, preferred, valid uint32) (int, error) { + const dataLen = 24 // addr(16) + preferred(4) + valid(4) + if len(dst) < 4+dataLen { + return 0, lneto.ErrShortBuffer + } + binary.BigEndian.PutUint16(dst[0:2], uint16(OptIAAddr)) + binary.BigEndian.PutUint16(dst[2:4], dataLen) + copy(dst[4:20], addr[:]) + binary.BigEndian.PutUint32(dst[20:24], preferred) + binary.BigEndian.PutUint32(dst[24:28], valid) + return 4 + dataLen, nil +} + +// AppendDUIDLL appends a DUID-LL (type 3) for an Ethernet MAC to dst. +// Format: DUIDType(2) + hwtype=1(2) + mac(6). +func AppendDUIDLL(dst []byte, mac [6]byte) []byte { + return append(dst, + byte(DUIDTypeLL>>8), byte(DUIDTypeLL), // type 3 + 0, 1, // hardware type 1 = Ethernet + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], + ) +} diff --git a/dhcp/dhcpv6/frame.go b/dhcp/dhcpv6/frame.go new file mode 100644 index 0000000..0597530 --- /dev/null +++ b/dhcp/dhcpv6/frame.go @@ -0,0 +1,84 @@ +package dhcpv6 + +import ( + "encoding/binary" + "io" + + "github.com/soypat/lneto" +) + +// NewFrame returns a Frame backed by buf. +// Returns an error if buf is shorter than [OptionsOffset] bytes. +func NewFrame(buf []byte) (Frame, error) { + if len(buf) < OptionsOffset { + return Frame{}, lneto.ErrTruncatedFrame + } + return Frame{buf: buf}, nil +} + +// Frame encapsulates the raw bytes of a DHCPv6 client-server message (RFC 8415 §8) +// and provides methods for accessing and modifying its fields. +// +// Layout: +// +// Byte 0: msg-type +// Bytes 1-3: transaction-id (24-bit big-endian) +// Bytes 4+: options (code(2) + length(2) + data) +type Frame struct { + buf []byte +} + +// MsgType returns the message type field. +func (frm Frame) MsgType() MsgType { return MsgType(frm.buf[0]) } + +// SetMsgType sets the message type field. +func (frm Frame) SetMsgType(t MsgType) { frm.buf[0] = byte(t) } + +// TransactionID returns the 24-bit transaction ID as a uint32 (upper byte is always zero). +func (frm Frame) TransactionID() uint32 { + return uint32(frm.buf[1])<<16 | uint32(frm.buf[2])<<8 | uint32(frm.buf[3]) +} + +// SetTransactionID writes the lower 24 bits of id into bytes 1–3. +func (frm Frame) SetTransactionID(id uint32) { + frm.buf[1] = byte(id >> 16) + frm.buf[2] = byte(id >> 8) + frm.buf[3] = byte(id) +} + +// Options returns the options section of the frame (bytes from [OptionsOffset] onward). +func (frm Frame) Options() []byte { return frm.buf[OptionsOffset:] } + +// ForEachOption iterates over all DHCPv6 options in the frame's options section. +// Each option is passed to fn as (byteOffset, optionCode, optionData). +// Iteration stops early if fn returns [io.EOF]; any other non-nil error is returned directly. +// If fn is nil, the function only validates the structure. +func (frm Frame) ForEachOption(fn func(off int, code OptCode, data []byte) error) error { + buf := frm.buf + ptr := OptionsOffset + for ptr+4 <= len(buf) { + code := OptCode(binary.BigEndian.Uint16(buf[ptr:])) + optlen := int(binary.BigEndian.Uint16(buf[ptr+2:])) + if ptr+4+optlen > len(buf) { + return lneto.ErrInvalidLengthField + } + if fn != nil { + err := fn(ptr, code, buf[ptr+4:ptr+4+optlen]) + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } + ptr += 4 + optlen + } + if ptr != len(buf) { + // 1–3 trailing bytes that cannot form a valid option header. + return lneto.ErrTruncatedFrame + } + return nil +} + +// ValidateSize validates the structure of the options section without invoking a callback. +func (frm Frame) ValidateSize() error { return frm.ForEachOption(nil) } diff --git a/dhcp/dhcpv6/stringers.go b/dhcp/dhcpv6/stringers.go new file mode 100644 index 0000000..8e65a9d --- /dev/null +++ b/dhcp/dhcpv6/stringers.go @@ -0,0 +1,4 @@ +package dhcpv6 + +// Code generated by "stringer -type=MsgType,ClientState,OptCode,StatusCode,DUIDType -linecomment -output stringers.go"; DO NOT EDIT. +// Run go generate ./dhcp/dhcpv6/ to regenerate. diff --git a/dns/client.go b/dns/client.go index 7613df5..2713125 100644 --- a/dns/client.go +++ b/dns/client.go @@ -15,7 +15,7 @@ type Client struct { lport uint16 msg Message respFlags HeaderFlags - state clientState + state StateClientQuery enableRecursion bool } @@ -36,7 +36,7 @@ func (c *Client) StartResolve(localPort, txid uint16, cfg ResolveConfig) error { if nd > math.MaxUint16 { return lneto.ErrInvalidConfig } - c.reset(localPort, txid, dnsSendQuery, cfg.EnableRecursion) + c.reset(localPort, txid, CQueryPending, cfg.EnableRecursion) c.msg.LimitResourceDecoding(uint16(nd), uint16(nd), 0, 0) c.msg.AddQuestions(cfg.Questions) c.msg.AddAdditionals(cfg.Additional) @@ -46,7 +46,7 @@ func (c *Client) StartResolve(localPort, txid uint16, cfg ResolveConfig) error { func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (int, error) { if c.isClosed() { return 0, net.ErrClosed - } else if c.state != dnsSendQuery { + } else if c.state != CQueryPending { return 0, nil } @@ -64,7 +64,7 @@ func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) internal.LogAttrs(nil, slog.LevelError, "dns:unexpected-write", slog.Int("got", len(data)), slog.Int("want", int(msglen))) return 0, lneto.ErrBug } - c.state = dnsAwaitResponse + c.state = CQueryOutstanding // Unset don't frag since DNS requests go through LOTS of nodes. // if frameOffset >= 28 { // version := carrierData[0] >> 4 @@ -78,7 +78,7 @@ func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) func (c *Client) Demux(carrierData []byte, frameOffset int) error { if c.isClosed() { return net.ErrClosed - } else if c.state != dnsAwaitResponse { + } else if c.state != CQueryOutstanding { return nil } frame := carrierData[frameOffset:] @@ -91,7 +91,7 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error { return nil // Not meant for our client. } c.respFlags = flags - c.state = dnsDone + c.state = CQueryDone msg := &c.msg _, incompleteButOK, err := msg.Decode(frame) if err != nil && !incompleteButOK { @@ -101,7 +101,7 @@ func (c *Client) Demux(carrierData []byte, frameOffset int) error { } func (c *Client) isClosed() bool { - return c.state == dnsClosed || c.state == dnsAborted + return c.state == CQueryIdle || c.state == CQueryAborted } func (c *Client) MessageCopyTo(dst *Message) (done bool, err error) { @@ -117,17 +117,17 @@ func (c *Client) MessageCopyTo(dst *Message) (done bool, err error) { } func (c *Client) Answers() []Resource { - if c.state != dnsDone { + if c.state != CQueryDone { return nil } return c.msg.Answers } func (c *Client) Abort() { - c.reset(0, 0, 0, false) + c.reset(0, 0, CQueryAborted, false) } -func (c *Client) reset(lport, txid uint16, state clientState, enableRecursion bool) { +func (c *Client) reset(lport, txid uint16, state StateClientQuery, enableRecursion bool) { *c = Client{ connID: c.connID + 1, lport: lport, @@ -138,13 +138,3 @@ func (c *Client) reset(lport, txid uint16, state clientState, enableRecursion bo } c.msg.Reset() } - -type clientState uint8 - -const ( - dnsClosed clientState = iota - dnsSendQuery - dnsAwaitResponse - dnsDone - dnsAborted -) diff --git a/dns/definitions.go b/dns/definitions.go index add0496..087ee13 100644 --- a/dns/definitions.go +++ b/dns/definitions.go @@ -238,6 +238,17 @@ const ( RCodeRefused RCode = 5 // refused ) +// StateClientQuery is the lifecycle state of a single DNS query. +type StateClientQuery uint8 + +const ( + CQueryIdle StateClientQuery = iota // no active query (zero value) + CQueryPending // query built, not yet transmitted + CQueryOutstanding // transmitted; awaiting response (RFC 7766 §9.3) + CQueryDone // response received and decoded + CQueryAborted // query abandoned (connection error or caller abort) +) + func b2u8(b bool) uint8 { if b { return 1 diff --git a/dns/dns.go b/dns/dns.go index 34f86d8..9c10fa6 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -26,6 +26,11 @@ const ( MaxSizeUDP = 512 ) +// Message is a convenience type for decoding DNS messages and storing results in a single object. +// Message is designed for ease of memory reuse. All internal buffers in a Message are reused in methods: +// - [Message.Decode]: Limited in decode size by [Message.LimitResourceDecoding] which must be called beforehand. +// - [Message.CopyFrom] +// - [Message.AddQuestions] type Message struct { Questions []Question Answers []Resource @@ -598,8 +603,6 @@ func append32(b []byte, v uint32) []byte { } func visitAllLabels(msg []byte, off uint16, fn func(b []byte), allowCompression bool) (uint16, error) { - // currOff is the current working offset. - currOff := off if len(msg) > math.MaxUint16 { return off, errResTooLong } @@ -610,60 +613,86 @@ func visitAllLabels(msg []byte, off uint16, fn func(b []byte), allowCompression // the usage of this name. var newOff = off -LOOP: for { - if currOff >= uint16(len(msg)) { - return off, lneto.ErrTruncatedFrame - } - c := uint16(msg[currOff]) - currOff++ - switch c & 0xc0 { - case 0x00: // String label (segment). - if c == 0x00 { - break LOOP // Nominal end of name, always ends with null terminator. - } - endOff := currOff + c - if endOff > uint16(len(msg)) { - return off, errCalcLen - } - - // Reject names containing dots. See issue golang/go#56246 - if bytes.IndexByte(msg[currOff:endOff], '.') >= 0 { - return off, errInvalidName - } - - fn(msg[currOff:endOff]) - currOff = endOff - - case 0xc0: // Pointer. - // https://cs.opensource.google/go/x/net/+/refs/tags/v0.19.0:dns/dnsmessage/message.go;l=2078 - if !allowCompression { - return off, errCompressedSRV - } - if currOff >= uint16(len(msg)) { - return off, errInvalidPtr - } - c1 := msg[currOff] - currOff++ + start, end, isPtr, err := NextLabel(msg[off:]) + if err != nil { + return off, err + } else if start == end { if ptr == 0 { - newOff = currOff + newOff = off + 1 // advance past the null terminator byte } - // Don't follow too many pointers, maybe there's a loop. - if ptr++; ptr > 10 { - return off, errTooManyPtr + break + } else if isPtr { + if !allowCompression { + return newOff, errCompressedSRV } - currOff = (c^0xC0)<<8 | uint16(c1) - default: - // Prefixes 0x80 and 0x40 are reserved. - return off, errReserved + if ptr == 0 { + newOff = off + 2 // next record follows the 2-byte pointer + } + off = start + if int(off) >= len(msg) { + return newOff, errInvalidPtr + } else if ptr++; ptr > 10 { + return newOff, errTooManyPtr + } + } else { + // Is normal label; start/end are relative to msg[off:]. + fn(msg[off+start : off+end]) + off += end } } - if ptr == 0 { - newOff = currOff - } return newOff, nil } +// NextLabel parses the first control byte of data and returns the position and extent of next DNS label. +// +// For a normal string label (RFC 1035 §3.1), isPointer==false and start/end are +// byte indices into data: data[start:end] holds the raw label bytes. +// A null terminator (c==0) signals the end of the name: start==end==1, err==nil. +// +// For a compression pointer (RFC 1035 §4.1.4), isPointer==true: +// - start is the absolute target offset within the full DNS message to jump to. +// - end==0 (sentinel; not a data range). +// +// Returns [lneto.ErrTruncatedFrame] if data is too short to read the full label or pointer. +// Returns errReserved for the 0x40 and 0x80 reserved prefix classes. +func NextLabel(data []byte) (start_RelOrAbs, endRel uint16, isAbsPointer bool, err error) { + // Default invalid values + start_RelOrAbs, endRel = 0, 0 + if len(data) == 0 { + return start_RelOrAbs, endRel, false, lneto.ErrTruncatedFrame + } + c := uint16(data[0]) + switch c & 0xc0 { + case 0: + start_RelOrAbs = 1 + // String label segment. + if c == 0 { + return start_RelOrAbs, start_RelOrAbs, false, nil // Null terminator. String ended. + } + endRel = start_RelOrAbs + c + if int(endRel) > len(data) { + return start_RelOrAbs, endRel, false, lneto.ErrTruncatedFrame + } + // Reject names containing dots. See issue golang/go#56246 + if bytes.IndexByte(data[start_RelOrAbs:endRel], '.') >= 0 { + return start_RelOrAbs, endRel, false, errInvalidName + } + // Correct label! + case 0xc0: + // Pointer. Start is absolute index in DNS message. + isAbsPointer = true + if len(data) < 2 { + return start_RelOrAbs, endRel, isAbsPointer, lneto.ErrTruncatedFrame // Need more data to fully read pointer. + } + c1 := uint16(data[1]) + start_RelOrAbs = (c^0xC0)<<8 | c1 + default: + err = errReserved + } + return start_RelOrAbs, endRel, isAbsPointer, err +} + func (dst *Message) CopyFrom(m Message) { internal.SliceReuse(&dst.Questions, len(m.Questions)) internal.SliceReuse(&dst.Answers, len(m.Answers)) diff --git a/ipv6/icmpv6/client.go b/ipv6/icmpv6/client.go new file mode 100644 index 0000000..9103078 --- /dev/null +++ b/ipv6/icmpv6/client.go @@ -0,0 +1,193 @@ +package icmpv6 + +import ( + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +var _ lneto.StackNode = (*Client)(nil) + +const ( + keyHashCompletedBit = 1 << 31 + keyHashSentBit = 1 << 30 + keyHashBits = (1 << 30) - 1 +) + +type ClientConfig struct { + // Echo (ping) fields. + ResponseQueueBuffer []byte + ResponseQueueLimit int + HashSeed uint32 + ID uint16 + // NDP fields; NDP is disabled when NDPCache == 0. + OurAddr [16]byte + OurMAC [6]byte + NDPCache int +} + +type Client struct { + connid uint64 + magic uint32 + _seq uint16 + id uint16 + + outgoingEcho []struct { + pattern []byte + key uint32 + size uint16 + raddr [16]byte + } + + // responseLengths stores the length of responses received. + // together they should add up to the written length of responseRing. + incomingEcho []struct { + length uint16 + id uint16 + seq uint16 + raddr [16]byte + } + responseRing internal.Ring + + // NDP address resolution fields. + ndpCache ndpCache + onresolve func(mac [6]byte, addr [16]byte) + ourMAC [6]byte + ourIP [16]byte +} + +func (client *Client) Configure(cfg ClientConfig) error { + echoOK := cfg.HashSeed != 0 && len(cfg.ResponseQueueBuffer) >= 16 && cfg.ResponseQueueLimit > 0 + ndpOK := cfg.NDPCache > 0 + if !echoOK && !ndpOK { + return lneto.ErrInvalidConfig + } + client.connid++ + if echoOK { + internal.SliceReuse(&client.outgoingEcho, cfg.ResponseQueueLimit) + internal.SliceReuse(&client.incomingEcho, cfg.ResponseQueueLimit) + client.responseRing = internal.Ring{Buf: cfg.ResponseQueueBuffer} + client.magic = cfg.HashSeed + client.id = cfg.ID + } + if ndpOK { + client.ourIP = cfg.OurAddr + client.ourMAC = cfg.OurMAC + client.ndpCache.reset(cfg.NDPCache) + } + return nil +} + +func (client *Client) Protocol() uint64 { return uint64(lneto.IPProtoIPv6ICMP) } +func (client *Client) LocalPort() uint16 { return 0 } +func (client *Client) ConnectionID() *uint64 { return &client.connid } + +func (client *Client) Abort() { + client.Reset() + client.connid++ +} + +func (client *Client) Reset() { + client.incomingEcho = client.incomingEcho[:0] + client.outgoingEcho = client.outgoingEcho[:0] + client.responseRing.Reset() + client.ndpCache.reset(0) +} + +func (client *Client) Demux(carrierData []byte, frameOffset int) error { + rawdata := carrierData[frameOffset:] + ifrm, err := NewFrame(rawdata) + if err != nil { + return err + } + tp := ifrm.Type() + ipEnabled := frameOffset >= 40 + var crc lneto.CRC791 + if ipEnabled { + crc.WriteEven(carrierData[8:40]) // IPv6 src(16B)+dst(16B) pseudo-header + crc.AddUint32(uint32(len(rawdata))) + crc.AddUint32(uint32(lneto.IPProtoIPv6ICMP)) + } + if crc.PayloadSum16(rawdata) != 0 { + return lneto.ErrBadCRC + } + switch tp { + case TypeEchoRequest, TypeEchoReply: + return client.demuxEcho(carrierData, frameOffset) + case TypeNeighborSolicitation, TypeNeighborAdvertisement: + return client.demuxNDP(carrierData, frameOffset) + default: + return lneto.ErrPacketDrop + } +} + +func (client *Client) Encapsulate(carrierData []byte, ipOffset, frameOffset int) (int, error) { + n, dst, err := client.encapsEcho(carrierData, frameOffset) + if n == 0 && err == nil { + n, dst, err = client.encapsNDP(carrierData, frameOffset) + } + if n == 0 || err != nil { + return n, err + } + ifrm, _ := NewFrame(carrierData[frameOffset : frameOffset+n]) + ifrm.SetCRC(0) + if ipOffset >= 0 { + if err = internal.SetIPAddrs(carrierData[ipOffset:], 0, nil, dst[:]); err != nil { + return 0, err + } + var crc lneto.CRC791 + crc.WriteEven(carrierData[ipOffset+8 : ipOffset+40]) + crc.AddUint32(uint32(n)) + crc.AddUint32(uint32(lneto.IPProtoIPv6ICMP)) + ifrm.SetCRC(crc.PayloadSum16(carrierData[frameOffset : frameOffset+n])) + } else { + var crc lneto.CRC791 + ifrm.SetCRC(crc.PayloadSum16(carrierData[frameOffset : frameOffset+n])) + } + return n, nil +} + +// NDP public API — mirrors NDPHandler but on the unified Client. + +func (client *Client) SetNDPResolveCallback(cb func(mac [6]byte, addr [16]byte)) { + client.onresolve = cb +} + +func (client *Client) NDPStartQuery(addr [16]byte, triggerCallback bool) error { + if addr == ([16]byte{}) { + return lneto.ErrZeroDestination + } + e := client.ndpCache.acquireNext() + e.use([6]byte{}, addr, ndpFlagIncomplete|ndpFlagIncompletePendingQuery|ndpFlagPriority) + if triggerCallback { + e.flags |= ndpFlagResolveTriggersCallback + } + return nil +} + +func (client *Client) NDPCacheLookup(addr [16]byte) ([6]byte, error) { + e := client.ndpCache.Lookup(addr) + if e == nil { + return [6]byte{}, errNDPQueryNotFound + } else if e.flags.hasAny(ndpFlagIncomplete) { + return [6]byte{}, errNDPQueryPending + } + return e.mac, nil +} + +func (client *Client) NDPCacheSeed(addr [16]byte, mac [6]byte) error { + if addr == ([16]byte{}) { + return lneto.ErrZeroDestination + } + e := client.ndpCache.acquireNext() + e.use(mac, addr, 0) + return nil +} + +func (client *Client) NDPCacheRemove(addr [16]byte) error { + e := client.ndpCache.Lookup(addr) + if e == nil { + return errNDPQueryNotFound + } + e.destroy() + return nil +} diff --git a/ipv6/icmpv6/client_ndp.go b/ipv6/icmpv6/client_ndp.go new file mode 100644 index 0000000..e81c15d --- /dev/null +++ b/ipv6/icmpv6/client_ndp.go @@ -0,0 +1,116 @@ +package icmpv6 + +import ( + "errors" + + "github.com/soypat/lneto" +) + +const ( + ndpOptSourceLinkAddr = 1 // RFC 4861 §4.6.1 + ndpOptTargetLinkAddr = 2 // RFC 4861 §4.6.2 +) + +var ( + errNDPQueryPending = errors.New("icmpv6: NDP query pending") + errNDPQueryNotFound = errors.New("icmpv6: NDP query not found") +) + +func (client *Client) demuxNDP(carrierData []byte, frameOffset int) error { + rawdata := carrierData[frameOffset:] + if len(rawdata) < sizeNDPBase { + return lneto.ErrTruncatedFrame + } + ifrm, _ := NewFrame(rawdata) // already validated by Demux + tp := ifrm.Type() + targetAddr := (*[16]byte)(rawdata[8:24]) + options := rawdata[24:] + ipEnabled := frameOffset >= 40 + switch tp { + case TypeNeighborSolicitation: + if *targetAddr != client.ourIP { + return nil // Not for us. + } + mac, ok := parseLinkLayerOption(options, ndpOptSourceLinkAddr) + if !ok { + return lneto.ErrPacketDrop + } + var senderAddr [16]byte + if ipEnabled { + copy(senderAddr[:], carrierData[8:24]) // IPv6 source address + } + e := client.ndpCache.acquireNext() + e.use(mac, senderAddr, ndpFlagPendingResponse) + + case TypeNeighborAdvertisement: + mac, ok := parseLinkLayerOption(options, ndpOptTargetLinkAddr) + if !ok { + return lneto.ErrPacketDrop + } + e := client.ndpCache.Lookup(*targetAddr) + if e == nil { + return nil // Unsolicited or already evicted. + } + e.mac = mac + e.flags &^= ndpFlagIncomplete | ndpFlagIncompletePendingQuery + if e.flags.hasAny(ndpFlagResolveTriggersCallback) && client.onresolve != nil { + client.onresolve(mac, *targetAddr) + } + } + return nil +} + +func (client *Client) encapsNDP(carrierData []byte, frameOffset int) (n int, dst [16]byte, err error) { + buf := carrierData[frameOffset:] + if len(buf) < sizeNDP { + return 0, [16]byte{}, lneto.ErrShortBuffer + } + tp := TypeNeighborAdvertisement + e := client.ndpCache.getNextFlagged(ndpFlagPendingResponse) // Prioritize responses. + if e == nil { + e = client.ndpCache.getNextFlagged(ndpFlagIncompletePendingQuery) + if e == nil { + return 0, [16]byte{}, nil + } + e.flags &^= ndpFlagIncompletePendingQuery + tp = TypeNeighborSolicitation + } else { + e.flags &^= ndpFlagPendingResponse + } + n, err = e.put(buf, client.ourIP, client.ourMAC, tp) + if err != nil { + return 0, [16]byte{}, err + } + switch tp { + case TypeNeighborSolicitation: + dst = solicitedNodeMulticast(e.addr) + case TypeNeighborAdvertisement: + dst = e.addr + } + return n, dst, nil +} + +// solicitedNodeMulticast returns the solicited-node multicast address for addr (RFC 4291 §2.7.1). +func solicitedNodeMulticast(addr [16]byte) [16]byte { + return [16]byte{ + 0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0xff, + addr[13], addr[14], addr[15], + } +} + +// parseLinkLayerOption scans NDP options for the first option of optType +// and returns the embedded 6-byte Ethernet address. +func parseLinkLayerOption(options []byte, optType byte) ([6]byte, bool) { + for len(options) >= sizeNDPOption { + t := options[0] + l := int(options[1]) * 8 + if l == 0 || l > len(options) { + break + } + if t == optType && l >= sizeNDPOption { + return [6]byte(options[2:8]), true + } + options = options[l:] + } + return [6]byte{}, false +} diff --git a/ipv6/icmpv6/client_ping.go b/ipv6/icmpv6/client_ping.go new file mode 100644 index 0000000..22a01a6 --- /dev/null +++ b/ipv6/icmpv6/client_ping.go @@ -0,0 +1,193 @@ +package icmpv6 + +import ( + "slices" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +func (client *Client) PingIncomingCapacity() int { + return cap(client.incomingEcho) +} + +func (client *Client) PingStart(remoteAddr [16]byte, pattern []byte, size uint16) (key uint32, err error) { + if int(size) < len(pattern) || len(pattern) == 0 { + return 0, lneto.ErrInvalidConfig + } else if remoteAddr == [16]byte{} { + return 0, lneto.ErrZeroDestination + } + free := cap(client.outgoingEcho) - len(client.outgoingEcho) + if free == 0 { + return 0, lneto.ErrExhausted + } + key = client.magichash(pattern, int(size)) & keyHashBits + v := internal.SliceReclaim(&client.outgoingEcho) + v.key = key + v.size = size + v.pattern = append(v.pattern[:0], pattern...) + v.raddr = remoteAddr + return key, nil +} + +func (client *Client) PingPeek(key uint32) (completed, ok bool) { + idx := client.pingidx(key) + if idx >= 0 { + return client.outgoingEcho[idx].key&keyHashCompletedBit != 0, true + } + return false, false +} + +func (client *Client) PingPop(key uint32) (completed, ok bool) { + idx := client.pingidx(key) + if idx >= 0 { + completed := client.outgoingEcho[idx].key&keyHashCompletedBit != 0 + client.outgoingEcho = slices.Delete(client.outgoingEcho, idx, idx+1) + return completed, true + } + return false, false +} + +func (client *Client) pingidx(key uint32) int { + for i := range client.outgoingEcho { + if client.outgoingEcho[i].key&keyHashBits == key { + return i + } + } + return -1 +} + +func (client *Client) seq() uint16 { + client._seq++ + return client._seq +} + +func (client *Client) magichash(pattern []byte, size int) (hash uint32) { + hash = client.magic + i := 0 + n := size / len(pattern) + for i < n { + for _, b := range pattern { + hash = hash*31 + uint32(b) + } + i++ + } + n = size % len(pattern) + for i = 0; i < n; i++ { + hash = hash*31 + uint32(pattern[i]) + } + return hash +} + +// demuxEcho handles TypeEchoRequest and TypeEchoReply frames. +// CRC has already been verified by the caller (Client.Demux). +func (client *Client) demuxEcho(carrierData []byte, frameOffset int) error { + rawdata := carrierData[frameOffset:] + ifrm, _ := NewFrame(rawdata) // already validated by Demux + tp := ifrm.Type() + ipEnabled := frameOffset >= 40 + var raddr [16]byte + if ipEnabled { + src, _, _, _, _ := internal.GetIPAddr(carrierData) + if len(src) == 16 { + raddr = [16]byte(src) + } + } + var err error + switch tp { + case TypeEchoRequest: + free := cap(client.incomingEcho) - len(client.incomingEcho) + if free == 0 { + return lneto.ErrExhausted + } + efrm := FrameEcho{Frame: ifrm} + data := efrm.Data() + if len(data) == 0 { + return lneto.ErrPacketDrop + } + n, werr := client.responseRing.Write(data) + if werr != nil { + err = werr + break + } + v := internal.SliceReclaim(&client.incomingEcho) + v.length = uint16(n) + v.id = efrm.Identifier() + v.seq = efrm.SequenceNumber() + v.raddr = raddr + + case TypeEchoReply: + efrm := FrameEcho{Frame: ifrm} + data := efrm.Data() + if len(data) == 0 { + return lneto.ErrPacketDrop + } + hash := client.magichash(data, len(data)) & keyHashBits + idx := client.pingidx(hash) + if idx < 0 || (ipEnabled && client.outgoingEcho[idx].raddr != raddr) { + err = lneto.ErrPacketDrop + break + } + client.outgoingEcho[idx].key |= keyHashCompletedBit + + default: + err = lneto.ErrPacketDrop + } + return err +} + +// encapsEcho writes an echo reply or request frame into carrierData[frameOffset:]. +// CRC and SetIPAddrs are handled by the caller (Client.Encapsulate). +func (client *Client) encapsEcho(carrierData []byte, frameOffset int) (n int, dst [16]byte, err error) { + if len(client.incomingEcho) == 0 && len(client.outgoingEcho) == 0 { + return 0, [16]byte{}, nil + } + ifrm, err := NewFrame(carrierData[frameOffset:]) + if err != nil { + return 0, [16]byte{}, err + } + if len(client.incomingEcho) > 0 { + // Priority: send echo reply. + inc := client.incomingEcho[0] + efrm := FrameEcho{Frame: ifrm} + efrm.SetType(TypeEchoReply) + efrm.SetIdentifier(inc.id) + efrm.SetSequenceNumber(inc.seq) + dataLen := int(inc.length) + _, rerr := client.responseRing.Read(efrm.Data()[:dataLen]) + if rerr != nil { + return 0, [16]byte{}, rerr + } + client.incomingEcho = slices.Delete(client.incomingEcho, 0, 1) + n = sizeHeader + dataLen + dst = inc.raddr + } else { + idx := 0 + for idx < len(client.outgoingEcho) && client.outgoingEcho[idx].key&keyHashSentBit != 0 { + idx++ + } + if idx >= len(client.outgoingEcho) { + return 0, [16]byte{}, nil // No pending packet to send. + } + out := &client.outgoingEcho[idx] + efrm := FrameEcho{Frame: ifrm} + efrm.SetType(TypeEchoRequest) + efrm.SetIdentifier(client.id) + efrm.SetSequenceNumber(client.seq()) + pattern := out.pattern + data := efrm.Data() + size := int(out.size) + written := 0 + for written+len(pattern) <= size && written+len(pattern) <= len(data) { + copy(data[written:], pattern) + written += len(pattern) + } + copy(data[written:written+size%len(pattern)], pattern) + n = sizeHeader + size + out.key |= keyHashSentBit + dst = out.raddr + } + ifrm.buf = carrierData[frameOffset : frameOffset+n] + ifrm.SetCode(0) + return n, dst, nil +} diff --git a/ipv6/icmpv6/client_test.go b/ipv6/icmpv6/client_test.go new file mode 100644 index 0000000..9d13efe --- /dev/null +++ b/ipv6/icmpv6/client_test.go @@ -0,0 +1,172 @@ +package icmpv6 + +import ( + "testing" + + "github.com/soypat/lneto/internal" +) + +const ( + testHashSeed = 0xdeadbeef +) + +func TestClients(t *testing.T) { + const sizebuffer = 64 + const queuesize = 2 + var sender, responder Client + err := sender.Configure(ClientConfig{ + ResponseQueueBuffer: make([]byte, sizebuffer), + ResponseQueueLimit: queuesize, + HashSeed: testHashSeed, + ID: 1, + }) + if err != nil { + t.Fatal(err) + } + err = responder.Configure(ClientConfig{ + ResponseQueueBuffer: make([]byte, sizebuffer), + ResponseQueueLimit: queuesize, + HashSeed: testHashSeed, + ID: 2, + }) + if err != nil { + t.Fatal(err) + } + pattern := []byte("ab12") + size := 8 + var buf [64]byte + key1 := testSingleExchange(t, &sender, &responder, buf[:], pattern, uint16(size)) + completed, ok := sender.PingPop(key1) + if !completed || !ok { + t.Fatal("ping did not complete or not exist") + } + n, err := sender.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Error(err) + } else if n > 0 { + t.Error("sender: expected no more data to be sent") + } + n, err = responder.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Error(err) + } else if n > 0 { + t.Error("responder: expected no more data to be sent") + } +} + +func testSingleExchange(t *testing.T, sender, responder *Client, buf []byte, pattern []byte, size uint16) (senderKey uint32) { + const frameOff = 0 + const ipOff = -1 + n, err := responder.Encapsulate(buf, ipOff, frameOff) + if err != nil { + t.Fatal(err) + } else if n > 0 { + t.Fatal("expected no data pending to be sent from responder") + } + senderKey, n = testSendEcho(t, sender, buf, pattern, size) + completed, ok := sender.PingPeek(senderKey) + if !ok { + t.Error("ping key not exist") + } else if completed { + t.Error("ping completed before response") + } + ifrm, _ := NewFrame(buf[frameOff : frameOff+n]) + efrm := FrameEcho{Frame: ifrm} + id, seq := efrm.Identifier(), efrm.SequenceNumber() + err1 := responder.Demux(buf[:frameOff+n], frameOff) + if err1 != nil { + t.Error("responder demux during single", err1) + } + n, err = responder.Encapsulate(buf, ipOff, frameOff) + if err != nil { + t.Error("responder encaps during single", err) + return + } else if n == 0 && err1 == nil { + t.Error("responder wrote no data") + return + } + ifrm, err = NewFrame(buf[frameOff : frameOff+n]) + if err != nil { + t.Fatal(err) + } + if ifrm.Type() != TypeEchoReply { + t.Fatalf("expected echo reply %d", ifrm.Type()) + } + efrm = FrameEcho{Frame: ifrm} + if efrm.Identifier() != id { + t.Error("mismatched identifier want/got:", id, efrm.Identifier()) + } + if efrm.SequenceNumber() != seq { + t.Error("mismatched sequence number want/got:", seq, efrm.SequenceNumber()) + } + data := efrm.Data() + testPatternMatch(t, data, pattern, int(size)) + err = sender.Demux(buf[:frameOff+n], frameOff) + if err != nil { + t.Error("sender demuxed response", err) + } + completed, ok = sender.PingPeek(senderKey) + if !completed { + t.Error("expected ping to have completed") + } + if !ok { + t.Error("ping key not exist after completion") + } + if completed2, ok2 := sender.PingPeek(senderKey); completed != completed2 || ok != ok2 { + t.Error("change in status after peek") + } + n, err = sender.Encapsulate(buf, ipOff, frameOff) + if err != nil { + t.Error("error after done") + } + if n > 0 { + t.Error("expected no data to be sent after ping completion", n) + } + return senderKey +} + +func testSendEcho(t *testing.T, sender *Client, buf []byte, pattern []byte, size uint16) (key uint32, n int) { + t.Helper() + const frameOff = 0 + const ipOff = -1 + n, err := sender.Encapsulate(buf, ipOff, frameOff) + if err != nil { + t.Fatal(err) + } else if n > 0 { + t.Fatal("expected no data pending to send on testSendEcho start") + } + key, err = sender.PingStart([16]byte{1}, pattern, size) + if err != nil { + t.Fatal(err) + } + + n, err = sender.Encapsulate(buf[:], ipOff, frameOff) + if err != nil { + t.Errorf("sender encapsulate: %v", err) + } + ifrm, err := NewFrame(buf[:n]) + if err != nil { + t.Fatal(err) // only fails in short frame case. + } + if ifrm.Type() != TypeEchoRequest { + t.Errorf("not echo request type on send: %d", ifrm.Type()) + } + efrm := FrameEcho{Frame: ifrm} + data := efrm.Data() + testPatternMatch(t, data, pattern, int(size)) + return key, n +} + +func testPatternMatch(t *testing.T, data []byte, pattern []byte, size int) { + t.Helper() + if len(data) != size { + t.Errorf("pattern size mismatch, want %d, got %d", size, len(data)) + } + for i := 0; i < size; i += len(pattern) { + got := data[i:min(len(data), i+len(pattern))] + want := pattern[:len(got)] + if !internal.BytesEqual(got, want) { + t.Errorf("pattern data mismatch at %d, got %s, want %s", i, got, want) + } + } +} diff --git a/ipv6/icmpv6/icmpv6.go b/ipv6/icmpv6/icmpv6.go new file mode 100644 index 0000000..6b6c215 --- /dev/null +++ b/ipv6/icmpv6/icmpv6.go @@ -0,0 +1,223 @@ +package icmpv6 + +import ( + "encoding/binary" + + "github.com/soypat/lneto" +) + +//go:generate stringer -type=Type,CodeDestinationUnreachable,CodeParameterProblem -linecomment -output stringers.go + +const ( + sizeHeader = 8 + sizeNDPBase = sizeHeader + 16 // 24: ICMPv6 header + 16-byte target address + sizeNDPOption = 8 // 1 type + 1 len + 6 MAC (Ethernet link-layer option, RFC 4861 §4.6.1) + sizeNDP = sizeNDPBase + sizeNDPOption +) + +type Type uint8 + +const ( + TypeDestinationUnreachable Type = 1 // destination unreachable + TypePacketTooBig Type = 2 // packet too big + TypeTimeExceeded Type = 3 // time exceeded + TypeParameterProblem Type = 4 // parameter problem + + TypeEchoRequest Type = 128 // echo request + TypeEchoReply Type = 129 // echo reply + + TypeRouterSolicitation Type = 133 // router solicitation + TypeRouterAdvertisement Type = 134 // router advertisement + TypeNeighborSolicitation Type = 135 // neighbor solicitation + TypeNeighborAdvertisement Type = 136 // neighbor advertisement + TypeRedirectMessage Type = 137 // redirect message +) + +type CodeTimeExceeded uint8 + +const ( + CodeHopLimitExceeded CodeTimeExceeded = iota // hop limit exceeded in transit + CodeFragmentReassembly // fragment reassembly time exceeded +) + +type CodeDestinationUnreachable uint8 + +const ( + CodeNoRoute CodeDestinationUnreachable = iota // no route to destination + CodeAdminProhibited // communication administratively prohibited + CodeBeyondScope // beyond scope of source address + CodeAddressUnreachable // address unreachable + CodePortUnreachable // port unreachable + CodeIngressEgressPolicy // source address failed ingress/egress policy + CodeRejectRoute // reject route to destination +) + +type CodeParameterProblem uint8 + +const ( + CodeErroneousHeaderField CodeParameterProblem = iota // erroneous header field encountered + CodeUnrecognizedNextHeader // unrecognized next header type encountered + CodeUnrecognizedIPv6Option // unrecognized IPv6 option encountered +) + +func NewFrame(buf []byte) (Frame, error) { + if len(buf) < sizeHeader { + return Frame{}, lneto.ErrTruncatedFrame + } + return Frame{buf: buf}, nil +} + +type Frame struct { + buf []byte +} + +func (frm Frame) Type() Type { return Type(frm.buf[0]) } + +func (frm Frame) SetType(t Type) { frm.buf[0] = uint8(t) } + +func (frm Frame) Code() uint8 { return frm.buf[1] } + +func (frm Frame) SetCode(code uint8) { frm.buf[1] = code } + +// CRC returns the checksum field of the frame. +func (frm Frame) CRC() uint16 { + return binary.BigEndian.Uint16(frm.buf[2:4]) +} + +// SetCRC sets the checksum field of the frame. +func (frm Frame) SetCRC(crc uint16) { + binary.BigEndian.PutUint16(frm.buf[2:4], crc) +} + +func (frm Frame) payload() []byte { + return frm.buf[4:] +} + +type FrameDestinationUnreachable struct { + Frame +} + +func (frm FrameDestinationUnreachable) Code() CodeDestinationUnreachable { + return CodeDestinationUnreachable(frm.Frame.Code()) +} + +func (frm FrameDestinationUnreachable) SetCode(code CodeDestinationUnreachable) { + frm.Frame.SetCode(uint8(code)) +} + +type FramePacketTooBig struct { + Frame +} + +func (frm FramePacketTooBig) MTU() uint32 { + return binary.BigEndian.Uint32(frm.buf[4:8]) +} + +func (frm FramePacketTooBig) SetMTU(mtu uint32) { + binary.BigEndian.PutUint32(frm.buf[4:8], mtu) +} + +type FrameParameterProblem struct { + Frame +} + +func (frm FrameParameterProblem) Code() CodeParameterProblem { + return CodeParameterProblem(frm.Frame.Code()) +} + +func (frm FrameParameterProblem) SetCode(code CodeParameterProblem) { + frm.Frame.SetCode(uint8(code)) +} + +// Pointer identifies the octet offset within the invoking packet where the error was detected. +func (frm FrameParameterProblem) Pointer() uint32 { + return binary.BigEndian.Uint32(frm.buf[4:8]) +} + +func (frm FrameParameterProblem) SetPointer(ptr uint32) { + binary.BigEndian.PutUint32(frm.buf[4:8], ptr) +} + +type FrameEcho struct { + Frame +} + +func (frm FrameEcho) Identifier() uint16 { + return binary.BigEndian.Uint16(frm.buf[4:6]) +} + +func (frm FrameEcho) SetIdentifier(id uint16) { + binary.BigEndian.PutUint16(frm.buf[4:6], id) +} + +func (frm FrameEcho) SequenceNumber() uint16 { + return binary.BigEndian.Uint16(frm.buf[6:8]) +} + +func (frm FrameEcho) SetSequenceNumber(seq uint16) { + binary.BigEndian.PutUint16(frm.buf[6:8], seq) +} + +func (frm FrameEcho) Data() []byte { + return frm.buf[8:] +} + +func (frm FrameEcho) RawData() []byte { + return frm.buf +} + +// FrameNeighborSolicitation accesses a Neighbor Solicitation message (RFC 4861 §4.3). +// Layout after ICMPv6 base header: Reserved(4B) | TargetAddr(16B) | Options. +type FrameNeighborSolicitation struct { + Frame +} + +// TargetAddr returns the IPv6 address being queried. +func (frm FrameNeighborSolicitation) TargetAddr() *[16]byte { + return (*[16]byte)(frm.buf[8:24]) +} + +// Options returns the bytes following the fixed header for parsing NDP options. +func (frm FrameNeighborSolicitation) Options() []byte { + return frm.buf[24:] +} + +// FrameNeighborAdvertisement accesses a Neighbor Advertisement message (RFC 4861 §4.4). +// Layout after ICMPv6 base header: R|S|O|Reserved(4B) | TargetAddr(16B) | Options. +type FrameNeighborAdvertisement struct { + Frame +} + +// Flags returns the R (router), S (solicited), O (override) flag bits. +func (frm FrameNeighborAdvertisement) Flags() (router, solicited, override bool) { + b := frm.buf[4] + return b&0x80 != 0, b&0x40 != 0, b&0x20 != 0 +} + +// SetFlags sets the R, S, O flags and zeroes the reserved bits. +func (frm FrameNeighborAdvertisement) SetFlags(router, solicited, override bool) { + var b byte + if router { + b |= 0x80 + } + if solicited { + b |= 0x40 + } + if override { + b |= 0x20 + } + frm.buf[4] = b + frm.buf[5] = 0 + frm.buf[6] = 0 + frm.buf[7] = 0 +} + +// TargetAddr returns the IPv6 address being announced. +func (frm FrameNeighborAdvertisement) TargetAddr() *[16]byte { + return (*[16]byte)(frm.buf[8:24]) +} + +// Options returns the bytes following the fixed header for parsing NDP options. +func (frm FrameNeighborAdvertisement) Options() []byte { + return frm.buf[24:] +} diff --git a/ipv6/icmpv6/ndp_cache.go b/ipv6/icmpv6/ndp_cache.go new file mode 100644 index 0000000..9eb6753 --- /dev/null +++ b/ipv6/icmpv6/ndp_cache.go @@ -0,0 +1,136 @@ +package icmpv6 + +import ( + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +type ndpCache struct { + entries []ndpEntry +} + +// ndpEntry maps an IPv6 address to a MAC. Designed for compactness: 24 bytes. +type ndpEntry struct { + addr [16]byte + mac [6]byte + age uint8 + flags ndpFlags +} + +type ndpFlags uint8 + +// unset ndpFlagInUse to signal the entry can be acquired for a new query. +const ( + ndpFlagInUse ndpFlags = 1 << iota + ndpFlagPendingResponse // received a NS for our addr; must send NA + ndpFlagIncomplete // query in flight; MAC not yet resolved + ndpFlagIncompletePendingQuery // NS not yet transmitted + ndpFlagPriority // user query; evicted last + ndpFlagResolveTriggersCallback // call onresolve when MAC is learned +) + +func (f ndpFlags) hasAny(bits ndpFlags) bool { return f&bits != 0 } + +func (e *ndpEntry) use(mac [6]byte, addr [16]byte, flags ndpFlags) { + e.flags = ndpFlagInUse | flags + e.addr = addr + e.age = 0 + e.mac = mac +} + +func (e *ndpEntry) destroy() { *e = ndpEntry{} } + +func (e *ndpEntry) put(buf []byte, ourAddr [16]byte, ourMAC [6]byte, tp Type) (int, error) { + if len(buf) < sizeNDP { + return 0, lneto.ErrShortBuffer + } + buf[0] = uint8(tp) + buf[1] = 0 + buf[2], buf[3] = 0, 0 // checksum zeroed; caller computes it + switch tp { + case TypeNeighborSolicitation: + buf[4], buf[5], buf[6], buf[7] = 0, 0, 0, 0 + copy(buf[8:24], e.addr[:]) // target = address being queried + buf[24] = ndpOptSourceLinkAddr + buf[25] = 1 // length in units of 8 bytes + copy(buf[26:32], ourMAC[:]) + case TypeNeighborAdvertisement: + buf[4] = 0x60 // S=1 (solicited), O=1 (override) + buf[5], buf[6], buf[7] = 0, 0, 0 + copy(buf[8:24], ourAddr[:]) // target = our address being announced + buf[24] = ndpOptTargetLinkAddr + buf[25] = 1 + copy(buf[26:32], ourMAC[:]) + default: + return 0, lneto.ErrUnsupported + } + return sizeNDP, nil +} + +func (c *ndpCache) ageEntries() { + for i := range c.entries { + if c.entries[i].flags&ndpFlagInUse != 0 && c.entries[i].age < 255 { + c.entries[i].age++ + } + } +} + +func (c *ndpCache) reset(maxLimit int) { + internal.SliceReuse(&c.entries, maxLimit) + c.entries = c.entries[:cap(c.entries)] +} + +func (c *ndpCache) getNextFlagged(flags ndpFlags) *ndpEntry { + for i := range c.entries { + f := c.entries[i].flags + if f&ndpFlagInUse != 0 && f.hasAny(flags) { + return &c.entries[i] + } + } + return nil +} + +func (c *ndpCache) clearFlags(entryHasFlags, clrTheseFlagsIfMatch ndpFlags) { + for i := range c.entries { + if c.entries[i].flags&entryHasFlags != 0 { + c.entries[i].flags &^= clrTheseFlagsIfMatch + } + } +} + +func (c *ndpCache) Lookup(addr [16]byte) *ndpEntry { + for i := range c.entries { + if c.entries[i].flags&ndpFlagInUse != 0 && c.entries[i].addr == addr { + return &c.entries[i] + } + } + return nil +} + +// acquireNext returns the next available entry, evicting the oldest passive +// (passively-learned) entry before touching active user queries or pending responses. +func (c *ndpCache) acquireNext() *ndpEntry { + const priorityFlags = ndpFlagPendingResponse | ndpFlagIncomplete | ndpFlagPriority + oldest, oldestPassive := 0, -1 + for i := range c.entries { + if c.entries[i].flags&ndpFlagInUse == 0 { + oldest = i + break + } + if !c.entries[i].flags.hasAny(priorityFlags) { + if oldestPassive < 0 || c.entries[i].age > c.entries[oldestPassive].age { + oldestPassive = i + } + } + if c.entries[i].age > c.entries[oldest].age { + oldest = i + } + } + if oldestPassive >= 0 && c.entries[oldest].flags&ndpFlagInUse != 0 { + oldest = oldestPassive + } + c.ageEntries() + e := &c.entries[oldest] + e.destroy() + return e +} diff --git a/ipv6/icmpv6/ndp_handler_test.go b/ipv6/icmpv6/ndp_handler_test.go new file mode 100644 index 0000000..48a4c47 --- /dev/null +++ b/ipv6/icmpv6/ndp_handler_test.go @@ -0,0 +1,122 @@ +package icmpv6 + +import ( + "bytes" + "testing" +) + +func TestClientNDP(t *testing.T) { + addr1 := [16]byte{0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + addr2 := [16]byte{0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} + mac1 := [6]byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01} + mac2 := [6]byte{0xc0, 0xff, 0xee, 0xc0, 0xff, 0x02} + + var h1, h2 Client + if err := h1.Configure(ClientConfig{OurAddr: addr1, OurMAC: mac1, NDPCache: 2}); err != nil { + t.Fatal(err) + } + if err := h2.Configure(ClientConfig{OurAddr: addr2, OurMAC: mac2, NDPCache: 2}); err != nil { + t.Fatal(err) + } + + var buf [64]byte + + // No pending work: both clients should be silent. + n, err := h1.Encapsulate(buf[:], -1, 0) + if err != nil || n > 0 { + t.Fatal("expected no data before query:", err, n) + } + n, err = h2.Encapsulate(buf[:], -1, 0) + if err != nil || n > 0 { + t.Fatal("expected no data before query:", err, n) + } + + // h1 queries h2's MAC. + if err = h1.NDPStartQuery(addr2, false); err != nil { + t.Fatal(err) + } + + // h1 sends a Neighbor Solicitation. + n, err = h1.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Fatal("h1 encapsulate NS:", err) + } else if n == 0 { + t.Fatal("expected NS to be written") + } + validateNDP(t, buf[:n], TypeNeighborSolicitation) + + // Verify NS target address is addr2. + ifrm, _ := NewFrame(buf[:n]) + nsfrm := FrameNeighborSolicitation{Frame: ifrm} + if *nsfrm.TargetAddr() != addr2 { + t.Errorf("NS target addr mismatch: want %x, got %x", addr2, *nsfrm.TargetAddr()) + } + + // h2 receives the NS. + if err = h2.Demux(buf[:n], 0); err != nil { + t.Fatal("h2 demux NS:", err) + } + + // h2 sends a Neighbor Advertisement. + n, err = h2.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Fatal("h2 encapsulate NA:", err) + } else if n == 0 { + t.Fatal("expected NA to be written") + } + validateNDP(t, buf[:n], TypeNeighborAdvertisement) + + // Verify NA target address is addr2 (h2's own address). + ifrm, _ = NewFrame(buf[:n]) + nafrm := FrameNeighborAdvertisement{Frame: ifrm} + if *nafrm.TargetAddr() != addr2 { + t.Errorf("NA target addr mismatch: want %x, got %x", addr2, *nafrm.TargetAddr()) + } + _, solicited, _ := nafrm.Flags() + if !solicited { + t.Error("expected solicited flag set in NA") + } + + // Double-tap: h2 should have nothing left to send. + n2, err := h2.Encapsulate(buf[:], -1, 0) + if err != nil || n2 > 0 { + t.Fatal("double tap: expected no more data from h2:", err, n2) + } + + // h1 receives the NA and resolves h2's MAC. + if err = h1.Demux(buf[:n], 0); err != nil { + t.Fatal("h1 demux NA:", err) + } + mac, err := h1.NDPCacheLookup(addr2) + if err != nil { + t.Fatal("cache lookup after resolution:", err) + } + if !bytes.Equal(mac[:], mac2[:]) { + t.Errorf("resolved MAC mismatch: want %x, got %x", mac2, mac) + } + + // h1 should have nothing left to send. + n, err = h1.Encapsulate(buf[:], -1, 0) + if err != nil || n > 0 { + t.Fatal("expected no data after completion:", err, n) + } +} + +func validateNDP(t *testing.T, buf []byte, wantType Type) { + t.Helper() + if len(buf) < sizeNDP { + t.Errorf("NDP frame too short: %d < %d", len(buf), sizeNDP) + return + } + ifrm, err := NewFrame(buf) + if err != nil { + t.Error("NewFrame:", err) + return + } + if ifrm.Type() != wantType { + t.Errorf("type mismatch: want %s, got %s", wantType, ifrm.Type()) + } + if ifrm.Code() != 0 { + t.Errorf("code must be zero, got %d", ifrm.Code()) + } +} diff --git a/ipv6/icmpv6/stringers.go b/ipv6/icmpv6/stringers.go new file mode 100644 index 0000000..f331802 --- /dev/null +++ b/ipv6/icmpv6/stringers.go @@ -0,0 +1,94 @@ +// Code generated by "stringer -type=Type,CodeDestinationUnreachable,CodeParameterProblem -linecomment -output stringers.go"; DO NOT EDIT. + +package icmpv6 + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TypeDestinationUnreachable-1] + _ = x[TypePacketTooBig-2] + _ = x[TypeTimeExceeded-3] + _ = x[TypeParameterProblem-4] + _ = x[TypeEchoRequest-128] + _ = x[TypeEchoReply-129] + _ = x[TypeRouterSolicitation-133] + _ = x[TypeRouterAdvertisement-134] + _ = x[TypeNeighborSolicitation-135] + _ = x[TypeNeighborAdvertisement-136] + _ = x[TypeRedirectMessage-137] +} + +const ( + _Type_name_0 = "destination unreachablepacket too bigtime exceededparameter problem" + _Type_name_1 = "echo requestecho reply" + _Type_name_2 = "router solicitationrouter advertisementneighbor solicitationneighbor advertisementredirect message" +) + +var ( + _Type_index_0 = [...]uint8{0, 23, 37, 50, 67} + _Type_index_1 = [...]uint8{0, 12, 22} + _Type_index_2 = [...]uint8{0, 19, 39, 60, 82, 98} +) + +func (i Type) String() string { + switch { + case 1 <= i && i <= 4: + i -= 1 + return _Type_name_0[_Type_index_0[i]:_Type_index_0[i+1]] + case 128 <= i && i <= 129: + i -= 128 + return _Type_name_1[_Type_index_1[i]:_Type_index_1[i+1]] + case 133 <= i && i <= 137: + i -= 133 + return _Type_name_2[_Type_index_2[i]:_Type_index_2[i+1]] + default: + return "Type(" + strconv.FormatInt(int64(i), 10) + ")" + } +} + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[CodeNoRoute-0] + _ = x[CodeAdminProhibited-1] + _ = x[CodeBeyondScope-2] + _ = x[CodeAddressUnreachable-3] + _ = x[CodePortUnreachable-4] + _ = x[CodeIngressEgressPolicy-5] + _ = x[CodeRejectRoute-6] +} + +const _CodeDestinationUnreachable_name = "no route to destinationcommunication administratively prohibitedbeyond scope of source addressaddress unreachableport unreachablesource address failed ingress/egress policyreject route to destination" + +var _CodeDestinationUnreachable_index = [...]uint8{0, 23, 64, 94, 113, 129, 172, 199} + +func (i CodeDestinationUnreachable) String() string { + if i >= CodeDestinationUnreachable(len(_CodeDestinationUnreachable_index)-1) { + return "CodeDestinationUnreachable(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _CodeDestinationUnreachable_name[_CodeDestinationUnreachable_index[i]:_CodeDestinationUnreachable_index[i+1]] +} + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[CodeErroneousHeaderField-0] + _ = x[CodeUnrecognizedNextHeader-1] + _ = x[CodeUnrecognizedIPv6Option-2] +} + +const _CodeParameterProblem_name = "erroneous header field encounteredunrecognized next header type encounteredunrecognized IPv6 option encountered" + +var _CodeParameterProblem_index = [...]uint8{0, 34, 75, 111} + +func (i CodeParameterProblem) String() string { + if i >= CodeParameterProblem(len(_CodeParameterProblem_index)-1) { + return "CodeParameterProblem(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _CodeParameterProblem_name[_CodeParameterProblem_index[i]:_CodeParameterProblem_index[i+1]] +} diff --git a/tcp/conn.go b/tcp/conn.go index 9e391df..2a5d62f 100644 --- a/tcp/conn.go +++ b/tcp/conn.go @@ -173,6 +173,20 @@ func (conn *Conn) OpenListen(localPort uint16, iss Value) error { return nil } +// CloseRead activates local discard mode on the connection. Incoming data is +// still ACKed normally but payload is dropped; future Read calls return io.EOF. +// The write side is unaffected. +func (conn *Conn) CloseRead() error { + conn.mu.Lock() + defer conn.mu.Unlock() + err := conn.checkPipeOpen() + if err != nil { + return err + } + conn.h.ShutdownRead() + return nil +} + func (conn *Conn) Close() error { conn.mu.Lock() defer conn.mu.Unlock() @@ -294,10 +308,11 @@ func (conn *Conn) Read(b []byte) (int, error) { break } else { state := conn.h.State() + rxRefuse := conn.h.shutdownRx conn.mu.Unlock() if state.IsClosed() { return n, net.ErrClosed - } else if !state.RxDataOpen() { + } else if !state.RxDataOpen() || rxRefuse { return n, io.EOF } else if conn.deadlineExceeded(&conn.rdead) { return n, errDeadlineExceeded diff --git a/tcp/handler.go b/tcp/handler.go index c9d3e94..cad6c06 100644 --- a/tcp/handler.go +++ b/tcp/handler.go @@ -28,8 +28,9 @@ type Handler struct { // connection is established via Open calls. This disambiguates whether // Read and Write calls belong to the current connection. - optcodec OptionCodec - closing bool + optcodec OptionCodec + closing bool + shutdownRx bool // nRetransmit stores the number of times the oldest packet was retransmit. nRetransmit uint8 } @@ -128,6 +129,7 @@ func (h *Handler) reset(localPort, remotePort uint16, iss Value) { validator: h.validator, logger: h.logger, closing: false, + shutdownRx: false, } h.bufTx.ResetOrReuse(nil, 0, iss) h.bufRx.Reset() @@ -185,7 +187,7 @@ func (h *Handler) Recv(incomingPacket []byte) error { if prevState != h.scb.State() { h.info("tcp.Handler:rx-statechange", slog.Uint64("port", uint64(h.localPort)), slog.String("old", prevState.String()), slog.String("new", h.scb.State().String()), slog.String("rxflags", segIncoming.Flags.String())) } - if segIncoming.DATALEN != 0 { + if segIncoming.DATALEN != 0 && !h.shutdownRx { _, err = h.bufRx.Write(payload) if err != nil { return err @@ -229,6 +231,13 @@ func (h *Handler) Recv(incomingPacket []byte) error { return nil } +// ShutdownRead activates local discard mode: incoming payload bytes are dropped +// (ACK/SEQ still advance normally) and Read returns io.EOF immediately. +// Not reversible within the lifetime of a connection; reset clears it. +func (h *Handler) ShutdownRead() { + h.shutdownRx = true +} + func (h *Handler) Close() error { h.trace("tcp.Handler.Close") if h.closing { @@ -338,6 +347,9 @@ func (h *Handler) Write(b []byte) (int, error) { // Read implements [io.Reader] by reading received data from remote peer in internal buffer. func (h *Handler) Read(b []byte) (n int, err error) { + if h.shutdownRx { + return 0, io.EOF + } if h.bufRx.Buffered() > 0 { n, err = h.bufRx.Read(b) } diff --git a/udp/conn.go b/udp/conn.go index 594938f..a86908f 100644 --- a/udp/conn.go +++ b/udp/conn.go @@ -164,7 +164,7 @@ func (conn *Conn) Read(b []byte) (int, error) { conn.mu.Unlock() return 0, net.ErrClosed } - n, err := conn.h.Read(b) + n, err := conn.h.ReadNext(b) conn.mu.Unlock() if n > 0 { return n, err diff --git a/udp/handler.go b/udp/handler.go index be8f50d..6a1c67d 100644 --- a/udp/handler.go +++ b/udp/handler.go @@ -141,10 +141,10 @@ func (h *Handler) Write(b []byte) (int, error) { return len(b), nil } -// Read dequeues the next received datagram into b. If b is smaller than the +// ReadNext dequeues the next received datagram into b. If b is smaller than the // datagram, the remaining bytes are discarded (SOCK_DGRAM semantics). // Returns 0, nil if no datagrams are available. -func (h *Handler) Read(b []byte) (int, error) { +func (h *Handler) ReadNext(b []byte) (int, error) { if len(h.rxDgrams) == 0 { return 0, nil } @@ -190,7 +190,7 @@ func (h *Handler) Abort() { } // BufferedInputNext returns the size of the next datagram to read. A call -// to [Handler.Read] will read up to this amount of bytes. +// to [Handler.ReadNext] will read up to this amount of bytes. func (h *Handler) BufferedInputNext() int { if len(h.rxDgrams) == 0 { return 0 diff --git a/udp/mux.go b/udp/mux.go new file mode 100644 index 0000000..489d0b1 --- /dev/null +++ b/udp/mux.go @@ -0,0 +1,360 @@ +package udp + +import ( + "fmt" + "math" + "net" + "net/netip" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +var ( + _ lneto.StackNode = (*muxHandler)(nil) + _ lneto.StackNode = (*MuxHandlerMIMO)(nil) + _ lneto.StackNode = (*MuxHandlerSIMO)(nil) +) + +// MuxHandlerSIMO is a single-input multiple-output UDP mux handler. +// It binds to one local UDP port and multiplexes transmit/receive using that port. +type MuxHandlerSIMO struct { + muxHandler + localPort uint16 +} + +// MuxHandlerMIMO is a multi-input multi-output UDP mux handler. +// It supports sending and receiving on multiple local UDP ports through shared state. +type MuxHandlerMIMO struct { + muxHandler +} + +// Configure initializes the SIMO handler for a single local source port. +// The provided localPort becomes the only permitted receive port. +func (ms *MuxHandlerSIMO) Configure(localPort uint16, cfg MuxConfig) (err error) { + if localPort == 0 { + return lneto.ErrZeroSource + } + err = ms.muxHandler.Configure(cfg) + if err != nil { + return err + } + ms.localPort = localPort + ms.muxHandler.FilterAddLocalPort(localPort, 1) + return nil +} + +// LocalPort returns the configured local UDP port for this SIMO handler. +func (ms *MuxHandlerSIMO) LocalPort() uint16 { return ms.localPort } + +// WriteTo queues a UDP payload for transmission from the handler's local port. +func (ms *MuxHandlerSIMO) WriteTo(buf []byte, raddr netip.AddrPort) error { + return ms.muxHandler.WriteTo(buf, ms.localPort, raddr) +} + +// ReadNext returns the next received datagram for this handler's local port. +// If the datagram is for a different port it is discarded. +func (ms *MuxHandlerSIMO) ReadNext(buf []byte) (n int, completeRead bool, raddr netip.AddrPort) { + var lport uint16 + n, completeRead, lport, raddr = ms.muxHandler.ReadNext(buf) + if lport != ms.localPort { // Can happen if user fiddles with filters. + return 0, false, raddr + } + return n, completeRead, raddr +} + +// MuxConfig configures receive/transmit buffers and queue sizes for a UDP mux handler. +type MuxConfig struct { + // Configure receive buffer. If not set will use previously set buffer in [MuxHandler.Configure]. + RxBuf []byte + // Configure transmit buffer. If not set will use previously set buffer in [MuxHandler.Configure]. + TxBuf []byte + RxQueueSize int + TxQueueSize int +} + +// muxHandler +type muxHandler struct { + connid uint64 + // filterLPorts stores rx port ranges over which Handler can receive data. + // If not set will not filter UDP data. + filterLPorts []struct { + startPort uint16 + nports uint16 // must be at least 1 to be valid. + } + // filterRAddrs. If not set will receive any data. + // filterRAddrs []struct { + // addr netip.Prefix + // } + rxRing internal.Ring + rxDgrams []struct { + length uint16 + lport uint16 + rport uint16 + raddr netip.Addr + } + txRing internal.Ring + txDgrams []struct { + length uint16 + lport uint16 + rport uint16 + raddr netip.Addr + } + closeCalled bool +} + +// Configure initializes the handler with the given buffer and queue configuration. +// Increments the connection ID, invalidating any prior stack registration. +func (mh *muxHandler) Configure(cfg MuxConfig) error { + if cfg.RxBuf == nil { + cfg.RxBuf = mh.rxRing.Buf + } + if cfg.TxBuf == nil { + cfg.TxBuf = mh.txRing.Buf + } + if len(cfg.RxBuf) < sizeHeader || len(cfg.TxBuf) < sizeHeader || cfg.RxQueueSize <= 0 || cfg.TxQueueSize <= 0 { + return lneto.ErrInvalidConfig + } + mh.Abort() + mh.rxRing = internal.Ring{Buf: cfg.RxBuf} + mh.txRing = internal.Ring{Buf: cfg.TxBuf} + internal.SliceReuse(&mh.rxDgrams, cfg.RxQueueSize) + internal.SliceReuse(&mh.txDgrams, cfg.TxQueueSize) + return nil +} + +// Protocol implements [lneto.StackNode]. +func (mh *muxHandler) Protocol() uint64 { return uint64(lneto.IPProtoUDP) } + +// ConnectionID implements [lneto.StackNode]. +func (mh *muxHandler) ConnectionID() *uint64 { return &mh.connid } + +// LocalPort implements [lneto.StackNode] but not applicable to mux. Mux is a multi Rx/Tx port abstraction. +func (mh *muxHandler) LocalPort() uint16 { return 0 } + +func (mh *muxHandler) FilterResetLocalPorts() { + mh.filterLPorts = mh.filterLPorts[:0] +} + +func (mh *muxHandler) FilterAddLocalPort(startPort, nports uint16) { + if int(startPort)+int(nports) > math.MaxUint16 { + panic("port overflow") + } + f := internal.SliceReclaim(&mh.filterLPorts) + f.startPort = startPort + f.nports = nports +} + +func (mh *muxHandler) FilterLocalPort(lport uint16) (filtered bool) { + filtered = len(mh.filterLPorts) > 0 + for i := range mh.filterLPorts { + maxPort := mh.filterLPorts[i].startPort + mh.filterLPorts[i].nports + if lport >= mh.filterLPorts[i].startPort && lport < maxPort { + filtered = false + break + } + } + return filtered +} + +// Recv parses a UDP frame from buf, validates the ports and length fields, +// and enqueues the payload into the rx ring buffer. Returns [lneto.ErrMismatch] +// if source/destination ports don't match the configured ports. +func (mh *muxHandler) Demux(carrierData []byte, frameOffset int) error { + if mh.closeCalled { + return net.ErrClosed + } + ufrm, err := NewFrame(carrierData[frameOffset:]) + if err != nil { + return err + } + // Rx port filter. + lport := ufrm.DestinationPort() + if mh.FilterLocalPort(lport) { + return lneto.ErrMismatch + } + + // Header size validation. + // No CRC validation at this level. + ul := ufrm.Length() + if ul < sizeHeader { + return lneto.ErrInvalidLengthField + } else if int(ul) > len(ufrm.RawData()) { + return lneto.ErrTruncatedFrame + } + + free := cap(mh.rxDgrams) - len(mh.rxDgrams) + if free == 0 { + return lneto.ErrExhausted + } + + payload := ufrm.Payload() + _, err = mh.rxRing.Write(payload) + if err != nil { + return err + } + dgram := internal.SliceReclaim(&mh.rxDgrams) + dgram.length = uint16(len(payload)) + dgram.lport = lport + dgram.rport = ufrm.SourcePort() + if frameOffset >= 20 { + src, _, _, _, _ := internal.GetIPAddr(carrierData) + dgram.raddr, _ = netip.AddrFromSlice(src) + } + return nil +} + +func (mh *muxHandler) Encapsulate(carrierData []byte, ipOffset, frameOffset int) (int, error) { + if mh.closeCalled { + return 0, net.ErrClosed + } else if len(mh.txDgrams) == 0 { + return 0, nil // No data to send. + } + buf := carrierData[frameOffset:] + ufrm, err := NewFrame(buf) + if err != nil { + return 0, err + } + + dgram := internal.SliceDequeueFront(&mh.txDgrams) + avail := len(buf) - 8 + if avail < int(dgram.length) { + // TODO(soypat): If packet is too long we discard it entirely. Maybe we prefer sending incomplete data? How do other stacks deal with this? + mh.txRing.ReadDiscard(int(dgram.length)) + return 0, lneto.ErrShortBuffer + } + + n, err := mh.txRing.Read(buf[8 : 8+dgram.length]) + if err != nil || n != int(dgram.length) { + panic(fmt.Sprintf("udp send handler failure %d %s", n, err)) + } + ufrm.SetSourcePort(dgram.lport) + ufrm.SetDestinationPort(dgram.rport) + ufrm.SetLength(8 + dgram.length) + if ipOffset >= 0 && dgram.raddr.IsValid() { + // Address write. Version check. + var addroffset int + switch carrierData[ipOffset] >> 4 { + case 4: + if !dgram.raddr.Is4() { + return 0, lneto.ErrUnsupported + } + addroffset = ipOffset + 16 + + case 6: + if !dgram.raddr.Is6() { + return 0, lneto.ErrUnsupported + } + addroffset = ipOffset + 24 + default: + return 0, lneto.ErrUnsupported + } + dgram.raddr.AppendBinary(carrierData[addroffset:addroffset]) + } + return int(8 + dgram.length), nil +} + +func (mh *muxHandler) WriteTo(buf []byte, lport uint16, raddr netip.AddrPort) error { + if mh.closeCalled { + return net.ErrClosed + } else if raddr.Port() == 0 || !raddr.IsValid() { + return lneto.ErrZeroDestination + } else if lport == 0 { + return lneto.ErrZeroSource + } + avail := cap(mh.txDgrams) - len(mh.txDgrams) + if avail == 0 { + return lneto.ErrExhausted + } else if mh.txRing.Free() < len(buf) { + return lneto.ErrBufferFull + } + n, err := mh.txRing.Write(buf) + if err != nil || n != len(buf) { + return lneto.ErrBug + } + dgram := internal.SliceReclaim(&mh.txDgrams) + dgram.length = uint16(len(buf)) + dgram.raddr = raddr.Addr() + dgram.lport = lport + dgram.rport = raddr.Port() + return nil +} + +// ReadNext dequeues the next received datagram into b. If b is smaller than the +// datagram, the remaining bytes are discarded (SOCK_DGRAM semantics). +// The port the datagram was destined to and address it was received from are returned. +// If bytes are discarded completeRead=false. +func (mh *muxHandler) ReadNext(buf []byte) (n int, completeRead bool, lport uint16, raddr netip.AddrPort) { + if len(mh.rxDgrams) == 0 { + return 0, false, 0, raddr + } + dgram := internal.SliceDequeueFront(&mh.rxDgrams) + dlen := int(dgram.length) + maxRead := min(dlen, len(buf)) + n, _ = mh.rxRing.Read(buf[:maxRead]) + if n < dlen { + mh.rxRing.ReadDiscard(dlen - n) + } + return n, n == dlen, dgram.lport, netip.AddrPortFrom(dgram.raddr, dgram.rport) +} + +// BufferedInputNext returns the size of the next datagram to read. A call +// to [Handler.ReadNext] will read up to this amount of bytes. +func (mh *muxHandler) BufferedInputNext() uint16 { + if len(mh.rxDgrams) > 0 { + return mh.rxDgrams[0].length + } + return 0 +} + +// BufferedInput returns the number of unread bytes in the receive buffer. +func (h *muxHandler) BufferedInput() int { + return h.rxRing.Buffered() +} + +// BufferedUnsent returns the number of written but unsent bytes in the transmit buffer. +func (h *muxHandler) BufferedOutput() int { + return h.txRing.Buffered() +} + +// SizeInput returns the total size of the receive ring buffer. +func (h *muxHandler) SizeInput() int { + return h.rxRing.Size() +} + +// SizeOutput returns the total size of the transmit ring buffer. +func (h *muxHandler) SizeOutput() int { + return h.txRing.Size() +} + +// FreeOutput returns the number of free bytes in the transmit buffer. +// This tells the user how many bytes can be written with Write method before write failing. +func (h *muxHandler) FreeOutput() int { + return h.txRing.Free() +} + +// FreeInput returns the number of free bytes in the receive buffer. +func (h *muxHandler) FreeInput() int { + return h.rxRing.Free() +} + +func (mh *muxHandler) IsOpen() bool { + return cap(mh.rxDgrams) > 0 && !mh.closeCalled +} + +func (mh *muxHandler) Close() { + mh.closeCalled = true +} + +func (mh *muxHandler) Abort() { + *mh = muxHandler{ + connid: mh.connid + 1, + filterLPorts: mh.filterLPorts[:0], + rxRing: mh.rxRing, + rxDgrams: mh.rxDgrams[:0], + txRing: mh.txRing, + txDgrams: mh.txDgrams[:0], + } + mh.rxRing.Reset() + mh.txRing.Reset() +} diff --git a/udp/mux_test.go b/udp/mux_test.go new file mode 100644 index 0000000..5f22f16 --- /dev/null +++ b/udp/mux_test.go @@ -0,0 +1,229 @@ +package udp + +import ( + "net/netip" + "testing" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/internal" +) + +func newTestSIMO(t *testing.T, localPort uint16) *MuxHandlerSIMO { + t.Helper() + var ms MuxHandlerSIMO + err := ms.Configure(localPort, MuxConfig{ + RxBuf: make([]byte, 256), + TxBuf: make([]byte, 256), + RxQueueSize: 4, + TxQueueSize: 4, + }) + if err != nil { + t.Fatal(err) + } + return &ms +} + +func TestMuxSIMO_Configure_ZeroPort(t *testing.T) { + var ms MuxHandlerSIMO + err := ms.Configure(0, MuxConfig{ + RxBuf: make([]byte, 256), + TxBuf: make([]byte, 256), + RxQueueSize: 4, + TxQueueSize: 4, + }) + if err != lneto.ErrZeroSource { + t.Fatalf("want ErrZeroSource, got %v", err) + } +} + +func TestMuxSIMO_LocalPort(t *testing.T) { + ms := newTestSIMO(t, 1234) + if ms.LocalPort() != 1234 { + t.Fatalf("want 1234, got %d", ms.LocalPort()) + } +} + +func TestMuxSIMO_WriteToEncapsulateRoundtrip(t *testing.T) { + const localPort = 1234 + raddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 0, 0, 1}), 8080) + ms := newTestSIMO(t, localPort) + payload := []byte("hello mux") + if err := ms.WriteTo(payload, raddr); err != nil { + t.Fatal(err) + } + var buf [128]byte + n, err := ms.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Fatal(err) + } + if want := 8 + len(payload); n != want { + t.Fatalf("encapsulated %d bytes, want %d", n, want) + } + ufrm, err := NewFrame(buf[:n]) + if err != nil { + t.Fatal(err) + } + if ufrm.SourcePort() != localPort { + t.Fatalf("src port %d, want %d", ufrm.SourcePort(), localPort) + } + if ufrm.DestinationPort() != raddr.Port() { + t.Fatalf("dst port %d, want %d", ufrm.DestinationPort(), raddr.Port()) + } + if !internal.BytesEqual(ufrm.Payload(), payload) { + t.Fatal("payload mismatch") + } + // No more pending. + n, err = ms.Encapsulate(buf[:], -1, 0) + if err != nil || n != 0 { + t.Fatalf("expected empty encapsulate, got n=%d err=%v", n, err) + } +} + +func TestMuxSIMO_DemuxReadNextRoundtrip(t *testing.T) { + const localPort = 1234 + const remotePort = 8080 + ms := newTestSIMO(t, localPort) + payload := []byte("incoming") + frame := makeUDPFrame(remotePort, localPort, payload) + if err := ms.Demux(frame, 0); err != nil { + t.Fatal(err) + } + var buf [64]byte + n, completeRead, raddr := ms.ReadNext(buf[:]) + if n != len(payload) { + t.Fatalf("read %d bytes, want %d", n, len(payload)) + } + if !completeRead { + t.Fatal("want completeRead=true") + } + if !internal.BytesEqual(buf[:n], payload) { + t.Fatal("payload mismatch") + } + if raddr.IsValid() { + t.Fatal("want zero raddr when no IP carrier (frameOffset=0 < 20)") + } +} + +func TestMuxSIMO_DemuxFiltersMismatch(t *testing.T) { + ms := newTestSIMO(t, 1234) + frame := makeUDPFrame(8080, 9999, []byte("wrong port")) + err := ms.Demux(frame, 0) + if err != lneto.ErrMismatch { + t.Fatalf("want ErrMismatch, got %v", err) + } +} + +func TestMuxSIMO_ReadNextTruncates(t *testing.T) { + const localPort = 1234 + ms := newTestSIMO(t, localPort) + payload := []byte("toolongpayload") + if err := ms.Demux(makeUDPFrame(8080, localPort, payload), 0); err != nil { + t.Fatal(err) + } + var small [4]byte + n, completeRead, _ := ms.ReadNext(small[:]) + if n != 4 { + t.Fatalf("read %d, want 4", n) + } + if completeRead { + t.Fatal("want completeRead=false on truncation") + } + if !internal.BytesEqual(small[:], payload[:4]) { + t.Fatal("truncated data mismatch") + } + // Next datagram should work cleanly after truncation. + payload2 := []byte("ok") + if err := ms.Demux(makeUDPFrame(8080, localPort, payload2), 0); err != nil { + t.Fatal(err) + } + var buf [64]byte + n, completeRead, _ = ms.ReadNext(buf[:]) + if !completeRead || !internal.BytesEqual(buf[:n], payload2) { + t.Fatal("post-truncation read mismatch") + } +} + +func TestMuxSIMO_MultipleDatagrams(t *testing.T) { + const localPort = 5000 + ms := newTestSIMO(t, localPort) + type send struct { + payload string + raddr netip.AddrPort + } + sends := []send{ + {"alpha", netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 0, 0, 1}), 100)}, + {"beta", netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 0, 0, 2}), 200)}, + {"gamma", netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 0, 0, 3}), 300)}, + } + for _, s := range sends { + if err := ms.WriteTo([]byte(s.payload), s.raddr); err != nil { + t.Fatal(err) + } + } + var buf [128]byte + for _, s := range sends { + n, err := ms.Encapsulate(buf[:], -1, 0) + if err != nil { + t.Fatal(err) + } + ufrm, err := NewFrame(buf[:n]) + if err != nil { + t.Fatal(err) + } + if got := string(ufrm.Payload()); got != s.payload { + t.Fatalf("payload %q, want %q", got, s.payload) + } + if ufrm.DestinationPort() != s.raddr.Port() { + t.Fatalf("dst port %d, want %d", ufrm.DestinationPort(), s.raddr.Port()) + } + } +} + +func TestMuxSIMO_TxQueueExhausted(t *testing.T) { + ms := newTestSIMO(t, 1234) // queue size 4 + raddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 0, 0, 1}), 9000) + for i := range 4 { + if err := ms.WriteTo([]byte{byte(i)}, raddr); err != nil { + t.Fatalf("WriteTo %d: %v", i, err) + } + } + if err := ms.WriteTo([]byte{0xff}, raddr); err != lneto.ErrExhausted { + t.Fatalf("want ErrExhausted, got %v", err) + } +} + +func TestMuxSIMO_RxQueueExhausted(t *testing.T) { + const localPort = 1234 + ms := newTestSIMO(t, localPort) // queue size 4 + for i := range 4 { + frame := makeUDPFrame(8080, localPort, []byte{byte(i)}) + if err := ms.Demux(frame, 0); err != nil { + t.Fatalf("Demux %d: %v", i, err) + } + } + frame := makeUDPFrame(8080, localPort, []byte{0xff}) + if err := ms.Demux(frame, 0); err != lneto.ErrExhausted { + t.Fatalf("want ErrExhausted, got %v", err) + } +} + +func TestMuxSIMO_ClosedBehavior(t *testing.T) { + ms := newTestSIMO(t, 1234) + ms.Close() + raddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 0, 0, 1}), 9000) + if err := ms.WriteTo([]byte("data"), raddr); err == nil { + t.Fatal("expected error writing to closed handler") + } + frame := makeUDPFrame(8080, 1234, []byte("data")) + if err := ms.Demux(frame, 0); err == nil { + t.Fatal("expected error demuxing to closed handler") + } +} + +func TestMuxSIMO_WriteToInvalidRaddr(t *testing.T) { + ms := newTestSIMO(t, 1234) + zeroPort := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 0, 0, 1}), 0) + if err := ms.WriteTo([]byte("data"), zeroPort); err != lneto.ErrZeroDestination { + t.Fatalf("want ErrZeroDestination, got %v", err) + } +}