<feed xmlns='http://www.w3.org/2005/Atom'>
<title>packeteer/PLAN.md, branch main</title>
<subtitle>Packet capture and analysis, TUI and desktop GUI.
</subtitle>
<id>https://srdusr.com/git/packeteer/atom?h=main</id>
<link rel='self' href='https://srdusr.com/git/packeteer/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/'/>
<updated>2026-05-31T21:29:00+00:00</updated>
<entry>
<title>Add LLDP - dispatched by ethertype, no IP layer at all</title>
<updated>2026-05-31T21:29:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-31T21:29:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=8c8708e43aeae394787d0d1aa71ee22dca635bbe'/>
<id>urn:sha1:8c8708e43aeae394787d0d1aa71ee22dca635bbe</id>
<content type='text'>
Real switches broadcast this every ~30s, but this project had zero
treatment for it (0x88CC was previously the test suite's own example
of an "unhandled ethertype"). Dispatched by ethertype the same way
ARP is, since LLDP sits directly on Ethernet. TLV-encoded; only the
three mandatory TLVs (Chassis ID, Port ID, TTL) plus System Name are
rendered, while every other TLV is still walked over correctly so
nothing after it is lost.

Live-verified two ways, since this machine is on WiFi (LLDP isn't
relayed to wireless clients even when a real switch sends it) with no
LLDP daemon installed to generate traffic locally either: a 15-second
passive capture confirmed no organic LLDP traffic exists to
accidentally rely on, then a real 802.1AB frame was sent via a raw
AF_PACKET socket onto the actual NIC (not fed directly to parse_lldp()
in a unit test) and captured through the full pipeline, decoding
correctly.
</content>
</entry>
<entry>
<title>Fix IPv4/IPv6 fragment continuation data being decoded as fake transport headers</title>
<updated>2026-05-29T20:56:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-29T20:56:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=719b7f439c1c8c76d0573b34daa329061c9ad8a6'/>
<id>urn:sha1:719b7f439c1c8c76d0573b34daa329061c9ad8a6</id>
<content type='text'>
A real correctness bug, not just missing visibility: a non-first
fragment's payload is pure continuation data with no TCP/UDP/ICMP
header in it at all, but it was being handed to the transport parsers
unconditionally, which could misread arbitrary payload bytes as port
numbers, sequence numbers, etc. and print a plausible-looking but
entirely fake decode.

IPv4 gained identification/more_fragments/fragment_offset fields; a
nonzero offset now stops summarize_packet before transport dispatch,
reporting the fragment instead. IPv6 expresses fragmentation as an
extension header instead, so the fix lives in
walk_ipv6_extension_headers(): it already walked past Fragment
headers, but never checked the offset before continuing on as if a
transport header followed - the same bug, reached through a
different path. Fixed with an ESP-style hard stop on a nonzero offset.

Caught and fixed a second bug while writing the first fix, before it
ever ran: the fragment's Identification field was a loop-local
variable, discarded the moment the walk continued past a *first*
fragment (offset zero) to keep decoding the real payload underneath --
every later return reported no fragment id even though one applied.
Fixed by hoisting it to a variable that persists across iterations,
the same category of mistake as an earlier IGMPv3 bug.

Fuzzed afterward regardless (fuzz_ipv4, fuzz_ipv6, fuzz_summarize,
~16.8M combined runs) - clean. Live-verified with a real 4000-byte
ping to the local gateway over the actual 1500-MTU interface: both
directions fragmented into 3 pieces each, the first decoded normally
with a fragmentation note, and the continuation fragments correctly
showed only fragment metadata, no fake ICMP decode attempted.
</content>
</entry>
<entry>
<title>Decode ICMP embedded flows - show which packet actually triggered an error</title>
<updated>2026-05-27T23:23:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-27T23:23:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=e41dade9ac3bbd7ffbc1eec826801af1a38d8b9e'/>
<id>urn:sha1:e41dade9ac3bbd7ffbc1eec826801af1a38d8b9e</id>
<content type='text'>
Destination Unreachable, Time Exceeded, Redirect, Source Quench, and
Parameter Problem (plus their ICMPv6 equivalents) all carry, after
their own fixed header, as much of the packet that triggered the
error as the network could fit - always at least its IP header plus
the first 8 bytes of payload, exactly enough to recover TCP/UDP port
numbers. That embedded flow is the actual reason an ICMP error shows
up in a capture at all, and wasn't shown before this.

