| Age | Commit message (Collapse) | Author | Files | Lines |
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
NTP decodes the fixed header's 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 BOOTP fixed header + magic cookie, then walks
the TLV options bounds-safely for option 53 (message type), plus
yiaddr when the server has assigned one. It's the first dissector
needing two well-known ports (67 server, 68 client) rather than one;
registering at just 67 still matches both directions since
l7_summarize() already falls back from dst_port to src_port.
Verified live: NTP against a real pool.ntp.org query on wlp1s0 (a
genuine stratum-2 reply came back). DHCP over loopback with a
synthetic-but-wire-format-real DISCOVER/OFFER exchange rather than a
real lease renewal, to avoid disrupting this machine's actual network
state - caught a test-setup mistake in the process (both packets
sent from the same ephemeral port instead of the OFFER actually
originating from port 67), not a dissector bug.
|
|
ARP had zero treatment until now - its ethertype just fell through
summarize_packet's "not IPv4/IPv6" branch, on traffic that appears on
essentially every real LAN capture. Scoped to Ethernet/IPv4 addressing
(the case that covers virtually all real ARP traffic), with tcpdump's
own "who-has X tell Y" / "X is-at Y" phrasing rather than inventing
new wording. Live-verified by flushing this machine's real gateway
ARP entry and capturing the resulting request/reply on wlp1s0.
TUI -c/-a were being parsed into RenderOptions but silently did
nothing: run_tui()'s consumer thread had its own loop that never read
them, unlike plain-text mode's render_packet(). Fixed to match, and
caught a real bug while doing it - pushing up to two rows per packet
(summary + reassembly line) against a single pop_front() would let
the row deque grow past its cap under sustained -a activity; needed a
while loop instead. Verified under tmux against the same split-segment
HTTP scenario used to verify -a on the CLI and GUI.
|
|
Decided on the name after weighing alternatives in NAMES.md: packeteer
(packet + -eer, "one who wields packets") fit the project's actual
scope better than the wire/frame pun once it had grown into full
L2-L7 dissection, reassembly, checksums, privilege dropping, and dual
TUI/GUI frontends. No existing packet-capture project uses the name;
the one real-world collision (Packeteer, Inc., a networking company
acquired and folded into Blue Coat/Symantec by 2008) is long defunct.
Mechanical rename throughout: CMake project/target names, the
wireframe:: namespace and include/wireframe/ directory (git mv,
history preserved), every #include path, CLI/GUI help text, and the
project's own working directory. NAMES.md rewritten to record the
decision instead of leaving stale self-referential etymology behind
from the blind rename pass.
Verified after every step: full rebuild (all four targets, no
warnings) and the full test suite (128/128 cases, 366/366 assertions)
both from a fresh reconfigure and again after the directory move.
|
|
GUI parity: checksum_status()/reassembled_http_status() moved out of
main.cpp into a shared wireframe/packet_diagnostics.hpp so the GUI can
show the same -c/-a diagnostics for the selected packet without
duplicating the Ethernet/IPv4/TCP walk. Visually verified under Xvfb
with the same split-segment scenario used to verify -a on the CLI.
Two new L7 dissectors: mDNS (reuses parse_dns outright - RFC 6762
keeps DNS's wire format, just a different port) and SSH's cleartext
identification banner. Live-verified against this machine's real
sshd and a real DNS-wire-format packet sent to port 5353.
Two new fuzz harnesses (fuzz_checksum, fuzz_tcp_reassembly) covering
code added here that the original nine harnesses never
touched. All 12 run clean across ~90M executions with no crashes.
NAMES.md and PLAN.md updated with this round's decisions and naming
candidates.
|
|
and TCP reassembly
Rounds out the build order in PLAN.md with six incremental additions:
drop root privileges immediately after opening the capture handle;
a standalone AF_PACKET/mmap ring-buffer demo (kept separate from
CaptureSession, see its header comment for why); ICMPv4/ICMPv6 type
and code decoding; opt-in IPv4/TCP/UDP checksum validation (-c);
CLI --help; and opt-in, in-order-only TCP stream reassembly (-a) so
HTTP requests/responses split across segments can be seen whole.
Each addition is unit-tested and, where it touches live traffic
behavior, verified against real captured packets - see PLAN.md's
Decisions section for the verification notes on each.
|
|
Terminal packet capture and analysis tool built to learn the C++
memory model (byte layout, alignment, endianness, std::span over
unowned buffers) via a real capture pipeline.
- Hand-rolled L2-L4 decoders (Ethernet, IPv4, IPv6 with extension
header walking, TCP, UDP) over std::span, no struct-casting
- L7 dissector interface with DNS, HTTP, and TLS SNI implementations
- pcapng read/write for Wireshark-compatible capture files
- Bounded capture queue: drop-on-backpressure for live capture,
blocking push for faithful file replay
- Kernel-level BPF filtering (-f) and a separate display-only search
(-g / interactive) that doesn't touch what's captured
- Replay mode (-r) reads a saved pcapng file back through the same
pipeline as live capture, no root or live device needed
- pcap_stats() surfaces kernel/interface drops invisible to the
capture queue's own counter
- Three frontends sharing one CaptureSession setup path: CLI, TUI
(FTXUI, primary), GUI (Dear ImGui + SDL3, secondary)
- 89 unit tests (doctest) plus 9 libFuzzer harnesses covering every
hand-rolled parser; fuzzing found and fixed a real OOM in the
pcapng reader (unbounded allocation from an untrusted length field)
|