srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--CMakeLists.txt136
-rw-r--r--NAMES.md104
-rw-r--r--PLAN.md188
-rw-r--r--fuzz/fuzz_dns.cpp15
-rw-r--r--fuzz/fuzz_ethernet.cpp9
-rw-r--r--fuzz/fuzz_http.cpp15
-rw-r--r--fuzz/fuzz_ipv4.cpp9
-rw-r--r--fuzz/fuzz_ipv6.cpp24
-rw-r--r--fuzz/fuzz_pcapng_reader.cpp21
-rw-r--r--fuzz/fuzz_summarize.cpp18
-rw-r--r--fuzz/fuzz_tcp.cpp9
-rw-r--r--fuzz/fuzz_tls.cpp17
-rw-r--r--fuzz/fuzz_udp.cpp9
-rw-r--r--include/wireframe/byteio.hpp22
-rw-r--r--include/wireframe/capture_queue.hpp98
-rw-r--r--include/wireframe/capture_session.hpp301
-rw-r--r--include/wireframe/filter.hpp36
-rw-r--r--include/wireframe/l7/dissector.hpp45
-rw-r--r--include/wireframe/l7/dns.hpp108
-rw-r--r--include/wireframe/l7/http.hpp113
-rw-r--r--include/wireframe/l7/tls.hpp139
-rw-r--r--include/wireframe/net/ethernet.hpp44
-rw-r--r--include/wireframe/net/ipv4.hpp56
-rw-r--r--include/wireframe/net/ipv6.hpp173
-rw-r--r--include/wireframe/net/tcp.hpp54
-rw-r--r--include/wireframe/net/udp.hpp35
-rw-r--r--include/wireframe/pcapng/reader.hpp123
-rw-r--r--include/wireframe/pcapng/writer.hpp94
-rw-r--r--include/wireframe/search.hpp26
-rw-r--r--include/wireframe/summarize.hpp248
-rw-r--r--src/gui_main.cpp280
-rw-r--r--src/main.cpp326
-rw-r--r--tests/main.cpp2
-rw-r--r--tests/test_byteio.cpp23
-rw-r--r--tests/test_capture_queue.cpp122
-rw-r--r--tests/test_capture_session.cpp138
-rw-r--r--tests/test_dns.cpp82
-rw-r--r--tests/test_filter.cpp69
-rw-r--r--tests/test_http.cpp78
-rw-r--r--tests/test_ipv6.cpp169
-rw-r--r--tests/test_net.cpp124
-rw-r--r--tests/test_pcapng.cpp142
-rw-r--r--tests/test_search.cpp27
-rw-r--r--tests/test_summarize.cpp185
-rw-r--r--tests/test_tls.cpp132
46 files changed, 4191 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2fb620a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+build/
+build-fuzz/
+compile_commands.json
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..211738a
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,136 @@
+cmake_minimum_required(VERSION 3.20)
+project(wireframe CXX)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+if(NOT CMAKE_BUILD_TYPE)
+ set(CMAKE_BUILD_TYPE Debug)
+endif()
+
+add_compile_options(-Wall -Wextra)
+
+find_package(Threads REQUIRED)
+
+include(FetchContent)
+FetchContent_Declare(
+ ftxui
+ GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
+ GIT_TAG v7.0.3
+ GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(ftxui)
+
+add_executable(wireframe src/main.cpp)
+target_include_directories(wireframe PRIVATE include)
+target_link_libraries(wireframe PRIVATE
+ pcap
+ Threads::Threads
+ ftxui::component
+ ftxui::dom
+ ftxui::screen
+)
+
+# GUI (secondary to the TUI - see PLAN.md Decisions). Dear ImGui +
+# SDL3, same FetchContent approach as FTXUI/doctest: no dependency on
+# a system package, so it builds the same way on every platform this
+# project targets. SDL3 over SDL2 because sdl2-compat (an SDL3-backed
+# shim) is what's actually packaged in this ecosystem now - SDL3 is
+# the live line. SDL_Renderer (not raw OpenGL3) avoids needing a
+# separate GL function loader as another dependency.
+set(SDL_SHARED OFF CACHE BOOL "" FORCE)
+set(SDL_STATIC ON CACHE BOOL "" FORCE)
+set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
+FetchContent_Declare(
+ sdl3
+ GIT_REPOSITORY https://github.com/libsdl-org/SDL.git
+ GIT_TAG release-3.4.14
+ GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(sdl3)
+
+FetchContent_Declare(
+ imgui
+ GIT_REPOSITORY https://github.com/ocornut/imgui.git
+ GIT_TAG v1.92.9b
+ GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(imgui)
+
+add_library(imgui STATIC
+ ${imgui_SOURCE_DIR}/imgui.cpp
+ ${imgui_SOURCE_DIR}/imgui_draw.cpp
+ ${imgui_SOURCE_DIR}/imgui_tables.cpp
+ ${imgui_SOURCE_DIR}/imgui_widgets.cpp
+ ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp
+ ${imgui_SOURCE_DIR}/backends/imgui_impl_sdlrenderer3.cpp
+)
+target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
+target_link_libraries(imgui PUBLIC SDL3::SDL3)
+
+add_executable(wireframe_gui src/gui_main.cpp)
+target_include_directories(wireframe_gui PRIVATE include)
+target_link_libraries(wireframe_gui PRIVATE pcap Threads::Threads imgui SDL3::SDL3)
+
+enable_testing()
+
+FetchContent_Declare(
+ doctest
+ GIT_REPOSITORY https://github.com/doctest/doctest.git
+ GIT_TAG v2.5.3
+ GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(doctest)
+
+add_executable(wireframe_tests
+ tests/main.cpp
+ tests/test_byteio.cpp
+ tests/test_net.cpp
+ tests/test_ipv6.cpp
+ tests/test_dns.cpp
+ tests/test_http.cpp
+ tests/test_tls.cpp
+ tests/test_pcapng.cpp
+ tests/test_capture_queue.cpp
+ tests/test_filter.cpp
+ tests/test_summarize.cpp
+ tests/test_capture_session.cpp
+ tests/test_search.cpp
+)
+target_include_directories(wireframe_tests PRIVATE include)
+target_link_libraries(wireframe_tests PRIVATE doctest::doctest Threads::Threads pcap)
+
+add_test(NAME wireframe_tests COMMAND wireframe_tests)
+
+# libFuzzer harnesses for the hand-rolled decoders - the actual point
+# of this project (byte layout/alignment/UB) makes these the highest-
+# value tests in the repo, not an afterthought. Opt-in and clang-only
+# (libFuzzer is a clang/compiler-rt feature) so a normal `cmake --build`
+# with the default compiler is unaffected.
+option(WIREFRAME_ENABLE_FUZZING "Build libFuzzer harnesses (requires clang)" OFF)
+if(WIREFRAME_ENABLE_FUZZING)
+ if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
+ message(FATAL_ERROR "WIREFRAME_ENABLE_FUZZING requires clang (libFuzzer); "
+ "reconfigure with -DCMAKE_CXX_COMPILER=clang++")
+ endif()
+
+ function(add_wireframe_fuzz_target name)
+ add_executable(${name} fuzz/${name}.cpp)
+ target_include_directories(${name} PRIVATE include)
+ target_link_libraries(${name} PRIVATE pcap)
+ target_compile_options(${name} PRIVATE -fsanitize=fuzzer,address,undefined -g -O1)
+ target_link_options(${name} PRIVATE -fsanitize=fuzzer,address,undefined)
+ endfunction()
+
+ add_wireframe_fuzz_target(fuzz_ethernet)
+ add_wireframe_fuzz_target(fuzz_ipv4)
+ add_wireframe_fuzz_target(fuzz_ipv6)
+ add_wireframe_fuzz_target(fuzz_tcp)
+ add_wireframe_fuzz_target(fuzz_udp)
+ add_wireframe_fuzz_target(fuzz_dns)
+ add_wireframe_fuzz_target(fuzz_http)
+ add_wireframe_fuzz_target(fuzz_tls)
+ add_wireframe_fuzz_target(fuzz_pcapng_reader)
+ add_wireframe_fuzz_target(fuzz_summarize)
+endif()
diff --git a/NAMES.md b/NAMES.md
new file mode 100644
index 0000000..ae0e1e6
--- /dev/null
+++ b/NAMES.md
@@ -0,0 +1,104 @@
+# Naming - alternatives to "wireframe"
+
+Current name: **wireframe** - wire (network) + frame (Ethernet/IP frame,
+also doubles as a UI "wireframe"). Already a decent pun, kept here as the
+baseline to beat.
+
+Landscape checked for collisions / conventions: tcpdump, Wireshark, tshark,
+termshark, ngrep, ettercap, etherape, snoop, bmon, iftop, nethogs,
+bandwhich, trippy, gping, dog, ntap, netwatch.
+
+## Conventions those projects use
+
+- **Unix terseness**: tcpdump, ngrep, ss, ip - short, lowercase, often a
+ syscall or protocol abbreviation mashed with a verb (dump, grep, top).
+- **-shark family**: Wireshark → tshark (terminal) → termshark (TUI). A
+ recognizable brand extended by prefixing the interface type.
+- **Verb-as-noun branding**: bandwhich, trippy, dog, bat, fd, ripgrep - a
+ plain English word or pun, repurposed, no domain jargon in the name
+ itself. This is the modern Rust-CLI convention.
+- **Portmanteau of domain nouns**: etherape (ether + ape), snoop, ettercap
+ (etter + cap, Italian "hetter" + capture).
+
+## Candidates
+
+### Unix-style short (syscall/tool-terse)
+- `pktap` - packet + tap
+- `nettap`
+- `rawtap`
+- `spantap` - nods to `std::span`, the project's core learning device
+- `ethtap`
+- `frmtap` - frame + tap
+
+### -shark / portmanteau branding (extends the Wireshark lineage like tshark/termshark did)
+- `frameshark`
+- `spanshark`
+- `wiresnoop`
+- `packsnoop`
+- `bytewire`
+- `netframe`
+- `packframe`
+- `framewire`
+
+### Evocative single word (bandwhich/trippy/dog convention - plain word, no jargon)
+- `peek`
+- `probe`
+- `glimpse`
+- `sift`
+- `trawl`
+- `snare`
+- `prowl`
+- `siphon`
+
+### References `std::span` directly (the project's actual technical hook)
+- `spancap`
+- `spanview`
+- `bytespan`
+- `octospan`
+
+### Playful / punny
+- `Framed` - "you've been framed" (packet frames)
+- `Packeteer`
+- `Sniffy`
+
+## Recommendation
+
+If staying close to the current identity: **frameshark** or **spanshark** -
+same wire/frame pun as `wireframe`, but the `-shark` suffix signals
+"Wireshark-family tool" the way `tshark`/`termshark` do, which is the
+convention someone browsing packet tools will actually recognize.
+
+If going for the modern terse-CLI convention instead: **peek** or **probe**
+- short, typeable, no collision found in the tools checked above.
+
+`spantap`/`spancap` are worth considering only if you want the name itself
+to advertise the `std::span`-over-raw-buffers learning goal from PLAN.md -
+more of an in-joke for yourself than a discoverable tool name.
+
+## More candidates (added after building the L2-L4 decoders)
+
+Building `include/wireframe/net/{ethernet,ipv4,tcp,udp}.hpp` surfaced a
+few more angles - the decoders read one **octet** at a time by hand (no
+struct-casting, per PLAN.md's alignment/UB concerns), and the live output
+is fundamentally a **packet list view**, which is its own naming lane.
+
+- `octet` - the actual networking term for a byte; short, real word,
+ precise, and nobody else in the landscape checked above uses it.
+- `octetap`
+- `byteframe`
+- `framecap`
+- `tapframe`
+- `pcapview`
+- `netspan` - pairs "span" (the `std::span` hook) with "net" instead of
+ a -tap/-cap suffix
+- `wiretap` - plain-word option in the bandwhich/trippy lane; flag: it's
+ a common enough English/legal term that it may already be taken
+ somewhere, worth a quick search before committing
+- `flagship` - pun on TCP flags (SYN/ACK/FIN etc. decoded in
+ `tcp.hpp`); cute but arguably too cute / unclear at a glance that it's
+ a network tool
+
+No changes to the recommendation above - `frameshark`/`spanshark` (brand
+lineage) or `peek`/`probe` (terse-CLI lane) are still the strongest picks.
+`octet` is the one addition here worth weighing seriously: it's the most
+precise single word for what the tool actually operates on.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..ee96d15
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,188 @@
+# wireframe - 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 done (wireframe/l7/); more
+ protocols can still be added incrementally, by design
+6. [done] Filtering (-f <expr>, libpcap's own BPF compiler - 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 wireframe::CaptureSession
+ (wireframe/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/wireframe/.
+ Every module gets unit tests as it's built, not backfilled later --
+ `cmake --build build && ./build/wireframe_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).
+ wireframe/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
+ -DWIREFRAME_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 (wireframe/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 (wireframe/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: wireframe/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 <term> (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 <file>): 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.
diff --git a/fuzz/fuzz_dns.cpp b/fuzz/fuzz_dns.cpp
new file mode 100644
index 0000000..136c8f0
--- /dev/null
+++ b/fuzz/fuzz_dns.cpp
@@ -0,0 +1,15 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/l7/dns.hpp"
+
+// DNS name decoding (length-prefixed labels, a historically bug-prone
+// area in real-world parsers) is the main risk here - fuzz both the
+// raw parser and the dissector wrapper main.cpp actually calls.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_dns({data, size});
+
+ wireframe::net::DnsDissector dissector;
+ dissector.summarize({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_ethernet.cpp b/fuzz/fuzz_ethernet.cpp
new file mode 100644
index 0000000..91aa6d5
--- /dev/null
+++ b/fuzz/fuzz_ethernet.cpp
@@ -0,0 +1,9 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/ethernet.hpp"
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_ethernet({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_http.cpp b/fuzz/fuzz_http.cpp
new file mode 100644
index 0000000..18a9f69
--- /dev/null
+++ b/fuzz/fuzz_http.cpp
@@ -0,0 +1,15 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/l7/http.hpp"
+
+// Hand-rolled string_view scanning (request-line split, Host: header
+// search) is new, bug-prone-by-nature logic - worth fuzzing on its own,
+// separate from fuzz_summarize's full-pipeline coverage.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_http({data, size});
+
+ wireframe::net::HttpDissector dissector;
+ dissector.summarize({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_ipv4.cpp b/fuzz/fuzz_ipv4.cpp
new file mode 100644
index 0000000..10b6530
--- /dev/null
+++ b/fuzz/fuzz_ipv4.cpp
@@ -0,0 +1,9 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/ipv4.hpp"
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_ipv4({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_ipv6.cpp b/fuzz/fuzz_ipv6.cpp
new file mode 100644
index 0000000..1cae072
--- /dev/null
+++ b/fuzz/fuzz_ipv6.cpp
@@ -0,0 +1,24 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/ipv6.hpp"
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ auto packet = wireframe::net::parse_ipv6({data, size});
+ if (packet) {
+ // Also exercise the RFC 5952 address formatter - it does its
+ // own byte manipulation (zero-run detection) independent of
+ // parse_ipv6, worth fuzzing on whatever bytes made it through.
+ wireframe::net::ipv6_to_string(packet->header.src);
+ wireframe::net::ipv6_to_string(packet->header.dst);
+
+ // Extension-header walking: a loop that repeatedly trusts an
+ // attacker-controlled length field to advance through the
+ // buffer, over up to 8 iterations - exactly the shape of bug
+ // most worth fuzzing. header.next_header seeds which branch of
+ // the walker runs first; the walker's own logic picks whatever
+ // comes after based on each header's own next_header byte.
+ wireframe::net::walk_ipv6_extension_headers(packet->header.next_header, packet->payload);
+ }
+ return 0;
+}
diff --git a/fuzz/fuzz_pcapng_reader.cpp b/fuzz/fuzz_pcapng_reader.cpp
new file mode 100644
index 0000000..e27675b
--- /dev/null
+++ b/fuzz/fuzz_pcapng_reader.cpp
@@ -0,0 +1,21 @@
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+
+#include "wireframe/pcapng/reader.hpp"
+
+// Reader parses file/network data that isn't necessarily our own
+// writer's output - a user could point it at any file. fmemopen()
+// gives libFuzzer's byte buffer a FILE* without touching a real file.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ FILE* file = fmemopen(const_cast<uint8_t*>(data), size, "rb");
+ if (file == nullptr) return 0;
+
+ wireframe::pcapng::Reader reader(file);
+ while (reader.next_packet()) {
+ // keep draining until EOF/malformed-block termination
+ }
+
+ std::fclose(file);
+ return 0;
+}
diff --git a/fuzz/fuzz_summarize.cpp b/fuzz/fuzz_summarize.cpp
new file mode 100644
index 0000000..c1872fd
--- /dev/null
+++ b/fuzz/fuzz_summarize.cpp
@@ -0,0 +1,18 @@
+#include <cstddef>
+#include <cstdint>
+#include <pcap.h>
+
+#include "wireframe/summarize.hpp"
+
+// Fuzzes the full decode chain together (Ethernet/RAW -> IPv4/IPv6 ->
+// TCP/UDP -> L7), not just each layer in isolation - catches bugs
+// that only show up from one layer's output feeding the next (e.g. a
+// span that's technically valid per-layer but wrong at the boundary).
+// The first byte selects which datalink summarize_packet() should
+// assume; the rest is the packet itself.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ if (size < 1) return 0;
+ int datalink = (data[0] % 2 == 0) ? DLT_EN10MB : DLT_RAW;
+ wireframe::summarize_packet({data + 1, size - 1}, datalink);
+ return 0;
+}
diff --git a/fuzz/fuzz_tcp.cpp b/fuzz/fuzz_tcp.cpp
new file mode 100644
index 0000000..c06a3dc
--- /dev/null
+++ b/fuzz/fuzz_tcp.cpp
@@ -0,0 +1,9 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/tcp.hpp"
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_tcp({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_tls.cpp b/fuzz/fuzz_tls.cpp
new file mode 100644
index 0000000..7860426
--- /dev/null
+++ b/fuzz/fuzz_tls.cpp
@@ -0,0 +1,17 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/l7/tls.hpp"
+
+// The nested TLV walk (record -> handshake -> extensions -> SNI, each
+// level bounds-checked against attacker-influenced length fields) is
+// the most structurally complex hand-rolled parser in the project so
+// far - exactly the kind of code most likely to have an off-by-one or
+// an unchecked length feeding a read past the buffer.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_tls_client_hello({data, size});
+
+ wireframe::net::TlsSniDissector dissector;
+ dissector.summarize({data, size});
+ return 0;
+}
diff --git a/fuzz/fuzz_udp.cpp b/fuzz/fuzz_udp.cpp
new file mode 100644
index 0000000..f2433f5
--- /dev/null
+++ b/fuzz/fuzz_udp.cpp
@@ -0,0 +1,9 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/udp.hpp"
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ wireframe::net::parse_udp({data, size});
+ return 0;
+}
diff --git a/include/wireframe/byteio.hpp b/include/wireframe/byteio.hpp
new file mode 100644
index 0000000..c37c29e
--- /dev/null
+++ b/include/wireframe/byteio.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+// Manual big-endian reads instead of reinterpret_cast onto a packed
+// struct: network buffers from pcap aren't guaranteed aligned for
+// multi-byte integer types, so casting would be undefined behavior.
+namespace wireframe {
+
+inline std::uint16_t read_be16(std::span<const unsigned char> bytes, std::size_t offset) {
+ return static_cast<std::uint16_t>((bytes[offset] << 8) | bytes[offset + 1]);
+}
+
+inline std::uint32_t read_be32(std::span<const unsigned char> bytes, std::size_t offset) {
+ return (static_cast<std::uint32_t>(bytes[offset]) << 24) |
+ (static_cast<std::uint32_t>(bytes[offset + 1]) << 16) |
+ (static_cast<std::uint32_t>(bytes[offset + 2]) << 8) |
+ static_cast<std::uint32_t>(bytes[offset + 3]);
+}
+
+} // namespace wireframe
diff --git a/include/wireframe/capture_queue.hpp b/include/wireframe/capture_queue.hpp
new file mode 100644
index 0000000..14794ba
--- /dev/null
+++ b/include/wireframe/capture_queue.hpp
@@ -0,0 +1,98 @@
+#pragma once
+
+#include <condition_variable>
+#include <cstdint>
+#include <mutex>
+#include <optional>
+#include <queue>
+#include <vector>
+
+// Bounded queue between the capture thread and the render/analysis
+// thread (PLAN.md's architecture sketch). Owns a copy of each packet's
+// bytes since the buffer libpcap hands the callback is only valid for
+// the duration of that call.
+namespace wireframe {
+
+struct CapturedPacket {
+ std::uint32_t ts_sec;
+ std::uint32_t ts_usec;
+ std::uint32_t original_len;
+ std::vector<unsigned char> data; // caplen bytes
+};
+
+// Single-producer / single-consumer. Two producer-side push variants
+// for two different producers with different constraints: a live
+// capture thread can't be allowed to stall (PLAN.md is explicit that a
+// traffic spike should drop packets, not block), but a replay-from-file
+// producer has no such real-time pressure, and dropping from a fixed
+// historical record would defeat the point of "faithfully replaying
+// what was captured" - so it blocks for room instead.
+class CaptureQueue {
+public:
+ explicit CaptureQueue(std::size_t capacity) : capacity_(capacity) {}
+
+ // Never blocks: drops the packet and counts it if the queue is full.
+ bool try_push(CapturedPacket&& packet) {
+ {
+ std::lock_guard<std::mutex> lock(mutex_);
+ if (queue_.size() >= capacity_) {
+ ++dropped_;
+ return false;
+ }
+ queue_.push(std::move(packet));
+ }
+ cv_.notify_all();
+ return true;
+ }
+
+ // Blocks until there's room, then pushes. Returns false without
+ // pushing if stop() is called while waiting - the consumer side is
+ // going away, so nothing will ever pop it.
+ bool push(CapturedPacket&& packet) {
+ {
+ std::unique_lock<std::mutex> lock(mutex_);
+ cv_.wait(lock, [this] { return queue_.size() < capacity_ || stopped_; });
+ if (stopped_) return false;
+ queue_.push(std::move(packet));
+ }
+ cv_.notify_all();
+ return true;
+ }
+
+ // Blocks until a packet is available. Returns nullopt only once
+ // stop() has been called and the queue has fully drained - so a
+ // consumer loop on pop() processes everything queued before the
+ // capture side stopped, rather than discarding it.
+ std::optional<CapturedPacket> pop() {
+ std::unique_lock<std::mutex> lock(mutex_);
+ cv_.wait(lock, [this] { return !queue_.empty() || stopped_; });
+ if (queue_.empty()) return std::nullopt;
+ CapturedPacket packet = std::move(queue_.front());
+ queue_.pop();
+ cv_.notify_all(); // wake a push() blocked on room, if any
+ return packet;
+ }
+
+ void stop() {
+ {
+ std::lock_guard<std::mutex> lock(mutex_);
+ stopped_ = true;
+ }
+ cv_.notify_all();
+ }
+
+ std::uint64_t dropped() const {
+ std::lock_guard<std::mutex> lock(mutex_);
+ return dropped_;
+ }
+
+private:
+ mutable std::mutex mutex_;
+ std::condition_variable cv_;
+ std::queue<CapturedPacket> queue_;
+ std::size_t capacity_;
+ bool stopped_ = false;
+ std::uint64_t dropped_ = 0;
+};
+
+} // namespace wireframe
diff --git a/include/wireframe/capture_session.hpp b/include/wireframe/capture_session.hpp
new file mode 100644
index 0000000..50764a8
--- /dev/null
+++ b/include/wireframe/capture_session.hpp
@@ -0,0 +1,301 @@
+#pragma once
+
+#include <pcap.h>
+
+#include <atomic>
+#include <csignal>
+#include <cstdio>
+#include <cstring>
+#include <optional>
+#include <string>
+#include <thread>
+
+#include "wireframe/capture_queue.hpp"
+#include "wireframe/filter.hpp"
+#include "wireframe/pcapng/reader.hpp"
+#include "wireframe/pcapng/writer.hpp"
+
+// Device-open -> datalink-validate -> filter/pcapng-setup -> signal-hook
+// pipeline, shared by every frontend (CLI, TUI, GUI). Centralized so a
+// new frontend can't silently skip a step the others rely on - e.g.
+// the DLT_RAW/DLT_EN10MB check that summarize_packet() depends on, or
+// the pcap_breakloop() shutdown hook that keeps a -w pcapng file from
+// being truncated on Ctrl-C (see main.cpp's history: both were real
+// bugs before this was centralized).
+//
+// Also covers replay mode (-r <file>): reading a previously-saved
+// pcapng file back through the exact same queue/render/search pipeline
+// as a live capture, so every frontend gets it for free rather than
+// needing a second code path. The render/consumer side only ever talks
+// to a CaptureQueue - it has no way to tell whether packets are
+// arriving from a live pcap_loop or being read back from disk.
+namespace wireframe {
+
+namespace detail {
+inline pcap_t* g_capture_handle = nullptr;
+inline std::atomic<bool>* g_replay_stop_flag = nullptr;
+inline void handle_stop_signal(int) {
+ if (g_capture_handle != nullptr) pcap_breakloop(g_capture_handle);
+ if (g_replay_stop_flag != nullptr) g_replay_stop_flag->store(true);
+}
+} // namespace detail
+
+struct CaptureSessionOptions {
+ std::string device; // empty = pick the first device via pcap_findalldevs
+ std::optional<std::string> filter_expr;
+ std::optional<std::string> pcapng_output_path;
+ std::optional<std::string> replay_input_path; // -r: read from this pcapng file, not a live device
+};
+
+inline bool is_supported_datalink(int datalink) {
+ return datalink == DLT_EN10MB || datalink == DLT_RAW;
+}
+
+// Kernel/NIC-level counters, distinct from CaptureQueue::dropped():
+// the queue can only count packets libpcap already handed to our
+// callback. A traffic spike can drop packets in the kernel's capture
+// buffer before that ever happens - invisible without this. Not
+// meaningful in replay mode (stats() returns nullopt there).
+struct CaptureStats {
+ unsigned int received; // ps_recv
+ unsigned int dropped; // ps_drop: kernel buffer had no room
+ unsigned int if_dropped; // ps_ifdrop: dropped by the interface/driver
+};
+
+class CaptureSession {
+public:
+ ~CaptureSession() { close(); }
+
+ CaptureSession() = default;
+ CaptureSession(const CaptureSession&) = delete;
+ CaptureSession& operator=(const CaptureSession&) = delete;
+
+ // Returns an error message on failure. The session remains safe to
+ // destroy (or close()) regardless of how far setup got.
+ std::optional<std::string> open(const CaptureSessionOptions& options) {
+ if (options.replay_input_path) {
+ if (options.filter_expr) {
+ return std::string(
+ "-f (capture filter) isn't supported with -r (replay); use -g to filter "
+ "what's displayed instead");
+ }
+ return open_replay(*options.replay_input_path, options.pcapng_output_path);
+ }
+
+ char errbuf[PCAP_ERRBUF_SIZE];
+
+ if (options.device.empty()) {
+ if (pcap_findalldevs(&all_devices_, errbuf) == -1 || all_devices_ == nullptr) {
+ return std::string("no capture device found: ") + errbuf;
+ }
+ device_ = all_devices_->name;
+ } else {
+ device_ = options.device;
+ }
+
+ handle_ = pcap_open_live(device_.c_str(), /*snaplen=*/65535, /*promisc=*/0,
+ /*to_ms=*/1000, errbuf);
+ if (handle_ == nullptr) {
+ return std::string("pcap_open_live failed: ") + errbuf;
+ }
+
+ datalink_ = pcap_datalink(handle_);
+ if (!is_supported_datalink(datalink_)) {
+ return std::string("unsupported datalink type on ") + device_ + ": " +
+ pcap_datalink_val_to_name(datalink_) + " (" +
+ pcap_datalink_val_to_description(datalink_) + ")";
+ }
+
+ if (options.filter_expr) {
+ bpf_program program{};
+ if (auto err = compile_filter(handle_, *options.filter_expr, &program)) {
+ return "invalid filter '" + *options.filter_expr + "': " + *err;
+ }
+ if (pcap_setfilter(handle_, &program) == -1) {
+ std::string err = std::string("pcap_setfilter failed: ") + pcap_geterr(handle_);
+ pcap_freecode(&program);
+ return err;
+ }
+ pcap_freecode(&program); // bytecode is copied into the kernel by pcap_setfilter
+ }
+
+ if (options.pcapng_output_path) {
+ if (auto err = open_pcapng_writer(*options.pcapng_output_path)) return err;
+ }
+
+ return std::nullopt;
+ }
+
+ // pcap_loop() blocks in a read/poll waiting for the next packet, so
+ // a plain "stop requested" flag wouldn't unblock it promptly.
+ // pcap_breakloop() is documented as signal-safe and is what
+ // actually interrupts that wait. Replay mode has no handle to
+ // breakloop, so it's interrupted via g_replay_stop_flag instead --
+ // both are armed here so one signal handler covers either mode.
+ void install_signal_handlers() {
+ detail::g_capture_handle = handle_;
+ detail::g_replay_stop_flag = &replay_stop_requested_;
+ std::signal(SIGINT, detail::handle_stop_signal);
+ std::signal(SIGTERM, detail::handle_stop_signal);
+ }
+
+ void request_stop() {
+ if (handle_ != nullptr) pcap_breakloop(handle_);
+ replay_stop_requested_.store(true);
+ }
+
+ // True once a stop has been explicitly requested - via
+ // request_stop() or an external SIGINT/SIGTERM (the signal handler
+ // sets the same flag). Lets a frontend tell "the producer stopped
+ // because someone asked it to" apart from "the producer ran out of
+ // data on its own" (replay reaching end-of-file), which call for
+ // different UI behavior: the former should close the window, the
+ // latter should leave it open so what's already loaded can still be
+ // browsed.
+ bool stop_requested() const { return replay_stop_requested_.load(); }
+
+ // Must be called before close()/the destructor - pcap_stats()
+ // needs a still-open handle. Safe to call after request_stop(),
+ // since breakloop only stops pcap_loop(), it doesn't close handle_.
+ // Always nullopt in replay mode (handle_ is never set there).
+ std::optional<CaptureStats> stats() const {
+ if (handle_ == nullptr) return std::nullopt;
+ pcap_stat stat{};
+ if (pcap_stats(handle_, &stat) == -1) return std::nullopt;
+ return CaptureStats{stat.ps_recv, stat.ps_drop, stat.ps_ifdrop};
+ }
+
+ // Capture-thread side: copy each packet into the queue and return
+ // immediately. No decoding, printing, or file I/O here - that's
+ // every frontend's own consumer-side job.
+ //
+ // Live mode drops on backpressure (try_push, via capture_callback)
+ // since a traffic spike can't be paused. Replay mode blocks instead
+ // (push): a file has no real-time pressure forcing a drop, and
+ // dropping from what's supposed to be a faithful replay of a fixed
+ // historical record would defeat the point of replaying it.
+ std::thread start_capture_thread(CaptureQueue& queue) {
+ if (is_replay_) {
+ return std::thread([this, &queue] {
+ queue.push(to_captured_packet(std::move(*first_replay_packet_)));
+ while (!replay_stop_requested_.load()) {
+ auto record = replay_reader_->next_packet();
+ if (!record) break;
+ if (!queue.push(to_captured_packet(std::move(*record)))) break;
+ }
+ queue.stop();
+ });
+ }
+ return std::thread([this, &queue] {
+ pcap_loop(handle_, /*count=*/-1, capture_callback,
+ reinterpret_cast<unsigned char*>(&queue));
+ queue.stop();
+ });
+ }
+
+ void close() {
+ if (handle_ != nullptr) {
+ pcap_close(handle_);
+ handle_ = nullptr;
+ }
+ if (all_devices_ != nullptr) {
+ pcap_freealldevs(all_devices_);
+ all_devices_ = nullptr;
+ }
+ if (pcapng_file_ != nullptr) {
+ std::fclose(pcapng_file_);
+ pcapng_file_ = nullptr;
+ }
+ if (replay_file_ != nullptr) {
+ std::fclose(replay_file_);
+ replay_file_ = nullptr;
+ }
+ }
+
+ pcap_t* handle() const { return handle_; }
+ const std::string& device() const { return device_; }
+ int datalink() const { return datalink_; }
+ bool is_replay() const { return is_replay_; }
+ pcapng::Writer* pcapng_writer() { return pcapng_writer_ ? &*pcapng_writer_ : nullptr; }
+
+private:
+ static void capture_callback(unsigned char* user, const pcap_pkthdr* header,
+ const unsigned char* raw) {
+ auto* queue = reinterpret_cast<CaptureQueue*>(user);
+ CapturedPacket packet;
+ packet.ts_sec = static_cast<std::uint32_t>(header->ts.tv_sec);
+ packet.ts_usec = static_cast<std::uint32_t>(header->ts.tv_usec);
+ packet.original_len = header->len;
+ packet.data.assign(raw, raw + header->caplen);
+ queue->try_push(std::move(packet));
+ }
+
+ static CapturedPacket to_captured_packet(pcapng::PacketRecord&& record) {
+ CapturedPacket packet;
+ packet.ts_sec = static_cast<std::uint32_t>(record.timestamp_us / 1'000'000ULL);
+ packet.ts_usec = static_cast<std::uint32_t>(record.timestamp_us % 1'000'000ULL);
+ packet.original_len = record.original_len;
+ packet.data = std::move(record.data);
+ return packet;
+ }
+
+ std::optional<std::string> open_pcapng_writer(const std::string& path) {
+ pcapng_file_ = std::fopen(path.c_str(), "wb");
+ if (pcapng_file_ == nullptr) {
+ return "failed to open " + path + " for writing: " + std::strerror(errno);
+ }
+ pcapng_writer_.emplace(pcapng_file_);
+ pcapng_writer_->write_section_header();
+ pcapng_writer_->write_interface_description(65535,
+ static_cast<std::uint16_t>(datalink_));
+ return std::nullopt;
+ }
+
+ std::optional<std::string> open_replay(const std::string& path,
+ const std::optional<std::string>& pcapng_output_path) {
+ replay_file_ = std::fopen(path.c_str(), "rb");
+ if (replay_file_ == nullptr) {
+ return "failed to open " + path + " for reading: " + std::strerror(errno);
+ }
+
+ replay_reader_.emplace(replay_file_);
+ // Reading the first packet is also what makes the reader consume
+ // the SHB/IDB blocks that precede it, which is what populates
+ // link_type() below - there's no separate "just read the
+ // header" step, so the packet itself is kept, not discarded.
+ first_replay_packet_ = replay_reader_->next_packet();
+ if (!first_replay_packet_) {
+ return "no packets found in " + path + " (empty, or not a valid pcapng file)";
+ }
+
+ auto link_type = replay_reader_->link_type();
+ if (!link_type || !is_supported_datalink(static_cast<int>(*link_type))) {
+ return "unsupported or missing link type in " + path;
+ }
+
+ datalink_ = static_cast<int>(*link_type);
+ device_ = path;
+ is_replay_ = true;
+
+ if (pcapng_output_path) {
+ if (auto err = open_pcapng_writer(*pcapng_output_path)) return err;
+ }
+
+ return std::nullopt;
+ }
+
+ pcap_t* handle_ = nullptr;
+ pcap_if_t* all_devices_ = nullptr;
+ std::string device_;
+ int datalink_ = 0;
+ std::FILE* pcapng_file_ = nullptr;
+ std::optional<pcapng::Writer> pcapng_writer_;
+
+ bool is_replay_ = false;
+ std::FILE* replay_file_ = nullptr;
+ std::optional<pcapng::Reader> replay_reader_;
+ std::optional<pcapng::PacketRecord> first_replay_packet_;
+ std::atomic<bool> replay_stop_requested_{false};
+};
+
+} // namespace wireframe
diff --git a/include/wireframe/filter.hpp b/include/wireframe/filter.hpp
new file mode 100644
index 0000000..49fa4ab
--- /dev/null
+++ b/include/wireframe/filter.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <pcap.h>
+
+#include <optional>
+#include <string>
+
+// Thin wrapper around libpcap's BPF filter compiler. tcpdump-style
+// filter syntax ("tcp port 80", "host 10.0.0.1 and not icmp") already
+// has a correct, well-tested parser and compiler in libpcap itself --
+// hand-rolling a second one would be a large, separate project with no
+// bearing on this one's actual goal (the C++ memory model), so this
+// wraps the existing implementation instead of reinventing it.
+namespace wireframe {
+
+// Compiles `expression` against `handle`'s linktype/snaplen into
+// `out`. `handle` can be a real, already-open capture handle, or a
+// throwaway one from pcap_open_dead() - pcap_compile() only needs the
+// handle to know the linktype and to report errors via pcap_geterr(),
+// it doesn't require an active capture. That's what makes this
+// testable without root or a real interface.
+//
+// Returns nullopt on success (with `out` filled in and owned by the
+// caller - pcap_freecode(out) once it's no longer needed, including
+// after a successful pcap_setfilter()). Returns pcap's error message
+// on failure, and leaves `out` unmodified.
+inline std::optional<std::string> compile_filter(pcap_t* handle, const std::string& expression,
+ bpf_program* out) {
+ if (pcap_compile(handle, out, expression.c_str(), /*optimize=*/1, PCAP_NETMASK_UNKNOWN) ==
+ -1) {
+ return std::string(pcap_geterr(handle));
+ }
+ return std::nullopt;
+}
+
+} // namespace wireframe
diff --git a/include/wireframe/l7/dissector.hpp b/include/wireframe/l7/dissector.hpp
new file mode 100644
index 0000000..9b2cc32
--- /dev/null
+++ b/include/wireframe/l7/dissector.hpp
@@ -0,0 +1,45 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <vector>
+
+// Small interface/vtable for L7 dissectors (PLAN.md's architecture
+// sketch), so protocols can be registered and added incrementally
+// without touching the L2-L4 decode path or main.cpp's dispatch logic.
+namespace wireframe::net {
+
+class L7Dissector {
+public:
+ virtual ~L7Dissector() = default;
+
+ // The transport port this dissector claims (e.g. 53 for DNS). A
+ // single fixed port is enough for the protocols in scope so far;
+ // dissectors needing a port range or heuristic sniffing can widen
+ // this later without changing the registry's shape.
+ virtual std::uint16_t port() const = 0;
+
+ // A one-line summary of the payload, or nullopt if it doesn't look
+ // like this protocol (e.g. truncated/malformed).
+ virtual std::optional<std::string> summarize(std::span<const unsigned char> payload) const = 0;
+};
+
+class L7Registry {
+public:
+ void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); }
+
+ std::optional<std::string> dissect(std::uint16_t port,
+ std::span<const unsigned char> payload) const {
+ for (const auto* dissector : dissectors_) {
+ if (dissector->port() == port) return dissector->summarize(payload);
+ }
+ return std::nullopt;
+ }
+
+private:
+ std::vector<const L7Dissector*> dissectors_;
+};
+
+} // namespace wireframe::net
diff --git a/include/wireframe/l7/dns.hpp b/include/wireframe/l7/dns.hpp
new file mode 100644
index 0000000..5c1ab36
--- /dev/null
+++ b/include/wireframe/l7/dns.hpp
@@ -0,0 +1,108 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <utility>
+
+#include "wireframe/byteio.hpp"
+#include "wireframe/l7/dissector.hpp"
+
+// Hand-rolled DNS message parsing: header + the first question record.
+// Answer/authority/additional records aren't decoded (not needed for a
+// one-line summary), so name-compression pointers there are never
+// followed - a pointer in the question section itself is rejected
+// rather than chased, keeping this a pure forward scan with no risk of
+// a pointer loop.
+namespace wireframe::net {
+
+inline constexpr std::uint16_t kDnsPort = 53;
+
+struct DnsHeader {
+ std::uint16_t id;
+ bool is_response;
+ std::uint8_t opcode;
+ std::uint8_t rcode;
+ std::uint16_t qdcount;
+ std::uint16_t ancount;
+};
+
+struct DnsQuestion {
+ std::string name;
+ std::uint16_t qtype;
+};
+
+struct DnsMessage {
+ DnsHeader header;
+ std::optional<DnsQuestion> question; // first question only
+};
+
+// Reads a (possibly multi-label) dotted name starting at offset.
+// Returns the name and the offset just past it, or nullopt on
+// truncation or a compression pointer (0xC0 prefix - valid in
+// answer/authority records, not supported here).
+inline std::optional<std::pair<std::string, std::size_t>> read_dns_name(
+ std::span<const unsigned char> bytes, std::size_t offset) {
+ std::string name;
+ while (true) {
+ if (offset >= bytes.size()) return std::nullopt;
+ std::uint8_t len = bytes[offset];
+ if (len == 0) {
+ ++offset;
+ break;
+ }
+ if ((len & 0xC0) == 0xC0) return std::nullopt; // compression pointer: unsupported
+ ++offset;
+ if (offset + len > bytes.size()) return std::nullopt;
+ if (!name.empty()) name += '.';
+ for (std::uint8_t i = 0; i < len; ++i) name += static_cast<char>(bytes[offset + i]);
+ offset += len;
+ }
+ return std::make_pair(std::move(name), offset);
+}
+
+inline std::optional<DnsMessage> parse_dns(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 12) return std::nullopt;
+
+ DnsHeader header{};
+ header.id = read_be16(bytes, 0);
+ std::uint16_t flags = read_be16(bytes, 2);
+ header.is_response = (flags & 0x8000) != 0;
+ header.opcode = static_cast<std::uint8_t>((flags >> 11) & 0x0F);
+ header.rcode = static_cast<std::uint8_t>(flags & 0x0F);
+ header.qdcount = read_be16(bytes, 4);
+ header.ancount = read_be16(bytes, 6);
+
+ DnsMessage msg{header, std::nullopt};
+ if (header.qdcount >= 1) {
+ if (auto result = read_dns_name(bytes, 12)) {
+ auto& [name, next_offset] = *result;
+ if (next_offset + 4 <= bytes.size()) {
+ msg.question = DnsQuestion{std::move(name), read_be16(bytes, next_offset)};
+ }
+ }
+ }
+ return msg;
+}
+
+class DnsDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kDnsPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_dns(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "DNS ";
+ out += msg->header.is_response ? "response" : "query";
+ out += " id=" + std::to_string(msg->header.id);
+ if (msg->header.is_response) out += " ancount=" + std::to_string(msg->header.ancount);
+ if (msg->question) {
+ out += " " + msg->question->name + " type=" + std::to_string(msg->question->qtype);
+ }
+ return out;
+ }
+};
+
+} // namespace wireframe::net
diff --git a/include/wireframe/l7/http.hpp b/include/wireframe/l7/http.hpp
new file mode 100644
index 0000000..4780b23
--- /dev/null
+++ b/include/wireframe/l7/http.hpp
@@ -0,0 +1,113 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#include "wireframe/l7/dissector.hpp"
+
+// Best-effort, single-segment HTTP/1.x request/status-line parsing (plus
+// the Host: header for requests). No TCP stream reassembly, so a
+// message split across multiple packets is only partially visible here
+// - the same scope DNS already has (single UDP datagram, no
+// reassembly). Good enough for a one-line summary, not a full dissector.
+namespace wireframe::net {
+
+inline constexpr std::uint16_t kHttpPort = 80;
+
+struct HttpMessage {
+ bool is_request;
+ std::string method_or_version; // request: method (GET); response: "HTTP/1.1"
+ std::string target_or_status; // request: target path; response: status code
+ std::optional<std::string> host; // request only, from a Host: header if present
+};
+
+inline std::optional<HttpMessage> parse_http(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size());
+
+ std::size_t line_end = text.find("\r\n");
+ std::size_t term_len = 2;
+ if (line_end == std::string_view::npos) {
+ line_end = text.find('\n');
+ term_len = 1;
+ if (line_end == std::string_view::npos) return std::nullopt;
+ }
+ std::string_view first_line = text.substr(0, line_end);
+
+ std::size_t sp1 = first_line.find(' ');
+ if (sp1 == std::string_view::npos) return std::nullopt;
+ std::size_t sp2 = first_line.find(' ', sp1 + 1);
+ if (sp2 == std::string_view::npos) return std::nullopt;
+
+ std::string_view field1 = first_line.substr(0, sp1);
+ std::string_view field2 = first_line.substr(sp1 + 1, sp2 - sp1 - 1);
+
+ HttpMessage msg;
+
+ if (field1.substr(0, 5) == "HTTP/") {
+ msg.is_request = false;
+ msg.method_or_version = std::string(field1);
+ msg.target_or_status = std::string(field2);
+ return msg;
+ }
+
+ static constexpr std::string_view kMethods[] = {"GET", "POST", "PUT", "DELETE",
+ "HEAD", "OPTIONS", "PATCH", "CONNECT",
+ "TRACE"};
+ bool known_method = false;
+ for (auto method : kMethods) {
+ if (field1 == method) {
+ known_method = true;
+ break;
+ }
+ }
+ if (!known_method) return std::nullopt;
+
+ msg.is_request = true;
+ msg.method_or_version = std::string(field1);
+ msg.target_or_status = std::string(field2);
+
+ // Best-effort Host: header scan, bounded by whatever this one
+ // packet contains and terminated at the first blank line (end of
+ // headers) or the end of the payload - never loops past text.size().
+ std::size_t pos = line_end + term_len;
+ while (pos < text.size()) {
+ std::size_t next_end = text.find("\r\n", pos);
+ std::size_t header_len = (next_end == std::string_view::npos) ? text.size() - pos
+ : next_end - pos;
+ std::string_view header_line = text.substr(pos, header_len);
+ if (header_line.empty()) break; // blank line: end of headers
+
+ if (header_line.size() > 5 &&
+ (header_line.substr(0, 5) == "Host:" || header_line.substr(0, 5) == "host:")) {
+ std::size_t value_start = 5;
+ while (value_start < header_line.size() && header_line[value_start] == ' ') {
+ ++value_start;
+ }
+ msg.host = std::string(header_line.substr(value_start));
+ }
+
+ if (next_end == std::string_view::npos) break;
+ pos = next_end + 2;
+ }
+
+ return msg;
+}
+
+class HttpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kHttpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_http(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "HTTP " + msg->method_or_version + " " + msg->target_or_status;
+ if (msg->host) out += " Host: " + *msg->host;
+ return out;
+ }
+};
+
+} // namespace wireframe::net
diff --git a/include/wireframe/l7/tls.hpp b/include/wireframe/l7/tls.hpp
new file mode 100644
index 0000000..1c6dc57
--- /dev/null
+++ b/include/wireframe/l7/tls.hpp
@@ -0,0 +1,139 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+
+#include "wireframe/byteio.hpp"
+#include "wireframe/l7/dissector.hpp"
+
+// TLS ClientHello -> SNI extension parsing. Most web traffic is TLS
+// today, so HTTP alone covers a shrinking fraction of it - SNI is what
+// makes a packet analyzer useful against that traffic without
+// decrypting anything: the server name is sent in cleartext in the
+// ClientHello, before any encryption starts, in every TLS version this
+// parses (the ClientHello/extension wire format hasn't changed across
+// versions - only what happens after it has).
+//
+// Same scope as the other L7 dissectors: single-segment, best-effort.
+// A ClientHello padded across multiple TCP segments (large cookie/PSK
+// extensions, unusual but possible) is only partially visible here.
+// Every length field is bounds-checked against what's actually left in
+// the buffer before use - this is exactly the kind of nested,
+// attacker-influenced TLV structure the project's decoders are meant
+// to get right.
+namespace wireframe::net {
+
+inline constexpr std::uint16_t kTlsPort = 443;
+inline constexpr std::uint8_t kTlsContentTypeHandshake = 0x16;
+inline constexpr std::uint8_t kTlsHandshakeTypeClientHello = 0x01;
+inline constexpr std::uint16_t kTlsExtensionServerName = 0x0000;
+
+struct TlsClientHello {
+ std::optional<std::string> server_name; // SNI, if the extension was present and well-formed
+};
+
+inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsigned char> bytes) {
+ // Record header: ContentType(1) ProtocolVersion(2) Length(2)
+ if (bytes.size() < 5) return std::nullopt;
+ if (bytes[0] != kTlsContentTypeHandshake) return std::nullopt;
+ std::uint16_t record_len = read_be16(bytes, 3);
+ if (bytes.size() < static_cast<std::size_t>(5) + record_len) return std::nullopt;
+
+ std::span<const unsigned char> handshake = bytes.subspan(5);
+
+ // Handshake header: HandshakeType(1) Length(3, 24-bit BE)
+ if (handshake.size() < 4) return std::nullopt;
+ if (handshake[0] != kTlsHandshakeTypeClientHello) return std::nullopt;
+ std::uint32_t hs_len = (static_cast<std::uint32_t>(handshake[1]) << 16) |
+ (static_cast<std::uint32_t>(handshake[2]) << 8) |
+ static_cast<std::uint32_t>(handshake[3]);
+
+ std::span<const unsigned char> body = handshake.subspan(4);
+ if (body.size() < hs_len) return std::nullopt;
+ body = body.first(hs_len); // never read past the declared handshake body
+
+ std::size_t offset = 0;
+
+ // client_version(2) + random(32)
+ if (body.size() < offset + 34) return std::nullopt;
+ offset += 34;
+
+ // legacy_session_id: length(1) + data
+ if (body.size() < offset + 1) return std::nullopt;
+ std::uint8_t session_id_len = body[offset];
+ offset += 1;
+ if (body.size() < offset + session_id_len) return std::nullopt;
+ offset += session_id_len;
+
+ // cipher_suites: length(2) + data
+ if (body.size() < offset + 2) return std::nullopt;
+ std::uint16_t cipher_suites_len = read_be16(body, offset);
+ offset += 2;
+ if (body.size() < static_cast<std::size_t>(offset) + cipher_suites_len) return std::nullopt;
+ offset += cipher_suites_len;
+
+ // legacy_compression_methods: length(1) + data
+ if (body.size() < offset + 1) return std::nullopt;
+ std::uint8_t compression_len = body[offset];
+ offset += 1;
+ if (body.size() < offset + compression_len) return std::nullopt;
+ offset += compression_len;
+
+ TlsClientHello hello;
+ if (offset == body.size()) return hello; // no extensions block: no SNI, still a valid hello
+
+ // extensions: length(2) + data
+ if (body.size() < offset + 2) return std::nullopt;
+ std::uint16_t extensions_len = read_be16(body, offset);
+ offset += 2;
+ if (body.size() < static_cast<std::size_t>(offset) + extensions_len) return std::nullopt;
+ std::size_t extensions_end = offset + extensions_len;
+
+ while (offset + 4 <= extensions_end) {
+ std::uint16_t ext_type = read_be16(body, offset);
+ std::uint16_t ext_len = read_be16(body, offset + 2);
+ std::size_t ext_data_start = offset + 4;
+ std::size_t ext_data_end = ext_data_start + ext_len;
+ if (ext_data_end > extensions_end) break; // malformed: stop, keep what we have
+
+ if (ext_type == kTlsExtensionServerName && ext_len >= 2) {
+ // ServerNameList: list_len(2) + entries; only the first
+ // entry is used, matching every real client's behavior of
+ // sending exactly one host_name entry.
+ std::uint16_t list_len = read_be16(body, ext_data_start);
+ std::size_t list_start = ext_data_start + 2;
+ std::size_t list_end = list_start + list_len;
+ if (list_end <= ext_data_end && list_start + 3 <= list_end) {
+ std::uint8_t name_type = body[list_start];
+ std::uint16_t name_len = read_be16(body, list_start + 1);
+ std::size_t name_start = list_start + 3;
+ if (name_type == 0 && name_start + name_len <= list_end) {
+ hello.server_name = std::string(
+ reinterpret_cast<const char*>(body.data() + name_start), name_len);
+ }
+ }
+ }
+
+ offset = ext_data_end;
+ }
+
+ return hello;
+}
+
+class TlsSniDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kTlsPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto hello = parse_tls_client_hello(payload);
+ if (!hello) return std::nullopt;
+
+ std::string out = "TLS ClientHello";
+ if (hello->server_name) out += " SNI=" + *hello->server_name;
+ return out;
+ }
+};
+
+} // namespace wireframe::net
diff --git a/include/wireframe/net/ethernet.hpp b/include/wireframe/net/ethernet.hpp
new file mode 100644
index 0000000..2da4cc8
--- /dev/null
+++ b/include/wireframe/net/ethernet.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "wireframe/byteio.hpp"
+
+namespace wireframe::net {
+
+inline constexpr std::size_t kEthernetHeaderLen = 14;
+inline constexpr std::uint16_t kEthertypeIPv4 = 0x0800;
+inline constexpr std::uint16_t kEthertypeIPv6 = 0x86DD;
+inline constexpr std::uint16_t kEthertypeArp = 0x0806;
+
+struct MacAddress {
+ std::array<unsigned char, 6> bytes;
+};
+
+struct EthernetHeader {
+ MacAddress dst;
+ MacAddress src;
+ std::uint16_t ethertype;
+};
+
+struct EthernetFrame {
+ EthernetHeader header;
+ std::span<const unsigned char> payload;
+};
+
+inline std::optional<EthernetFrame> parse_ethernet(std::span<const unsigned char> bytes) {
+ if (bytes.size() < kEthernetHeaderLen) return std::nullopt;
+
+ EthernetHeader header{};
+ std::copy_n(bytes.begin(), 6, header.dst.bytes.begin());
+ std::copy_n(bytes.begin() + 6, 6, header.src.bytes.begin());
+ header.ethertype = read_be16(bytes, 12);
+
+ return EthernetFrame{header, bytes.subspan(kEthernetHeaderLen)};
+}
+
+} // namespace wireframe::net
diff --git a/include/wireframe/net/ipv4.hpp b/include/wireframe/net/ipv4.hpp
new file mode 100644
index 0000000..f53b4f2
--- /dev/null
+++ b/include/wireframe/net/ipv4.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "wireframe/byteio.hpp"
+
+namespace wireframe::net {
+
+inline constexpr std::uint8_t kProtoIcmp = 1;
+inline constexpr std::uint8_t kProtoTcp = 6;
+inline constexpr std::uint8_t kProtoUdp = 17;
+
+struct Ipv4Address {
+ std::array<unsigned char, 4> bytes;
+};
+
+struct Ipv4Header {
+ std::uint8_t version;
+ std::uint8_t ihl; // header length in 32-bit words
+ std::uint16_t total_length;
+ std::uint8_t ttl;
+ std::uint8_t protocol;
+ Ipv4Address src;
+ Ipv4Address dst;
+};
+
+struct Ipv4Packet {
+ Ipv4Header header;
+ std::span<const unsigned char> payload;
+};
+
+inline std::optional<Ipv4Packet> parse_ipv4(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 20) return std::nullopt;
+
+ std::uint8_t version = static_cast<std::uint8_t>(bytes[0] >> 4);
+ std::uint8_t ihl = bytes[0] & 0x0F;
+ std::size_t header_len = static_cast<std::size_t>(ihl) * 4;
+ if (version != 4 || header_len < 20 || bytes.size() < header_len) return std::nullopt;
+
+ Ipv4Header header{};
+ header.version = version;
+ header.ihl = ihl;
+ header.total_length = read_be16(bytes, 2);
+ header.ttl = bytes[8];
+ header.protocol = bytes[9];
+ std::copy_n(bytes.begin() + 12, 4, header.src.bytes.begin());
+ std::copy_n(bytes.begin() + 16, 4, header.dst.bytes.begin());
+
+ return Ipv4Packet{header, bytes.subspan(header_len)};
+}
+
+} // namespace wireframe::net
diff --git a/include/wireframe/net/ipv6.hpp b/include/wireframe/net/ipv6.hpp
new file mode 100644
index 0000000..4b6b28a
--- /dev/null
+++ b/include/wireframe/net/ipv6.hpp
@@ -0,0 +1,173 @@
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+
+#include "wireframe/byteio.hpp"
+
+namespace wireframe::net {
+
+inline constexpr std::size_t kIpv6HeaderLen = 40;
+inline constexpr std::uint8_t kNextHeaderHopByHop = 0;
+inline constexpr std::uint8_t kNextHeaderRouting = 43;
+inline constexpr std::uint8_t kNextHeaderFragment = 44;
+inline constexpr std::uint8_t kNextHeaderEsp = 50;
+inline constexpr std::uint8_t kNextHeaderAh = 51;
+inline constexpr std::uint8_t kNextHeaderIcmpv6 = 58;
+inline constexpr std::uint8_t kNextHeaderDestOptions = 60;
+
+struct Ipv6Address {
+ std::array<unsigned char, 16> bytes;
+};
+
+struct Ipv6Header {
+ std::uint8_t version;
+ std::uint8_t traffic_class;
+ std::uint32_t flow_label;
+ std::uint16_t payload_length;
+ std::uint8_t next_header; // transport protocol, or an extension header type
+ std::uint8_t hop_limit;
+ Ipv6Address src;
+ Ipv6Address dst;
+};
+
+struct Ipv6Packet {
+ Ipv6Header header;
+ std::span<const unsigned char> payload;
+};
+
+// Only the fixed 40-byte header is decoded here - header.next_header
+// may name an extension header rather than a transport protocol.
+// walk_ipv6_extension_headers() (below) resolves that; parse_ipv6()
+// itself stays a direct, unconditional decode of exactly the fixed
+// header, nothing more.
+inline std::optional<Ipv6Packet> parse_ipv6(std::span<const unsigned char> bytes) {
+ if (bytes.size() < kIpv6HeaderLen) return std::nullopt;
+
+ std::uint8_t version = static_cast<std::uint8_t>(bytes[0] >> 4);
+ if (version != 6) return std::nullopt;
+
+ Ipv6Header header{};
+ header.version = version;
+ std::uint32_t first_word = read_be32(bytes, 0);
+ header.traffic_class = static_cast<std::uint8_t>((first_word >> 20) & 0xFF);
+ header.flow_label = first_word & 0x000FFFFF;
+ header.payload_length = read_be16(bytes, 4);
+ header.next_header = bytes[6];
+ header.hop_limit = bytes[7];
+ std::copy_n(bytes.begin() + 8, 16, header.src.bytes.begin());
+ std::copy_n(bytes.begin() + 24, 16, header.dst.bytes.begin());
+
+ return Ipv6Packet{header, bytes.subspan(kIpv6HeaderLen)};
+}
+
+struct Ipv6ExtensionWalkResult {
+ std::uint8_t final_next_header; // a transport protocol, or an extension type we stopped at
+ std::span<const unsigned char> payload; // bytes after every extension header walked
+ bool stopped_at_esp; // true if ESP was hit - see walk_ipv6_extension_headers()
+};
+
+// Walks Hop-by-Hop, Routing, Destination Options, Fragment, and AH
+// extension headers to find the real transport protocol underneath
+// them, so e.g. TCP/UDP wrapped in a Hop-by-Hop options header is still
+// decoded instead of silently stopping at "next_header=0". Each header
+// carries its own length, so this never needs to understand a header
+// type's *meaning* to skip over it correctly - only Hop-by-Hop/
+// Routing/Dest-Options (length in 8-byte units from a trailing byte),
+// Fragment (fixed 8 bytes), and AH (length in 4-byte units, RFC 4302)
+// have different encodings, all handled explicitly below.
+//
+// ESP is a hard stop, not a bug: its own next-header field lives in a
+// trailer *after* the encrypted payload, at an offset this code has no
+// way to know without decrypting first. Reported as stopped_at_esp
+// rather than guessed at.
+//
+// Bounded to a handful of iterations as defense in depth against a
+// hostile/corrupt chain - not strictly needed for termination (every
+// header is at least 8 bytes, so payload.size() strictly decreases
+// each iteration and the loop can't actually run forever), but a
+// pathological chain of many tiny headers would otherwise still cost
+// real work for no legitimate reason.
+inline Ipv6ExtensionWalkResult walk_ipv6_extension_headers(std::uint8_t next_header,
+ std::span<const unsigned char> payload) {
+ constexpr int kMaxExtensionHeaders = 8;
+
+ for (int i = 0; i < kMaxExtensionHeaders; ++i) {
+ if (next_header == kNextHeaderEsp) {
+ return {next_header, payload, /*stopped_at_esp=*/true};
+ }
+
+ std::size_t ext_len;
+ if (next_header == kNextHeaderFragment) {
+ if (payload.size() < 8) return {next_header, payload, false};
+ ext_len = 8;
+ } else if (next_header == kNextHeaderAh) {
+ if (payload.size() < 2) return {next_header, payload, false};
+ ext_len = (static_cast<std::size_t>(payload[1]) + 2) * 4;
+ } else if (next_header == kNextHeaderHopByHop || next_header == kNextHeaderRouting ||
+ next_header == kNextHeaderDestOptions) {
+ if (payload.size() < 2) return {next_header, payload, false};
+ ext_len = (static_cast<std::size_t>(payload[1]) + 1) * 8;
+ } else {
+ break; // TCP/UDP/ICMPv6/anything else we don't chain through: stop here
+ }
+
+ if (payload.size() < ext_len) return {next_header, payload, false}; // truncated: stop
+
+ std::uint8_t this_next_header = payload[0];
+ payload = payload.subspan(ext_len);
+ next_header = this_next_header;
+ }
+
+ return {next_header, payload, false};
+}
+
+// RFC 5952 canonical text form: lowercase hex, and the longest run of
+// two-or-more consecutive zero groups (leftmost wins a tie) collapsed to
+// "::". A lone zero group is left as "0", not compressed, per 5952 4.2.2.
+inline std::string ipv6_to_string(const Ipv6Address& addr) {
+ std::array<std::uint16_t, 8> groups{};
+ for (std::size_t i = 0; i < 8; ++i) {
+ groups[i] = static_cast<std::uint16_t>((addr.bytes[i * 2] << 8) | addr.bytes[i * 2 + 1]);
+ }
+
+ int best_start = -1;
+ int best_len = 0;
+ int cur_start = -1;
+ int cur_len = 0;
+ for (int i = 0; i < 8; ++i) {
+ if (groups[i] == 0) {
+ if (cur_start < 0) cur_start = i;
+ ++cur_len;
+ if (cur_len > best_len) {
+ best_start = cur_start;
+ best_len = cur_len;
+ }
+ } else {
+ cur_start = -1;
+ cur_len = 0;
+ }
+ }
+ if (best_len < 2) best_start = -1; // don't compress a lone zero group
+
+ std::string out;
+ char buf[6];
+ for (int i = 0; i < 8; ++i) {
+ if (i == best_start) {
+ out += "::";
+ i += best_len - 1; // the for-loop's ++i advances past the run
+ continue;
+ }
+ if (!out.empty() && out.back() != ':') out += ':';
+ std::snprintf(buf, sizeof(buf), "%x", groups[i]);
+ out += buf;
+ }
+ return out;
+}
+
+} // namespace wireframe::net
diff --git a/include/wireframe/net/tcp.hpp b/include/wireframe/net/tcp.hpp
new file mode 100644
index 0000000..f691a7f
--- /dev/null
+++ b/include/wireframe/net/tcp.hpp
@@ -0,0 +1,54 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "wireframe/byteio.hpp"
+
+namespace wireframe::net {
+
+// Lower 6 bits of the flags byte: URG ACK PSH RST SYN FIN. CWR/ECE (the
+// top 2 bits) are masked off - not needed for now.
+inline constexpr std::uint8_t kTcpFin = 0x01;
+inline constexpr std::uint8_t kTcpSyn = 0x02;
+inline constexpr std::uint8_t kTcpRst = 0x04;
+inline constexpr std::uint8_t kTcpPsh = 0x08;
+inline constexpr std::uint8_t kTcpAck = 0x10;
+inline constexpr std::uint8_t kTcpUrg = 0x20;
+
+struct TcpHeader {
+ std::uint16_t src_port;
+ std::uint16_t dst_port;
+ std::uint32_t seq;
+ std::uint32_t ack;
+ std::uint8_t data_offset; // header length in 32-bit words
+ std::uint8_t flags;
+ std::uint16_t window;
+};
+
+struct TcpSegment {
+ TcpHeader header;
+ std::span<const unsigned char> payload;
+};
+
+inline std::optional<TcpSegment> parse_tcp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 20) return std::nullopt;
+
+ std::uint8_t data_offset = static_cast<std::uint8_t>(bytes[12] >> 4);
+ std::size_t header_len = static_cast<std::size_t>(data_offset) * 4;
+ if (header_len < 20 || bytes.size() < header_len) return std::nullopt;
+
+ TcpHeader header{};
+ header.src_port = read_be16(bytes, 0);
+ header.dst_port = read_be16(bytes, 2);
+ header.seq = read_be32(bytes, 4);
+ header.ack = read_be32(bytes, 8);
+ header.data_offset = data_offset;
+ header.flags = bytes[13] & 0x3F;
+ header.window = read_be16(bytes, 14);
+
+ return TcpSegment{header, bytes.subspan(header_len)};
+}
+
+} // namespace wireframe::net
diff --git a/include/wireframe/net/udp.hpp b/include/wireframe/net/udp.hpp
new file mode 100644
index 0000000..07664c2
--- /dev/null
+++ b/include/wireframe/net/udp.hpp
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+
+#include "wireframe/byteio.hpp"
+
+namespace wireframe::net {
+
+inline constexpr std::size_t kUdpHeaderLen = 8;
+
+struct UdpHeader {
+ std::uint16_t src_port;
+ std::uint16_t dst_port;
+ std::uint16_t length;
+};
+
+struct UdpDatagram {
+ UdpHeader header;
+ std::span<const unsigned char> payload;
+};
+
+inline std::optional<UdpDatagram> parse_udp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < kUdpHeaderLen) return std::nullopt;
+
+ UdpHeader header{};
+ header.src_port = read_be16(bytes, 0);
+ header.dst_port = read_be16(bytes, 2);
+ header.length = read_be16(bytes, 4);
+
+ return UdpDatagram{header, bytes.subspan(kUdpHeaderLen)};
+}
+
+} // namespace wireframe::net
diff --git a/include/wireframe/pcapng/reader.hpp b/include/wireframe/pcapng/reader.hpp
new file mode 100644
index 0000000..d01b431
--- /dev/null
+++ b/include/wireframe/pcapng/reader.hpp
@@ -0,0 +1,123 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <vector>
+
+// Minimal pcapng reader, paired with writer.hpp: reads Enhanced Packet
+// Blocks sequentially, skipping the Section Header Block, Interface
+// Description Block, and any other block type transparently.
+//
+// Assumes little-endian block encoding (checked against the Section
+// Header Block's byte-order magic, not just assumed) since that's what
+// writer.hpp emits and what pcapng writers on this class of hardware
+// (tcpdump, dumpcap) produce. A big-endian file is out of scope - this
+// pairs with our own writer, not general pcapng interop.
+namespace wireframe::pcapng {
+
+struct PacketRecord {
+ std::uint32_t interface_id;
+ std::uint64_t timestamp_us;
+ std::uint32_t original_len;
+ std::vector<unsigned char> data;
+};
+
+class Reader {
+public:
+ explicit Reader(std::FILE* file) : file_(file) {}
+
+ // Returns the next packet, or nullopt once the file is exhausted or
+ // a malformed/unsupported block is hit - treated as end of stream
+ // rather than a hard error, to keep this reader small.
+ std::optional<PacketRecord> next_packet() {
+ for (;;) {
+ std::array<std::uint8_t, 4> field{};
+ if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt;
+ std::uint32_t type = get_u32(field);
+
+ if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt;
+ std::uint32_t total_len = get_u32(field);
+ if (total_len < 12) return std::nullopt;
+
+ std::size_t body_len = total_len - 12;
+ // total_len is an untrusted 32-bit value straight from the
+ // file; without a cap, a corrupted/hostile file can claim
+ // a multi-gigabyte block and OOM the process on the
+ // allocation below before a single byte is even read to
+ // check whether the file actually contains that much data
+ // (found by fuzzing fuzz_pcapng_reader.cpp - real crash,
+ // not theoretical). Bounded well above any block our own
+ // writer produces (packets capped at a 65535 snaplen; this
+ // reader is explicitly scoped to pair with that writer,
+ // not arbitrary pcapng interop).
+ if (body_len > kMaxBlockBodyLen) return std::nullopt;
+ std::vector<std::uint8_t> body(body_len);
+ if (body_len > 0 && std::fread(body.data(), 1, body_len, file_) != body_len) {
+ return std::nullopt;
+ }
+
+ if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt;
+ if (get_u32(field) != total_len) return std::nullopt; // corrupt trailer
+
+ if (type == kBlockTypeShb) {
+ if (body_len < 4 || get_u32({body.data(), 4}) != kByteOrderMagic) {
+ return std::nullopt; // not little-endian, or malformed
+ }
+ continue;
+ }
+ if (type == kBlockTypeIdb) {
+ // LinkType is the first 2 bytes of the IDB body (see
+ // writer.hpp's write_interface_description). Only the
+ // first IDB is captured - correct for a file our own
+ // writer produced, which only ever writes one
+ // interface, matching this reader's documented scope.
+ if (!link_type_ && body_len >= 2) {
+ link_type_ = static_cast<std::uint16_t>(body[0] | (body[1] << 8));
+ }
+ continue;
+ }
+ if (type != kBlockTypeEpb) continue; // anything else: skip
+
+ if (body_len < 20) return std::nullopt;
+
+ PacketRecord record;
+ record.interface_id = get_u32({body.data() + 0, 4});
+ std::uint32_t ts_high = get_u32({body.data() + 4, 4});
+ std::uint32_t ts_low = get_u32({body.data() + 8, 4});
+ record.timestamp_us = (static_cast<std::uint64_t>(ts_high) << 32) | ts_low;
+ std::uint32_t caplen = get_u32({body.data() + 12, 4});
+ record.original_len = get_u32({body.data() + 16, 4});
+
+ if (body_len < 20 + caplen) return std::nullopt;
+ record.data.assign(body.begin() + 20, body.begin() + 20 + caplen);
+ return record;
+ }
+ }
+
+ // The interface's link type, learned from the Interface
+ // Description Block once next_packet() has read past it (which
+ // happens before it ever returns the first EPB, so this is
+ // populated by the time the first successful next_packet() call
+ // returns). nullopt if no IDB has been seen yet.
+ std::optional<std::uint16_t> link_type() const { return link_type_; }
+
+private:
+ static std::uint32_t get_u32(std::span<const std::uint8_t> b) {
+ return static_cast<std::uint32_t>(b[0]) | (static_cast<std::uint32_t>(b[1]) << 8) |
+ (static_cast<std::uint32_t>(b[2]) << 16) | (static_cast<std::uint32_t>(b[3]) << 24);
+ }
+
+ static constexpr std::uint32_t kBlockTypeShb = 0x0A0D0D0A;
+ static constexpr std::uint32_t kBlockTypeIdb = 0x00000001;
+ static constexpr std::uint32_t kBlockTypeEpb = 0x00000006;
+ static constexpr std::uint32_t kByteOrderMagic = 0x1A2B3C4D;
+ static constexpr std::size_t kMaxBlockBodyLen = 1 << 20; // 1 MiB
+
+ std::FILE* file_;
+ std::optional<std::uint16_t> link_type_;
+};
+
+} // namespace wireframe::pcapng
diff --git a/include/wireframe/pcapng/writer.hpp b/include/wireframe/pcapng/writer.hpp
new file mode 100644
index 0000000..18f6022
--- /dev/null
+++ b/include/wireframe/pcapng/writer.hpp
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <algorithm>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+#include <vector>
+
+// Minimal pcapng writer: one Section Header Block, one Interface
+// Description Block, then an Enhanced Packet Block per captured packet.
+// Per-block Options are skipped entirely - they're optional in the
+// spec, and a block with none simply omits that section, so this stays
+// a valid, Wireshark-readable file without needing to hand-encode TLVs.
+//
+// Multi-byte fields are written little-endian by hand (matching the
+// 0x1A2B3C4D byte-order magic below) rather than via struct-casting,
+// for the same alignment/UB reasons as the src/wireframe/net decoders.
+namespace wireframe::pcapng {
+
+inline constexpr std::uint32_t kBlockTypeShb = 0x0A0D0D0A;
+inline constexpr std::uint32_t kBlockTypeIdb = 0x00000001;
+inline constexpr std::uint32_t kBlockTypeEpb = 0x00000006;
+inline constexpr std::uint32_t kByteOrderMagic = 0x1A2B3C4D;
+inline constexpr std::uint16_t kLinkTypeEthernet = 1;
+
+class Writer {
+public:
+ explicit Writer(std::FILE* file) : file_(file) {}
+
+ void write_section_header() {
+ std::uint8_t body[16];
+ put_u32(body + 0, kByteOrderMagic);
+ put_u16(body + 4, 1); // major version
+ put_u16(body + 6, 0); // minor version
+ put_u64(body + 8, 0xFFFFFFFFFFFFFFFFULL); // section length: unknown
+ write_block(kBlockTypeShb, {body, sizeof(body)});
+ }
+
+ void write_interface_description(std::uint32_t snaplen, std::uint16_t link_type) {
+ std::uint8_t body[8];
+ put_u16(body + 0, link_type);
+ put_u16(body + 2, 0); // reserved
+ put_u32(body + 4, snaplen);
+ write_block(kBlockTypeIdb, {body, sizeof(body)});
+ }
+
+ void write_packet(std::uint32_t interface_id, std::uint32_t ts_sec, std::uint32_t ts_usec,
+ std::span<const unsigned char> data, std::uint32_t original_len) {
+ std::uint64_t ts_us = static_cast<std::uint64_t>(ts_sec) * 1'000'000ULL + ts_usec;
+ std::uint32_t ts_high = static_cast<std::uint32_t>(ts_us >> 32);
+ std::uint32_t ts_low = static_cast<std::uint32_t>(ts_us & 0xFFFFFFFFULL);
+
+ std::size_t padded_len = (data.size() + 3) & ~std::size_t(3);
+ std::vector<std::uint8_t> body(20 + padded_len, 0); // tail is padding, stays zero
+ put_u32(body.data() + 0, interface_id);
+ put_u32(body.data() + 4, ts_high);
+ put_u32(body.data() + 8, ts_low);
+ put_u32(body.data() + 12, static_cast<std::uint32_t>(data.size()));
+ put_u32(body.data() + 16, original_len);
+ std::copy(data.begin(), data.end(), body.begin() + 20);
+
+ write_block(kBlockTypeEpb, body);
+ }
+
+private:
+ static void put_u16(std::uint8_t* p, std::uint16_t v) {
+ p[0] = static_cast<std::uint8_t>(v & 0xFF);
+ p[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
+ }
+
+ static void put_u32(std::uint8_t* p, std::uint32_t v) {
+ for (int i = 0; i < 4; ++i) p[i] = static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF);
+ }
+
+ static void put_u64(std::uint8_t* p, std::uint64_t v) {
+ for (int i = 0; i < 8; ++i) p[i] = static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF);
+ }
+
+ void write_block(std::uint32_t type, std::span<const std::uint8_t> body) {
+ std::uint32_t total_len = static_cast<std::uint32_t>(8 + body.size() + 4);
+ std::uint8_t type_buf[4];
+ std::uint8_t len_buf[4];
+ put_u32(type_buf, type);
+ put_u32(len_buf, total_len);
+ std::fwrite(type_buf, 1, 4, file_);
+ std::fwrite(len_buf, 1, 4, file_);
+ std::fwrite(body.data(), 1, body.size(), file_);
+ std::fwrite(len_buf, 1, 4, file_);
+ }
+
+ std::FILE* file_;
+};
+
+} // namespace wireframe::pcapng
diff --git a/include/wireframe/search.hpp b/include/wireframe/search.hpp
new file mode 100644
index 0000000..4cb9203
--- /dev/null
+++ b/include/wireframe/search.hpp
@@ -0,0 +1,26 @@
+#pragma once
+
+#include <algorithm>
+#include <cctype>
+#include <string>
+
+// A display filter, distinct from -f's capture filter (wireframe/filter.hpp):
+// -f decides what's captured - and, combined with -w, what's written to
+// disk. This decides what's shown, without touching either. Same
+// distinction Wireshark draws between a capture filter and a display
+// filter, just without the display filter's expression language - a
+// plain case-insensitive substring match over the packet's summary line
+// is enough for "find the packets mentioning this host/port", which is
+// the actual use case.
+namespace wireframe {
+
+inline bool matches_search(const std::string& haystack, const std::string& needle) {
+ if (needle.empty()) return true;
+ auto it = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(),
+ [](unsigned char a, unsigned char b) {
+ return std::tolower(a) == std::tolower(b);
+ });
+ return it != haystack.end();
+}
+
+} // namespace wireframe
diff --git a/include/wireframe/summarize.hpp b/include/wireframe/summarize.hpp
new file mode 100644
index 0000000..59aa621
--- /dev/null
+++ b/include/wireframe/summarize.hpp
@@ -0,0 +1,248 @@
+#pragma once
+
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <pcap.h>
+
+#include "wireframe/l7/dissector.hpp"
+#include "wireframe/l7/dns.hpp"
+#include "wireframe/l7/http.hpp"
+#include "wireframe/l7/tls.hpp"
+#include "wireframe/net/ethernet.hpp"
+#include "wireframe/net/ipv4.hpp"
+#include "wireframe/net/ipv6.hpp"
+#include "wireframe/net/tcp.hpp"
+#include "wireframe/net/udp.hpp"
+
+// Packet -> human-readable summary. Shared by every frontend (plain
+// CLI, TUI, GUI) so they can't drift apart on what a given packet
+// decodes to - one source of truth, not three copies to keep in sync.
+namespace wireframe {
+
+inline std::string mac_to_string(const net::MacAddress& mac) {
+ char buf[18];
+ std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", mac.bytes[0], mac.bytes[1],
+ mac.bytes[2], mac.bytes[3], mac.bytes[4], mac.bytes[5]);
+ return buf;
+}
+
+inline std::string ipv4_to_string(const net::Ipv4Address& ip) {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", ip.bytes[0], ip.bytes[1], ip.bytes[2],
+ ip.bytes[3]);
+ return buf;
+}
+
+inline std::string tcp_flags_to_string(std::uint8_t flags) {
+ using namespace net;
+ std::string out;
+ if (flags & kTcpSyn) out += 'S';
+ if (flags & kTcpAck) out += 'A';
+ if (flags & kTcpFin) out += 'F';
+ if (flags & kTcpRst) out += 'R';
+ if (flags & kTcpPsh) out += 'P';
+ if (flags & kTcpUrg) out += 'U';
+ return out.empty() ? "-" : out;
+}
+
+// Registered once. DNS (UDP) was the first L7 dissector, proving the
+// interface (wireframe/l7/dissector.hpp) is enough to add a protocol
+// without touching the L2-L4 decode path; HTTP (TCP) is the second,
+// and the first to actually exercise L7Registry's TCP-payload path --
+// DNS alone never did, since it only ever runs over UDP port 53. TLS
+// (also TCP, port 443) covers what HTTP increasingly can't: most web
+// traffic today is encrypted, and SNI is the one piece of a TLS
+// handshake still readable without decrypting anything.
+inline const net::L7Registry& l7_registry() {
+ static const net::DnsDissector dns_dissector;
+ static const net::HttpDissector http_dissector;
+ static const net::TlsSniDissector tls_dissector;
+ static const net::L7Registry registry = [] {
+ net::L7Registry r;
+ r.add(&dns_dissector);
+ r.add(&http_dissector);
+ r.add(&tls_dissector);
+ return r;
+ }();
+ return registry;
+}
+
+// Tries the destination port first (the common case: a client talking
+// to a well-known server port), then the source port (a server's
+// reply, coming from that same well-known port).
+inline std::optional<std::string> l7_summarize(std::span<const unsigned char> payload,
+ std::uint16_t src_port, std::uint16_t dst_port) {
+ if (auto summary = l7_registry().dissect(dst_port, payload)) return summary;
+ return l7_registry().dissect(src_port, payload);
+}
+
+// IPv4 and IPv6 headers carry different fields (ttl vs. hop_limit,
+// 4-byte vs. 16-byte addresses), but everything above the IP layer --
+// TCP/UDP decode plus the L7 lookup - is identical once normalized to
+// this. Keeping that dispatch in one place means TCP/UDP/L7 formatting
+// can't drift between the two IP versions.
+struct IpInfo {
+ const char* label; // "IPv4" or "IPv6"
+ std::string src_str;
+ std::string dst_str;
+ std::uint8_t ttl_or_hop_limit;
+ std::uint8_t proto;
+ std::span<const unsigned char> payload;
+};
+
+inline std::string summarize_transport_and_above(const IpInfo& info) {
+ char ip_buf[160];
+ std::snprintf(ip_buf, sizeof(ip_buf), " | %s %s -> %s ttl=%u proto=%u", info.label,
+ info.src_str.c_str(), info.dst_str.c_str(), info.ttl_or_hop_limit, info.proto);
+ std::string out = ip_buf;
+
+ if (info.proto == net::kProtoTcp) {
+ if (auto tcp = net::parse_tcp(info.payload)) {
+ char tcp_buf[128];
+ std::snprintf(tcp_buf, sizeof(tcp_buf), " | TCP %u -> %u [%s] seq=%u ack=%u win=%u",
+ tcp->header.src_port, tcp->header.dst_port,
+ tcp_flags_to_string(tcp->header.flags).c_str(), tcp->header.seq,
+ tcp->header.ack, tcp->header.window);
+ out += tcp_buf;
+ if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port)) {
+ out += " | " + *l7;
+ }
+ }
+ } else if (info.proto == net::kProtoUdp) {
+ if (auto udp = net::parse_udp(info.payload)) {
+ char udp_buf[64];
+ std::snprintf(udp_buf, sizeof(udp_buf), " | UDP %u -> %u len=%u",
+ udp->header.src_port, udp->header.dst_port, udp->header.length);
+ out += udp_buf;
+ if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port)) {
+ out += " | " + *l7;
+ }
+ }
+ } else if (info.proto == net::kNextHeaderIcmpv6) {
+ out += " | ICMPv6";
+ }
+ return out;
+}
+
+// `datalink` is the interface's actual pcap_datalink() type, not an
+// assumption: tunnel/VPN interfaces (tailscale0, wireguard, plain
+// tun/tap) hand libpcap raw IP with no link-layer header at all
+// (DLT_RAW), unlike a real NIC or even `lo` (both DLT_EN10MB on
+// Linux). Treating raw IP bytes as an Ethernet frame silently produces
+// garbage MACs and ethertypes - verified by actually capturing on
+// tailscale0 before this branch existed.
+//
+// IP version is read from the packet itself (the first nibble), not
+// inferred from ethertype/datalink: DLT_RAW has no ethertype to key
+// off at all, and even on Ethernet this keeps IPv4/IPv6 dispatch in
+// one place.
+inline std::string summarize_packet(std::span<const unsigned char> bytes, int datalink) {
+ std::span<const unsigned char> ip_bytes;
+ std::string out;
+
+ if (datalink == DLT_RAW) {
+ out = "RAW";
+ ip_bytes = bytes;
+ } else {
+ auto eth = net::parse_ethernet(bytes);
+ if (!eth) {
+ char buf[64];
+ std::snprintf(buf, sizeof(buf), "[%zu bytes] truncated ethernet frame", bytes.size());
+ return buf;
+ }
+
+ out = "ETH " + mac_to_string(eth->header.src) + " -> " + mac_to_string(eth->header.dst);
+ char eth_buf[32];
+ std::snprintf(eth_buf, sizeof(eth_buf), " ethertype=0x%04x", eth->header.ethertype);
+ out += eth_buf;
+
+ if (eth->header.ethertype != net::kEthertypeIPv4 &&
+ eth->header.ethertype != net::kEthertypeIPv6) {
+ return out;
+ }
+ ip_bytes = eth->payload;
+ }
+
+ if (ip_bytes.empty()) {
+ out += " | IP (empty payload)";
+ return out;
+ }
+ std::uint8_t version = static_cast<std::uint8_t>(ip_bytes[0] >> 4);
+
+ if (version == 4) {
+ auto ip = net::parse_ipv4(ip_bytes);
+ if (!ip) {
+ out += " | IPv4 (truncated)";
+ return out;
+ }
+ out += summarize_transport_and_above({"IPv4", ipv4_to_string(ip->header.src),
+ ipv4_to_string(ip->header.dst), ip->header.ttl,
+ ip->header.protocol, ip->payload});
+ } else if (version == 6) {
+ auto ip6 = net::parse_ipv6(ip_bytes);
+ if (!ip6) {
+ out += " | IPv6 (truncated)";
+ return out;
+ }
+ std::string src_str = net::ipv6_to_string(ip6->header.src);
+ std::string dst_str = net::ipv6_to_string(ip6->header.dst);
+
+ // next_header may name an extension header (Hop-by-Hop,
+ // Routing, Dest Options, Fragment, AH) rather than the actual
+ // transport protocol; walk through those to find it.
+ auto walked = net::walk_ipv6_extension_headers(ip6->header.next_header, ip6->payload);
+ if (walked.stopped_at_esp) {
+ char buf[160];
+ std::snprintf(buf, sizeof(buf), " | IPv6 %s -> %s ttl=%u proto=%u | ESP (encrypted)",
+ src_str.c_str(), dst_str.c_str(), ip6->header.hop_limit,
+ net::kNextHeaderEsp);
+ out += buf;
+ } else {
+ out += summarize_transport_and_above({"IPv6", src_str, dst_str, ip6->header.hop_limit,
+ walked.final_next_header, walked.payload});
+ }
+ } else {
+ out += " | IP version " + std::to_string(version) + " (unsupported)";
+ }
+ return out;
+}
+
+// One formatted line per 16 bytes: offset, hex, ASCII gutter. Returned
+// as lines rather than printed so both the CLI's -x output and a GUI
+// details pane can use the same formatting.
+inline std::vector<std::string> hex_dump_lines(std::span<const unsigned char> bytes) {
+ std::vector<std::string> lines;
+ for (std::size_t offset = 0; offset < bytes.size(); offset += 16) {
+ char offset_buf[32];
+ std::snprintf(offset_buf, sizeof(offset_buf), "%06zx ", offset);
+ std::string line = offset_buf;
+
+ std::size_t line_len = std::min<std::size_t>(16, bytes.size() - offset);
+ for (std::size_t i = 0; i < 16; ++i) {
+ if (i < line_len) {
+ char byte_buf[4];
+ std::snprintf(byte_buf, sizeof(byte_buf), "%02x ", bytes[offset + i]);
+ line += byte_buf;
+ } else {
+ line += " ";
+ }
+ if (i == 7) line += ' ';
+ }
+
+ line += " |";
+ for (std::size_t i = 0; i < line_len; ++i) {
+ unsigned char c = bytes[offset + i];
+ line += (c >= 0x20 && c < 0x7f) ? static_cast<char>(c) : '.';
+ }
+ line += '|';
+
+ lines.push_back(std::move(line));
+ }
+ return lines;
+}
+
+} // namespace wireframe
diff --git a/src/gui_main.cpp b/src/gui_main.cpp
new file mode 100644
index 0000000..d5d4518
--- /dev/null
+++ b/src/gui_main.cpp
@@ -0,0 +1,280 @@
+// GUI frontend (secondary to the TUI - see PLAN.md Decisions). Same
+// capture/decode/filter/pcapng pipeline as main.cpp's CLI/TUI modes,
+// via wireframe::CaptureSession - not a hand-copied setup path, so it
+// can't drift on datalink validation, filter errors, or the
+// pcap_breakloop() shutdown hook the way two independent
+// implementations eventually would.
+//
+// Dear ImGui + SDL3 (see CMakeLists.txt for the toolkit decision).
+
+#include <SDL3/SDL.h>
+#include <imgui.h>
+#include <imgui_impl_sdl3.h>
+#include <imgui_impl_sdlrenderer3.h>
+
+#include <atomic>
+#include <cstdio>
+#include <cstring>
+#include <deque>
+#include <mutex>
+#include <optional>
+#include <span>
+#include <string>
+#include <thread>
+
+#include "wireframe/capture_session.hpp"
+#include "wireframe/search.hpp"
+#include "wireframe/summarize.hpp"
+
+namespace {
+
+struct PacketRow {
+ std::string summary;
+ std::vector<unsigned char> data;
+};
+
+constexpr std::size_t kMaxRows = 5000; // cap memory; oldest rows scroll off
+
+struct SharedState {
+ std::mutex mutex;
+ std::deque<PacketRow> rows;
+ std::uint64_t packet_count = 0;
+ bool replay_finished = false; // guarded by mutex, like rows/packet_count
+ // Set once the consumer loop drains and exits from an explicit stop
+ // (the window's quit action or an external SIGINT/SIGTERM) - but
+ // NOT when a replay simply reaches end-of-file on its own, since the
+ // point of replaying a file is browsing/searching it afterward, not
+ // watching it flash by and vanish. The render loop watches this so
+ // an external signal still closes the whole app in every other case
+ // - not just the capture, leaving a frozen window behind - the
+ // same single shutdown path run_tui() uses.
+ std::atomic<bool> capture_alive{true};
+};
+
+void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue,
+ SharedState& state) {
+ while (auto packet = queue.pop()) {
+ std::span<const unsigned char> bytes{packet->data};
+ std::string summary = wireframe::summarize_packet(bytes, session.datalink());
+
+ if (auto* writer = session.pcapng_writer()) {
+ writer->write_packet(/*interface_id=*/0, packet->ts_sec, packet->ts_usec, bytes,
+ packet->original_len);
+ }
+
+ std::lock_guard<std::mutex> lock(state.mutex);
+ state.rows.push_back({std::move(summary), std::move(packet->data)});
+ if (state.rows.size() > kMaxRows) state.rows.pop_front();
+ ++state.packet_count;
+ }
+ if (session.is_replay() && !session.stop_requested()) {
+ std::lock_guard<std::mutex> lock(state.mutex);
+ state.replay_finished = true;
+ } else {
+ state.capture_alive.store(false);
+ }
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ wireframe::CaptureSessionOptions options;
+ for (int i = 1; i < argc; ++i) {
+ if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) {
+ options.pcapng_output_path = argv[++i];
+ } else if (std::strcmp(argv[i], "-f") == 0 && i + 1 < argc) {
+ options.filter_expr = argv[++i];
+ } else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
+ options.replay_input_path = argv[++i];
+ } else if (options.device.empty()) {
+ options.device = argv[i];
+ }
+ }
+
+ wireframe::CaptureSession session;
+ if (auto err = session.open(options)) {
+ std::fprintf(stderr, "%s\n", err->c_str());
+ return 1;
+ }
+ session.install_signal_handlers();
+
+ if (!SDL_Init(SDL_INIT_VIDEO)) {
+ std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
+ return 1;
+ }
+
+ SDL_Window* window =
+ SDL_CreateWindow("wireframe", 1000, 650, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN);
+ if (window == nullptr) {
+ std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
+ SDL_Quit();
+ return 1;
+ }
+ SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr);
+ if (renderer == nullptr) {
+ std::fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError());
+ SDL_DestroyWindow(window);
+ SDL_Quit();
+ return 1;
+ }
+ SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
+ SDL_ShowWindow(window);
+
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+ ImGui::GetIO().IniFilename = nullptr; // no layout file: this is a fixed, simple layout
+ ImGui_ImplSDL3_InitForSDLRenderer(window, renderer);
+ ImGui_ImplSDLRenderer3_Init(renderer);
+
+ wireframe::CaptureQueue queue(4096);
+ SharedState state;
+ std::thread capture_thread = session.start_capture_thread(queue);
+ std::thread consumer_thread(consumer_loop, std::ref(session), std::ref(queue),
+ std::ref(state));
+
+ int selected_row = -1;
+ bool quit = false;
+ char search_buf[256] = {};
+ ImGuiIO& io = ImGui::GetIO();
+ while (!quit && state.capture_alive.load()) {
+ SDL_Event event;
+ while (SDL_PollEvent(&event)) {
+ ImGui_ImplSDL3_ProcessEvent(&event);
+ if (event.type == SDL_EVENT_QUIT) {
+ quit = true;
+ session.request_stop();
+ }
+ // io.WantCaptureKeyboard reflects whether an ImGui widget
+ // (the search box) held keyboard focus as of the last
+ // completed frame - without this check, Escape would quit
+ // the whole app while the user is just trying to clear a
+ // search term, instead of doing what the TUI's Escape does
+ // in the same context (clear the term, stay open).
+ if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE &&
+ !io.WantCaptureKeyboard) {
+ quit = true;
+ session.request_stop();
+ }
+ }
+
+ ImGui_ImplSDLRenderer3_NewFrame();
+ ImGui_ImplSDL3_NewFrame();
+ ImGui::NewFrame();
+
+ ImGui::SetNextWindowPos(ImVec2(0, 0));
+ ImGui::SetNextWindowSize(io.DisplaySize);
+ ImGui::Begin("wireframe", nullptr,
+ ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
+ ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse);
+
+ if (session.is_replay()) {
+ ImGui::Text("replaying %s (%s)", session.device().c_str(),
+ pcap_datalink_val_to_name(session.datalink()));
+ } else {
+ ImGui::Text("capturing on %s (%s)", session.device().c_str(),
+ pcap_datalink_val_to_name(session.datalink()));
+ }
+ ImGui::SameLine();
+ ImGui::SetNextItemWidth(300);
+ ImGui::InputTextWithHint("##search", "search (display filter, not capture filter)",
+ search_buf, sizeof(search_buf));
+ if (ImGui::IsItemFocused() && ImGui::IsKeyPressed(ImGuiKey_Escape)) {
+ search_buf[0] = '\0'; // same behavior as the TUI's Esc-while-searching
+ }
+ std::string search_term(search_buf);
+ ImGui::Separator();
+
+ float details_height = 160.0f;
+ std::size_t shown = 0;
+ ImGui::BeginChild("packet_list", ImVec2(0, -details_height - 8), ImGuiChildFlags_Borders);
+ {
+ std::lock_guard<std::mutex> lock(state.mutex);
+ for (std::size_t i = 0; i < state.rows.size(); ++i) {
+ if (!wireframe::matches_search(state.rows[i].summary, search_term)) continue;
+ ++shown;
+
+ // ImGui derives a widget's ID from its label text by
+ // default; two rows with identical summary text (e.g.
+ // repeated ICMP lines) would otherwise collide on the
+ // same ID. PushID(index) makes each row's ID unique
+ // regardless of what text it displays.
+ ImGui::PushID(static_cast<int>(i));
+ bool is_selected = (selected_row == static_cast<int>(i));
+ if (ImGui::Selectable(state.rows[i].summary.c_str(), is_selected)) {
+ selected_row = static_cast<int>(i);
+ }
+ ImGui::PopID();
+ }
+ // Auto-scroll to the newest row unless the user has scrolled up
+ // to look at something (a manual scroll leaves the view short
+ // of the max, which is what we check here).
+ if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) {
+ ImGui::SetScrollHereY(1.0f);
+ }
+ }
+ ImGui::EndChild();
+
+ ImGui::BeginChild("packet_details", ImVec2(0, details_height), ImGuiChildFlags_Borders);
+ {
+ std::lock_guard<std::mutex> lock(state.mutex);
+ if (selected_row >= 0 && selected_row < static_cast<int>(state.rows.size())) {
+ for (const auto& line : wireframe::hex_dump_lines(state.rows[selected_row].data)) {
+ ImGui::TextUnformatted(line.c_str());
+ }
+ } else {
+ ImGui::TextDisabled("select a packet to see its hex dump");
+ }
+ }
+ ImGui::EndChild();
+
+ ImGui::Separator();
+ {
+ std::lock_guard<std::mutex> lock(state.mutex);
+ const char* finished = state.replay_finished ? " [replay finished]" : "";
+ if (search_term.empty()) {
+ ImGui::Text("packets: %llu%s dropped: %llu (Esc to quit)",
+ static_cast<unsigned long long>(state.packet_count), finished,
+ static_cast<unsigned long long>(queue.dropped()));
+ } else {
+ ImGui::Text("packets: %llu (%zu shown)%s dropped: %llu (Esc to clear search)",
+ static_cast<unsigned long long>(state.packet_count), shown, finished,
+ static_cast<unsigned long long>(queue.dropped()));
+ }
+ }
+ // Kernel/interface-level drops: a traffic spike can drop
+ // packets before libpcap ever hands them to our callback,
+ // which the queue-side counter above can't see.
+ if (auto stats = session.stats()) {
+ if (stats->dropped > 0 || stats->if_dropped > 0) {
+ ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f),
+ "kernel/interface dropped %u/%u (received %u)", stats->dropped,
+ stats->if_dropped, stats->received);
+ }
+ }
+
+ ImGui::End();
+
+ ImGui::Render();
+ SDL_SetRenderDrawColor(renderer, 30, 30, 30, 255);
+ SDL_RenderClear(renderer);
+ ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), renderer);
+ SDL_RenderPresent(renderer);
+ }
+
+ session.request_stop();
+ capture_thread.join();
+ consumer_thread.join();
+
+ if (queue.dropped() > 0) {
+ std::fprintf(stderr, "dropped %llu packets (render side fell behind)\n",
+ static_cast<unsigned long long>(queue.dropped()));
+ }
+
+ ImGui_ImplSDLRenderer3_Shutdown();
+ ImGui_ImplSDL3_Shutdown();
+ ImGui::DestroyContext();
+ SDL_DestroyRenderer(renderer);
+ SDL_DestroyWindow(window);
+ SDL_Quit();
+ return 0;
+}
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..3a3e925
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,326 @@
+// Stage 2 (PLAN.md): Ethernet/IP/TCP/UDP decoders producing a live
+// packet-list line per capture. The TUI itself is still an open
+// question (PLAN.md), so this prints to stdout for now; -x keeps the
+// stage-1 hex dump available underneath each summary.
+//
+// Stage 3: -w <file> writes the same capture out as pcapng alongside
+// the summary, so files stay Wireshark-compatible (PLAN.md).
+//
+// Stage 4: capture thread -> bounded CaptureQueue -> render loop on
+// the main thread (PLAN.md's architecture sketch). The capture thread
+// only copies raw bytes into the queue; decoding, printing, and
+// pcapng-writing all happen on the consumer side, so a slow render
+// path can never block the capture thread - a full queue drops the
+// packet and counts it instead.
+//
+// Stage 5: L7 dissectors register into an L7Registry keyed by port
+// (wireframe/l7/dissector.hpp) and get consulted from summarize_packet
+// once TCP/UDP decode a port number. DNS is the first one, proving the
+// interface against real traffic rather than synthetic bytes.
+//
+// IPv6: dispatched by version nibble rather than assumed absent --
+// every live-capture test so far has shown real IPv6 background
+// traffic silently dropped once the decoder only handled IPv4.
+//
+// Stage 6: -f <expr> compiles a tcpdump-style BPF expression via
+// libpcap's own compiler (wireframe/filter.hpp) and installs it with
+// pcap_setfilter(), filtering in the kernel before packets ever reach
+// userspace - rather than hand-rolling a second BPF parser.
+//
+// Device-open/datalink-validate/filter/pcapng/signal-handler setup all
+// goes through wireframe::CaptureSession (wireframe/capture_session.hpp)
+// - the same one gui_main.cpp uses - so the CLI/TUI and GUI frontends
+// can't drift apart on that setup path.
+
+#include <pcap.h>
+
+#include <cstdio>
+#include <cstring>
+#include <deque>
+#include <mutex>
+#include <optional>
+#include <span>
+#include <string>
+#include <thread>
+
+#include <ftxui/component/component.hpp>
+#include <ftxui/component/screen_interactive.hpp>
+#include <ftxui/dom/elements.hpp>
+
+#include "wireframe/capture_session.hpp"
+#include "wireframe/search.hpp"
+#include "wireframe/summarize.hpp"
+
+namespace {
+
+// Queue capacity: how many packets can be buffered between the capture
+// thread and the render loop before new packets get dropped. Sized as
+// a fixed constant rather than a flag - tune later if a real workload
+// needs it, not speculatively now.
+constexpr std::size_t kQueueCapacity = 4096;
+
+void hex_dump(std::span<const unsigned char> bytes) {
+ for (const auto& line : wireframe::hex_dump_lines(bytes)) {
+ std::printf("%s\n", line.c_str());
+ }
+ std::printf("\n");
+}
+
+struct RenderOptions {
+ bool verbose_hex;
+ int datalink;
+ wireframe::pcapng::Writer* pcapng_writer;
+ std::string search_term; // display filter - see wireframe/search.hpp
+};
+
+void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& opts) {
+ std::span<const unsigned char> bytes{packet.data};
+
+ std::string line = wireframe::summarize_packet(bytes, opts.datalink);
+
+ // -g is a display filter, not a capture filter: still written to
+ // -w regardless of whether it matches, since -w should reflect
+ // what was actually captured (that's -f's job), not what's shown.
+ if (opts.pcapng_writer) {
+ opts.pcapng_writer->write_packet(/*interface_id=*/0, packet.ts_sec, packet.ts_usec, bytes,
+ packet.original_len);
+ }
+
+ if (!wireframe::matches_search(line, opts.search_term)) return;
+
+ std::printf("%s\n", line.c_str());
+ if (opts.verbose_hex) hex_dump(bytes);
+
+ // Flush per packet: stdout is fully buffered off a tty, and this is
+ // a live capture tool, not a batch one.
+ std::fflush(stdout);
+}
+
+// TUI mode (-t): a scrolling packet list in a full-screen view, built
+// with FTXUI (see NAMES.md-adjacent decision: chosen over notcurses for
+// pure-C++ portability - no C build-system/dependency chain to fight
+// on every platform PLAN.md targets, and genuine Windows console
+// support, which notcurses lacks).
+//
+// A dedicated consumer thread pops from the capture queue and appends
+// formatted rows to shared state; the UI thread just redraws on
+// Event::Custom. 'q'/Esc triggers the same pcap_breakloop() shutdown
+// path as Ctrl-C, so there's one shutdown sequence, not two: breakloop
+// -> capture thread's pcap_loop returns -> queue.stop() -> consumer
+// drains and calls screen.Exit() -> screen.Loop() returns.
+void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue,
+ RenderOptions& opts) {
+ using namespace ftxui;
+
+ constexpr std::size_t kMaxRows = 2000; // cap memory; oldest rows scroll off
+
+ std::mutex state_mutex;
+ std::deque<std::string> rows;
+ std::uint64_t packet_count = 0;
+
+ // '/' search: a display filter over `rows`, independent of the
+ // capture itself (wireframe/search.hpp) - typed and read only on
+ // the UI thread (the consumer thread never touches it), so unlike
+ // `rows`/`packet_count` it doesn't need state_mutex.
+ bool searching = false;
+ std::string search_term;
+ bool replay_finished = false; // guarded by state_mutex, like rows/packet_count
+
+ auto screen = ScreenInteractive::Fullscreen();
+
+ std::thread consumer_thread([&] {
+ while (auto packet = queue.pop()) {
+ std::span<const unsigned char> bytes{packet->data};
+ std::string line = wireframe::summarize_packet(bytes, opts.datalink);
+
+ if (opts.pcapng_writer) {
+ opts.pcapng_writer->write_packet(/*interface_id=*/0, packet->ts_sec,
+ packet->ts_usec, bytes, packet->original_len);
+ }
+
+ {
+ std::lock_guard<std::mutex> lock(state_mutex);
+ rows.push_back(std::move(line));
+ if (rows.size() > kMaxRows) rows.pop_front();
+ ++packet_count;
+ }
+ screen.PostEvent(Event::Custom);
+ }
+ // Replay reaching end-of-file on its own (stop_requested() still
+ // false) shouldn't close the window - the point of replaying a
+ // file is browsing/searching it afterward, not watching it flash
+ // by. An explicit stop (q/Esc below, or an external signal, both
+ // of which set stop_requested()) always closes, live capture
+ // included - that's still the same behavior as before.
+ if (session.is_replay() && !session.stop_requested()) {
+ {
+ std::lock_guard<std::mutex> lock(state_mutex);
+ replay_finished = true;
+ }
+ screen.PostEvent(Event::Custom); // one more redraw for the final state
+ } else {
+ screen.Exit();
+ }
+ });
+
+ auto renderer = Renderer([&] {
+ std::lock_guard<std::mutex> lock(state_mutex);
+ Elements lines;
+ std::size_t shown = 0;
+ for (const auto& row : rows) {
+ if (!wireframe::matches_search(row, search_term)) continue;
+ lines.push_back(text(row));
+ ++shown;
+ }
+
+ std::string status = "packets: " + std::to_string(packet_count);
+ if (!search_term.empty()) status += " (" + std::to_string(shown) + " shown)";
+ if (replay_finished) status += " [replay finished]";
+ status += " dropped: " + std::to_string(queue.dropped()) +
+ (searching ? " (Enter to apply, Esc to clear)" : " (/ to search, q to quit)");
+ // Kernel/interface-level drops: a traffic spike can drop
+ // packets before libpcap ever hands them to our callback,
+ // which the queue-side counter above can't see.
+ Elements footer = {text(status) | dim};
+ if (auto stats = session.stats()) {
+ if (stats->dropped > 0 || stats->if_dropped > 0) {
+ std::string kernel_status = "kernel/interface dropped " +
+ std::to_string(stats->dropped) + "/" +
+ std::to_string(stats->if_dropped) + " (received " +
+ std::to_string(stats->received) + ")";
+ footer.push_back(text(kernel_status) | color(Color::Yellow));
+ }
+ }
+ if (searching || !search_term.empty()) {
+ footer.push_back(text("search: " + search_term + (searching ? "_" : "")) |
+ color(Color::Green));
+ }
+ std::string title = session.is_replay() ? ("wireframe - replaying " + session.device())
+ : "wireframe - live capture";
+ return vbox({
+ text(title) | bold | color(Color::Cyan),
+ separator(),
+ vbox(std::move(lines)) | yframe | flex,
+ separator(),
+ vbox(std::move(footer)),
+ }) |
+ border;
+ });
+
+ auto component = CatchEvent(renderer, [&](const Event& event) {
+ if (searching) {
+ if (event == Event::Return) {
+ searching = false;
+ return true;
+ }
+ if (event == Event::Escape) {
+ searching = false;
+ search_term.clear();
+ return true;
+ }
+ if (event == Event::Backspace) {
+ if (!search_term.empty()) search_term.pop_back();
+ return true;
+ }
+ if (event.is_character()) {
+ search_term += event.character();
+ return true;
+ }
+ return true; // swallow anything else while typing (don't let it fall through to quit)
+ }
+ if (event == Event::Character('/')) {
+ searching = true;
+ return true;
+ }
+ if (event == Event::Character('q') || event == Event::Escape) {
+ session.request_stop();
+ // Closes immediately rather than waiting for the capture/
+ // replay thread to actually finish and drain the queue --
+ // necessary for replay mode specifically (that thread may
+ // already be long gone once the user quits after browsing a
+ // finished replay, so nothing else would ever call this).
+ // main() still joins the thread properly afterward either way.
+ screen.Exit();
+ return true;
+ }
+ return false;
+ });
+
+ screen.Loop(component);
+ consumer_thread.join();
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ wireframe::CaptureSessionOptions options;
+ bool tui_mode = false;
+ RenderOptions opts{
+ .verbose_hex = false, .datalink = 0, .pcapng_writer = nullptr, .search_term = ""};
+
+ for (int i = 1; i < argc; ++i) {
+ if (std::strcmp(argv[i], "-x") == 0) {
+ opts.verbose_hex = true;
+ } else if (std::strcmp(argv[i], "-t") == 0 || std::strcmp(argv[i], "--tui") == 0) {
+ tui_mode = true;
+ } else if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) {
+ options.pcapng_output_path = argv[++i];
+ } else if (std::strcmp(argv[i], "-f") == 0 && i + 1 < argc) {
+ options.filter_expr = argv[++i];
+ } else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
+ options.replay_input_path = argv[++i];
+ } else if (std::strcmp(argv[i], "-g") == 0 && i + 1 < argc) {
+ opts.search_term = argv[++i];
+ } else if (options.device.empty()) {
+ options.device = argv[i];
+ }
+ }
+
+ wireframe::CaptureSession session;
+ if (auto err = session.open(options)) {
+ std::fprintf(stderr, "%s\n", err->c_str());
+ return 1;
+ }
+ opts.datalink = session.datalink();
+ opts.pcapng_writer = session.pcapng_writer();
+ session.install_signal_handlers();
+
+ if (!tui_mode) {
+ if (session.is_replay()) {
+ std::printf("replaying %s (%s)\n", session.device().c_str(),
+ pcap_datalink_val_to_name(session.datalink()));
+ } else {
+ std::printf("capturing on %s (%s, ctrl-c to stop)\n", session.device().c_str(),
+ pcap_datalink_val_to_name(session.datalink()));
+ }
+ }
+
+ wireframe::CaptureQueue queue(kQueueCapacity);
+ std::thread capture_thread = session.start_capture_thread(queue);
+
+ if (tui_mode) {
+ run_tui(session, queue, opts);
+ } else {
+ while (auto packet = queue.pop()) {
+ render_packet(*packet, opts);
+ }
+ }
+
+ capture_thread.join();
+
+ if (queue.dropped() > 0) {
+ std::fprintf(stderr, "dropped %llu packets (render side fell behind)\n",
+ static_cast<unsigned long long>(queue.dropped()));
+ }
+ // Kernel-level counters, queried before the session closes its
+ // handle: a traffic spike can drop packets before libpcap ever
+ // hands them to our callback, which queue.dropped() can't see.
+ if (auto stats = session.stats()) {
+ if (stats->dropped > 0 || stats->if_dropped > 0) {
+ std::fprintf(stderr, "kernel/interface dropped %u/%u packets (received %u)\n",
+ stats->dropped, stats->if_dropped, stats->received);
+ }
+ }
+
+ return 0;
+}
diff --git a/tests/main.cpp b/tests/main.cpp
new file mode 100644
index 0000000..0a3f254
--- /dev/null
+++ b/tests/main.cpp
@@ -0,0 +1,2 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
diff --git a/tests/test_byteio.cpp b/tests/test_byteio.cpp
new file mode 100644
index 0000000..81fa741
--- /dev/null
+++ b/tests/test_byteio.cpp
@@ -0,0 +1,23 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/byteio.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("read_be16 reads a big-endian 16-bit value") {
+ std::vector<unsigned char> bytes = {0x12, 0x34};
+ CHECK(read_be16(bytes, 0) == 0x1234);
+}
+
+TEST_CASE("read_be32 reads a big-endian 32-bit value") {
+ std::vector<unsigned char> bytes = {0xDE, 0xAD, 0xBE, 0xEF};
+ CHECK(read_be32(bytes, 0) == 0xDEADBEEFu);
+}
+
+TEST_CASE("read_be16/read_be32 read from a nonzero offset") {
+ std::vector<unsigned char> bytes = {0x00, 0x00, 0x12, 0x34, 0x56, 0x78};
+ CHECK(read_be16(bytes, 2) == 0x1234);
+ CHECK(read_be32(bytes, 2) == 0x12345678u);
+}
diff --git a/tests/test_capture_queue.cpp b/tests/test_capture_queue.cpp
new file mode 100644
index 0000000..da2da28
--- /dev/null
+++ b/tests/test_capture_queue.cpp
@@ -0,0 +1,122 @@
+#include <doctest/doctest.h>
+
+#include <atomic>
+#include <chrono>
+#include <thread>
+
+#include "wireframe/capture_queue.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("try_push/pop returns packets in FIFO order") {
+ CaptureQueue queue(4);
+ for (std::uint32_t i = 0; i < 3; ++i) {
+ CapturedPacket p;
+ p.ts_sec = i;
+ p.data = {static_cast<unsigned char>(i)};
+ CHECK(queue.try_push(std::move(p)));
+ }
+ for (std::uint32_t i = 0; i < 3; ++i) {
+ auto p = queue.pop();
+ REQUIRE(p.has_value());
+ CHECK(p->ts_sec == i);
+ }
+ CHECK(queue.dropped() == 0);
+}
+
+TEST_CASE("try_push drops and counts once the queue is full") {
+ CaptureQueue queue(2);
+ CapturedPacket a, b, c;
+ CHECK(queue.try_push(std::move(a)));
+ CHECK(queue.try_push(std::move(b)));
+ CHECK_FALSE(queue.try_push(std::move(c))); // full: dropped, not blocked
+ CHECK(queue.dropped() == 1);
+}
+
+TEST_CASE("stop() drains items already queued before pop() returns nullopt") {
+ CaptureQueue queue(4);
+ CapturedPacket a, b;
+ queue.try_push(std::move(a));
+ queue.try_push(std::move(b));
+ queue.stop();
+
+ CHECK(queue.pop().has_value());
+ CHECK(queue.pop().has_value());
+ CHECK_FALSE(queue.pop().has_value()); // drained and stopped
+}
+
+TEST_CASE("pop() blocks until a packet is pushed") {
+ CaptureQueue queue(4);
+ std::thread producer([&] {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ CapturedPacket p;
+ p.data = {0x42};
+ queue.try_push(std::move(p));
+ });
+
+ auto p = queue.pop();
+ REQUIRE(p.has_value());
+ CHECK(p->data[0] == 0x42);
+ producer.join();
+}
+
+TEST_CASE("push() succeeds immediately when there's room") {
+ CaptureQueue queue(4);
+ CapturedPacket p;
+ p.data = {0x01};
+ CHECK(queue.push(std::move(p)));
+ CHECK(queue.dropped() == 0);
+}
+
+TEST_CASE("push() blocks for room instead of dropping, unlike try_push()") {
+ CaptureQueue queue(1);
+ CapturedPacket a;
+ a.data = {0xAA};
+ CHECK(queue.try_push(std::move(a))); // fills the only slot
+
+ std::atomic<bool> pushed{false};
+ std::thread producer([&] {
+ CapturedPacket b;
+ b.data = {0xBB};
+ CHECK(queue.push(std::move(b))); // must block until the pop() below frees room
+ pushed.store(true);
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ CHECK_FALSE(pushed.load()); // still blocked: queue was full this whole time
+
+ auto first = queue.pop(); // frees a slot
+ REQUIRE(first.has_value());
+ CHECK(first->data[0] == 0xAA);
+
+ producer.join();
+ CHECK(pushed.load());
+ CHECK(queue.dropped() == 0); // never dropped - it waited instead
+
+ auto second = queue.pop();
+ REQUIRE(second.has_value());
+ CHECK(second->data[0] == 0xBB);
+}
+
+TEST_CASE("push() returns false without pushing if stop() is called while it's waiting") {
+ CaptureQueue queue(1);
+ CapturedPacket a;
+ a.data = {0xAA};
+ queue.try_push(std::move(a)); // fill the only slot
+
+ std::atomic<bool> result_ready{false};
+ std::atomic<bool> push_result{true};
+ std::thread producer([&] {
+ CapturedPacket b;
+ b.data = {0xBB};
+ push_result.store(queue.push(std::move(b)));
+ result_ready.store(true);
+ });
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ queue.stop();
+ producer.join();
+
+ CHECK(result_ready.load());
+ CHECK_FALSE(push_result.load());
+}
diff --git a/tests/test_capture_session.cpp b/tests/test_capture_session.cpp
new file mode 100644
index 0000000..691131a
--- /dev/null
+++ b/tests/test_capture_session.cpp
@@ -0,0 +1,138 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+#include <unistd.h>
+
+#include <cstdio>
+#include <string>
+#include <vector>
+
+#include "wireframe/capture_session.hpp"
+#include "wireframe/pcapng/writer.hpp"
+
+using namespace wireframe;
+
+namespace {
+
+// A real, named pcapng file on disk - CaptureSession::open() takes a
+// path, not a FILE*, so a std::tmpfile() (unnamed) doesn't work here
+// the way it does in test_pcapng.cpp. Cleans itself up via RAII.
+struct TempPcapngFile {
+ std::string path;
+
+ explicit TempPcapngFile(int link_type, const std::vector<std::vector<unsigned char>>& packets) {
+ char path_template[] = "/tmp/wireframe_test_XXXXXX";
+ int fd = mkstemp(path_template);
+ REQUIRE(fd != -1);
+ path = path_template;
+
+ std::FILE* f = fdopen(fd, "wb");
+ REQUIRE(f != nullptr);
+ pcapng::Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, static_cast<std::uint16_t>(link_type));
+ std::uint32_t ts = 1700000000;
+ for (const auto& packet : packets) {
+ writer.write_packet(0, ts++, 0, packet,
+ static_cast<std::uint32_t>(packet.size()));
+ }
+ std::fclose(f);
+ }
+
+ ~TempPcapngFile() { std::remove(path.c_str()); }
+};
+
+} // namespace
+
+TEST_CASE("is_supported_datalink accepts EN10MB and RAW, rejects others") {
+ CHECK(is_supported_datalink(DLT_EN10MB));
+ CHECK(is_supported_datalink(DLT_RAW));
+ CHECK_FALSE(is_supported_datalink(DLT_IEEE802_11));
+}
+
+TEST_CASE("CaptureSession::open reports an error for a nonexistent device, without needing root") {
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.device = "this-device-does-not-exist-0xdeadbeef";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("CaptureSession::stats returns nullopt before open()") {
+ CaptureSession session;
+ CHECK_FALSE(session.stats().has_value());
+}
+
+TEST_CASE("CaptureSession::open replays a pcapng file without needing root or a live device") {
+ TempPcapngFile file(DLT_EN10MB, {{0xDE, 0xAD}, {0xBE, 0xEF}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+
+ CHECK_FALSE(session.open(options).has_value());
+ CHECK(session.is_replay());
+ CHECK(session.datalink() == DLT_EN10MB);
+ CHECK(session.device() == file.path);
+ CHECK_FALSE(session.stats().has_value()); // pcap_stats() needs a live handle; replay has none
+}
+
+TEST_CASE("CaptureSession::open rejects combining -r (replay) with -f (capture filter)") {
+ TempPcapngFile file(DLT_EN10MB, {{0x01}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+ options.filter_expr = "tcp";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK(err->find("-r") != std::string::npos);
+}
+
+TEST_CASE("CaptureSession::open reports an error for a nonexistent replay file") {
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = "/tmp/this-file-does-not-exist-0xdeadbeef.pcapng";
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("CaptureSession::open reports an error for a pcapng file with no packets") {
+ TempPcapngFile file(DLT_EN10MB, {}); // just SHB + IDB, no EPBs
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+
+ auto err = session.open(options);
+ REQUIRE(err.has_value());
+}
+
+TEST_CASE("replayed packets reach the CaptureQueue in order, and the thread stops on its own") {
+ TempPcapngFile file(DLT_RAW, {{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}});
+
+ CaptureSession session;
+ CaptureSessionOptions options;
+ options.replay_input_path = file.path;
+ REQUIRE_FALSE(session.open(options).has_value());
+ CHECK(session.datalink() == DLT_RAW);
+
+ CaptureQueue queue(4096);
+ auto capture_thread = session.start_capture_thread(queue);
+
+ std::vector<unsigned char> first_bytes;
+ int count = 0;
+ while (auto packet = queue.pop()) {
+ if (count == 0) first_bytes = packet->data;
+ ++count;
+ }
+ capture_thread.join();
+
+ CHECK(count == 3);
+ REQUIRE(first_bytes.size() == 2);
+ CHECK(first_bytes[0] == 0x01);
+}
diff --git a/tests/test_dns.cpp b/tests/test_dns.cpp
new file mode 100644
index 0000000..9a389bb
--- /dev/null
+++ b/tests/test_dns.cpp
@@ -0,0 +1,82 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/dns.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+// "example.com" A query, id=0x129d - the same shape as the real query
+// captured live over tailscale0 while testing the DNS dissector against
+// tshark (id 0x129d / 4765 matched tshark's independent decode exactly).
+std::vector<unsigned char> example_com_query() {
+ return {
+ 0x12, 0x9d, // id = 4765
+ 0x01, 0x00, // flags: RD=1
+ 0x00, 0x01, // qdcount = 1
+ 0x00, 0x00, // ancount = 0
+ 0x00, 0x00, // nscount = 0
+ 0x00, 0x00, // arcount = 0
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, // qtype = A
+ 0x00, 0x01, // qclass = IN
+ };
+}
+
+} // namespace
+
+TEST_CASE("parse_dns decodes a query") {
+ auto msg = parse_dns(example_com_query());
+ REQUIRE(msg.has_value());
+ CHECK(msg->header.id == 4765);
+ CHECK_FALSE(msg->header.is_response);
+ CHECK(msg->header.qdcount == 1);
+ REQUIRE(msg->question.has_value());
+ CHECK(msg->question->name == "example.com");
+ CHECK(msg->question->qtype == 1);
+}
+
+TEST_CASE("parse_dns decodes a response") {
+ std::vector<unsigned char> bytes = {
+ 0x12, 0x9d,
+ 0x81, 0x80, // flags: QR=1 (response), RD=1, RA=1
+ 0x00, 0x01, // qdcount = 1
+ 0x00, 0x02, // ancount = 2
+ 0x00, 0x00,
+ 0x00, 0x00,
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, 0x00, 0x01,
+ };
+ auto msg = parse_dns(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->header.is_response);
+ CHECK(msg->header.ancount == 2);
+}
+
+TEST_CASE("parse_dns rejects a truncated header") {
+ std::vector<unsigned char> bytes(5, 0);
+ CHECK_FALSE(parse_dns(bytes).has_value());
+}
+
+TEST_CASE("read_dns_name rejects a compression pointer") {
+ std::vector<unsigned char> bytes = {0xC0, 0x0C}; // pointer: unsupported by design
+ CHECK_FALSE(read_dns_name(bytes, 0).has_value());
+}
+
+TEST_CASE("DnsDissector claims port 53 and its summary matches parse_dns") {
+ DnsDissector dissector;
+ CHECK(dissector.port() == kDnsPort);
+
+ auto summary = dissector.summarize(example_com_query());
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 9) == "DNS query");
+ CHECK(summary->find("example.com") != std::string::npos);
+}
+
+TEST_CASE("DnsDissector::summarize returns nullopt for a truncated payload") {
+ DnsDissector dissector;
+ std::vector<unsigned char> bytes(5, 0);
+ CHECK_FALSE(dissector.summarize(bytes).has_value());
+}
diff --git a/tests/test_filter.cpp b/tests/test_filter.cpp
new file mode 100644
index 0000000..1dd9373
--- /dev/null
+++ b/tests/test_filter.cpp
@@ -0,0 +1,69 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+
+#include "wireframe/filter.hpp"
+
+namespace {
+
+// pcap_open_dead() creates a handle that isn't attached to any real
+// interface - exactly what pcap_compile() needs (linktype + snaplen)
+// without requiring root or a live device.
+struct DeadHandle {
+ pcap_t* handle;
+ explicit DeadHandle(int datalink) : handle(pcap_open_dead(datalink, 65535)) {}
+ ~DeadHandle() {
+ if (handle) pcap_close(handle);
+ }
+};
+
+} // namespace
+
+TEST_CASE("compile_filter accepts a valid tcpdump-style expression") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "tcp port 80", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter accepts a compound expression") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "host 10.0.0.1 and not icmp", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter rejects invalid syntax with an error message") {
+ DeadHandle dead(DLT_EN10MB);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "this is not a valid filter !!", &prog);
+ REQUIRE(err.has_value());
+ CHECK_FALSE(err->empty());
+}
+
+TEST_CASE("compile_filter works against DLT_RAW, not just Ethernet") {
+ DeadHandle dead(DLT_RAW);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ auto err = wireframe::compile_filter(dead.handle, "udp", &prog);
+ CHECK_FALSE(err.has_value());
+ pcap_freecode(&prog);
+}
+
+TEST_CASE("compile_filter rejects an Ethernet-only expression against DLT_RAW") {
+ DeadHandle dead(DLT_RAW);
+ REQUIRE(dead.handle != nullptr);
+
+ bpf_program prog{};
+ // "ether" primitives are meaningless without a link-layer header.
+ auto err = wireframe::compile_filter(dead.handle, "ether host 00:11:22:33:44:55", &prog);
+ CHECK(err.has_value());
+}
diff --git a/tests/test_http.cpp b/tests/test_http.cpp
new file mode 100644
index 0000000..7ccc9ff
--- /dev/null
+++ b/tests/test_http.cpp
@@ -0,0 +1,78 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/http.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+std::vector<unsigned char> to_bytes(std::string_view text) {
+ return std::vector<unsigned char>(text.begin(), text.end());
+}
+
+} // namespace
+
+TEST_CASE("parse_http decodes a GET request with a Host header") {
+ auto bytes = to_bytes("GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: x\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->is_request);
+ CHECK(msg->method_or_version == "GET");
+ CHECK(msg->target_or_status == "/index.html");
+ REQUIRE(msg->host.has_value());
+ CHECK(*msg->host == "example.com");
+}
+
+TEST_CASE("parse_http decodes a POST request without a Host header") {
+ auto bytes = to_bytes("POST /api/submit HTTP/1.1\r\nContent-Length: 0\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->method_or_version == "POST");
+ CHECK(msg->target_or_status == "/api/submit");
+ CHECK_FALSE(msg->host.has_value());
+}
+
+TEST_CASE("parse_http decodes a status line as a response") {
+ auto bytes = to_bytes("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK_FALSE(msg->is_request);
+ CHECK(msg->method_or_version == "HTTP/1.1");
+ CHECK(msg->target_or_status == "404");
+}
+
+TEST_CASE("parse_http tolerates a bare LF request line") {
+ auto bytes = to_bytes("GET / HTTP/1.0\n\n");
+ auto msg = parse_http(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->target_or_status == "/");
+}
+
+TEST_CASE("parse_http rejects payloads that don't look like HTTP") {
+ auto bytes = to_bytes("this is not http traffic at all\r\n");
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("parse_http rejects an empty payload") {
+ std::vector<unsigned char> bytes;
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("parse_http rejects a request line with no target/version fields") {
+ auto bytes = to_bytes("GET\r\n\r\n");
+ CHECK_FALSE(parse_http(bytes).has_value());
+}
+
+TEST_CASE("HttpDissector claims port 80 and its summary matches parse_http") {
+ HttpDissector dissector;
+ CHECK(dissector.port() == kHttpPort);
+
+ auto bytes = to_bytes("GET /path HTTP/1.1\r\nHost: wireframe.test\r\n\r\n");
+ auto summary = dissector.summarize(bytes);
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 4) == "HTTP");
+ CHECK(summary->find("GET /path") != std::string::npos);
+ CHECK(summary->find("wireframe.test") != std::string::npos);
+}
diff --git a/tests/test_ipv6.cpp b/tests/test_ipv6.cpp
new file mode 100644
index 0000000..438fadc
--- /dev/null
+++ b/tests/test_ipv6.cpp
@@ -0,0 +1,169 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/net/ipv4.hpp" // for kProtoTcp
+#include "wireframe/net/ipv6.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+Ipv6Address addr_from_groups(std::array<std::uint16_t, 8> groups) {
+ Ipv6Address addr{};
+ for (std::size_t i = 0; i < 8; ++i) {
+ addr.bytes[i * 2] = static_cast<unsigned char>(groups[i] >> 8);
+ addr.bytes[i * 2 + 1] = static_cast<unsigned char>(groups[i] & 0xFF);
+ }
+ return addr;
+}
+
+} // namespace
+
+TEST_CASE("parse_ipv6 decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes(40, 0);
+ bytes[0] = 0x60; // version 6, traffic class high nibble 0
+ bytes[1] = 0x00; // traffic class low nibble 0, flow label starts 0
+ bytes[4] = 0x00;
+ bytes[5] = 0x04; // payload_length = 4
+ bytes[6] = kProtoTcp;
+ bytes[7] = 64; // hop_limit
+ // src = 2001:0db8::1
+ bytes[8] = 0x20; bytes[9] = 0x01; bytes[10] = 0x0d; bytes[11] = 0xb8;
+ bytes[23] = 0x01;
+ // dst = ::1
+ bytes[39] = 0x01;
+ bytes.insert(bytes.end(), {0xAA, 0xBB, 0xCC, 0xDD});
+
+ auto ip6 = parse_ipv6(bytes);
+ REQUIRE(ip6.has_value());
+ CHECK(ip6->header.version == 6);
+ CHECK(ip6->header.payload_length == 4);
+ CHECK(ip6->header.next_header == kProtoTcp);
+ CHECK(ip6->header.hop_limit == 64);
+ REQUIRE(ip6->payload.size() == 4);
+ CHECK(ip6->payload[0] == 0xAA);
+}
+
+TEST_CASE("parse_ipv6 rejects a non-IPv6 version") {
+ std::vector<unsigned char> bytes(40, 0);
+ bytes[0] = 0x45; // version 4
+ CHECK_FALSE(parse_ipv6(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv6 rejects a buffer shorter than the 40-byte header") {
+ std::vector<unsigned char> bytes(39, 0);
+ bytes[0] = 0x60;
+ CHECK_FALSE(parse_ipv6(bytes).has_value());
+}
+
+TEST_CASE("ipv6_to_string compresses the loopback address") {
+ CHECK(ipv6_to_string(addr_from_groups({0, 0, 0, 0, 0, 0, 0, 1})) == "::1");
+}
+
+TEST_CASE("ipv6_to_string compresses the unspecified address") {
+ CHECK(ipv6_to_string(addr_from_groups({0, 0, 0, 0, 0, 0, 0, 0})) == "::");
+}
+
+TEST_CASE("ipv6_to_string compresses a zero run in the middle") {
+ CHECK(ipv6_to_string(addr_from_groups({0x2001, 0x0db8, 0, 0, 0, 0, 0, 1})) == "2001:db8::1");
+}
+
+TEST_CASE("ipv6_to_string does not compress a lone zero group") {
+ CHECK(ipv6_to_string(addr_from_groups({0x2001, 0, 0x0db8, 1, 1, 1, 1, 1})) ==
+ "2001:0:db8:1:1:1:1:1");
+}
+
+TEST_CASE("ipv6_to_string picks the leftmost run when two runs tie in length") {
+ // Two runs of length 2: groups[1..2] and groups[5..6]. Leftmost wins.
+ CHECK(ipv6_to_string(addr_from_groups({1, 0, 0, 2, 3, 0, 0, 4})) == "1::2:3:0:0:4");
+}
+
+TEST_CASE("ipv6_to_string leaves an address with no zero run untouched") {
+ CHECK(ipv6_to_string(addr_from_groups({1, 2, 3, 4, 5, 6, 7, 8})) == "1:2:3:4:5:6:7:8");
+}
+
+TEST_CASE("walk_ipv6_extension_headers passes a direct transport protocol through unchanged") {
+ std::vector<unsigned char> payload = {0xAA, 0xBB, 0xCC};
+ auto result = walk_ipv6_extension_headers(kProtoTcp, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ CHECK_FALSE(result.stopped_at_esp);
+ REQUIRE(result.payload.size() == 3);
+ CHECK(result.payload[0] == 0xAA);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks a single Hop-by-Hop header to reach TCP") {
+ // Hop-by-Hop: next_header(1)=TCP, hdr_ext_len(1)=0 -> total len (0+1)*8=8 bytes.
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp), 0x00,
+ 0, 0, 0, 0, 0, 0}; // 6 bytes of option padding
+ std::vector<unsigned char> tcp_marker = {0xDE, 0xAD};
+ payload.insert(payload.end(), tcp_marker.begin(), tcp_marker.end());
+
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ CHECK_FALSE(result.stopped_at_esp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xDE);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks a chain of two extension headers") {
+ // Hop-by-Hop (8 bytes) -> Destination Options (8 bytes) -> UDP.
+ std::vector<unsigned char> payload = {
+ kNextHeaderDestOptions, 0x00, 0, 0, 0, 0, 0, 0, // Hop-by-Hop, len 8
+ static_cast<unsigned char>(kProtoUdp), 0x00, 0, 0, 0, 0, 0, 0, // Dest Options, len 8
+ 0xFE, 0xED, // "UDP header" marker
+ };
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kProtoUdp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xFE);
+}
+
+TEST_CASE("walk_ipv6_extension_headers walks the fixed-size Fragment header") {
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp), 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // 8-byte fragment header
+ 0xCA, 0xFE};
+ auto result = walk_ipv6_extension_headers(kNextHeaderFragment, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0xCA);
+}
+
+TEST_CASE("walk_ipv6_extension_headers applies AH's 4-byte-unit length formula") {
+ // AH: next_header(1)=TCP, payload_len(1)=1 -> total len (1+2)*4=12 bytes.
+ std::vector<unsigned char> payload(12, 0);
+ payload[0] = static_cast<unsigned char>(kProtoTcp);
+ payload[1] = 0x01;
+ payload.push_back(0x11);
+ payload.push_back(0x22);
+
+ auto result = walk_ipv6_extension_headers(kNextHeaderAh, payload);
+ CHECK(result.final_next_header == kProtoTcp);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0x11);
+}
+
+TEST_CASE("walk_ipv6_extension_headers stops at ESP without guessing past it") {
+ std::vector<unsigned char> payload = {0x01, 0x02, 0x03, 0x04};
+ auto result = walk_ipv6_extension_headers(kNextHeaderEsp, payload);
+ CHECK(result.stopped_at_esp);
+ CHECK(result.final_next_header == kNextHeaderEsp);
+ REQUIRE(result.payload.size() == 4);
+ CHECK(result.payload[0] == 0x01); // untouched: ESP payload starts right here
+}
+
+TEST_CASE("walk_ipv6_extension_headers stops gracefully on a truncated extension header") {
+ std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp),
+ 0xFF}; // claims (255+1)*8 bytes; nowhere near present
+ auto result = walk_ipv6_extension_headers(kNextHeaderHopByHop, payload);
+ CHECK(result.final_next_header == kNextHeaderHopByHop); // never resolved past it
+ CHECK_FALSE(result.stopped_at_esp);
+}
+
+TEST_CASE("walk_ipv6_extension_headers passes an unknown next_header through untouched") {
+ std::vector<unsigned char> payload = {0x01, 0x02};
+ auto result = walk_ipv6_extension_headers(200, payload); // not a known extension type
+ CHECK(result.final_next_header == 200);
+ REQUIRE(result.payload.size() == 2);
+ CHECK(result.payload[0] == 0x01);
+}
diff --git a/tests/test_net.cpp b/tests/test_net.cpp
new file mode 100644
index 0000000..09de9b0
--- /dev/null
+++ b/tests/test_net.cpp
@@ -0,0 +1,124 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/net/ethernet.hpp"
+#include "wireframe/net/ipv4.hpp"
+#include "wireframe/net/tcp.hpp"
+#include "wireframe/net/udp.hpp"
+
+using namespace wireframe::net;
+
+TEST_CASE("parse_ethernet decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype: IPv4
+ 0xDE, 0xAD, 0xBE, 0xEF, // payload
+ };
+ auto frame = parse_ethernet(bytes);
+ REQUIRE(frame.has_value());
+ CHECK(frame->header.dst.bytes == std::array<unsigned char, 6>{0x11, 0x22, 0x33, 0x44, 0x55, 0x66});
+ CHECK(frame->header.src.bytes == std::array<unsigned char, 6>{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF});
+ CHECK(frame->header.ethertype == kEthertypeIPv4);
+ REQUIRE(frame->payload.size() == 4);
+ CHECK(frame->payload[0] == 0xDE);
+}
+
+TEST_CASE("parse_ethernet rejects a frame shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0); // header is 14 bytes
+ CHECK_FALSE(parse_ethernet(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x45; // version 4, IHL 5 (20-byte header, no options)
+ bytes[2] = 0x00;
+ bytes[3] = 0x28; // total_length = 40
+ bytes[8] = 64; // ttl
+ bytes[9] = kProtoTcp;
+ bytes[12] = 10; bytes[13] = 0; bytes[14] = 0; bytes[15] = 1; // src 10.0.0.1
+ bytes[16] = 10; bytes[17] = 0; bytes[18] = 0; bytes[19] = 2; // dst 10.0.0.2
+ bytes.push_back(0x01);
+ bytes.push_back(0x02);
+
+ auto ip = parse_ipv4(bytes);
+ REQUIRE(ip.has_value());
+ CHECK(ip->header.version == 4);
+ CHECK(ip->header.ihl == 5);
+ CHECK(ip->header.total_length == 40);
+ CHECK(ip->header.ttl == 64);
+ CHECK(ip->header.protocol == kProtoTcp);
+ CHECK(ip->header.src.bytes == std::array<unsigned char, 4>{10, 0, 0, 1});
+ CHECK(ip->header.dst.bytes == std::array<unsigned char, 4>{10, 0, 0, 2});
+ REQUIRE(ip->payload.size() == 2);
+ CHECK(ip->payload[0] == 0x01);
+}
+
+TEST_CASE("parse_ipv4 rejects a non-IPv4 version") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x65; // version 6
+ CHECK_FALSE(parse_ipv4(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0);
+ CHECK_FALSE(parse_ipv4(bytes).has_value());
+}
+
+TEST_CASE("parse_ipv4 honors IHL > 5 (options present)") {
+ std::vector<unsigned char> bytes(24, 0); // IHL=6 -> 24-byte header
+ bytes[0] = 0x46;
+ bytes[9] = kProtoUdp;
+
+ auto ip = parse_ipv4(bytes);
+ REQUIRE(ip.has_value());
+ CHECK(ip->header.ihl == 6);
+ CHECK(ip->payload.empty());
+}
+
+TEST_CASE("parse_tcp decodes header fields and flags") {
+ std::vector<unsigned char> bytes(20, 0);
+ bytes[0] = 0x00; bytes[1] = 0x50; // src port 80
+ bytes[2] = 0x1F; bytes[3] = 0x90; // dst port 8080
+ bytes[4] = 0; bytes[5] = 0; bytes[6] = 0; bytes[7] = 1; // seq = 1
+ bytes[8] = 0; bytes[9] = 0; bytes[10] = 0; bytes[11] = 2; // ack = 2
+ bytes[12] = 5 << 4; // data_offset = 5 (20-byte header, no options)
+ bytes[13] = 0x12; // SYN | ACK
+ bytes[14] = 0xFF; bytes[15] = 0xFF; // window 65535
+
+ auto tcp = parse_tcp(bytes);
+ REQUIRE(tcp.has_value());
+ CHECK(tcp->header.src_port == 80);
+ CHECK(tcp->header.dst_port == 8080);
+ CHECK(tcp->header.seq == 1);
+ CHECK(tcp->header.ack == 2);
+ CHECK(tcp->header.data_offset == 5);
+ CHECK((tcp->header.flags & kTcpSyn) != 0);
+ CHECK((tcp->header.flags & kTcpAck) != 0);
+ CHECK((tcp->header.flags & kTcpFin) == 0);
+ CHECK(tcp->header.window == 65535);
+ CHECK(tcp->payload.empty());
+}
+
+TEST_CASE("parse_tcp rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(10, 0);
+ CHECK_FALSE(parse_tcp(bytes).has_value());
+}
+
+TEST_CASE("parse_udp decodes header fields and leaves the right payload") {
+ std::vector<unsigned char> bytes = {0x00, 0x35, 0x1F, 0x90, 0x00, 0x0A,
+ 0x00, 0x00, 'h', 'i'};
+ auto udp = parse_udp(bytes);
+ REQUIRE(udp.has_value());
+ CHECK(udp->header.src_port == 53);
+ CHECK(udp->header.dst_port == 8080);
+ CHECK(udp->header.length == 10);
+ REQUIRE(udp->payload.size() == 2);
+ CHECK(udp->payload[0] == 'h');
+}
+
+TEST_CASE("parse_udp rejects a buffer shorter than the header") {
+ std::vector<unsigned char> bytes(4, 0);
+ CHECK_FALSE(parse_udp(bytes).has_value());
+}
diff --git a/tests/test_pcapng.cpp b/tests/test_pcapng.cpp
new file mode 100644
index 0000000..f292d32
--- /dev/null
+++ b/tests/test_pcapng.cpp
@@ -0,0 +1,142 @@
+#include <doctest/doctest.h>
+
+#include <cstdio>
+#include <vector>
+
+#include "wireframe/pcapng/reader.hpp"
+#include "wireframe/pcapng/writer.hpp"
+
+using namespace wireframe::pcapng;
+
+TEST_CASE("pcapng writer/reader round-trip a single packet") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ std::vector<unsigned char> packet_data = {0xDE, 0xAD, 0xBE, 0xEF, 0x00};
+ writer.write_packet(/*interface_id=*/0, /*ts_sec=*/1700000000, /*ts_usec=*/123456,
+ packet_data, /*original_len=*/5);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ auto record = reader.next_packet();
+ REQUIRE(record.has_value());
+ CHECK(record->interface_id == 0);
+ CHECK(record->timestamp_us == 1700000000ULL * 1'000'000ULL + 123456ULL);
+ CHECK(record->original_len == 5);
+ CHECK(record->data == packet_data);
+
+ CHECK_FALSE(reader.next_packet().has_value()); // only one packet was written
+
+ std::fclose(f);
+}
+
+TEST_CASE("Reader::link_type reflects the IDB, populated by the time the first packet returns") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, /*link_type=*/12); // DLT_RAW, arbitrary for this test
+
+ std::vector<unsigned char> data = {0x01};
+ writer.write_packet(0, 1, 0, data, 1);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ CHECK_FALSE(reader.link_type().has_value()); // nothing read yet
+ auto record = reader.next_packet();
+ REQUIRE(record.has_value());
+ REQUIRE(reader.link_type().has_value());
+ CHECK(*reader.link_type() == 12);
+
+ std::fclose(f);
+}
+
+TEST_CASE("pcapng writer/reader round-trip multiple packets in order") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ for (unsigned char i = 0; i < 5; ++i) {
+ std::vector<unsigned char> data = {i};
+ writer.write_packet(0, 1700000000 + i, 0, data, 1);
+ }
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ int count = 0;
+ while (auto record = reader.next_packet()) {
+ REQUIRE(record->data.size() == 1);
+ CHECK(record->data[0] == static_cast<unsigned char>(count));
+ ++count;
+ }
+ CHECK(count == 5);
+
+ std::fclose(f);
+}
+
+TEST_CASE("pcapng writer pads packet data to a 4-byte boundary without corrupting the next block") {
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ Writer writer(f);
+ writer.write_section_header();
+ writer.write_interface_description(65535, kLinkTypeEthernet);
+
+ // 3 bytes of packet data forces padding - the case most likely to
+ // misalign the following block if the padding math is wrong.
+ std::vector<unsigned char> first = {0x01, 0x02, 0x03};
+ std::vector<unsigned char> second = {0xAA, 0xBB};
+ writer.write_packet(0, 1, 0, first, 3);
+ writer.write_packet(0, 2, 0, second, 2);
+
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ auto r1 = reader.next_packet();
+ REQUIRE(r1.has_value());
+ CHECK(r1->data == first);
+
+ auto r2 = reader.next_packet();
+ REQUIRE(r2.has_value());
+ CHECK(r2->data == second);
+
+ std::fclose(f);
+}
+
+TEST_CASE("Reader rejects a block claiming an implausibly large body instead of allocating it") {
+ // Found by fuzzing (fuzz/fuzz_pcapng_reader.cpp): total_len is an
+ // untrusted 32-bit value straight from the file. A block claiming
+ // ~4GB used to be handed straight to `std::vector` before a single
+ // body byte was read, OOM-crashing the process on a corrupt or
+ // hostile file. This constructs exactly that: a valid-looking
+ // block type, followed by a total_len far beyond anything our own
+ // writer would ever produce.
+ std::FILE* f = std::tmpfile();
+ REQUIRE(f != nullptr);
+
+ std::uint8_t block[8];
+ block[0] = 0x06; block[1] = 0x00; block[2] = 0x00; block[3] = 0x00; // EPB
+ block[4] = 0xFF; block[5] = 0xFF; block[6] = 0xFF; block[7] = 0x7F; // total_len ~2GB
+ std::fwrite(block, 1, sizeof(block), f);
+ std::fflush(f);
+ std::fseek(f, 0, SEEK_SET);
+
+ Reader reader(f);
+ CHECK_FALSE(reader.next_packet().has_value()); // rejected, not an OOM attempt
+
+ std::fclose(f);
+}
diff --git a/tests/test_search.cpp b/tests/test_search.cpp
new file mode 100644
index 0000000..ed687e0
--- /dev/null
+++ b/tests/test_search.cpp
@@ -0,0 +1,27 @@
+#include <doctest/doctest.h>
+
+#include "wireframe/search.hpp"
+
+using namespace wireframe;
+
+TEST_CASE("matches_search finds a substring") {
+ CHECK(matches_search("IPv4 10.0.0.1 -> 10.0.0.2 proto=6", "10.0.0.2"));
+}
+
+TEST_CASE("matches_search is case-insensitive") {
+ CHECK(matches_search("DNS query example.COM", "example.com"));
+ CHECK(matches_search("DNS query example.com", "EXAMPLE.COM"));
+}
+
+TEST_CASE("matches_search returns false when the term isn't present") {
+ CHECK_FALSE(matches_search("IPv4 10.0.0.1 -> 10.0.0.2", "192.168.1.1"));
+}
+
+TEST_CASE("matches_search treats an empty term as matching everything") {
+ CHECK(matches_search("anything at all", ""));
+ CHECK(matches_search("", ""));
+}
+
+TEST_CASE("matches_search returns false against an empty haystack with a nonempty term") {
+ CHECK_FALSE(matches_search("", "x"));
+}
diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp
new file mode 100644
index 0000000..d10053d
--- /dev/null
+++ b/tests/test_summarize.cpp
@@ -0,0 +1,185 @@
+#include <doctest/doctest.h>
+#include <pcap.h>
+
+#include <string_view>
+#include <vector>
+
+#include "wireframe/summarize.hpp"
+
+namespace {
+
+// Ethernet + IPv4 + UDP + DNS query for "example.com", assembled the
+// same way the real capture path hands bytes to summarize_packet: one
+// contiguous frame, no struct-casting.
+std::vector<unsigned char> ethernet_ipv4_udp_dns_frame() {
+ std::vector<unsigned char> dns = {
+ 0x12, 0x9d, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0,
+ 0x00, 0x01, 0x00, 0x01,
+ };
+
+ std::vector<unsigned char> udp(8, 0);
+ udp[0] = 0xD4; udp[1] = 0x31; // src port 54321
+ udp[2] = 0x00; udp[3] = 0x35; // dst port 53
+ std::uint16_t udp_len = static_cast<std::uint16_t>(8 + dns.size());
+ udp[4] = static_cast<unsigned char>(udp_len >> 8);
+ udp[5] = static_cast<unsigned char>(udp_len & 0xFF);
+
+ std::vector<unsigned char> ip(20, 0);
+ ip[0] = 0x45;
+ ip[8] = 64; // ttl
+ ip[9] = wireframe::net::kProtoUdp; // proto
+ ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1; // src 10.0.0.1
+ ip[16] = 10; ip[17] = 0; ip[18] = 0; ip[19] = 2; // dst 10.0.0.2
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype IPv4
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip.begin(), ip.end());
+ frame.insert(frame.end(), udp.begin(), udp.end());
+ frame.insert(frame.end(), dns.begin(), dns.end());
+ return frame;
+}
+
+// Ethernet + IPv4 + TCP + an HTTP GET request. This is the only test
+// exercising L7Registry's TCP-payload path with a real registered
+// dissector - DNS only ever runs over UDP, so summarize_packet's TCP
+// branch calling into l7_summarize() was otherwise unverified.
+std::vector<unsigned char> ethernet_ipv4_tcp_http_frame() {
+ std::string_view request = "GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n";
+ std::vector<unsigned char> http(request.begin(), request.end());
+
+ std::vector<unsigned char> tcp(20, 0);
+ tcp[0] = 0xC3; tcp[1] = 0x50; // src port 50000
+ tcp[2] = 0x00; tcp[3] = 0x50; // dst port 80
+ tcp[12] = 5 << 4; // data_offset = 5 (20-byte header)
+ tcp[13] = 0x18; // PSH | ACK
+
+ std::vector<unsigned char> ip(20, 0);
+ ip[0] = 0x45;
+ ip[8] = 64; // ttl
+ ip[9] = wireframe::net::kProtoTcp; // proto
+ ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1; // src 10.0.0.1
+ ip[16] = 10; ip[17] = 0; ip[18] = 0; ip[19] = 2; // dst 10.0.0.2
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x08, 0x00, // ethertype IPv4
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip.begin(), ip.end());
+ frame.insert(frame.end(), tcp.begin(), tcp.end());
+ frame.insert(frame.end(), http.begin(), http.end());
+ return frame;
+}
+
+// Ethernet + IPv6 + a Hop-by-Hop Options extension header + TCP. Proves
+// walk_ipv6_extension_headers() is actually wired into summarize_packet's
+// IPv6 branch, not just unit-tested in isolation - without it, this
+// packet's TCP layer (and any L7 behind it) would be silently invisible.
+std::vector<unsigned char> ethernet_ipv6_hopbyhop_tcp_frame() {
+ std::vector<unsigned char> tcp(20, 0);
+ tcp[0] = 0x00; tcp[1] = 0x50; // src port 80
+ tcp[2] = 0x00; tcp[3] = 0x51; // dst port 81
+ tcp[12] = 5 << 4; // data_offset = 5
+ tcp[13] = 0x02; // SYN
+
+ std::vector<unsigned char> hop_by_hop = {
+ static_cast<unsigned char>(wireframe::net::kProtoTcp),
+ 0x00, // hdr_ext_len = 0 -> total length (0+1)*8 = 8 bytes
+ 0, 0, 0, 0, 0, 0, // option padding
+ };
+
+ std::vector<unsigned char> ip6(40, 0);
+ ip6[0] = 0x60; // version 6
+ std::uint16_t payload_len = static_cast<std::uint16_t>(hop_by_hop.size() + tcp.size());
+ ip6[4] = static_cast<unsigned char>(payload_len >> 8);
+ ip6[5] = static_cast<unsigned char>(payload_len & 0xFF);
+ ip6[6] = wireframe::net::kNextHeaderHopByHop;
+ ip6[7] = 64; // hop_limit
+ ip6[23] = 0x01; // src = ::1
+ ip6[39] = 0x01; // dst = ::1
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, // dst mac
+ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac
+ 0x86, 0xDD, // ethertype IPv6
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip6.begin(), ip6.end());
+ frame.insert(frame.end(), hop_by_hop.begin(), hop_by_hop.end());
+ frame.insert(frame.end(), tcp.begin(), tcp.end());
+ return frame;
+}
+
+} // namespace
+
+TEST_CASE("summarize_packet walks a Hop-by-Hop extension header to reach TCP") {
+ auto line = wireframe::summarize_packet(ethernet_ipv6_hopbyhop_tcp_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x86dd"
+ " | IPv6 ::1 -> ::1 ttl=64 proto=6"
+ " | TCP 80 -> 81 [S] seq=0 ack=0 win=0");
+}
+
+TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/TCP/HTTP frame end to end") {
+ auto line = wireframe::summarize_packet(ethernet_ipv4_tcp_http_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0800"
+ " | IPv4 10.0.0.1 -> 10.0.0.2 ttl=64 proto=6"
+ " | TCP 50000 -> 80 [AP] seq=0 ack=0 win=0"
+ " | HTTP GET /index.html Host: example.com");
+}
+
+TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/UDP/DNS frame end to end") {
+ auto line = wireframe::summarize_packet(ethernet_ipv4_udp_dns_frame(), DLT_EN10MB);
+ CHECK(line ==
+ "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0800"
+ " | IPv4 10.0.0.1 -> 10.0.0.2 ttl=64 proto=17"
+ " | UDP 54321 -> 53 len=37"
+ " | DNS query id=4765 example.com type=1");
+}
+
+TEST_CASE("summarize_packet on DLT_RAW skips the Ethernet line entirely") {
+ auto frame = ethernet_ipv4_udp_dns_frame();
+ std::vector<unsigned char> raw(frame.begin() + wireframe::net::kEthernetHeaderLen, frame.end());
+
+ auto line = wireframe::summarize_packet(raw, DLT_RAW);
+ CHECK(line.substr(0, 3) == "RAW");
+ CHECK(line.find("ETH") == std::string::npos);
+ CHECK(line.find("IPv4 10.0.0.1 -> 10.0.0.2") != std::string::npos);
+}
+
+TEST_CASE("summarize_packet reports a truncated Ethernet frame without decoding further") {
+ std::vector<unsigned char> bytes(10, 0); // shorter than the 14-byte header
+ auto line = wireframe::summarize_packet(bytes, DLT_EN10MB);
+ CHECK(line == "[10 bytes] truncated ethernet frame");
+}
+
+TEST_CASE("summarize_packet stops after the Ethernet line for a non-IP ethertype") {
+ std::vector<unsigned char> bytes = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
+ 0x08, 0x06, // ARP, not IPv4/IPv6
+ };
+ auto line = wireframe::summarize_packet(bytes, DLT_EN10MB);
+ CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0806");
+}
+
+TEST_CASE("hex_dump_lines produces one line per 16 bytes, with the right byte count") {
+ std::vector<unsigned char> bytes(20, 0);
+ for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<unsigned char>(i);
+
+ auto lines = wireframe::hex_dump_lines(bytes);
+ REQUIRE(lines.size() == 2);
+ CHECK(lines[0].substr(0, 6) == "000000");
+ CHECK(lines[1].substr(0, 6) == "000010");
+ CHECK(lines[0].find("00 01 02 03") != std::string::npos);
+ CHECK(lines[0].find('|') != std::string::npos);
+}
diff --git a/tests/test_tls.cpp b/tests/test_tls.cpp
new file mode 100644
index 0000000..65784a9
--- /dev/null
+++ b/tests/test_tls.cpp
@@ -0,0 +1,132 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/tls.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+void append_be16(std::vector<unsigned char>& out, std::uint16_t v) {
+ out.push_back(static_cast<unsigned char>(v >> 8));
+ out.push_back(static_cast<unsigned char>(v & 0xFF));
+}
+
+// Builds a real, well-formed TLS record containing a ClientHello with
+// (optionally) a single SNI host_name extension. Every length field is
+// computed from the actual bytes assembled, not hand-counted - the
+// same lesson from this session's earlier UDP-length test typo.
+//
+// `corrupt_sni_ext_len`, when set, writes an oversized SNI extension
+// length instead of the real one (computed here, not via post-hoc
+// offset math into the finished buffer - equally fragile).
+std::vector<unsigned char> build_client_hello(const std::string& sni,
+ bool corrupt_sni_ext_len = false) {
+ std::vector<unsigned char> body;
+ body.push_back(0x03);
+ body.push_back(0x03); // client_version: TLS 1.2
+ body.insert(body.end(), 32, 0x00); // random
+ body.push_back(0x00); // session_id length: 0
+ append_be16(body, 2); // cipher_suites length
+ body.push_back(0x00);
+ body.push_back(0x2F); // one arbitrary cipher suite
+ body.push_back(0x01); // compression_methods length: 1
+ body.push_back(0x00); // null compression
+
+ std::vector<unsigned char> extensions;
+ if (!sni.empty()) {
+ std::vector<unsigned char> server_name_list;
+ server_name_list.push_back(0x00); // name_type: host_name
+ append_be16(server_name_list, static_cast<std::uint16_t>(sni.size()));
+ server_name_list.insert(server_name_list.end(), sni.begin(), sni.end());
+
+ std::vector<unsigned char> sni_ext_data;
+ append_be16(sni_ext_data, static_cast<std::uint16_t>(server_name_list.size()));
+ sni_ext_data.insert(sni_ext_data.end(), server_name_list.begin(), server_name_list.end());
+
+ append_be16(extensions, kTlsExtensionServerName);
+ std::uint16_t ext_len = corrupt_sni_ext_len
+ ? static_cast<std::uint16_t>(0xFFFF)
+ : static_cast<std::uint16_t>(sni_ext_data.size());
+ append_be16(extensions, ext_len);
+ extensions.insert(extensions.end(), sni_ext_data.begin(), sni_ext_data.end());
+ }
+ append_be16(body, static_cast<std::uint16_t>(extensions.size()));
+ body.insert(body.end(), extensions.begin(), extensions.end());
+
+ std::vector<unsigned char> handshake;
+ handshake.push_back(kTlsHandshakeTypeClientHello);
+ std::uint32_t hs_len = static_cast<std::uint32_t>(body.size());
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 16) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>((hs_len >> 8) & 0xFF));
+ handshake.push_back(static_cast<unsigned char>(hs_len & 0xFF));
+ handshake.insert(handshake.end(), body.begin(), body.end());
+
+ std::vector<unsigned char> record;
+ record.push_back(kTlsContentTypeHandshake);
+ record.push_back(0x03);
+ record.push_back(0x01); // record-layer version (legacy compat value)
+ append_be16(record, static_cast<std::uint16_t>(handshake.size()));
+ record.insert(record.end(), handshake.begin(), handshake.end());
+
+ return record;
+}
+
+} // namespace
+
+TEST_CASE("parse_tls_client_hello extracts a real SNI extension") {
+ auto record = build_client_hello("example.com");
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ REQUIRE(hello->server_name.has_value());
+ CHECK(*hello->server_name == "example.com");
+}
+
+TEST_CASE("parse_tls_client_hello succeeds with no SNI when there's no extensions block") {
+ auto record = build_client_hello("");
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value());
+ CHECK_FALSE(hello->server_name.has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a non-Handshake record") {
+ auto record = build_client_hello("example.com");
+ record[0] = 0x17; // application_data, not handshake
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a non-ClientHello handshake type") {
+ auto record = build_client_hello("example.com");
+ record[5] = 0x02; // ServerHello, not ClientHello
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a truncated record") {
+ auto record = build_client_hello("example.com");
+ record.resize(record.size() - 5); // claims more than it has
+ CHECK_FALSE(parse_tls_client_hello(record).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello rejects a buffer shorter than the record header") {
+ std::vector<unsigned char> bytes(4, 0);
+ CHECK_FALSE(parse_tls_client_hello(bytes).has_value());
+}
+
+TEST_CASE("parse_tls_client_hello stops gracefully on a malformed extension length") {
+ auto record = build_client_hello("example.com", /*corrupt_sni_ext_len=*/true);
+ auto hello = parse_tls_client_hello(record);
+ REQUIRE(hello.has_value()); // still a structurally valid ClientHello otherwise
+ CHECK_FALSE(hello->server_name.has_value()); // SNI extension was malformed, so skipped
+}
+
+TEST_CASE("TlsSniDissector claims port 443 and its summary matches parse_tls_client_hello") {
+ TlsSniDissector dissector;
+ CHECK(dissector.port() == kTlsPort);
+
+ auto record = build_client_hello("wireframe.test");
+ auto summary = dissector.summarize(record);
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 3) == "TLS");
+ CHECK(summary->find("SNI=wireframe.test") != std::string::npos);
+}