From 7d323aae193d4ded2688968e79682cfb56b0ab58 Mon Sep 17 00:00:00 2001 From: Pat Whittingslow Date: Mon, 23 Feb 2026 13:50:13 +0100 Subject: [PATCH] Tcp rst handling (#40) * claude suggests a way forward * add timing to capture printer * add pcap.Flags * fix ICMP CRC calculation and add test * bugfix: still send data on half-close state(close-wait) * fix pcap test --- internal/ltesto/ltesto.go | 68 +++++++++++++++ internet/pcap/capture.go | 35 ++++---- internet/pcap/capture_test.go | 14 ++-- internet/pcap/format.go | 8 +- internet/stack-ip.go | 7 +- tcp/control.go | 30 +++---- tcp/handler_test.go | 152 ++++++++++++++++++++++++++++++++++ tcp/listener.go | 8 +- x/xnet/pcap-printer.go | 30 +++++++ x/xnet/xnet_test.go | 80 ++++++++++++++++++ 10 files changed, 386 insertions(+), 46 deletions(-) diff --git a/internal/ltesto/ltesto.go b/internal/ltesto/ltesto.go index 85b5bc2..3079ea7 100644 --- a/internal/ltesto/ltesto.go +++ b/internal/ltesto/ltesto.go @@ -8,6 +8,7 @@ import ( "github.com/soypat/lneto" "github.com/soypat/lneto/ethernet" "github.com/soypat/lneto/ipv4" + "github.com/soypat/lneto/ipv4/icmpv4" "github.com/soypat/lneto/tcp" ) @@ -160,6 +161,73 @@ func (gen *PacketGen) AppendRandomIPv4TCPPacket(dst []byte, rng *rand.Rand, seg return dst } +// ICMPEchoConfig configures an ICMP echo request packet. +type ICMPEchoConfig struct { + Identifier uint16 + SequenceNumber uint16 + Payload []byte +} + +// AppendIPv4ICMPEcho builds and appends a complete Ethernet+IPv4+ICMP echo request packet to dst. +// The packet has valid Ethernet, IPv4, and ICMP checksums. +func (gen *PacketGen) AppendIPv4ICMPEcho(dst []byte, cfg ICMPEchoConfig) []byte { + const icmpHdrLen = 8 + ethsize := sizeHeaderEthNoVLAN + if gen.EnableVLAN { + ethsize += 4 + } + totalPayload := icmpHdrLen + len(cfg.Payload) + off := len(dst) + dst = append(dst, make([]byte, ethsize+sizeHeaderIPv4+totalPayload)...) + pkt := dst[off:] + + // Ethernet header. + efrm, err := ethernet.NewFrame(pkt) + if err != nil { + panic(err) + } + *efrm.DestinationHardwareAddr() = gen.DstMAC + *efrm.SourceHardwareAddr() = gen.SrcMAC + efrm.SetEtherType(ethernet.TypeIPv4) + + // IPv4 header. + ethernetPayload := efrm.Payload() + ifrm, err := ipv4.NewFrame(ethernetPayload) + if err != nil { + panic(err) + } + ifrm.SetVersionAndIHL(4, 5) + ifrm.SetTotalLength(uint16(sizeHeaderIPv4 + totalPayload)) + ifrm.SetID(0) + ifrm.SetFlags(0) + ifrm.SetTTL(64) + ifrm.SetProtocol(lneto.IPProtoICMP) + *ifrm.SourceAddr() = gen.SrcIPv4 + *ifrm.DestinationAddr() = gen.DstIPv4 + ifrm.SetCRC(0) + ifrm.SetCRC(ifrm.CalculateHeaderCRC()) + + // ICMP echo request. + icmpData := ifrm.Payload() + icmpFrm, err := icmpv4.NewFrame(icmpData) + if err != nil { + panic(err) + } + icmpFrm.SetType(icmpv4.TypeEcho) + icmpFrm.SetCode(0) + echo := icmpv4.FrameEcho{Frame: icmpFrm} + echo.SetIdentifier(cfg.Identifier) + echo.SetSequenceNumber(cfg.SequenceNumber) + copy(echo.Data(), cfg.Payload) + + // ICMP checksum covers the entire ICMP message (no pseudo header). + icmpFrm.SetCRC(0) + var crc lneto.CRC791 + icmpFrm.SetCRC(crc.PayloadSum16(icmpData[:totalPayload])) + + return dst +} + func sizeWord(l int) uint8 { return uint8((l + 3) / 4) } diff --git a/internet/pcap/capture.go b/internet/pcap/capture.go index 185f197..0a066f6 100644 --- a/internet/pcap/capture.go +++ b/internet/pcap/capture.go @@ -603,10 +603,21 @@ type FrameField struct { FrameBitOffset int BitLength int SubFields []FrameField - RightAligned bool - Legacy bool + + Flags Flags } +// Flags stores frame field interpretation bits. +type Flags uint32 + +const ( + FlagRightAligned Flags = 1 << iota + FlagLegacy +) + +func (ff Flags) IsLegacy() bool { return ff&FlagLegacy != 0 } +func (ff Flags) IsRightAligned() bool { return ff&FlagRightAligned != 0 } + type Frame struct { Protocol any Fields []FrameField @@ -653,7 +664,7 @@ func (frm Frame) FieldAsUint(fieldIdx int, pkt []byte) (uint64, error) { return badUint64, errors.New("invalid field index") } field := frm.Fields[fieldIdx] - return fieldAsUint(pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.RightAligned) + return fieldAsUint(pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.Flags.IsRightAligned()) } // AppendField appends the binary on-the-wire representation of the field and aligns the field so it starts at the first bit of appended data. @@ -662,7 +673,7 @@ func (frm Frame) AppendField(dst []byte, fieldIdx int, pkt []byte) ([]byte, erro return dst, errors.New("invalid field index") } field := frm.Fields[fieldIdx] - return appendField(dst, pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.RightAligned) + return appendField(dst, pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.Flags.IsRightAligned()) } func fieldAsUint(pkt []byte, fieldBitStart, bitlen int, rightAligned bool) (uint64, error) { @@ -860,14 +871,14 @@ var baseIPv6Fields = [...]FrameField{ Class: FieldClassFlags, FrameBitOffset: 4, BitLength: 1 * octet, - RightAligned: true, + Flags: FlagRightAligned, }, { Name: "Flow Label", Class: FieldClassID, FrameBitOffset: 12, BitLength: 20, - RightAligned: true, + Flags: FlagRightAligned, }, { Name: "Total Length", @@ -928,11 +939,6 @@ var baseIPv4Fields = [...]FrameField{ FrameBitOffset: 4 * octet, BitLength: 2 * octet, }, - { - Class: FieldClassID, - FrameBitOffset: 4 * octet, - BitLength: 2 * octet, - }, { Class: FieldClassFlags, FrameBitOffset: 6 * octet, @@ -1000,7 +1006,7 @@ var baseTCPFields = [...]FrameField{ Class: FieldClassFlags, FrameBitOffset: 12*octet + 4, BitLength: 12, - RightAligned: true, + Flags: FlagRightAligned, }, { Name: "Window", @@ -1018,6 +1024,7 @@ var baseTCPFields = [...]FrameField{ Class: 0, FrameBitOffset: 18 * octet, BitLength: 2 * octet, + Flags: FlagLegacy, }, } @@ -1199,14 +1206,14 @@ var baseDHCPv4Fields = [...]FrameField{ Class: FieldClassBinaryText, FrameBitOffset: (28 + 6) * octet, // Part of Client Hardware Address(16 bytes) but unused. BitLength: 10 * octet, - Legacy: true, + Flags: FlagLegacy, }, { Name: "BOOTP", Class: FieldClassBinaryText, FrameBitOffset: (28 + 16) * octet, BitLength: (dhcpv4.OptionsOffset - (28 + 16)) * octet, - Legacy: true, + Flags: FlagLegacy, }, } diff --git a/internet/pcap/capture_test.go b/internet/pcap/capture_test.go index adc4f67..ab45e5f 100644 --- a/internet/pcap/capture_test.go +++ b/internet/pcap/capture_test.go @@ -474,15 +474,15 @@ func ExampleFormatter_dhcp() { fmt.Println(string(out)) // Output: // Ethernet len=14; destination=ff:ff:ff:ff:ff:ff; source=de:ad:be:ef:ca:fe; protocol=0x0800 - // IPv4 len=20; version=0x04; (Header Length)=5; (Type of Service)=0x00; (Total Length)=312; identification=0x6043; identification=0x6043; flags=0x4000; (Time to live)=0x40; protocol=0x11; checksum=0xd972; source=0.0.0.0; destination=255.255.255.255 + // IPv4 len=20; version=0x04; (Header Length)=5; (Type of Service)=0x00; (Total Length)=312; identification=0x6043; flags=0x4000; (Time to live)=0x40; protocol=0x11; checksum=0xd972; source=0.0.0.0; destination=255.255.255.255 // UDP [RFC768] len=8; (Source port)=68; (Destination port)=67; size=292; checksum=0x0000 // DHCPv4 len=240; op=1; (Hardware Address Type)=0x01; (Hardware Address Length)=6; Hops=0x00; (Transaction ID)=0xdeadbeef; (Start Time)=0x0001; Flags=0x0000; (Client Address)=0.0.0.0; (Offered Address)=0.0.0.0; (Server Next Address)=255.255.255.255; (Relay Agent Address)=0.0.0.0; (Client Hardware Address)=de:ad:be:ef:ca:fe; options - // (DHCP message type.)=1 - // (Parameter request list)=0x0102031a1c060f2a - // (DHCP maximum message size)=558 - // (Requested IP address)=192.168.1.100 - // (Client identifier)="lneto-test" - // (Hostname string)="myhost" + // (DHCP message type.)=1 + // (Parameter request list)=0x0102031a1c060f2a + // (DHCP maximum message size)=558 + // (Requested IP address)=192.168.1.100 + // (Client identifier)="lneto-test" + // (Hostname string)="myhost" } func writeOpt(dst []byte, opt dhcpv4.OptNum, data ...byte) int { dst[0] = byte(opt) diff --git a/internet/pcap/format.go b/internet/pcap/format.go index ea0d5c2..4696d37 100644 --- a/internet/pcap/format.go +++ b/internet/pcap/format.go @@ -68,7 +68,7 @@ func (f *Formatter) FormatFrame(dst []byte, frm Frame, pkt []byte) (_ []byte, er if field.Class == FieldClassFlags && frm.Protocol == lneto.IPProtoTCP { // TCP flags pretty print special case. dst = append(dst, "flags="...) - v, err := fieldAsUint(pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.RightAligned) + v, err := fieldAsUint(pkt, frm.PacketBitOffset+field.FrameBitOffset, field.BitLength, field.Flags.IsRightAligned()) if err != nil { return dst, err } @@ -95,7 +95,7 @@ func (f *Formatter) FormatFrame(dst []byte, frm Frame, pkt []byte) (_ []byte, er func (f *Formatter) filterField(field FrameField) bool { return f.FilterClasses != nil && !slices.Contains(f.FilterClasses, field.Class) || - (field.Legacy && !f.DisableLegacyFilter) + (field.Flags.IsLegacy() && !f.DisableLegacyFilter) } func (f *Formatter) FormatField(dst []byte, pktStartOff int, field FrameField, pkt []byte) (_ []byte, err error) { @@ -133,7 +133,7 @@ func (f *Formatter) formatField(dst []byte, pktStartOff int, field FrameField, p dst = append(dst, '=') f.mubuf.Lock() defer f.mubuf.Unlock() - f.buf, err = appendField(f.buf[:0], pkt, field.FrameBitOffset+pktStartOff, field.BitLength, field.RightAligned) + f.buf, err = appendField(f.buf[:0], pkt, field.FrameBitOffset+pktStartOff, field.BitLength, field.Flags.IsRightAligned()) if err != nil { return dst, err } @@ -158,7 +158,7 @@ func (f *Formatter) formatField(dst []byte, pktStartOff int, field FrameField, p case FieldClassDst, FieldClassSrc, FieldClassSize, FieldClassAddress, FieldClassOperation: // IP, MAC addresses and ports. if field.BitLength <= 16 { - v, err := fieldAsUint(pkt, fieldBitStart, field.BitLength, field.RightAligned) + v, err := fieldAsUint(pkt, fieldBitStart, field.BitLength, field.Flags.IsRightAligned()) if err != nil { return dst, err } diff --git a/internet/stack-ip.go b/internet/stack-ip.go index a7274dd..804c941 100644 --- a/internet/stack-ip.go +++ b/internet/stack-ip.go @@ -98,7 +98,7 @@ func (sb *StackIP) Demux(carrierData []byte, offset int) error { totalLen := ifrm.TotalLength() proto := ifrm.Protocol() if proto == lneto.IPProtoICMP { - return sb.recvicmp(ifrm.RawData(), ifrm.HeaderLength()) + return sb.recvicmp(ifrm.Payload()) } node := sb.handlers.nodeByProto(uint16(proto)) // nodeIdx := getNodeByProto(sb.handlers, uint16(proto)) @@ -205,10 +205,9 @@ func (sb *StackIP) Register(h StackNode) error { return sb.handlers.registerByPortProto(nodeFromStackNode(h, h.LocalPort(), proto, nil)) } -func (sb *StackIP) recvicmp(carrierData []byte, offset int) error { - frameData := carrierData[offset:] +func (sb *StackIP) recvicmp(icmpData []byte) error { var crc lneto.CRC791 - if crc.PayloadSum16(frameData) != 0 { + if crc.PayloadSum16(icmpData) != 0 { return errors.New("ICMP CRC mismatch") } return nil diff --git a/tcp/control.go b/tcp/control.go index ec53190..c1e9164 100644 --- a/tcp/control.go +++ b/tcp/control.go @@ -183,8 +183,9 @@ func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) { } pending := tcb.pending[0] established := tcb._state == StateEstablished - if !established && tcb._state != StateCloseWait { - payloadLen = 0 // Can't send data if not established. + canSendData := established || tcb._state == StateCloseWait + if !canSendData { + payloadLen = 0 // Can't send data if not established or close-wait. } if pending == 0 && payloadLen == 0 { return Segment{}, false // No pending segment. @@ -206,7 +207,7 @@ func (tcb *ControlBlock) PendingSegment(payloadLen int) (_ Segment, ok bool) { pending |= FlagPSH // By default ensure all data flushed to destination application immediately on receive. } - if established { + if canSendData { pending |= FlagACK // ACK is always set in established state. Not in RFC9293 but somehow expected? } else { payloadLen = 0 // Can't send data if not established. @@ -420,7 +421,7 @@ func (tcb *ControlBlock) validateIncomingSegment(seg Segment) (err error) { case checkSEQ && !seg.Last().InWindow(tcb.rcv.NXT, tcb.rcv.WND) && !zeroWindowOK: err = errLastNotInWindow - case checkSEQ && seg.SEQ != tcb.rcv.NXT: + case checkSEQ && !flags.HasAny(FlagRST) && seg.SEQ != tcb.rcv.NXT: // This part diverts from TCB as described in RFC 9293. We want to support // only sequential segments to keep implementation simple and maintainable. See SHLD-31. err = errRequireSequential @@ -485,22 +486,23 @@ func (tcb *ControlBlock) resetRcv(localWND Size, remoteISS Value) { func (tcb *ControlBlock) handleRST(seq Value) error { tcb.debug("rcv:RST", slog.String("state", tcb._state.String())) - if seq != tcb.rcv.NXT { - // See RFC9293: If the RST bit is set and the sequence number does not exactly match the next expected sequence value, yet is within the current receive window, TCP endpoints MUST send an acknowledgment (challenge ACK). - tcb.challengeAck = true - tcb.pending[0] |= FlagACK - return errDropSegment - } if tcb._state.IsPreestablished() { + // RFC 9293 §3.5.3: non-synchronized states accept RST if SEQ is in window. + // No challenge ACK for non-synchronized states. Return to LISTEN. tcb.pending[0] = 0 tcb._state = StateListen tcb.resetSnd(tcb.snd.ISS+tcb.rstJump(), tcb.snd.WND) tcb.resetRcv(tcb.rcv.WND, 3_14159_2653^tcb.rcv.IRS) - } else { - tcb.Abort() // Enter closed state and return. - return net.ErrClosed + return errDropSegment } - return errDropSegment + // Synchronized states: exact match required, challenge ACK for in-window non-exact. + if seq != tcb.rcv.NXT { + tcb.challengeAck = true + tcb.pending[0] |= FlagACK + return errDropSegment + } + tcb.Abort() + return net.ErrClosed } func (tcb *ControlBlock) rstJump() Value { diff --git a/tcp/handler_test.go b/tcp/handler_test.go index b352b8a..0dc0d35 100644 --- a/tcp/handler_test.go +++ b/tcp/handler_test.go @@ -468,6 +468,158 @@ func TestWindowUpdateSWSAvoidance(t *testing.T) { } } +// TestWriteAfterRemoteFIN verifies that when a remote peer sends FIN (entering +// CLOSE_WAIT on our side), we can still write and send data before closing. +// This is a regression test for a panic in sentlist.AddPacket caused by +// PendingSegment returning DATALEN=0 while Handler.Send calls MakePacket with +// available > 0, creating degenerate zero-data packets in the sent queue. +// +// The sequence that triggers the panic: +// 1. Connection established +// 2. Remote sends FIN,ACK → local enters CLOSE_WAIT +// 3. Application writes data to TX buffer +// 4. Handler.Send() is called: PendingSegment sets PSH because payloadLen>0, +// then zeroes payloadLen because !established → DATALEN=0 but ok=true +// 5. MakePacket called with zero-length buffer → creates {off:0,end:0} entry +// 6. Handler.Send() called again → same thing → AddPacket panics because +// off=0 but lastPkt.end=0 != bufsize +func TestWriteAfterRemoteFIN(t *testing.T) { + const mtu = 1500 + const maxpackets = 3 + rng := rand.New(rand.NewSource(11)) + client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets) + setupClientServer(t, rng, client, server) + var rawbuf [mtu]byte + establish(t, client, server, rawbuf[:]) + + if server.State() != StateEstablished { + t.Fatal("server not established:", server.State()) + } + + // Client initiates close (sends FIN). + err := client.Close() + if err != nil { + t.Fatal("client close:", err) + } + clear(rawbuf[:]) + n, err := client.Send(rawbuf[:]) + if err != nil { + t.Fatal("client sending FIN:", err) + } + if n < sizeHeaderTCP { + t.Fatal("expected FIN packet") + } + if client.State() != StateFinWait1 { + t.Fatal("client not in FIN_WAIT_1:", client.State()) + } + + // Server receives FIN → enters CLOSE_WAIT. + err = server.Recv(rawbuf[:n]) + if err != nil { + t.Fatal("server receiving FIN:", err) + } + if server.State() != StateCloseWait { + t.Fatal("server not in CLOSE_WAIT:", server.State()) + } + + // Application writes data (like an HTTP 404 response). + responseData := []byte("HTTP/1.1 404 Not Found\r\n\r\n") + nw, err := server.Write(responseData) + if err != nil { + t.Fatal("server write:", err) + } + if nw != len(responseData) { + t.Fatal("short write:", nw) + } + + // Server sends response — this should include the data, not panic. + // The bug causes a panic on the second Send() call because the first + // creates a degenerate zero-data packet in the sentlist. + clear(rawbuf[:]) + n, err = server.Send(rawbuf[:]) + if err != nil { + t.Fatal("server send 1:", err) + } + + clear(rawbuf[:]) + n, err = server.Send(rawbuf[:]) + if err != nil { + t.Fatal("server send 2:", err) + } +} + +// TestRSTinSynReceived verifies that a RST received during the SYN-RECEIVED +// state correctly reverts the connection to LISTEN per RFC 9293 §3.5.3. +// This is a regression test for a bug where RST segments in non-synchronized +// states were blocked by errRequireSequential, causing connection pool leaks. +func TestRSTinSynReceived(t *testing.T) { + const mtu = 1500 + const maxpackets = 3 + rng := rand.New(rand.NewSource(2)) + client, server := newHandler(t, mtu, maxpackets), newHandler(t, mtu, maxpackets) + setupClientServer(t, rng, client, server) + var rawbuf [mtu]byte + + // Client sends SYN. + clear(rawbuf[:]) + n, err := client.Send(rawbuf[:]) + if err != nil { + t.Fatal("client sending SYN:", err) + } + if client.State() != StateSynSent { + t.Fatal("client not in SynSent:", client.State()) + } + + // Server receives SYN → transitions to SYN-RECEIVED. + err = server.Recv(rawbuf[:n]) + if err != nil { + t.Fatal("server receiving SYN:", err) + } + if server.State() != StateSynRcvd { + t.Fatal("server not in SynRcvd:", server.State()) + } + + // Server sends SYN,ACK. + clear(rawbuf[:]) + n, err = server.Send(rawbuf[:]) + if err != nil { + t.Fatal("server sending SYN,ACK:", err) + } + if n < sizeHeaderTCP { + t.Fatal("expected SYN,ACK packet") + } + synackFrm, _ := NewFrame(rawbuf[:n]) + synackSeg := synackFrm.Segment(0) + + // Construct RST packet from client perspective (as if the remote peer + // rejected the connection). SEQ = ACK from SYN,ACK, no ACK flag, no payload. + clear(rawbuf[:]) + rstFrm, err := NewFrame(rawbuf[:]) + if err != nil { + t.Fatal("new frame:", err) + } + rstSeg := Segment{ + SEQ: synackSeg.ACK, // SEQ = server's ACK value = in window. + Flags: FlagRST, + } + rstFrm.SetSourcePort(client.localPort) + rstFrm.SetDestinationPort(server.localPort) + rstFrm.SetSegment(rstSeg, 5) + rstFrm.SetUrgentPtr(0) + + // Server receives RST → should revert to LISTEN per RFC 9293 §3.5.3. + err = server.Recv(rawbuf[:sizeHeaderTCP]) + if !IsDroppedErr(err) { + t.Fatal("expected drop segment error from RST recv, got:", err) + } + if server.State() != StateListen { + t.Fatalf("expected server LISTEN after RST in SYN-RECEIVED, got %s", server.State()) + } + if server.scb.HasPending() { + t.Fatal("server should have no pending segments after RST") + } +} + // TestBufferNotClearedOnPassiveClose tests that data remains readable after // the TCP connection is closed by the remote peer. This is a regression test // for a bug where the receive buffer was cleared when the connection transitioned diff --git a/tcp/listener.go b/tcp/listener.go index a5c8b74..d44feb5 100644 --- a/tcp/listener.go +++ b/tcp/listener.go @@ -264,8 +264,9 @@ func (listener *Listener) maintainConns() { continue } state := conn.State() - if state > StateEstablished || state.IsClosed() { - // Something went wrong in handshake or pool aborted/closed the connection. + if state > StateEstablished || state.IsClosed() || state == StateListen { + // Something went wrong in handshake, pool aborted/closed the connection, + // or RST reverted the connection to LISTEN (RFC 9293 §3.5.3). listener.returnIncoming(i) } } @@ -289,7 +290,8 @@ func getConn(conns []handler, remotePort uint16, remoteAddr []byte) int { func (listener *Listener) maintainConn(conns []handler, idx int, err error) error { if err == net.ErrClosed { - listener.returnAccepted(idx) + listener.poolReturn(conns[idx].conn) + conns[idx] = handler{} return nil // avoid closing listener entirely. } return err diff --git a/x/xnet/pcap-printer.go b/x/xnet/pcap-printer.go index b0fbb90..5efb218 100644 --- a/x/xnet/pcap-printer.go +++ b/x/xnet/pcap-printer.go @@ -3,12 +3,19 @@ package xnet import ( "io" "strconv" + "time" "github.com/soypat/lneto/internet/pcap" ) type CapturePrinterConfig struct { NamespaceWidth int + // TimePrecision if non-zero is used to print timestamp + // at which the packet was received. By default the amount of + // seconds since configuration is printed. + TimePrecision int + // Now returns the current time. + Now func() time.Time } // CapturePrinter prints internet packets using the [pcap.PacketBreakdown] and [pcap.Formatter] types. @@ -20,9 +27,18 @@ type CapturePrinter struct { fmtPcapBuf []byte // minimum length of namespace on print. namespaceminwidth int + + timeprec int + origin time.Time + now func() time.Time } func (stack *CapturePrinter) Configure(writer io.Writer, cfg CapturePrinterConfig) error { + stack.timeprec = cfg.TimePrecision + stack.now = cfg.Now + if stack.printTimestamps() { + stack.origin = cfg.Now() + } stack.namespaceminwidth = cfg.NamespaceWidth stack.write = writer.Write return nil @@ -36,9 +52,19 @@ func (stack *CapturePrinter) Formatter() *pcap.Formatter { func (stack *CapturePrinter) PrintPacket(prefix string, pkt []byte) { fmtbuf := stack.fmtPcapBuf[:0] + useTimestamps := stack.printTimestamps() + var captime time.Time + if useTimestamps { + captime = stack.now() + } var err error stack.frms, err = stack.cap.CaptureEthernet(stack.frms[:0], pkt, 0) if err == nil { + if useTimestamps { + diff := captime.Sub(stack.origin) + fmtbuf = strconv.AppendFloat(fmtbuf, diff.Seconds(), 'f', stack.timeprec, 32) + fmtbuf = append(fmtbuf, ' ') + } fmtbuf = append(fmtbuf, prefix...) // Ensure minimum width of packet length display for less jitter in log viewline. prevlen := len(prefix) @@ -60,3 +86,7 @@ func (stack *CapturePrinter) PrintPacket(prefix string, pkt []byte) { stack.write(fmtbuf) stack.fmtPcapBuf = fmtbuf[:0] // Reuse buffer if allocated at larger size. } + +func (stack *CapturePrinter) printTimestamps() bool { + return stack.timeprec > 0 && stack.now != nil +} diff --git a/x/xnet/xnet_test.go b/x/xnet/xnet_test.go index c038548..2b7e62b 100644 --- a/x/xnet/xnet_test.go +++ b/x/xnet/xnet_test.go @@ -11,6 +11,7 @@ import ( "github.com/soypat/lneto/arp" "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/internal/ltesto" "github.com/soypat/lneto/internet/pcap" "github.com/soypat/lneto/ipv4" "github.com/soypat/lneto/tcp" @@ -909,3 +910,82 @@ func TestTCPConn_BufferNotClearedOnPassiveClose(t *testing.T) { t.Fatalf("read wrong data: got %q, want %q", readBuf[:n], sendData) } } + +func TestStackAsync_ICMPEchoChecksum(t *testing.T) { + const MTU = 1500 + stackAddr := netip.AddrFrom4([4]byte{192, 168, 1, 99}) + stackMAC := [6]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff} + routerAddr := [4]byte{192, 168, 1, 1} + routerMAC := [6]byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55} + + stack := new(StackAsync) + err := stack.Reset(StackConfig{ + Hostname: "ICMPTest", + RandSeed: 42, + StaticAddress: stackAddr, + HardwareAddress: stackMAC, + MTU: MTU, + }) + if err != nil { + t.Fatal(err) + } + + gen := ltesto.PacketGen{ + SrcMAC: routerMAC, + DstMAC: stackMAC, + SrcIPv4: routerAddr, + DstIPv4: stackAddr.As4(), + } + icmpPayload := []byte("abcdefghijklmnopqrstuvwxyz012345") // 32 bytes, typical ping payload. + + // Test 1: Valid ICMP echo request should be accepted. + pkt := gen.AppendIPv4ICMPEcho(nil, ltesto.ICMPEchoConfig{ + Identifier: 0x1234, + SequenceNumber: 1, + Payload: icmpPayload, + }) + err = stack.Demux(pkt, 0) + if err != nil { + t.Fatalf("valid ICMP echo rejected: %v", err) + } + + // Test 2: Valid ICMP with trailing FCS bytes (simulates real PIO hardware capture). + // This is a regression test for the bug where recvicmp checksummed ifrm.RawData() + // instead of ifrm.Payload(), causing the 4 trailing FCS bytes to corrupt the checksum. + pkt = gen.AppendIPv4ICMPEcho(nil, ltesto.ICMPEchoConfig{ + Identifier: 0x1234, + SequenceNumber: 2, + Payload: icmpPayload, + }) + pkt = append(pkt, 0xDE, 0xAD, 0xBE, 0xEF) // Simulate Ethernet FCS. + err = stack.Demux(pkt, 0) + if err != nil { + t.Fatalf("valid ICMP with trailing FCS rejected: %v", err) + } + + // Test 3: Corrupted ICMP checksum should be rejected. + pkt = gen.AppendIPv4ICMPEcho(nil, ltesto.ICMPEchoConfig{ + Identifier: 0x1234, + SequenceNumber: 3, + Payload: icmpPayload, + }) + pkt[len(pkt)-1] ^= 0xFF // Flip bits in last payload byte to corrupt ICMP checksum. + err = stack.Demux(pkt, 0) + if err == nil { + t.Fatal("corrupted ICMP accepted, expected CRC error") + } + + // Test 4: Corrupted ICMP with trailing FCS should also be rejected. + pkt = gen.AppendIPv4ICMPEcho(nil, ltesto.ICMPEchoConfig{ + Identifier: 0x1234, + SequenceNumber: 4, + Payload: icmpPayload, + }) + pkt[len(pkt)-1] ^= 0xFF // Corrupt ICMP payload. + pkt = append(pkt, 0xDE, 0xAD, 0xBE, 0xEF) // Simulate Ethernet FCS. + err = stack.Demux(pkt, 0) + if err == nil { + t.Fatal("corrupted ICMP with FCS accepted, expected CRC error") + } + +}