This commit is contained in:
JackDoan
2026-05-07 13:18:41 -05:00
parent 01b31360df
commit 400cbc26a1
9 changed files with 145 additions and 281 deletions

View File

@@ -7,9 +7,9 @@ type RxBatcher interface {
Reserve(sz int) []byte
// Commit borrows pkt. The caller must keep pkt valid until the next Flush.
// Walks IP+L4 headers itself; prefer CommitInbound when the caller already
// has an RxParsed in hand from ParseInbound.
// has an RxParsed in hand from ParsePacket.
Commit(pkt []byte) error
// CommitInbound is Commit with a hint produced by ParseInbound, so the
// CommitInbound is Commit with a hint produced by ParsePacket, so the
// batcher can skip the IP+L4 re-parse. Borrowed slice contract is the
// same as Commit. Implementations that don't coalesce may delegate to
// Commit.

View File

@@ -22,16 +22,16 @@ const (
icmpv6TypeEchoReply = 129
)
// Inbound parse errors. Match outside.go's sentinel set so the unified
// parser can drop in as a replacement for newPacket without callers
// noticing a behavior change.
// Packet parse errors — the canonical sentinel set for IP+L4 parsing.
// Both inbound and outbound callers share this surface, so any code path
// that ends up at firewall.PacketKey reports drops with the same errors.
var (
ErrInboundPacketTooShort = errors.New("packet is too short")
ErrInboundUnknownIPVersion = errors.New("packet is an unknown ip version")
ErrInboundIPv4InvalidHdrLen = errors.New("invalid ipv4 header length")
ErrInboundIPv4TooShort = errors.New("ipv4 packet is too short")
ErrInboundIPv6TooShort = errors.New("ipv6 packet is too short")
ErrInboundIPv6NoPayload = errors.New("could not find payload in ipv6 packet")
ErrPacketTooShort = errors.New("packet is too short")
ErrUnknownIPVersion = errors.New("packet is an unknown ip version")
ErrIPv4InvalidHeaderLength = errors.New("invalid ipv4 header length")
ErrIPv4PacketTooShort = errors.New("ipv4 packet is too short")
ErrIPv6PacketTooShort = errors.New("ipv6 packet is too short")
ErrIPv6CouldNotFindPayload = errors.New("could not find payload in ipv6 packet")
)
// RxKind discriminates how an inbound plaintext packet should be committed
@@ -61,19 +61,27 @@ type RxParsed struct {
udp parsedUDP
}
// ParseInbound walks an inbound plaintext packet once and fills:
// - parsed.Key with the dense, Local/Remote-oriented conntrack key the
// firewall uses (replaces the netip.Addr-rich path through newPacket).
// - parsed.{tcp,udp} with the coalescer hint, when the shape is
// coalesce-eligible.
// ParsePacket walks an IP packet once and fills parsed.Key. When incoming
// is true and the L4 shape is coalesce-eligible, also fills parsed.tcp /
// parsed.udp so CommitInbound can dispatch into the coalescer without
// re-walking the headers.
//
// Eligibility rules match the coalescer's own parseTCPBase/parseUDP:
// Direction selects the Key orientation:
//
// incoming=true → wire src → Key.RemoteAddr/Port, wire dst → Key.LocalAddr/Port
// incoming=false → wire src → Key.LocalAddr/Port, wire dst → Key.RemoteAddr/Port
//
// ICMP always lands the identifier in Key.RemotePort, regardless of direction.
//
// Eligibility rules for the coalescer hint match the coalescer's own
// parseTCPBase/parseUDP:
// - IPv4 strict: IHL == 20, no fragmentation (MF or offset), proto TCP/UDP.
// - IPv6 strict: NextHeader is directly TCP or UDP (no extension headers).
//
// Returns the same set of errors newPacket returns for malformed input —
// callers can treat those as drop.
func ParseInbound(pkt []byte, parsed *RxParsed) error {
// The hint is only filled for incoming packets, since the outbound path
// does not feed an inbound coalescer. Outbound callers see Kind stay at
// RxKindPassthrough and parsed.tcp/udp stay zero.
func ParsePacket(pkt []byte, incoming bool, parsed *RxParsed) error {
parsed.Kind = RxKindPassthrough
// Reset Key in full: v4 only writes the low 4 bytes of each address
// field, so without this a v6 call followed by a v4 reusing the same
@@ -81,26 +89,27 @@ func ParseInbound(pkt []byte, parsed *RxParsed) error {
// map equality for v4 flows.
parsed.Key = firewall.PacketKey{}
if len(pkt) < 1 {
return ErrInboundPacketTooShort
return ErrPacketTooShort
}
switch pkt[0] >> 4 {
case 4:
return parseInboundV4(pkt, parsed)
return parsePacketV4(pkt, incoming, parsed)
case 6:
return parseInboundV6(pkt, parsed)
return parsePacketV6(pkt, incoming, parsed)
}
return ErrInboundUnknownIPVersion
return ErrUnknownIPVersion
}
// parseInboundV4 mirrors parseV4(incoming=true) for the firewall side and
// also fills the coalescer hint when the shape is strict.
func parseInboundV4(pkt []byte, parsed *RxParsed) error {
// parsePacketV4 fills parsed.Key from an IPv4 packet. Direction selects
// Local/Remote orientation. When incoming and the shape is strict, also
// fills the coalescer hint.
func parsePacketV4(pkt []byte, incoming bool, parsed *RxParsed) error {
if len(pkt) < 20 {
return ErrInboundIPv4TooShort
return ErrIPv4PacketTooShort
}
ihl := int(pkt[0]&0x0f) << 2
if ihl < 20 {
return ErrInboundIPv4InvalidHdrLen
return ErrIPv4InvalidHeaderLength
}
flagsfrags := binary.BigEndian.Uint16(pkt[6:8])
parsed.Key.Fragment = (flagsfrags & 0x1FFF) != 0
@@ -118,12 +127,16 @@ func parseInboundV4(pkt []byte, parsed *RxParsed) error {
}
}
if len(pkt) < minLen {
return ErrInboundIPv4InvalidHdrLen
return ErrIPv4InvalidHeaderLength
}
// Inbound orientation: wire src → Remote, wire dst → Local.
copy(parsed.Key.RemoteAddr[:4], pkt[12:16])
copy(parsed.Key.LocalAddr[:4], pkt[16:20])
if incoming {
copy(parsed.Key.RemoteAddr[:4], pkt[12:16])
copy(parsed.Key.LocalAddr[:4], pkt[16:20])
} else {
copy(parsed.Key.LocalAddr[:4], pkt[12:16])
copy(parsed.Key.RemoteAddr[:4], pkt[16:20])
}
switch {
case parsed.Key.Fragment:
@@ -132,11 +145,18 @@ func parseInboundV4(pkt []byte, parsed *RxParsed) error {
case parsed.Key.Protocol == firewall.ProtoICMP:
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[ihl+4 : ihl+6])
parsed.Key.LocalPort = 0
default:
case incoming:
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[ihl : ihl+2])
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[ihl+2 : ihl+4])
default:
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[ihl : ihl+2])
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[ihl+2 : ihl+4])
}
// Coalescer hint is inbound-only: no inbound coalescer fires on outgoing.
if !incoming {
return nil
}
// Coalescer-eligible? Strict shape: IHL==20, no MF/offset, TCP or UDP.
if ihl != 20 || (flagsfrags&0x3FFF) != 0 {
return nil
@@ -208,28 +228,43 @@ func fillParsedUDPv4(pkt []byte, parsed *RxParsed) {
parsed.Kind = RxKindUDP
}
// parseInboundV6 mirrors parseV6(incoming=true). The coalescer-eligible
// fast path triggers only when NextHeader is directly TCP or UDP — any
// extension header chain falls into the lenient walk below.
func parseInboundV6(pkt []byte, parsed *RxParsed) error {
// parsePacketV6 fills parsed.Key from an IPv6 packet. Direction selects
// Local/Remote orientation. The coalescer hint fast path only triggers
// when NextHeader is directly TCP or UDP — any extension header chain
// falls into the lenient walk below, and the hint stays unfilled.
func parsePacketV6(pkt []byte, incoming bool, parsed *RxParsed) error {
if len(pkt) < 40 {
return ErrInboundIPv6TooShort
return ErrIPv6PacketTooShort
}
parsed.Key.IsV6 = true
copy(parsed.Key.RemoteAddr[:], pkt[8:24])
copy(parsed.Key.LocalAddr[:], pkt[24:40])
if incoming {
copy(parsed.Key.RemoteAddr[:], pkt[8:24])
copy(parsed.Key.LocalAddr[:], pkt[24:40])
} else {
copy(parsed.Key.LocalAddr[:], pkt[8:24])
copy(parsed.Key.RemoteAddr[:], pkt[24:40])
}
if proto := pkt[6]; proto == ipProtoTCP || proto == ipProtoUDP {
// Strict v6: ports are at the IP header end. Always fill key; only
// fill the coalescer hint if the L4 shape passes.
if len(pkt) < 44 {
return ErrInboundIPv6TooShort
return ErrIPv6PacketTooShort
}
parsed.Key.Protocol = proto
parsed.Key.Fragment = false
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[40:42])
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[42:44])
if incoming {
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[40:42])
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[42:44])
} else {
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[40:42])
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[42:44])
}
// Coalescer hint is inbound-only.
if !incoming {
return nil
}
payloadLen := int(binary.BigEndian.Uint16(pkt[4:6]))
if 40+payloadLen > len(pkt) {
return nil
@@ -245,8 +280,9 @@ func parseInboundV6(pkt []byte, parsed *RxParsed) error {
return nil
}
// Slow path: walk extension header chain just like parseV6 does.
return walkInboundV6Headers(pkt, parsed)
// Slow path: walk extension header chain. Coalescer hint never fires
// here, so direction only matters for L4 port orientation.
return walkV6Headers(pkt, incoming, parsed)
}
func fillParsedTCPv6(pkt []byte, parsed *RxParsed) {
@@ -295,12 +331,13 @@ func fillParsedUDPv6(pkt []byte, parsed *RxParsed) {
parsed.Kind = RxKindUDP
}
// walkInboundV6Headers handles every IPv6 case parseV6 handles that isn't
// the strict "NextHeader == TCP/UDP" fast path: ESP, NoNextHeader, ICMPv6,
// fragment headers (first vs later), AH, generic extension headers.
// Coalescer eligibility is always RxKindPassthrough on this path (parsed
// already initialised that way).
func walkInboundV6Headers(pkt []byte, parsed *RxParsed) error {
// walkV6Headers handles every IPv6 case the strict "NextHeader == TCP/UDP"
// fast path doesn't: ESP, NoNextHeader, ICMPv6, fragment headers (first vs
// later), AH, generic extension headers. Coalescer eligibility is always
// RxKindPassthrough on this path (parsed already initialised that way).
// Direction matters only for the L4 port orientation when the chain
// terminates at TCP/UDP.
func walkV6Headers(pkt []byte, incoming bool, parsed *RxParsed) error {
dataLen := len(pkt)
protoAt := 6
offset := 40
@@ -320,7 +357,7 @@ func walkInboundV6Headers(pkt []byte, parsed *RxParsed) error {
case ipProtoICMPv6:
if dataLen < offset+6 {
return ErrInboundIPv6TooShort
return ErrIPv6PacketTooShort
}
parsed.Key.Protocol = proto
parsed.Key.LocalPort = 0
@@ -338,17 +375,22 @@ func walkInboundV6Headers(pkt []byte, parsed *RxParsed) error {
// strict-eligible fast path above already handled the no-extension
// case; here we only fill firewall ports and stay passthrough.
if dataLen < offset+4 {
return ErrInboundIPv6TooShort
return ErrIPv6PacketTooShort
}
parsed.Key.Protocol = proto
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[offset : offset+2])
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[offset+2 : offset+4])
if incoming {
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[offset : offset+2])
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[offset+2 : offset+4])
} else {
parsed.Key.LocalPort = binary.BigEndian.Uint16(pkt[offset : offset+2])
parsed.Key.RemotePort = binary.BigEndian.Uint16(pkt[offset+2 : offset+4])
}
parsed.Key.Fragment = false
return nil
case ipProtoIPv6Fragment:
if dataLen < offset+8 {
return ErrInboundIPv6TooShort
return ErrIPv6PacketTooShort
}
fragmentOffset := binary.BigEndian.Uint16(pkt[offset+2:offset+4]) &^ uint16(0x7)
if fragmentOffset != 0 {
@@ -380,7 +422,7 @@ func walkInboundV6Headers(pkt []byte, parsed *RxParsed) error {
protoAt = offset
offset = offset + next
}
return ErrInboundIPv6NoPayload
return ErrIPv6CouldNotFindPayload
}
// CommitInbound dispatches pkt to the appropriate lane using parsed.Kind,

View File

@@ -176,7 +176,7 @@ func runRxUnified(b *testing.B, pkts [][]byte, batchSize int) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
pkt := pkts[i%len(pkts)]
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
b.Fatal(err)
}
if err := m.CommitInbound(pkt, &parsed); err != nil {
@@ -344,7 +344,7 @@ func runRxUnifiedWithCache(b *testing.B, pkts [][]byte, batchSize int) {
cache := make(firewall.ConntrackCache, len(pkts))
for _, pkt := range pkts {
var seed RxParsed
if err := ParseInbound(pkt, &seed); err != nil {
if err := ParsePacket(pkt, true, &seed); err != nil {
b.Fatal(err)
}
cache[seed.Key] = struct{}{}
@@ -355,7 +355,7 @@ func runRxUnifiedWithCache(b *testing.B, pkts [][]byte, batchSize int) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
pkt := pkts[i%len(pkts)]
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
b.Fatal(err)
}
if _, ok := cache[parsed.Key]; !ok {

View File

@@ -32,8 +32,8 @@ func TestParseInboundParity(t *testing.T) {
var fpUnified, fpBaseline firewall.Packet
var parsed RxParsed
if err := ParseInbound(tc.pkt, &parsed); err != nil {
t.Fatalf("ParseInbound: %v", err)
if err := ParsePacket(tc.pkt, true, &parsed); err != nil {
t.Fatalf("ParsePacket: %v", err)
}
parsed.Key.Hydrate(&fpUnified)
var ok bool
@@ -61,7 +61,7 @@ func TestParseInboundFlowKey(t *testing.T) {
t.Run("tcp_v4", func(t *testing.T) {
pkt := buildTCPv4Ports(1234, 443, 5000, tcpAck, make([]byte, 800))
var parsed RxParsed
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
t.Fatal(err)
}
if parsed.Kind != RxKindTCP {
@@ -79,7 +79,7 @@ func TestParseInboundFlowKey(t *testing.T) {
t.Run("udp_v4", func(t *testing.T) {
pkt := buildUDPv4(40000, 53, []byte("dnsquery"))
var parsed RxParsed
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
t.Fatal(err)
}
if parsed.Kind != RxKindUDP {
@@ -97,7 +97,7 @@ func TestParseInboundFlowKey(t *testing.T) {
t.Run("tcp_v6", func(t *testing.T) {
pkt := buildTCPv6(0, 9000, tcpAck, make([]byte, 800))
var parsed RxParsed
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
t.Fatal(err)
}
if parsed.Kind != RxKindTCP {
@@ -126,7 +126,7 @@ func TestParseInboundICMPPassthrough(t *testing.T) {
pkt[25] = 0xcd
var parsed RxParsed
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
t.Fatal(err)
}
if parsed.Kind != RxKindPassthrough {
@@ -162,7 +162,7 @@ func TestParseInboundV4Fragment(t *testing.T) {
pkt[7] = 0x10 // offset = 16 (in 8-byte units)
var parsed RxParsed
if err := ParseInbound(pkt, &parsed); err != nil {
if err := ParsePacket(pkt, true, &parsed); err != nil {
t.Fatal(err)
}
if !parsed.Key.Fragment {