diff options
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. @@ -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); +} |