Reuses parse_ipv4()/parse_ipv6() directly on the embedded bytes rather
than a separate parser - it's a genuine (if truncated) IP packet, not
a different format. Two small is_error_type() helpers gate which ICMP
types this is attempted for, since echo/timestamp/Neighbor Discovery
types don't carry an embedded packet at all.

Live-verified with a real traceroute to 8.8.8.8 captured on wlp1s0:
genuine Time Exceeded messages from real intermediate routers - this
machine's own gateway, then real ISP infrastructure several hops out
- all correctly showing the flow that triggered them, matching
traceroute's own hop output.
</content>
</entry>
<entry>
<title>Decode DNS answer records (A/AAAA/CNAME) - resolved addresses, not just ancount</title>
<updated>2026-05-26T23:04:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-26T23:04:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=0122045b492f2bd41a74769b8e6a9cefc73f988b'/>
<id>urn:sha1:0122045b492f2bd41a74769b8e6a9cefc73f988b</id>
<content type='text'>
Probably the single most-wanted thing a packet analyzer shows that
this one didn't yet: responses showed ancount=N but never what a
query actually resolved to. A, AAAA, and CNAME rdata now render into
readable text; every other type is still walked correctly
(name/type/ttl/rdlength read and bounds-checked) but not rendered.

Needed a second name reader alongside the existing question-only
read_dns_name(): real answer records almost always compress their
NAME field as a 2-byte pointer back to the question, which the
original reader deliberately rejects. read_dns_name_following_pointers()
actually follows them, bounded by a maximum jump count rather than a
backward-only check - a cycle across pointers pointing at each other
would still loop forever under "must point backward", but can't
survive a hard cap on jumps followed.

Fuzzed the new pointer-chasing logic specifically before trusting it
(fuzz_dns, fuzz_summarize, ~5.1M combined runs) - exactly the kind of
attacker-influenced-offset code this project's fuzzing exists for.
Clean, no crashes or timeouts.

Live-verified extensively on wlp1s0: a direct query to 8.8.8.8 for
example.com resolved two real A records; a query for www.github.com
showed a real CNAME chain; and organic background DNS traffic from
this machine's own browser sessions showed AAAA records (including an
8-address response, all correctly listed) and DNS RR type 65 (HTTPS
records) correctly producing no answers suffix.
</content>
</entry>
<entry>
<title>Add deeper TLS (ServerHello, ALPN); fix a QUIC/TCP false-positive bug</title>
<updated>2026-05-26T07:16:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-05-26T07:16:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=c098f1742bb04fbe41fc6cf492cd334efef734eb'/>
<id>urn:sha1:c098f1742bb04fbe41fc6cf492cd334efef734eb</id>
<content type='text'>
TLS: ServerHello now reports the negotiated version and cipher suite
alongside the existing ClientHello SNI support, plus ClientHello's
ALPN extension. ServerHello's version prefers the supported_versions
extension over legacy_version when present - TLS 1.3 always sets
legacy_version to 0x0303 for middlebox compatibility, so reading only
that field would misreport every real TLS 1.3 connection as 1.2.
Cipher suite names are hardcoded only for TLS 1.3's five suites (a
small closed set); everything else reports as raw hex rather than a
guessed name from a "common suites" list.

Live-verifying that against a real Cloudflare TLS 1.3 handshake
surfaced a real, unrelated bug in the QUIC dissector added earlier: it was also being tried against TCP port-443 payloads
(a side effect of the earlier L7Registry port-sharing fix), and
produced false "QUIC" labels on TLS ciphertext continuation fragments
- large encrypted records split across multiple TCP segments, each
fed to the parser independently since this project doesn't reassemble
by default, so a later fragment's effectively random bytes
occasionally passed as a plausible QUIC header.

