# packeteer - Packet Analyzer / Network TUI ## Overview Terminal packet capture and analysis tool. Primary goal: learn the C++ memory model (byte layout, alignment, endianness, `std::span` over unowned buffers) via a real-world capture pipeline. ## Stack - Language: C++ (first of two C++ projects - build this one first) - Capture: libpcap, or raw `AF_PACKET` with an mmap'd ring buffer to skip libpcap's copies - Parsing: hand-rolled L2-L4 decoders over `std::span`, L7 dissectors as a small interface/vtable so protocols can be added incrementally - Optional: `aya`-style in-kernel filtering isn't available in C++; if eBPF filtering is wanted later, that's a separate learning detour - UI: TUI (library TBD - ftxui or notcurses are the usual C++ options) - Output format: pcapng (not pcap) so interface metadata survives and files stay Wireshark-compatible ## Architecture sketch - Capture thread (owns the pcap/AF_PACKET handle) -> bounded channel -> render/analysis thread. A traffic spike should drop packets, not block the UI. - Drop privileges immediately after opening the capture handle; use `CAP_NET_RAW` via file capabilities instead of running as root. ## Build order 1. [done] Raw capture -> hex dump to stdout 2. [done] Ethernet/IP/TCP/UDP decoders + live packet list in TUI (-t) 3. [done] pcapng read/write 4. [done] Bounded channel + drop-on-backpressure between capture and render 5. [in progress] L7 dissector interface, add protocols incrementally -- interface + DNS + HTTP + TLS SNI + mDNS + SSH banner + NTP + DHCP + FTP + SMTP + TFTP + QUIC + SNMP done (packeteer/l7/); ARP/VLAN/IGMP done at the L2/L3 level too, and RTP/RTCP as a heuristic UDP fallback (packeteer/net/); more protocols can still be added incrementally, by design 6. [done] Filtering (-f , libpcap's own BPF compiler - see Decisions) 7. [done] Drop privileges after opening the capture handle (see Decisions) 8. [done] TCP stream reassembly, opt-in via -a (see Decisions) ## Open questions None currently open. ## Decisions - TUI library: FTXUI (v7.0.3, fetched via CMake FetchContent). Chosen over notcurses for pure-C++ portability (no C build-system/dependency chain to fight on Gentoo/low-spec machines) and genuine native Windows console support, which notcurses lacks - both matter given this needs to work everywhere. - GUI added as a secondary frontend - TUI stays primary (explicit user direction). Dear ImGui + SDL3 (v1.92.9b / release-3.4.14, both FetchContent, same approach as FTXUI/doctest - no system-package dependency, builds the same way everywhere). SDL3 over SDL2: this ecosystem already carries sdl2-compat as an SDL3-backed shim, so SDL3 is the live line, not legacy. SDL_Renderer backend, not raw OpenGL3 - avoids needing a separate GL function loader as another dependency, which matters more here than raw rendering performance does. src/gui_main.cpp; parity with the CLI/TUI is structural, not incidental - all three go through the same packeteer::CaptureSession (packeteer/capture_session.hpp) for device-open/datalink-validate/ filter/pcapng/signal-handler setup, so the GUI can't silently skip a step (e.g. the DLT_RAW check) the way two hand-copied setups would eventually drift. - Tests: doctest (v2.5.3, FetchContent), tests/ mirrors include/packeteer/. Every module gets unit tests as it's built, not backfilled later -- `cmake --build build && ./build/packeteer_tests` (or `ctest`) should stay green at every commit. - Filtering: libpcap's own pcap_compile()/pcap_setfilter() (tcpdump syntax, kernel-level via BPF), not a hand-rolled parser - the parser/compiler already exists, is correct, and reimplementing it has no bearing on this project's actual goal (the C++ memory model). packeteer/filter.hpp wraps compilation; testable without root via pcap_open_dead(). Verified live: -f "tcp port N" and -f icmp each correctly suppressed non-matching traffic that was actually present. - pcap_stats(): CaptureSession::stats() surfaces kernel/interface-level drops (ps_recv/ps_drop/ps_ifdrop), shown in CLI/TUI/GUI whenever nonzero. Distinct from CaptureQueue::dropped() - verified live that the two really do measure different things: a short capture showed ps_recv=12 against only 4 packets actually rendered, i.e. packets the kernel had already received but that were never dispatched to our callback before shutdown, with queue-side drops at 0 throughout. - Fuzzing: libFuzzer harnesses (fuzz/, clang + ASan/UBSan, opt-in via -DPACKETEER_ENABLE_FUZZING=ON -DCMAKE_CXX_COMPILER=clang++, separate build-fuzz/ dir) for every hand-rolled decoder plus the pcapng reader and the full summarize_packet() pipeline - the highest-value tests in the repo given the project's actual goal (byte layout/alignment/ UB on parsers over untrusted bytes), not an afterthought. Found and fixed a real bug on the first run: Reader::next_packet() allocated a block's claimed size (an untrusted 32-bit field straight from the file) before validating it, so a corrupted/hostile pcapng file could OOM the process. Fixed with a 1 MiB body-size cap (reader.hpp is explicitly scoped to pair with our own writer, whose packets are capped at a 65535 snaplen, so this is generous, not tight) and locked in with both a unit test and a passing re-fuzz of the exact crashing input. ~23M total fuzz executions across all 8 harnesses this session, one bug found and fixed, zero remaining crashes. - HTTP L7 dissector (packeteer/l7/http.hpp): best-effort single-segment request/status-line parse (+ Host: header for requests), same scope DNS already has - no TCP stream reassembly, so a message split across packets is only partially visible. This is the first registered dissector to actually exercise L7Registry's TCP-payload path; DNS alone never did, since it only runs over UDP. Verified live against a real HTTP request/response (curl -> python http.server on port 80): both directions decoded correctly, including the dst-port-then-src-port fallback in l7_summarize (request matches on dst_port=80, response matches on src_port=80). Fuzzed separately (fuzz_http.cpp, 5.3M runs, no crashes) since the string_view request- line/header scanning is new hand-rolled logic distinct from anything fuzz_summarize's binary-format parsers already cover. - TLS SNI L7 dissector (packeteer/l7/tls.hpp): parses a ClientHello's record/handshake/extensions structure (nested TLVs, every length bounds-checked against attacker-influenced fields at every level -- the most structurally complex hand-rolled parser in the project) to extract the SNI extension. Answers what HTTP alone increasingly can't: most web traffic is TLS-encrypted, and the server name is the one thing still readable in cleartext, in every TLS version, before encryption starts. Same single-segment scope as DNS/HTTP. Verified against real, unsolicited internet traffic captured live on wlp1s0 (not loopback/synthetic) - correctly extracted a genuine SNI from a real ClientHello. Fuzzed the hardest of any target so far given the nesting depth: fuzz_tls.cpp, 25.7M runs, no crashes. - IPv6 extension headers: walk_ipv6_extension_headers() (ipv6.hpp) walks Hop-by-Hop, Routing, Destination Options, Fragment, and AH to find the real transport protocol underneath them, so e.g. TCP wrapped in a Hop-by-Hop options header is decoded instead of silently stopping. ESP is a deliberate hard stop, not an oversight: its own next-header field lives in a trailer after the encrypted payload, at an offset unknowable without decrypting first - reported as "ESP (encrypted)" rather than guessed at. parse_ipv6() itself stays an unconditional decode of just the fixed 40-byte header; the walk is a separate, composable function summarize.hpp calls, so parse_ipv6's existing tests didn't need to change. Verified end-to-end (a Hop-by-Hop-wrapped TCP frame decodes through to the TCP layer via summarize_packet, not just the walker in isolation) and fuzzed (extended fuzz_ipv6.cpp, 6.3M runs; fuzz_summarize.cpp indirectly covers it too, 4.3M more) - no crashes. This was the last item on the known-gaps list; none remain. - Post-capture search: packeteer/search.hpp's matches_search() is a display filter, deliberately distinct from -f's capture filter -- -f decides what's captured (and written to -w); search decides what's shown, without touching either, same distinction Wireshark draws between a capture filter and a display filter. CLI: -g (only suppresses what's printed; -w output is unaffected). TUI: '/' opens live-filtered search (Enter keeps the filter and returns to browsing, Esc clears it), verified interactively via a real terminal (tmux capture-pane) - typing, backspace, both Enter and Esc paths, and confirmed 'q' still quits correctly afterward. GUI: a search box next to the capture-info line, using io.WantCaptureKeyboard to route Esc to "clear the search" while the box has focus vs. "quit the app" otherwise - verified visually via Xvfb, including the focused/ unfocused Esc distinction actually working both ways. One real methodology lesson from building this: an initial pty-based interactive test of the TUI (raw-byte capture, regex-matched against unparsed ANSI escape sequences) appeared to show a redraw bug -- typing "abc" only ever displayed "a". Chasing it added an unnecessary PostEvent "fix" before re-verifying under tmux (which properly resolves escape sequences via a real terminal emulator) showed the original code was correct all along; the first test method just wasn't reliable enough to trust. The PostEvent change was reverted -- correct code, not narrowly-passing code, was the actual goal. - Replay mode (-r ): reads a previously-saved pcapng file back through the exact same CaptureQueue/render/search pipeline as a live capture - the render/consumer side only ever talks to a CaptureQueue, so it can't tell whether packets are arriving from pcap_loop or being read back from disk. All in CaptureSession, so every frontend gets it for free rather than needing a second code path. Reader gained link_type() (the datalink from the file's IDB, previously discarded) so replayed packets decode with the correct DLT_EN10MB/DLT_RAW branch instead of an assumption; CaptureQueue gained a blocking push() alongside the existing drop-on-full try_push(), because a live capture thread can't be allowed to stall but a file has no real-time pressure forcing a drop - dropping from what's supposed to be a faithful replay of a fixed record would defeat the point of replaying it. -f (capture filter) is rejected outright when combined with -r, with an actionable error pointing at -g, rather than silently ignored. Verified live end-to-end, not just via unit tests: captured real traffic with -w on both DLT_EN10MB (lo) and DLT_RAW (tailscale0), replayed each file with -r with no root/live device needed, and the output matched the original capture exactly, including L7 dissection (DNS) surviving the round-trip. The replay thread finishing (not killed via request_stop()) means the file is exhausted, not that the user wants to quit - CaptureSession::stop_requested() distinguishes the two, and TUI/GUI both leave the window open on natural end-of-file (the point of replaying into an interactive frontend is browsing/searching afterward, not watching it flash by), closing only on an explicit 'q'/Esc/window-close or an external signal. Verified interactively in both: tmux capture-pane confirmed the TUI stays open with "[replay finished]" shown, search still works against the now-static list, and 'q' closes it; Xvfb confirmed the same for the GUI, including a live process check across a multi-second wait to rule out a delayed auto-close. - Privilege dropping (packeteer/privileges.hpp): after pcap_open_live() succeeds - the only operation that actually needs CAP_NET_RAW - and before the datalink check or a -w file is even created, drop from root to the invoking user via sudo's SUDO_UID/SUDO_GID. setuid() to a nonzero UID clears the process's Linux capability sets as a kernel side effect too, so this covers both "ran via sudo" and "root's own CAP_NET_RAW" without a separate libcap dependency, and as a side benefit means -w's output file ends up owned by the real user, not root (previously needed a manual chown after every capture - every live test earlier this session did). Recommended usage skips this path entirely: `sudo setcap cap_net_raw+ep ` once, then run unprivileged forever after, matching PLAN.md's original "use CAP_NET_RAW via file capabilities instead of running as root." Only the non-root no-op path is unit-testable without a test process permanently dropping its own privileges mid-suite, which would be a surprising thing for a unit test to do - so the real drop sequence was verified live instead: running via sudo, /proc//status showed Uid go from 0 to the real UID and CapEff/CapPrm both go to zero within about a second of startup, with capture continuing to work correctly afterward (proving the already-open fd keeps working regardless of the process's current privilege level, which is the whole point of "drop after open"). Separately verified the setcap-without-sudo path works with zero privilege escalation at any point in the process's life. - AF_PACKET/mmap ring buffer (src/afpacket_capture.cpp, packeteer_afpacket_demo, Linux-only): PLAN.md's originally-listed alternative capture backend, built as a standalone artifact rather than swapped into CaptureSession - the existing pipeline has real, tested value riding on libpcap's APIs (pcap_setfilter, pcap_stats, pcap_datalink) that a raw-socket path would need to reimplement from scratch at every one of CaptureSession's already-verified call sites, real risk to 90 passing tests for a copy-avoidance benefit modern libpcap on Linux already gets much of internally. TPACKET_V2 (simpler one-frame-per-slot layout than V3's block-batching) mmap'd directly into the process, packets read via std::span pointing straight into that kernel-shared mapping - no read()/recv(), no buffer of our own, genuinely zero copies between the NIC and summarize_packet() seeing the bytes. Reuses drop_privileges_if_root() (same principle, same code, right after the ring is mapped and bound). Verified against real traffic on both lo and the physical wlp1s0 interface - full TCP handshakes, DNS, mDNS, ICMPv6 all decoded correctly across a large volume of genuine traffic, no crashes, no leaked sockets/mappings after exit, tests and the rest of the build entirely unaffected by its addition. - ICMP decoding (packeteer/net/icmp.hpp): previously every ICMPv4 packet just showed "proto=1" with nothing further - no dissector existed at all - despite ICMP being most of this session's own test traffic (every ping). ICMPv6 was labeled but not decoded either. ICMPv4 and ICMPv6 share the same first-4-byte shape (type/code/ checksum) but a completely different type namespace - the same number means something different in each (ICMPv4 type 8 is Echo Request; ICMPv6's Echo Request is 128, and its own type 8 isn't defined at all) - so they get separate parse functions and type-name tables, not one shared by number. Neither protocol has ports, so this doesn't fit L7Registry's port-keyed dispatch; both are handled directly by IP protocol number in summarize_transport_and_above instead. Verified live against real ping traffic on both lo (proto=1) and ::1 (proto=58) - request/reply pairs decoded correctly on both, including matching identifier/sequence numbers between each request and its reply. - -h/--help: both packeteer and packeteer_gui now print real usage text (each binary's actual flag set - the GUI never had -x/-t/-g, so its help doesn't claim it does) and exit 0 before touching a device or any privilege at all. Previously -x -t -w -f -g -r all existed with zero discoverability outside reading the source. - Checksum validation (packeteer/net/checksum.hpp): RFC 1071 Internet checksum, plus IPv4-header/TCP/UDP verification built on it (IPv6 checksums use a different pseudo-header and different optionality rules - not done here, a reasonable follow-on if wanted). UDP's checksum is optional over IPv4: a transmitted value of exactly 0x0000 means "not computed", reported as its own kNotPresent state, not folded into invalid. Deliberately not part of summarize_packet's shared output - opt-in via the CLI's -c flag only (same plain-text-mode-only precedent -x/hex-dump already set), because checksum offload means many outbound and loopback packets can legitimately show an invalid checksum with nothing actually wrong: the NIC computes the real one in hardware during DMA, which is often after the capture point already saw the packet. Wireshark makes this opt-in for the same reason. internet_checksum() itself is verified against RFC 1071's own worked example (an external reference value, not derived from this code), not just internal self-consistency. Live-tested on lo and the physical wlp1s0 - both showed IP=ok/UDP=ok/TCP=ok throughout; `ethtool -k wlp1s0` shows tx-checksumming off on this machine's driver, which is exactly why (no hardware offload means the kernel computes real checksums in software) - so the "offload causes false BAD" case this feature exists to route around couldn't be reproduced on this specific sandboxed machine's NIC, but that's a property of this hardware, not a gap in the reasoning: most real NICs ship tx-checksum offload on by default, which is exactly the scenario -c's opt-in-ness is meant to keep from reading as false positives. - TCP stream reassembly (packeteer/net/tcp_reassembly.hpp): in-order-only - out-of-order segments and retransmissions are dropped, not buffered for later reordering. A real limitation, but an honest one for a learning tool captured directly on an endpoint (lo/wlp1s0/tailscale0, everything this project has actually run against), where segments mostly do arrive in order; a capture point far from either endpoint (a middlebox) would need real reorder buffering this doesn't attempt. Deliberately kept out of summarize_packet()'s shared signature and the TUI/GUI consumer loops - adding a TcpReassembler& parameter there would ripple into every call site and both frontends' render paths, risking the (at the time) 108 passing tests for a single opt-in feature. Instead it's CLI-only, opt-in via -a, same plain-text-mode-only precedent -x/-c already set: a separate TcpReassembler instance lives in main(), and render_packet() does its own minimal Ethernet/IPv4/TCP walk (mirroring checksum_status()) to feed segments in and, when new contiguous bytes come back, re-runs parse_http() (packeteer/l7/http.hpp) against the joined stream and prints the result as a distinct "[reassembled ...]" line, not folded into the per-packet summary. Deliberately calls parse_http() directly rather than going through L7Registry, so it isn't gated to port 80 the way the shared per-packet summary is - a deliberate difference, not an oversight. Live-verified against real split traffic: a Python client sent an HTTP request's request-line and its Host: header in two separate sendall() calls 0.3s apart with TCP_NODELAY set (to stop the kernel coalescing them back into one segment), captured on lo. The first segment's reassembled view showed the request line with no Host: (correct - it hadn't arrived yet); only once the second segment landed did Host: appear, confirming the two segments were actually joined rather than the dissector getting lucky on one segment alone. Buffers are capped per direction (64 KiB default) and the flow table is capped in total flow count, both to bound memory without needing active FIN/RST-triggered flow teardown - simpler, and stale entries past those caps don't affect correctness, just bounded memory use. - GUI parity for -c/-a: checksum_status() and reassembled_http_status() moved out of main.cpp into a new shared header (packeteer/packet_diagnostics.hpp) rather than duplicated into gui_main.cpp - the same reasoning packeteer::CaptureSession exists for at the setup layer, applied here to the diagnostics layer. GUI's hex dump was already always-on for the selected row (no -x-equivalent flag needed); checksum status is computed lazily when a row is selected (stateless, cheap); reassembled HTTP status has to be computed at consume time instead (reassembly needs in-order state across packets), so PacketRow gained an optional reassembled_http field set once in consumer_loop. Both are still opt-in via the same -c/-a flag names as the CLI, off by default. Visually verified under Xvfb (python-xlib synthetic click) with the same split-segment scenario used to verify -a on the CLI: selecting the packet whose segment completed the request showed both "checksums: IP=ok TCP=BAD" and "reassembled request: GET /split-test Host: split.example.com (72 bytes so far)" together in the details pane, and a second run with neither flag confirmed both lines are absent by default. The TCP=BAD reading on lo in that screenshot is the checksum-offload caveat working as documented, not a bug - Linux's loopback receive path typically never computes a real TCP checksum at all (CHECKSUM_UNNECESSARY), which is exactly the false-positive scenario -c's opt-in-ness exists to guard against. - mDNS (packeteer/l7/mdns.hpp) and SSH banner (packeteer/l7/ssh.hpp) dissectors, registered alongside DNS/HTTP/TLS in l7_registry(). mDNS reuses parse_dns() outright - RFC 6762 keeps DNS's exact wire format, just over UDP 5353 instead of 53 - and deliberately omits the id= field DNS's own summary shows, since RFC 6762 18.1 has multicast queries send it as zero, which would just be "id=0" noise on every real packet. SSH's identification banner (RFC 4253 4.2) is the one part of an SSH connection ever sent in the clear - a single line before key exchange encrypts everything else - so unlike every other dissector here, there's structurally nothing further to add to it later. Live-verified against real traffic: SSH against this machine's actual running sshd via /dev/tcp on lo, correctly decoding "SSH 2.0 OpenSSH_10.4" (matching the real installed OpenSSH version); mDNS via a real DNS-wire-format query sent to 127.0.0.1:5353 (no avahi/mDNS responder running on this sandboxed machine, so a synthetic-but-wire-format-real packet substituted for organic traffic), correctly decoding "mDNS query myhost.local type=1" with no id= field present. - Fuzzing: two new libFuzzer harnesses added alongside the original nine - fuzz_checksum (internet_checksum/verify_ipv4_checksum directly, plus verify_tcp/udp_checksum_ipv4 with the first 8 input bytes providing src/dst addresses) and fuzz_tcp_reassembly (unlike every other harness, drives *one* TcpReassembler with a whole sequence of segments parsed out of a single input, since the interesting bugs in cross-call state - the flow map, per-direction sequence tracking, the buffer cap - don't show up from one segment alone). All 12 harnesses (the original nine plus these two) run clean - no crashes, no ASan/UBSan errors, no leak/timeout artifacts - across roughly 90 million total executions in a 20-second-each pass; summarize's own harness also now exercises the ICMP decode path added earlier this session and the new mDNS/SSH dissectors, since all of that lives inside summarize_packet()'s call graph already. - Renamed wireframe -> packeteer (packet + -eer). See NAMES.md for the reasoning and the collisions checked before committing to it. - ARP (packeteer/net/arp.hpp): the one real-traffic L2 protocol that had zero treatment until now - an ARP frame's ethertype (0x0806) just fell through summarize_packet's "not IPv4/IPv6, stop after the Ethernet line" branch, on traffic that shows up on essentially every real LAN capture. Scoped to Ethernet hardware addresses (hlen=6) and IPv4 protocol addresses (plen=4) - the case that accounts for virtually all real ARP traffic; other combinations still decode the fixed header (hwtype/protype/opcode) without guessing at a different address width. Summary phrasing deliberately matches tcpdump's own "who-has X tell Y" / "X is-at Y" convention rather than inventing new wording, since that phrasing is already how anyone reading ARP traffic expects to see it. Verified against real traffic: flushed this machine's ARP cache entry for its actual default gateway and captured the resulting request/reply on wlp1s0 - "who-has 192.168.1.1 tell 192.168.1.104 (28:39:26:71:cd:dd)" followed by "192.168.1.1 is-at bc:07:1d:ff:54:e9", both addresses matching this machine's real interface/gateway. - TUI parity for -c/-a: unlike -x (hex dump, never ported to the TUI), -c/-a were already being parsed into RenderOptions for every mode -- main()'s arg parsing doesn't distinguish TUI from plain-text - but run_tui()'s consumer thread had its own separate loop that never read opts.verbose_checksums/opts.reassembler, so the flags silently did nothing in TUI mode despite appearing to be accepted. Fixed by mirroring plain-text render_packet()'s logic: checksum status appended inline to the row, a reassembled-HTTP line pushed as a second row right after, both via the same packeteer::checksum_status/ reassembled_http_status() the CLI and GUI already share. Caught a real bug while wiring this in, before it ever ran: pushing up to two rows per packet against a single `if (rows.size() > kMaxRows) pop_front()` would let the row deque grow unboundedly under sustained -a activity, since one pop can't offset two pushes -- changed to a while loop. Verified under tmux (capture-pane, not raw pty - see the search-bug methodology note above) against the same split-segment HTTP scenario used to verify -a on the CLI and GUI: both "checksums: IP=ok TCP=..." and "[reassembled request: ... Host: ... (72 bytes so far)]" appeared correctly inline in the packet list. - NTP (packeteer/l7/ntp.hpp) and DHCP (packeteer/l7/dhcp.hpp) dissectors. NTP decodes only the first two header bytes (version, mode, stratum) - the timestamp fields need NTP era/fraction fixed-point math to render meaningfully and add nothing a one-line summary needs, so they're left alone. DHCP decodes RFC 2131's fixed 236-byte BOOTP header + 4-byte magic cookie, then walks the variable-length TLV options bounds-safely to find option 53 (message type) - the one field that actually says DISCOVER/OFFER/REQUEST/ACK/ etc; other options are skipped over, not decoded. yiaddr (the address being offered/assigned) is shown when non-zero since it's genuinely new information, not a repeat of the IPv4 line's src/dst above it -- formatted with a small local snprintf helper inside dhcp.hpp rather than reusing summarize.hpp's ipv4_to_string, since summarize.hpp already depends on this header and the reverse include would be circular (same reasoning as arp_summary living in summarize.hpp instead of arp.hpp, just resolved in the other direction here). DHCP is the first dissector needing two well-known ports (67 server, 68 client) rather than one; registering at just 67 still matches both directions because l7_summarize() already falls back from dst_port to src_port. Verified live: NTP against a real query to pool.ntp.org on wlp1s0 ("NTP v4 client stratum=0" request, "NTP v4 server stratum=2" reply from an actual stratum-2 timeserver at 196.10.55.57). DHCP verified over loopback with a synthetic-but- wire-format-real DISCOVER/OFFER exchange (not a real DHCP renewal, to avoid disrupting this machine's actual network state) - caught a test-setup mistake in the process, not a dissector bug: the first attempt sent both packets from the same arbitrary ephemeral port, so the OFFER's source port never matched DHCP's server port and nothing decoded; fixed by actually binding the "server" send to port 67 (as real DHCP servers do), which then correctly decoded "DHCP OFFER yiaddr=192.168.1.50" on top of "DHCP DISCOVER" for the request. - VLAN (802.1Q/802.1ad) tag unwrapping: walk_vlan_tags() (ethernet.hpp) was the single highest-value gap found while pushing toward broader protocol coverage - not a new protocol dissector but a structural fix, since a tagged frame's ethertype reads as 0x8100 and every existing decoder (ARP, IPv4, IPv6, and everything built on top of them) was completely invisible on any VLAN-tagged network before this. Composable and separate from parse_ethernet() the same way walk_ipv6_extension_headers() is separate from parse_ipv6() - the base parse stays an unconditional fixed-header decode; this is what a caller reaches for when it needs the real protocol underneath. Handles stacked (QinQ, 802.1ad) tags, bounded at 4 levels so a corrupt/hostile frame claiming an unbounded tag chain can't spin -- real QinQ stacks are 2 deep at most. summarize_packet() still shows the literal on-the-wire outer ethertype (0x8100) plus a vlan=N (or vlan=N,M for stacked) annotation, then dispatches ARP/IPv4/IPv6 on the real ethertype underneath. Live-verified with genuine kernel-tagged frames, not synthetic bytes: created a `dummy0` interface with an 802.1Q `dummy0.42` sub-interface (VLAN 42), captured on the parent while pinging out the sub-interface, and got real 802.1Q-tagged ICMP echo requests back - "ethertype=0x8100 vlan=42 | IPv4 10.99.99.1 -> 10.99.99.2 ... | ICMP Echo Request" correctly unwrapped. Both virtual interfaces and the dummy/8021q kernel modules they pulled in were torn down afterward, restoring the machine to its prior state. - FTP (l7/ftp.hpp), SMTP (l7/smtp.hpp), TFTP (l7/tftp.hpp), and IGMP (net/igmp.hpp), pushing further toward broad real-world coverage. FTP's control channel and SMTP share the same line-based response-code-or-command shape as HTTP (SMTP's own RFC predates and clearly borrowed from FTP's), but were kept as separate files with their own command vocabularies rather than sharing a parser - the overlap is real but shallower than DNS/mDNS's identical wire format, not worth coupling two otherwise-independent protocols over. FTP passwords (PASS) are shown as-is, not redacted: FTP sends them in the clear regardless, so this reflects what's genuinely on the wire, the same reasoning Wireshark itself uses. TFTP is a small binary protocol instead (opcode + a shape that depends on it), decoded via RFC 1350; OACK is recognized by opcode but its options aren't parsed. IGMP sits directly on IP (protocol 2) like ICMP, so it's dispatched by protocol number in summarize_transport_and_above() rather than through the port-keyed L7Registry the other four use. Live-verified: FTP and SMTP against minimal real TCP servers written for this (no vsftpd/postfix installed on this machine) speaking genuine line protocol over real loopback TCP segments - both directions of a full USER/PASS/QUIT and EHLO/MAIL/RCPT/QUIT exchange decoded correctly. TFTP against a real atftpd server and atftp client - the client's actual RRQ packet decoded as "TFTP RRQ testfile.txt (octet)" (the transfer itself didn't complete, an atftpd sandbox/config issue unrelated to the dissector, but the request itself is what needed verifying). IGMP against real multicast traffic on wlp1s0: joining 239.255.255.250 from Python produced genuine IGMPv3 Membership Reports, and a real group-specific query later arrived from the actual router (192.168.1.1) - "IGMP Membership Query group=239.255.255.250". That same live traffic caught a real bug before it shipped further: the first parse_igmp() read bytes[4:8] as a group address for every message type, but IGMPv3 reports put Reserved+RecordCount there instead - a real V3 report showed "group=0.0.0.1" (literally "0 reserved, 1 group record" misread as an IP). Fixed by only populating IgmpMessage::group (now optional) for the types where those bytes genuinely are an address (query/v1/v2 report/leave); re-verified against the same live traffic afterward, confirmed clean, and a regression test locks in the exact byte pattern that triggered it. - QUIC (l7/quic.hpp), decoding only what RFC 9000 actually sends in cleartext at the framing level: long vs. short header form, version, long-packet type (Initial/0-RTT/Handshake/Retry/Version Negotiation), and both connection IDs. Everything past that -- packet numbers, frames, the payload - is encrypted from the first protected byte onward, even for Initial packets (whose keys derive via HKDF from a public per-version salt, then AES-GCM); actually decrypting that is real crypto machinery this project deliberately doesn't take on, the same call already made for TLS's SNI-only extraction. A short-header packet's destination connection ID has no length field in the packet itself - the receiver already knows it from earlier connection state a passive observer doesn't have - so short-header packets are reported by form alone. Discovered and fixed a real, previously-latent bug while wiring this in, caught by design review before it ever touched live traffic: 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 else already registered (443: TLS over TCP, QUIC over UDP; the registry has no transport dimension, only a port number). Without the fix, tls_dissector (registered first) would silently claim every port-443 lookup and return nullopt for all QUIC traffic, forever, regardless of registration order past it. Fixed to try each same-port dissector until one actually succeeds; locked in with tests/test_dissector.cpp using two stub dissectors on a shared port, independent of any real protocol's parsing logic. Live-verified about as thoroughly as anything in this project: real HTTP/3 traffic to google.com via `curl --http3-only` (curl here links ngtcp2/nghttp3), captured on wlp1s0, correctly decoding the full connection lifecycle - Initial packets (including a genuine connection ID migration mid-handshake, dcid changing from a2d81abd... to e2d81abd..., real QUIC behavior, not a parsing artifact), Handshake packets, and finally 1-RTT short-header packets - against Google's actual production QUIC implementation. This also serves as a real-traffic confirmation that the L7Registry fix works: without it, none of this would have decoded at all, since tls_dissector claims port 443 first. - SNMP (l7/snmp.hpp), scoped to v1/v2c - the first dissector needing actual ASN.1 BER decoding, via a small local TLV reader (tag/length/ value only, not a general ASN.1 decoder: no indefinite-length encoding, no multi-byte tag numbers, nothing beyond what SNMP's own SEQUENCE/INTEGER/OCTET STRING structure uses). v3 wraps the PDU in its own security-parameters header instead of a plain community string, and the PDU can be encrypted - reported by version alone, not decoded further, the same "don't take on real crypto" call as TLS/QUIC. Community strings are shown as-is, not redacted: v1/v2c send them in the clear regardless, same reasoning as FTP's PASS. SnmpDissector takes its port in the constructor rather than a fixed override, so it's registered twice - 161 (agent) and 162 (trap receiver). Unlike DHCP's 67/68, there's no port shared by both directions for l7_summarize()'s dst-then-src fallback to land on: a trap goes from an ephemeral source port straight to 162, touching 161 nowhere at all, so both had to be registered explicitly rather than relying on the fallback the way DHCP could. Live-verified against a real snmpd (net-snmp 5.9.5.2) on loopback: a real `snmpget` GetRequest/GetResponse exchange on port 161 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, not just the first. - RTP/RTCP (net/rtp.hpp, net/rtcp.hpp) - architecturally different from every other L7 protocol here: 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 simply doesn't apply. Handled instead as a heuristic fallback tried only when a UDP packet's normal port-based lookup finds nothing, and every match is labeled with a "?" (e.g. "RTCP? SR") to mark it as inferred from packet shape, not certain the way a port-matched dissector's result is - the same honesty Wireshark itself applies to heuristic dissection (off by default there for exactly this reason). The two heuristics are deliberately not equally trusted: RTCP's is comparatively strong (packet type in a narrow 200-204 range unlikely by chance, plus an exact self-declared length field); RTP's leans mostly on the 2-bit version field being 2, since CSRC count/extension/padding consistency checks are trivially satisfied whenever those bits are zero - the common case even for unrelated traffic. Both still ship, since a labeled guess on real RTP/RTCP traffic is more useful than silence, but this is the first place in the project where "matched" doesn't mean "certain." Live-verified against genuine media traffic: `ffmpeg -f lavfi -i testsrc ... -f rtp rtp://127.0.0.1:5004`, captured on loopback, correctly decoded a real RTP video stream (payload type 96, incrementing sequence numbers, one consistent SSRC across the whole stream) and a real RTCP Sender Report ffmpeg sent alongside it. - Deeper TLS: ServerHello (negotiated version, cipher suite) alongside the existing ClientHello (SNI) support, plus ClientHello's ALPN extension. Both hellos share almost all of their wire structure, so the record/handshake header parsing was factored into one shared detail::read_tls_handshake() rather than duplicated a second time. ServerHello's negotiated_version prefers the supported_versions extension over legacy_version when present: TLS 1.3 always sets legacy_version to 0x0303 (TLS 1.2) 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 (RFC 8446 B.4, a small closed set) - everything else is reported as a raw hex value rather than guessed at from a curated "common suites" list, which would be more misleading than a plain number for the suites it didn't happen to cover. Live-verified against a real Cloudflare TLS 1.3 handshake on wlp1s0: "TLS ServerHello version=TLS1.3 cipher=TLS_AES_256_GCM_SHA384" from cloudflare.com's actual production server, confirming both the supported_versions override and the cipher-suite naming. That same live capture surfaced a real, unrelated bug in the QUIC dissector added earlier this session: QuicDissector was also being tried against *TCP* port-443 payloads (a side effect of the L7Registry fix that let QUIC and TLS share port 443 at all), and produced real false "QUIC" labels on TLS 1.3 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: (1) parse_quic() now enforces RFC 9000 17.2's real 20-byte cap on connection ID lengths, closing most of the long-header false-positive surface; (2) L7Dissector gained a transport() method (default kAny, preserving every other dissector's exact current behavior unchanged) so QuicDissector could declare itself UDP-only - necessary because layer (1) alone couldn't touch QUIC's short-header form at all, 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: zero false QUIC labels on the same Cloudflare TCP handshake afterward, and a repeat of the earlier real HTTP/3 capture against google.com confirmed genuine QUIC traffic still decodes correctly on UDP. - DNS answer records: 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 are rendered into readable text; every other type is still walked correctly (name/type/ttl/rdlength all read and bounds-checked, so parsing the rest of the message doesn't break) but not rendered, the same "decode the common cases precisely rather than guess at everything" pattern as TLS's cipher suite names. 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 (RFC 1035 4.1.4), which the original reader deliberately rejects (a design decision from when only the question was parsed, preserved as-is). read_dns_name_following_pointers() actually follows them, bounded by a maximum jump count rather than a backward-only check - a cycle across several pointers pointing at each other would still loop forever under "must point backward", but can't survive a hard cap on jumps followed. AAAA is rendered with a plain, uncompressed hex-group formatter local to dns.hpp rather than summarize.hpp's RFC-5952-canonical ipv6_to_string, for the same circular-include reason arp_summary/dhcp's yiaddr formatter are where they are: correct, just not maximally compact. Fuzzed the new pointer-chasing logic specifically before trusting it (fuzz_dns, fuzz_summarize, ~2.4M and ~2.7M runs) - exactly the kind of attacker-influenced-offset code this project's fuzzing exists for, and the jump-bound is exactly the sort of thing worth confirming can't be made to hang, not just reasoned about. Clean, no crashes or timeouts either target. Live-verified extensively against real DNS traffic on wlp1s0: a direct query to 8.8.8.8 for example.com correctly resolved two real A records; a query for www.github.com correctly showed a real CNAME chain (-> github.com -> 20.87.245.0); and organic background DNS traffic from this machine's own browser sessions incidentally captured alongside it showed AAAA records (including one response with 8 real IPv6 addresses, all correctly listed), and DNS RR type 65 (HTTPS records, ancount=0 in these captures) correctly producing no answers suffix since there was nothing to list. - ICMP embedded-flow decoding: Destination Unreachable, Time Exceeded, Redirect, Source Quench, and Parameter Problem (ICMPv4) / their ICMPv6 equivalents all carry, after their own fixed header, as much of the packet that actually triggered the error as the network could fit - always at least its IP header plus the first 8 bytes of payload (RFC 792/4443), 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, the same insight DNS's answer-record work leaned on when it reused parse_ipv4's sibling reasoning for name compression. Two small is_error_type() helpers gate which ICMP types this is even attempted for (echo/timestamp/Neighbor Discovery types don't carry an embedded packet at all), rather than relying on parse_ipv4/parse_ipv6 to just fail gracefully on irrelevant types. Live-verified with a real traceroute to 8.8.8.8 (traceroute -I, ICMP-based) 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 "[this machine -> 8.8.8.8 proto=1]", matching traceroute's own hop output. - IPv4/IPv6 fragmentation: a real correctness fix, not just added visibility. Before this, a non-first fragment's payload - pure continuation data, no TCP/UDP/ICMP header present at all - was 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 on Ipv4Header; a nonzero fragment_offset now stops summarize_packet before it ever reaches transport dispatch, reporting the fragment itself instead. IPv6 expresses fragmentation as a Fragment extension header instead of header fields, so the fix lives in walk_ipv6_extension_headers(): it already walked past Fragment headers, but never checked the offset before continuing on to decode whatever followed as if it were a transport header -- exactly the same bug, just reached through the extension-header path instead of a header field. Fixed by having the walk stop immediately (like the existing ESP hard-stop) whenever the offset is nonzero, and reporting that stop via two new Ipv6ExtensionWalkResult fields. Caught and fixed a real bug in this fix while writing it, before it ever ran: the first version computed a fragment's Identification field as a loop-local variable, so it was silently discarded the moment the walk continued past a *first* fragment's header (offset zero) to keep decoding the real transport protocol underneath -- every return path after that point reported no fragment id at all, even though one genuinely applied. Fixed by hoisting it to a variable that persists across loop iterations, the same category of mistake (and the same fix) as this session's earlier IGMPv3 bug: state that needs to survive past the specific branch that computed it. Fuzzed afterward regardless (fuzz_ipv4, fuzz_ipv6, fuzz_summarize, ~16.8M combined runs) - clean, no crashes. Live-verified with genuine IPv4 fragmentation: a 4000-byte ping to the real local gateway over the actual 1500-MTU wlp1s0 interface, captured on the wire. Both directions fragmented into exactly 3 pieces each; the first fragment decoded normally (ICMP Echo Request/Reply, correct id/seq) with a "(fragmented, ...)" note, and the two continuation fragments correctly showed only fragment metadata - no fake ICMP decode attempted on their payload. IPv6 fragmentation is unit-tested (including the exact byte pattern that triggered the fragment-id bug above) but not live-verified: this machine has no real global IPv6 connectivity to generate genuine IPv6-fragmented traffic against, only link-local addresses. - LLDP (net/lldp.hpp), dispatched by ethertype (0x88CC) the same way ARP is - no IP layer at all. TLV-encoded (7-bit type + 9-bit length packed into each TLV's 2-byte header); only the three mandatory TLVs (Chassis ID, Port ID, TTL) plus System Name - usually the single most human-readable field in the whole frame - are rendered, while every other TLV type is still walked over correctly so nothing after it is lost. Chassis/Port ID's MAC-address subtype renders as hex-colon; every other subtype (interface name, locally-assigned string, etc.) as plain text, except "network address" (its own AFI-prefixed encoding, not decoded specially - uncommon enough in practice not to be worth a separate path). Live-verified two ways given this machine is on WiFi, where LLDP isn't relayed to wireless clients even when a real switch upstream sends it, and no LLDP daemon (lldpd et al.) is installed here to generate real Linux-side traffic either: first, a 15-second passive capture confirmed no organic LLDP traffic exists on this network segment to accidentally rely on; then a real, wire-format-correct 802.1AB frame was sent via a raw AF_PACKET socket onto the actual wlp1s0 NIC (not fed directly to parse_lldp() in a unit test) and captured through the full real pipeline - libpcap capture, Ethernet decode, TLV walk - correctly decoding "LLDP chassis=de:ad:be:ef:00:01 port=eth0 ttl=120 name=packeteer-test-host", an exact match for what was actually sent.