Fixed in two layers: parse_quic() now enforces RFC 9000's real 20-byte
cap on connection ID lengths, closing most of the long-header false-
positive surface; and L7Dissector gained a transport() method
(defaulting to kAny, so every other dissector's behavior is unchanged)
so QuicDissector can declare itself UDP-only - necessary because the
length cap alone can't touch QUIC's short-header form, which by design
has no structural signal beyond one bit once header protection can't
be removed without connection state.

Re-verified against the identical live scenario afterward: zero false
QUIC labels on the same Cloudflare TCP handshake, and a repeat of the
earlier real HTTP/3 capture confirmed genuine QUIC still decodes
correctly on UDP.
</content>
</entry>
<entry>
<title>Add RTP/RTCP as a labeled heuristic fallback for unmatched UDP traffic</title>
<updated>2025-11-19T19:18:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-11-19T19:18:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=2d010c9f851ea4eb851179db012c8977ce6e4bd5'/>
<id>urn:sha1:2d010c9f851ea4eb851179db012c8977ce6e4bd5</id>
<content type='text'>
Architecturally different from every other protocol added so far: RTP
has no fixed well-known port at all - it's negotiated per call via
SDP/SIP/WebRTC signaling this project doesn't parse - so L7Registry's
port-keyed dispatch doesn't apply. Handled instead as a fallback tried
only when a UDP packet's normal port-based lookup finds nothing, with
every match labeled "?" (e.g. "RTCP? SR") to mark it as inferred from
packet shape rather than certain - the same honesty Wireshark itself
applies to heuristic dissection, which is off by default there for
exactly this reason.

The two heuristics aren't equally trusted, and the code says so: RTCP
checks a narrow packet-type range (200-204) plus an exact self-declared
length, both unlikely to occur by chance; RTP leans mostly on the 2-bit
version field, since its other structural checks are trivially
satisfied whenever those bits happen to be zero, the common case even
for unrelated traffic. Shipped anyway - a labeled guess on real
RTP/RTCP traffic is more useful than silence - but this is the first
place in the project where a match doesn't mean certainty.

Live-verified against genuine media traffic: ffmpeg streaming a real
RTP video test pattern to loopback, correctly decoded with incrementing
sequence numbers and a consistent SSRC across the stream, plus a real
RTCP Sender Report ffmpeg sent alongside it.
</content>
</entry>
<entry>
<title>Add SNMP (v1/v2c) with a minimal local ASN.1 BER reader</title>
<updated>2025-11-18T20:04:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-11-18T20:04:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=a8f4866576fd70894ef0080c7797708db664880e'/>
<id>urn:sha1:a8f4866576fd70894ef0080c7797708db664880e</id>
<content type='text'>
First dissector needing actual ASN.1 decoding - a small local
tag/length/value reader, not a general ASN.1 decoder, just enough to
walk SNMP's own SEQUENCE/INTEGER/OCTET STRING structure. v3 wraps the
PDU in its own security-parameters header instead of a plain community
string and can be encrypted, so it's reported by version alone, the
same "don't take on real crypto" call already made for TLS/QUIC.
Community strings are shown as-is, matching FTP's PASS precedent --
v1/v2c send them in the clear regardless.

SnmpDissector takes its port in the constructor so it can be
registered twice, at 161 (agent) and 162 (trap receiver). Unlike
DHCP's 67/68, trap traffic never touches 161 on either side (ephemeral
source port straight to 162), so there's no shared port for
l7_summarize()'s dst-then-src fallback to land on - both ports need
explicit registration.

Live-verified against a real snmpd (net-snmp 5.9.5.2) on loopback: a
real snmpget GetRequest/GetResponse exchange decoded correctly with
matching request-ids across both directions, and a real snmptrap
SNMPv2-Trap on port 162 confirmed the second registered port actually
gets used.
</content>
</entry>
<entry>
<title>Add a cleartext-only QUIC dissector; fix a port-collision bug in L7Registry</title>
<updated>2025-11-16T20:17:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-11-16T20:17:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=3af3e356d6fdf43e6772dc2e91b322f8e8148f62'/>
<id>urn:sha1:3af3e356d6fdf43e6772dc2e91b322f8e8148f62</id>
<content type='text'>
QUIC decodes only what RFC 9000 sends in cleartext at the framing
level: long/short header form, version, long-packet type, and both
connection IDs. Everything past that is encrypted from the first
protected byte onward, even for Initial packets - decrypting that is
real crypto machinery this project deliberately doesn't take on, the
same call already made for TLS's SNI-only extraction.

Caught a real, previously-latent bug while wiring this in, by design
review rather than live-traffic debugging: L7Registry::dissect()
returned on the first dissector whose port() matched, even if that
dissector's summarize() then failed. Harmless while every registered
port was unique, but QUIC is the first protocol here to genuinely
share a well-known port with something already registered (443: TLS
over TCP, QUIC over UDP - the registry has no transport dimension).
Without the fix, tls_dissector would silently claim every port-443
lookup and QUIC would never be reachable. Fixed to try each same-port
dissector until one actually succeeds, locked in with stub-dissector
tests independent of any real protocol's parsing.

Live-verified thoroughly: real HTTP/3 traffic to google.com via
`curl --http3-only`, captured on wlp1s0, correctly decoding the full
connection lifecycle against Google's actual production QUIC
implementation - Initial packets (including a genuine connection ID
migration mid-handshake), Handshake packets, and 1-RTT short-header
packets. This also confirms the L7Registry fix live: without it none
of this would have decoded at all.
</content>
</entry>
<entry>
<title>Add FTP, SMTP, TFTP, and IGMP; fix an IGMPv3 group-address misparse</title>
<updated>2025-06-30T22:40:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-06-30T22:40:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=407249eb5d654b5a951c43bc1722fd397d5d922c'/>
<id>urn:sha1:407249eb5d654b5a951c43bc1722fd397d5d922c</id>
<content type='text'>
FTP and SMTP share HTTP's line-based response-code-or-command shape
but keep their own command vocabularies in separate files rather than
sharing a parser. FTP passwords are shown as-is, not redacted - FTP
sends them in the clear regardless, matching Wireshark's own behavior.
TFTP is a small binary opcode protocol (RFC 1350) instead. IGMP sits
directly on IP like ICMP, so it's dispatched by protocol number rather
than through the port-keyed L7Registry the other three use.

Live-verified: FTP/SMTP against minimal real TCP servers written for
this (nothing installed locally), a full command/response exchange
decoded correctly in both directions. TFTP against a real atftpd
server and atftp client - the RRQ decoded correctly even though the
transfer itself didn't complete (an atftpd sandbox issue, not this
code). IGMP against real multicast traffic on wlp1s0, including a
genuine query from the actual router.

That live IGMP traffic caught a real bug before it shipped further:
parse_igmp() read bytes[4:8] as a group address for every message
type, but IGMPv3 reports use those bytes for Reserved+RecordCount
instead - a real V3 report showed "group=0.0.0.1" (0 reserved, 1
record, misread as an IP). Fixed by only populating group for the
types where it's genuinely an address; re-verified against the same
live traffic, and a regression test locks in the exact pattern.
</content>
</entry>
<entry>
<title>Unwrap VLAN (802.1Q/802.1ad) tags before protocol dispatch</title>
<updated>2025-06-26T12:59:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-06-26T12:59:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/packeteer/commit/?id=9046e6a10fd2d987cf6f6dc601ed3a75d286793f'/>
<id>urn:sha1:9046e6a10fd2d987cf6f6dc601ed3a75d286793f</id>
<content type='text'>
The single highest-value coverage gap so far, and structural rather
than a new dissector: a VLAN-tagged frame's ethertype reads as 0x8100,
so every existing decoder - ARP, IPv4, IPv6, and everything built on
top of them - was completely invisible on any tagged network.

walk_vlan_tags() (ethernet.hpp) is composable and separate from
parse_ethernet(), the same relationship walk_ipv6_extension_headers()
has to parse_ipv6(): the base parse stays an unconditional fixed-header
decode, and this is what a caller reaches for when it needs the real
protocol underneath. Handles stacked (QinQ) tags, bounded at 4 levels
against a corrupt/hostile frame claiming an unbounded chain.

Live-verified with genuine kernel-tagged frames, not synthetic bytes:
a dummy0 interface with an 802.1Q dummy0.42 sub-interface (VLAN 42),
captured on the parent while pinging out the sub-interface. Both
interfaces and the kernel modules they pulled in were torn down
afterward.
</content>
</entry>
</feed>
