From b565d7d9c47ca1ec5af0effd828431ee96027d60 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Mon, 27 May 2024 22:00:00 +0200 Subject: Rename project from wireframe to packeteer Decided on the name after weighing alternatives in NAMES.md: packeteer (packet + -eer, "one who wields packets") fit the project's actual scope better than the wire/frame pun once it had grown into full L2-L7 dissection, reassembly, checksums, privilege dropping, and dual TUI/GUI frontends. No existing packet-capture project uses the name; the one real-world collision (Packeteer, Inc., a networking company acquired and folded into Blue Coat/Symantec by 2008) is long defunct. Mechanical rename throughout: CMake project/target names, the wireframe:: namespace and include/wireframe/ directory (git mv, history preserved), every #include path, CLI/GUI help text, and the project's own working directory. NAMES.md rewritten to record the decision instead of leaving stale self-referential etymology behind from the blind rename pass. Verified after every step: full rebuild (all four targets, no warnings) and the full test suite (128/128 cases, 366/366 assertions) both from a fresh reconfigure and again after the directory move. --- CMakeLists.txt | 60 +++--- NAMES.md | 148 +++++++-------- PLAN.md | 42 ++--- fuzz/fuzz_checksum.cpp | 6 +- fuzz/fuzz_dns.cpp | 6 +- fuzz/fuzz_ethernet.cpp | 4 +- fuzz/fuzz_http.cpp | 6 +- fuzz/fuzz_ipv4.cpp | 4 +- fuzz/fuzz_ipv6.cpp | 10 +- fuzz/fuzz_pcapng_reader.cpp | 4 +- fuzz/fuzz_summarize.cpp | 4 +- fuzz/fuzz_tcp.cpp | 4 +- fuzz/fuzz_tcp_reassembly.cpp | 4 +- fuzz/fuzz_tls.cpp | 6 +- fuzz/fuzz_udp.cpp | 4 +- include/packeteer/byteio.hpp | 22 +++ include/packeteer/capture_queue.hpp | 98 ++++++++++ include/packeteer/capture_session.hpp | 312 +++++++++++++++++++++++++++++++ include/packeteer/filter.hpp | 36 ++++ include/packeteer/l7/dissector.hpp | 45 +++++ include/packeteer/l7/dns.hpp | 108 +++++++++++ include/packeteer/l7/http.hpp | 113 +++++++++++ include/packeteer/l7/mdns.hpp | 44 +++++ include/packeteer/l7/ssh.hpp | 65 +++++++ include/packeteer/l7/tls.hpp | 139 ++++++++++++++ include/packeteer/net/checksum.hpp | 91 +++++++++ include/packeteer/net/ethernet.hpp | 44 +++++ include/packeteer/net/icmp.hpp | 84 +++++++++ include/packeteer/net/ipv4.hpp | 56 ++++++ include/packeteer/net/ipv6.hpp | 173 +++++++++++++++++ include/packeteer/net/tcp.hpp | 54 ++++++ include/packeteer/net/tcp_reassembly.hpp | 125 +++++++++++++ include/packeteer/net/udp.hpp | 35 ++++ include/packeteer/packet_diagnostics.hpp | 92 +++++++++ include/packeteer/pcapng/reader.hpp | 123 ++++++++++++ include/packeteer/pcapng/writer.hpp | 94 ++++++++++ include/packeteer/privileges.hpp | 87 +++++++++ include/packeteer/search.hpp | 26 +++ include/packeteer/summarize.hpp | 275 +++++++++++++++++++++++++++ include/wireframe/byteio.hpp | 22 --- include/wireframe/capture_queue.hpp | 98 ---------- include/wireframe/capture_session.hpp | 312 ------------------------------- include/wireframe/filter.hpp | 36 ---- include/wireframe/l7/dissector.hpp | 45 ----- include/wireframe/l7/dns.hpp | 108 ----------- include/wireframe/l7/http.hpp | 113 ----------- include/wireframe/l7/mdns.hpp | 44 ----- include/wireframe/l7/ssh.hpp | 65 ------- include/wireframe/l7/tls.hpp | 139 -------------- include/wireframe/net/checksum.hpp | 91 --------- include/wireframe/net/ethernet.hpp | 44 ----- include/wireframe/net/icmp.hpp | 84 --------- include/wireframe/net/ipv4.hpp | 56 ------ include/wireframe/net/ipv6.hpp | 173 ----------------- include/wireframe/net/tcp.hpp | 54 ------ include/wireframe/net/tcp_reassembly.hpp | 125 ------------- include/wireframe/net/udp.hpp | 35 ---- include/wireframe/packet_diagnostics.hpp | 92 --------- include/wireframe/pcapng/reader.hpp | 123 ------------ include/wireframe/pcapng/writer.hpp | 94 ---------- include/wireframe/privileges.hpp | 87 --------- include/wireframe/search.hpp | 26 --- include/wireframe/summarize.hpp | 275 --------------------------- src/afpacket_capture.cpp | 10 +- src/gui_main.cpp | 40 ++-- src/main.cpp | 56 +++--- tests/test_byteio.cpp | 4 +- tests/test_capture_queue.cpp | 4 +- tests/test_capture_session.cpp | 8 +- tests/test_checksum.cpp | 4 +- tests/test_dns.cpp | 4 +- tests/test_filter.cpp | 12 +- tests/test_http.cpp | 8 +- tests/test_icmp.cpp | 4 +- tests/test_ipv6.cpp | 6 +- tests/test_mdns.cpp | 4 +- tests/test_net.cpp | 10 +- tests/test_pcapng.cpp | 6 +- tests/test_privileges.cpp | 4 +- tests/test_search.cpp | 4 +- tests/test_ssh.cpp | 4 +- tests/test_summarize.cpp | 26 +-- tests/test_tcp_reassembly.cpp | 8 +- tests/test_tls.cpp | 8 +- 84 files changed, 2604 insertions(+), 2624 deletions(-) create mode 100644 include/packeteer/byteio.hpp create mode 100644 include/packeteer/capture_queue.hpp create mode 100644 include/packeteer/capture_session.hpp create mode 100644 include/packeteer/filter.hpp create mode 100644 include/packeteer/l7/dissector.hpp create mode 100644 include/packeteer/l7/dns.hpp create mode 100644 include/packeteer/l7/http.hpp create mode 100644 include/packeteer/l7/mdns.hpp create mode 100644 include/packeteer/l7/ssh.hpp create mode 100644 include/packeteer/l7/tls.hpp create mode 100644 include/packeteer/net/checksum.hpp create mode 100644 include/packeteer/net/ethernet.hpp create mode 100644 include/packeteer/net/icmp.hpp create mode 100644 include/packeteer/net/ipv4.hpp create mode 100644 include/packeteer/net/ipv6.hpp create mode 100644 include/packeteer/net/tcp.hpp create mode 100644 include/packeteer/net/tcp_reassembly.hpp create mode 100644 include/packeteer/net/udp.hpp create mode 100644 include/packeteer/packet_diagnostics.hpp create mode 100644 include/packeteer/pcapng/reader.hpp create mode 100644 include/packeteer/pcapng/writer.hpp create mode 100644 include/packeteer/privileges.hpp create mode 100644 include/packeteer/search.hpp create mode 100644 include/packeteer/summarize.hpp delete mode 100644 include/wireframe/byteio.hpp delete mode 100644 include/wireframe/capture_queue.hpp delete mode 100644 include/wireframe/capture_session.hpp delete mode 100644 include/wireframe/filter.hpp delete mode 100644 include/wireframe/l7/dissector.hpp delete mode 100644 include/wireframe/l7/dns.hpp delete mode 100644 include/wireframe/l7/http.hpp delete mode 100644 include/wireframe/l7/mdns.hpp delete mode 100644 include/wireframe/l7/ssh.hpp delete mode 100644 include/wireframe/l7/tls.hpp delete mode 100644 include/wireframe/net/checksum.hpp delete mode 100644 include/wireframe/net/ethernet.hpp delete mode 100644 include/wireframe/net/icmp.hpp delete mode 100644 include/wireframe/net/ipv4.hpp delete mode 100644 include/wireframe/net/ipv6.hpp delete mode 100644 include/wireframe/net/tcp.hpp delete mode 100644 include/wireframe/net/tcp_reassembly.hpp delete mode 100644 include/wireframe/net/udp.hpp delete mode 100644 include/wireframe/packet_diagnostics.hpp delete mode 100644 include/wireframe/pcapng/reader.hpp delete mode 100644 include/wireframe/pcapng/writer.hpp delete mode 100644 include/wireframe/privileges.hpp delete mode 100644 include/wireframe/search.hpp delete mode 100644 include/wireframe/summarize.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 264fcc4..0db3e85 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(wireframe CXX) +project(packeteer CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -22,9 +22,9 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(ftxui) -add_executable(wireframe src/main.cpp) -target_include_directories(wireframe PRIVATE include) -target_link_libraries(wireframe PRIVATE +add_executable(packeteer src/main.cpp) +target_include_directories(packeteer PRIVATE include) +target_link_libraries(packeteer PRIVATE pcap Threads::Threads ftxui::component @@ -37,9 +37,9 @@ target_link_libraries(wireframe PRIVATE # Linux-specific socket family, unlike the portable libpcap path the # rest of this project uses - only built on Linux. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - add_executable(wireframe_afpacket_demo src/afpacket_capture.cpp) - target_include_directories(wireframe_afpacket_demo PRIVATE include) - target_link_libraries(wireframe_afpacket_demo PRIVATE pcap) + add_executable(packeteer_afpacket_demo src/afpacket_capture.cpp) + target_include_directories(packeteer_afpacket_demo PRIVATE include) + target_link_libraries(packeteer_afpacket_demo PRIVATE pcap) endif() # GUI (secondary to the TUI - see PLAN.md Decisions). Dear ImGui + @@ -79,9 +79,9 @@ add_library(imgui STATIC 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) +add_executable(packeteer_gui src/gui_main.cpp) +target_include_directories(packeteer_gui PRIVATE include) +target_link_libraries(packeteer_gui PRIVATE pcap Threads::Threads imgui SDL3::SDL3) enable_testing() @@ -93,7 +93,7 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(doctest) -add_executable(wireframe_tests +add_executable(packeteer_tests tests/main.cpp tests/test_byteio.cpp tests/test_net.cpp @@ -114,24 +114,24 @@ add_executable(wireframe_tests tests/test_checksum.cpp tests/test_tcp_reassembly.cpp ) -target_include_directories(wireframe_tests PRIVATE include) -target_link_libraries(wireframe_tests PRIVATE doctest::doctest Threads::Threads pcap) +target_include_directories(packeteer_tests PRIVATE include) +target_link_libraries(packeteer_tests PRIVATE doctest::doctest Threads::Threads pcap) -add_test(NAME wireframe_tests COMMAND wireframe_tests) +add_test(NAME packeteer_tests COMMAND packeteer_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) +option(PACKETEER_ENABLE_FUZZING "Build libFuzzer harnesses (requires clang)" OFF) +if(PACKETEER_ENABLE_FUZZING) if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") - message(FATAL_ERROR "WIREFRAME_ENABLE_FUZZING requires clang (libFuzzer); " + message(FATAL_ERROR "PACKETEER_ENABLE_FUZZING requires clang (libFuzzer); " "reconfigure with -DCMAKE_CXX_COMPILER=clang++") endif() - function(add_wireframe_fuzz_target name) + function(add_packeteer_fuzz_target name) add_executable(${name} fuzz/${name}.cpp) target_include_directories(${name} PRIVATE include) target_link_libraries(${name} PRIVATE pcap) @@ -139,16 +139,16 @@ if(WIREFRAME_ENABLE_FUZZING) 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) - add_wireframe_fuzz_target(fuzz_checksum) - add_wireframe_fuzz_target(fuzz_tcp_reassembly) + add_packeteer_fuzz_target(fuzz_ethernet) + add_packeteer_fuzz_target(fuzz_ipv4) + add_packeteer_fuzz_target(fuzz_ipv6) + add_packeteer_fuzz_target(fuzz_tcp) + add_packeteer_fuzz_target(fuzz_udp) + add_packeteer_fuzz_target(fuzz_dns) + add_packeteer_fuzz_target(fuzz_http) + add_packeteer_fuzz_target(fuzz_tls) + add_packeteer_fuzz_target(fuzz_pcapng_reader) + add_packeteer_fuzz_target(fuzz_summarize) + add_packeteer_fuzz_target(fuzz_checksum) + add_packeteer_fuzz_target(fuzz_tcp_reassembly) endif() diff --git a/NAMES.md b/NAMES.md index b07af22..d5cf43e 100644 --- a/NAMES.md +++ b/NAMES.md @@ -1,8 +1,21 @@ -# 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. +# Naming + +**Decided: packeteer.** packet + `-eer` (the agent-noun suffix in +*engineer*, *puppeteer*, *musketeer*, *auctioneer* - "one who wields the +thing"), landing on a practitioner/character feel rather than a plain +descriptive tool name. Checked before committing: no existing +open-source packet-capture/analysis project uses it. Two known, +non-blocking collisions worth remembering if this ever comes up - +**Packeteer, Inc.** (1996-2008, NASDAQ: PKTR) was a real networking +company that made *PacketShaper*, a WAN traffic-shaping appliance, +acquired by Blue Coat Systems and fully absorbed since - defunct, no +live trademark, but it'll surface in searches; and the bare +`packeteer` username/org on GitHub is already held by an unrelated +individual, so the repo lives under this project's own namespace +rather than as a top-level org name. + +The rest of this file is the brainstorm that led here, kept for the +record rather than pruned. Landscape checked for collisions / conventions: tcpdump, Wireshark, tshark, termshark, ngrep, ettercap, etherape, snoop, bmon, iftop, nethogs, @@ -19,8 +32,20 @@ bandwhich, trippy, gping, dog, ntap, netwatch. itself. This is the modern Rust-CLI convention. - **Portmanteau of domain nouns**: etherape (ether + ape), snoop, ettercap (etter + cap, Italian "hetter" + capture). +- **Agent-noun branding**: packeteer (packet + -eer, "one who wields + packets") - the convention this project's name actually landed on; + not represented in the landscape checked above, which leaned + Unix-terse/portmanteau/plain-word instead. + +## Names considered along the way (not chosen) -## Candidates +### Wire/frame lineage (the project's working name for most of its build) +`wireframe` - wire (network) + frame (Ethernet/IP frame, also a UI +"wireframe" pun) - was the working name up to this point. Dropped in +favor of packeteer once the project had grown well past "one narrow +decoder" into full L2-L7 dissection, reassembly, checksums, privilege +dropping, and dual frontends - packeteer's agent-noun framing fit +that breadth better than a still-literal wire/frame pun. ### Unix-style short (syscall/tool-terse) - `pktap` - packet + tap @@ -39,6 +64,14 @@ bandwhich, trippy, gping, dog, ntap, netwatch. - `netframe` - `packframe` - `framewire` +- `layershark` / `stackshark` - added later, once L2-L7 were all decoded +- `wirehawk` - same wire+animal cadence as Wireshark, swapping the + predator for "hawk-eyed" (keen observation) instead +- `wirespider` - a spider senses everything through vibrations along + silk threads, a close metaphor for sensing traffic on a wire; + arguably the tightest metaphor fit in this whole lineage +- `orca` - orcas are one of the few animals that hunt sharks; considered + as a way to "supersede" the Wireshark pun rather than extend it ### Evocative single word (bandwhich/trippy/dog convention - plain word, no jargon) - `peek` @@ -49,95 +82,42 @@ bandwhich, trippy, gping, dog, ntap, netwatch. - `snare` - `prowl` - `siphon` +- `dissect` - plain, describes exactly what the tool does at every + layer; risk is it's a generic verb likely to collide with something ### References `std::span` directly (the project's actual technical hook) - `spancap` - `spanview` - `bytespan` - `octospan` +- `netspan` -### 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. +### Byte/octet lane (surfaced once the L2-L4 decoders read one octet at a time by hand) +- `octet` - the actual networking term for a byte; precise, unclaimed + in the landscape checked - `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. - -## More candidates (added after TCP reassembly, checksums, privilege -## dropping, and a wider L7 protocol set - DNS/mDNS/HTTP/TLS SNI/SSH/ICMP) - -The project has since grown two angles the earlier lists didn't have -anything for: **stitching segments back into a stream** (TCP -reassembly, wireframe/net/tcp_reassembly.hpp) and **actively dropping -root** the moment the capture handle is open (wireframe/privileges.hpp) -rather than just capturing passively. -- `flowtap` - "flow" is the actual industry term for what - TcpReassembler tracks (a 4-tuple's worth of state across many - packets), not just "stream" -- `stitchtap` - literal, describes reassembly specifically; maybe too - literal/cute -- `reflow` - re- (reassemble) + flow; short, but collides conceptually - with CSS/text "reflow", possibly confusing -- `dropcap` - pun on dropping root/CAP_NET_RAW after opening the - capture handle, which doubles as an actual typography term ("drop - cap": an oversized first letter) - two real meanings landing on the - same word is rare enough to be worth serious consideration -- `polytap` - poly- (many protocols: DNS/HTTP/TLS/mDNS/SSH/ICMP) + tap, - keeps the -tap suffix family from the first list -- `layershark` / `stackshark` - extends the -shark lineage with the - OSI-layer angle (L2 through L7 all decoded by hand now) -- `dissect` - plain English word, no jargon, describes exactly what - the tool does at every layer; downside is it's a very generic verb, - likely to collide with something already using it +### Reassembly/privilege-dropping lane (surfaced once those features landed) +- `flowtap` - "flow" is the real industry term for a TCP 4-tuple's + worth of tracked state, more precise than "stream" +- `stitchtap` - literal description of reassembly +- `reflow` - collides conceptually with CSS/text "reflow" +- `dropcap` - pun on dropping root/CAP_NET_RAW right after opening the + capture handle, which also happens to be a real typography term (an + oversized first letter) - two genuine meanings on one word, the + strongest pun found in this whole search +- `polytap` - poly- (the many protocols dissected: DNS/HTTP/TLS/mDNS/ + SSH/ICMP) + tap -## Current standing recommendation - -Given how much the project now actually does - full L2-L7 decode -(including reassembly), pcapng, filtering, checksum verification, -privilege dropping, dual TUI/GUI frontends - a name that still reads -as "one narrow tool" undersells it less than it used to when this list -started. `frameshark` remains the strongest brand-lineage pick; -`dropcap` is the strongest new candidate from this round, on the -strength of its double meaning actually being true of the tool's own -behavior rather than a stretch. +### Playful / punny +- `Framed` - "you've been framed" (packet frames) +- `Packeteer` - **chosen**, see top of file +- `Sniffy` +- `wiretap` - plain-word option; flagged as possibly already taken + somewhere given how common the word is, never fully checked +- `flagship` - pun on TCP flags (SYN/ACK/FIN); cute but unclear at a + glance that it's a network tool diff --git a/PLAN.md b/PLAN.md index 6defe1d..20450d1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,4 +1,4 @@ -# wireframe - Packet Analyzer / Network TUI +# packeteer - Packet Analyzer / Network TUI ## Overview Terminal packet capture and analysis tool. Primary goal: learn the C++ @@ -31,7 +31,7 @@ unowned buffers) via a real-world capture pipeline. 4. [done] Bounded channel + drop-on-backpressure between capture and render 5. [in progress] L7 dissector interface, add protocols incrementally -- interface + DNS + HTTP + TLS SNI + mDNS + SSH banner done - (wireframe/l7/); more protocols can still be added incrementally, + (packeteer/l7/); more protocols can still be added incrementally, by design 6. [done] Filtering (-f , libpcap's own BPF compiler - see Decisions) 7. [done] Drop privileges after opening the capture handle (see Decisions) @@ -55,20 +55,20 @@ None currently open. 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/ + incidental - all three go through the same packeteer::CaptureSession + (packeteer/capture_session.hpp) for device-open/datalink-validate/ filter/pcapng/signal-handler setup, so the GUI can't silently skip a step (e.g. the DLT_RAW check) the way two hand-copied setups would eventually drift. -- Tests: doctest (v2.5.3, FetchContent), tests/ mirrors include/wireframe/. +- Tests: doctest (v2.5.3, FetchContent), tests/ mirrors include/packeteer/. Every module gets unit tests as it's built, not backfilled later -- - `cmake --build build && ./build/wireframe_tests` (or `ctest`) should + `cmake --build build && ./build/packeteer_tests` (or `ctest`) should stay green at every commit. - Filtering: libpcap's own pcap_compile()/pcap_setfilter() (tcpdump syntax, kernel-level via BPF), not a hand-rolled parser - the parser/compiler already exists, is correct, and reimplementing it has no bearing on this project's actual goal (the C++ memory model). - wireframe/filter.hpp wraps compilation; testable without root via + packeteer/filter.hpp wraps compilation; testable without root via pcap_open_dead(). Verified live: -f "tcp port N" and -f icmp each correctly suppressed non-matching traffic that was actually present. - pcap_stats(): CaptureSession::stats() surfaces kernel/interface-level @@ -79,7 +79,7 @@ None currently open. 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 + -DPACKETEER_ENABLE_FUZZING=ON -DCMAKE_CXX_COMPILER=clang++, separate build-fuzz/ dir) for every hand-rolled decoder plus the pcapng reader and the full summarize_packet() pipeline - the highest-value tests in the repo given the project's actual goal (byte layout/alignment/ @@ -93,7 +93,7 @@ None currently open. 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 +- HTTP L7 dissector (packeteer/l7/http.hpp): best-effort single-segment request/status-line parse (+ Host: header for requests), same scope DNS already has - no TCP stream reassembly, so a message split across packets is only partially visible. This is the first @@ -106,7 +106,7 @@ None currently open. (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 +- TLS SNI L7 dissector (packeteer/l7/tls.hpp): parses a ClientHello's record/handshake/extensions structure (nested TLVs, every length bounds-checked against attacker-influenced fields at every level -- the most structurally complex hand-rolled parser in the project) to @@ -134,7 +134,7 @@ None currently open. (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 +- Post-capture search: packeteer/search.hpp's matches_search() is a display filter, deliberately distinct from -f's capture filter -- -f decides what's captured (and written to -w); search decides what's shown, without touching either, same distinction Wireshark draws @@ -189,7 +189,7 @@ None currently open. now-static list, and 'q' closes it; Xvfb confirmed the same for the GUI, including a live process check across a multi-second wait to rule out a delayed auto-close. -- Privilege dropping (wireframe/privileges.hpp): after pcap_open_live() +- Privilege dropping (packeteer/privileges.hpp): after pcap_open_live() succeeds - the only operation that actually needs CAP_NET_RAW - and before the datalink check or a -w file is even created, drop from root to the invoking user via sudo's SUDO_UID/SUDO_GID. setuid() to a @@ -213,7 +213,7 @@ None currently open. whole point of "drop after open"). Separately verified the setcap-without-sudo path works with zero privilege escalation at any point in the process's life. -- AF_PACKET/mmap ring buffer (src/afpacket_capture.cpp, wireframe_afpacket_demo, +- AF_PACKET/mmap ring buffer (src/afpacket_capture.cpp, packeteer_afpacket_demo, Linux-only): PLAN.md's originally-listed alternative capture backend, built as a standalone artifact rather than swapped into CaptureSession - the existing pipeline has real, tested value riding on libpcap's @@ -232,7 +232,7 @@ None currently open. ICMPv6 all decoded correctly across a large volume of genuine traffic, no crashes, no leaked sockets/mappings after exit, tests and the rest of the build entirely unaffected by its addition. -- ICMP decoding (wireframe/net/icmp.hpp): previously every ICMPv4 +- ICMP decoding (packeteer/net/icmp.hpp): previously every ICMPv4 packet just showed "proto=1" with nothing further - no dissector existed at all - despite ICMP being most of this session's own test traffic (every ping). ICMPv6 was labeled but not decoded either. @@ -248,12 +248,12 @@ None currently open. and ::1 (proto=58) - request/reply pairs decoded correctly on both, including matching identifier/sequence numbers between each request and its reply. -- -h/--help: both wireframe and wireframe_gui now print real usage +- -h/--help: both packeteer and packeteer_gui now print real usage text (each binary's actual flag set - the GUI never had -x/-t/-g, so its help doesn't claim it does) and exit 0 before touching a device or any privilege at all. Previously -x -t -w -f -g -r all existed with zero discoverability outside reading the source. -- Checksum validation (wireframe/net/checksum.hpp): RFC 1071 Internet +- Checksum validation (packeteer/net/checksum.hpp): RFC 1071 Internet checksum, plus IPv4-header/TCP/UDP verification built on it (IPv6 checksums use a different pseudo-header and different optionality rules - not done here, a reasonable follow-on if wanted). UDP's @@ -279,7 +279,7 @@ None currently open. hardware, not a gap in the reasoning: most real NICs ship tx-checksum offload on by default, which is exactly the scenario -c's opt-in-ness is meant to keep from reading as false positives. -- TCP stream reassembly (wireframe/net/tcp_reassembly.hpp): in-order-only +- TCP stream reassembly (packeteer/net/tcp_reassembly.hpp): in-order-only - out-of-order segments and retransmissions are dropped, not buffered for later reordering. A real limitation, but an honest one for a learning tool captured directly on an endpoint (lo/wlp1s0/tailscale0, @@ -295,7 +295,7 @@ None currently open. TcpReassembler instance lives in main(), and render_packet() does its own minimal Ethernet/IPv4/TCP walk (mirroring checksum_status()) to feed segments in and, when new contiguous bytes come back, re-runs - parse_http() (wireframe/l7/http.hpp) against the joined stream and + parse_http() (packeteer/l7/http.hpp) against the joined stream and prints the result as a distinct "[reassembled ...]" line, not folded into the per-packet summary. Deliberately calls parse_http() directly rather than going through L7Registry, so it isn't gated to port 80 the @@ -315,8 +315,8 @@ None currently open. bounded memory use. - GUI parity for -c/-a: checksum_status() and reassembled_http_status() moved out of main.cpp into a new shared header - (wireframe/packet_diagnostics.hpp) rather than duplicated into - gui_main.cpp - the same reasoning wireframe::CaptureSession exists + (packeteer/packet_diagnostics.hpp) rather than duplicated into + gui_main.cpp - the same reasoning packeteer::CaptureSession exists for at the setup layer, applied here to the diagnostics layer. GUI's hex dump was already always-on for the selected row (no -x-equivalent flag needed); checksum status is computed lazily when a row is @@ -336,7 +336,7 @@ None currently open. a bug - Linux's loopback receive path typically never computes a real TCP checksum at all (CHECKSUM_UNNECESSARY), which is exactly the false-positive scenario -c's opt-in-ness exists to guard against. -- mDNS (wireframe/l7/mdns.hpp) and SSH banner (wireframe/l7/ssh.hpp) +- mDNS (packeteer/l7/mdns.hpp) and SSH banner (packeteer/l7/ssh.hpp) dissectors, registered alongside DNS/HTTP/TLS in l7_registry(). mDNS reuses parse_dns() outright - RFC 6762 keeps DNS's exact wire format, just over UDP 5353 instead of 53 - and deliberately omits diff --git a/fuzz/fuzz_checksum.cpp b/fuzz/fuzz_checksum.cpp index f490d1c..b6127c3 100644 --- a/fuzz/fuzz_checksum.cpp +++ b/fuzz/fuzz_checksum.cpp @@ -1,10 +1,10 @@ #include #include -#include "wireframe/net/checksum.hpp" -#include "wireframe/net/ipv4.hpp" +#include "packeteer/net/checksum.hpp" +#include "packeteer/net/ipv4.hpp" -using namespace wireframe::net; +using namespace packeteer::net; // internet_checksum() itself takes arbitrary bytes directly. The // verify_*_checksum_ipv4() wrappers additionally need two addresses, diff --git a/fuzz/fuzz_dns.cpp b/fuzz/fuzz_dns.cpp index 136c8f0..da6f5f1 100644 --- a/fuzz/fuzz_dns.cpp +++ b/fuzz/fuzz_dns.cpp @@ -1,15 +1,15 @@ #include #include -#include "wireframe/l7/dns.hpp" +#include "packeteer/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}); + packeteer::net::parse_dns({data, size}); - wireframe::net::DnsDissector dissector; + packeteer::net::DnsDissector dissector; dissector.summarize({data, size}); return 0; } diff --git a/fuzz/fuzz_ethernet.cpp b/fuzz/fuzz_ethernet.cpp index 91aa6d5..1153be7 100644 --- a/fuzz/fuzz_ethernet.cpp +++ b/fuzz/fuzz_ethernet.cpp @@ -1,9 +1,9 @@ #include #include -#include "wireframe/net/ethernet.hpp" +#include "packeteer/net/ethernet.hpp" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - wireframe::net::parse_ethernet({data, size}); + packeteer::net::parse_ethernet({data, size}); return 0; } diff --git a/fuzz/fuzz_http.cpp b/fuzz/fuzz_http.cpp index 18a9f69..47029f1 100644 --- a/fuzz/fuzz_http.cpp +++ b/fuzz/fuzz_http.cpp @@ -1,15 +1,15 @@ #include #include -#include "wireframe/l7/http.hpp" +#include "packeteer/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}); + packeteer::net::parse_http({data, size}); - wireframe::net::HttpDissector dissector; + packeteer::net::HttpDissector dissector; dissector.summarize({data, size}); return 0; } diff --git a/fuzz/fuzz_ipv4.cpp b/fuzz/fuzz_ipv4.cpp index 10b6530..2250998 100644 --- a/fuzz/fuzz_ipv4.cpp +++ b/fuzz/fuzz_ipv4.cpp @@ -1,9 +1,9 @@ #include #include -#include "wireframe/net/ipv4.hpp" +#include "packeteer/net/ipv4.hpp" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - wireframe::net::parse_ipv4({data, size}); + packeteer::net::parse_ipv4({data, size}); return 0; } diff --git a/fuzz/fuzz_ipv6.cpp b/fuzz/fuzz_ipv6.cpp index 1cae072..eded2a2 100644 --- a/fuzz/fuzz_ipv6.cpp +++ b/fuzz/fuzz_ipv6.cpp @@ -1,16 +1,16 @@ #include #include -#include "wireframe/net/ipv6.hpp" +#include "packeteer/net/ipv6.hpp" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - auto packet = wireframe::net::parse_ipv6({data, size}); + auto packet = packeteer::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); + packeteer::net::ipv6_to_string(packet->header.src); + packeteer::net::ipv6_to_string(packet->header.dst); // Extension-header walking: a loop that repeatedly trusts an // attacker-controlled length field to advance through the @@ -18,7 +18,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { // 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); + packeteer::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 index e27675b..d62fff5 100644 --- a/fuzz/fuzz_pcapng_reader.cpp +++ b/fuzz/fuzz_pcapng_reader.cpp @@ -2,7 +2,7 @@ #include #include -#include "wireframe/pcapng/reader.hpp" +#include "packeteer/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() @@ -11,7 +11,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { FILE* file = fmemopen(const_cast(data), size, "rb"); if (file == nullptr) return 0; - wireframe::pcapng::Reader reader(file); + packeteer::pcapng::Reader reader(file); while (reader.next_packet()) { // keep draining until EOF/malformed-block termination } diff --git a/fuzz/fuzz_summarize.cpp b/fuzz/fuzz_summarize.cpp index c1872fd..f887f92 100644 --- a/fuzz/fuzz_summarize.cpp +++ b/fuzz/fuzz_summarize.cpp @@ -2,7 +2,7 @@ #include #include -#include "wireframe/summarize.hpp" +#include "packeteer/summarize.hpp" // Fuzzes the full decode chain together (Ethernet/RAW -> IPv4/IPv6 -> // TCP/UDP -> L7), not just each layer in isolation - catches bugs @@ -13,6 +13,6 @@ 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); + packeteer::summarize_packet({data + 1, size - 1}, datalink); return 0; } diff --git a/fuzz/fuzz_tcp.cpp b/fuzz/fuzz_tcp.cpp index c06a3dc..4035af4 100644 --- a/fuzz/fuzz_tcp.cpp +++ b/fuzz/fuzz_tcp.cpp @@ -1,9 +1,9 @@ #include #include -#include "wireframe/net/tcp.hpp" +#include "packeteer/net/tcp.hpp" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - wireframe::net::parse_tcp({data, size}); + packeteer::net::parse_tcp({data, size}); return 0; } diff --git a/fuzz/fuzz_tcp_reassembly.cpp b/fuzz/fuzz_tcp_reassembly.cpp index 78ca91c..26389d8 100644 --- a/fuzz/fuzz_tcp_reassembly.cpp +++ b/fuzz/fuzz_tcp_reassembly.cpp @@ -1,9 +1,9 @@ #include #include -#include "wireframe/net/tcp_reassembly.hpp" +#include "packeteer/net/tcp_reassembly.hpp" -using namespace wireframe::net; +using namespace packeteer::net; // Unlike the other fuzz harnesses, this drives *one* TcpReassembler // with a whole sequence of segments parsed out of a single input -- diff --git a/fuzz/fuzz_tls.cpp b/fuzz/fuzz_tls.cpp index 7860426..dbe98aa 100644 --- a/fuzz/fuzz_tls.cpp +++ b/fuzz/fuzz_tls.cpp @@ -1,7 +1,7 @@ #include #include -#include "wireframe/l7/tls.hpp" +#include "packeteer/l7/tls.hpp" // The nested TLV walk (record -> handshake -> extensions -> SNI, each // level bounds-checked against attacker-influenced length fields) is @@ -9,9 +9,9 @@ // 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}); + packeteer::net::parse_tls_client_hello({data, size}); - wireframe::net::TlsSniDissector dissector; + packeteer::net::TlsSniDissector dissector; dissector.summarize({data, size}); return 0; } diff --git a/fuzz/fuzz_udp.cpp b/fuzz/fuzz_udp.cpp index f2433f5..557a3fb 100644 --- a/fuzz/fuzz_udp.cpp +++ b/fuzz/fuzz_udp.cpp @@ -1,9 +1,9 @@ #include #include -#include "wireframe/net/udp.hpp" +#include "packeteer/net/udp.hpp" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - wireframe::net::parse_udp({data, size}); + packeteer::net::parse_udp({data, size}); return 0; } diff --git a/include/packeteer/byteio.hpp b/include/packeteer/byteio.hpp new file mode 100644 index 0000000..cf0cb19 --- /dev/null +++ b/include/packeteer/byteio.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +// 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 packeteer { + +inline std::uint16_t read_be16(std::span bytes, std::size_t offset) { + return static_cast((bytes[offset] << 8) | bytes[offset + 1]); +} + +inline std::uint32_t read_be32(std::span bytes, std::size_t offset) { + return (static_cast(bytes[offset]) << 24) | + (static_cast(bytes[offset + 1]) << 16) | + (static_cast(bytes[offset + 2]) << 8) | + static_cast(bytes[offset + 3]); +} + +} // namespace packeteer diff --git a/include/packeteer/capture_queue.hpp b/include/packeteer/capture_queue.hpp new file mode 100644 index 0000000..cce254c --- /dev/null +++ b/include/packeteer/capture_queue.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// 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 packeteer { + +struct CapturedPacket { + std::uint32_t ts_sec; + std::uint32_t ts_usec; + std::uint32_t original_len; + std::vector 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 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 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 pop() { + std::unique_lock 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 lock(mutex_); + stopped_ = true; + } + cv_.notify_all(); + } + + std::uint64_t dropped() const { + std::lock_guard lock(mutex_); + return dropped_; + } + +private: + mutable std::mutex mutex_; + std::condition_variable cv_; + std::queue queue_; + std::size_t capacity_; + bool stopped_ = false; + std::uint64_t dropped_ = 0; +}; + +} // namespace packeteer diff --git a/include/packeteer/capture_session.hpp b/include/packeteer/capture_session.hpp new file mode 100644 index 0000000..cc8ac27 --- /dev/null +++ b/include/packeteer/capture_session.hpp @@ -0,0 +1,312 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "packeteer/capture_queue.hpp" +#include "packeteer/filter.hpp" +#include "packeteer/pcapng/reader.hpp" +#include "packeteer/pcapng/writer.hpp" +#include "packeteer/privileges.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 ): 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 packeteer { + +namespace detail { +inline pcap_t* g_capture_handle = nullptr; +inline std::atomic* 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 filter_expr; + std::optional pcapng_output_path; + std::optional 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 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; + } + + // Everything CAP_NET_RAW/root was needed for is done: the + // handle is open. Drop immediately, before the datalink check + // or -w's file is even created - the latter is also why this + // runs this early rather than at the very end of open(), since + // it means a -w output file gets created as the real user, not + // root, and doesn't need a manual chown to read back afterward. + if (auto err = drop_privileges_if_root()) { + return "failed to drop root privileges after opening the capture handle: " + *err; + } + + 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 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(&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(user); + CapturedPacket packet; + packet.ts_sec = static_cast(header->ts.tv_sec); + packet.ts_usec = static_cast(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(record.timestamp_us / 1'000'000ULL); + packet.ts_usec = static_cast(record.timestamp_us % 1'000'000ULL); + packet.original_len = record.original_len; + packet.data = std::move(record.data); + return packet; + } + + std::optional 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(datalink_)); + return std::nullopt; + } + + std::optional open_replay(const std::string& path, + const std::optional& 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(*link_type))) { + return "unsupported or missing link type in " + path; + } + + datalink_ = static_cast(*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_; + + bool is_replay_ = false; + std::FILE* replay_file_ = nullptr; + std::optional replay_reader_; + std::optional first_replay_packet_; + std::atomic replay_stop_requested_{false}; +}; + +} // namespace packeteer diff --git a/include/packeteer/filter.hpp b/include/packeteer/filter.hpp new file mode 100644 index 0000000..1cfcd88 --- /dev/null +++ b/include/packeteer/filter.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include + +// 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 packeteer { + +// 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 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 packeteer diff --git a/include/packeteer/l7/dissector.hpp b/include/packeteer/l7/dissector.hpp new file mode 100644 index 0000000..e918baf --- /dev/null +++ b/include/packeteer/l7/dissector.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include + +// 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 packeteer::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 summarize(std::span payload) const = 0; +}; + +class L7Registry { +public: + void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); } + + std::optional dissect(std::uint16_t port, + std::span payload) const { + for (const auto* dissector : dissectors_) { + if (dissector->port() == port) return dissector->summarize(payload); + } + return std::nullopt; + } + +private: + std::vector dissectors_; +}; + +} // namespace packeteer::net diff --git a/include/packeteer/l7/dns.hpp b/include/packeteer/l7/dns.hpp new file mode 100644 index 0000000..2626887 --- /dev/null +++ b/include/packeteer/l7/dns.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" +#include "packeteer/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 packeteer::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 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> read_dns_name( + std::span 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(bytes[offset + i]); + offset += len; + } + return std::make_pair(std::move(name), offset); +} + +inline std::optional parse_dns(std::span 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((flags >> 11) & 0x0F); + header.rcode = static_cast(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 summarize(std::span 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 packeteer::net diff --git a/include/packeteer/l7/http.hpp b/include/packeteer/l7/http.hpp new file mode 100644 index 0000000..f42bf9f --- /dev/null +++ b/include/packeteer/l7/http.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "packeteer/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 packeteer::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 host; // request only, from a Host: header if present +}; + +inline std::optional parse_http(std::span payload) { + std::string_view text(reinterpret_cast(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 summarize(std::span 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 packeteer::net diff --git a/include/packeteer/l7/mdns.hpp b/include/packeteer/l7/mdns.hpp new file mode 100644 index 0000000..b7a8529 --- /dev/null +++ b/include/packeteer/l7/mdns.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include + +#include "packeteer/l7/dissector.hpp" +#include "packeteer/l7/dns.hpp" + +// mDNS (RFC 6762) reuses DNS's exact wire format - same header layout, +// same question/name encoding - just over a different port (5353, +// usually to/from the multicast address 224.0.0.251) and typically +// with many questions/answers per packet instead of DNS's usual one. +// parse_dns() already only looks at the first question, which is true +// here too; the only real difference worth a label is which protocol +// this traffic actually is, so real-world capture output doesn't read +// "DNS" for traffic that never touched a resolver. +namespace packeteer::net { + +inline constexpr std::uint16_t kMdnsPort = 5353; + +class MdnsDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kMdnsPort; } + + std::optional summarize(std::span payload) const override { + auto msg = parse_dns(payload); + if (!msg) return std::nullopt; + + // No id= field here unlike DnsDissector's summary: RFC 6762 + // 18.1 has multicast queries send it as zero, so printing it + // would just be "id=0" noise on real traffic. + std::string out = "mDNS "; + out += msg->header.is_response ? "response" : "query"; + 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 packeteer::net diff --git a/include/packeteer/l7/ssh.hpp b/include/packeteer/l7/ssh.hpp new file mode 100644 index 0000000..261b8e1 --- /dev/null +++ b/include/packeteer/l7/ssh.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "packeteer/l7/dissector.hpp" + +// SSH's identification exchange (RFC 4253 section 4.2) is the one part +// of an SSH connection sent in the clear, before key exchange starts +// encrypting everything: both sides open with a single line of the +// form "SSH-protoversion-softwareversion[ comments]" terminated by +// CR LF (a bare LF is tolerated too, same leniency this project's HTTP +// dissector already uses). Only that first line is ever readable -- +// everything after key exchange is opaque, so this dissector only ever +// has one line to look at, on either side of the connection. +namespace packeteer::net { + +inline constexpr std::uint16_t kSshPort = 22; + +struct SshBanner { + std::string proto_version; + std::string software_version; +}; + +inline std::optional parse_ssh_banner(std::span payload) { + std::string_view text(reinterpret_cast(payload.data()), payload.size()); + if (text.substr(0, 4) != "SSH-") return std::nullopt; + + std::size_t line_end = text.find("\r\n"); + if (line_end == std::string_view::npos) { + line_end = text.find('\n'); + if (line_end == std::string_view::npos) return std::nullopt; + } + std::string_view line = text.substr(4, line_end - 4); // past "SSH-" + + std::size_t dash = line.find('-'); + if (dash == std::string_view::npos) return std::nullopt; + + SshBanner banner; + banner.proto_version = std::string(line.substr(0, dash)); + + // The software version runs up to the first space (start of an + // optional comment) or the end of the line, whichever is first. + std::string_view rest = line.substr(dash + 1); + std::size_t space = rest.find(' '); + banner.software_version = std::string(space == std::string_view::npos ? rest + : rest.substr(0, space)); + return banner; +} + +class SshDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kSshPort; } + + std::optional summarize(std::span payload) const override { + auto banner = parse_ssh_banner(payload); + if (!banner) return std::nullopt; + return "SSH " + banner->proto_version + " " + banner->software_version; + } +}; + +} // namespace packeteer::net diff --git a/include/packeteer/l7/tls.hpp b/include/packeteer/l7/tls.hpp new file mode 100644 index 0000000..40893fc --- /dev/null +++ b/include/packeteer/l7/tls.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" +#include "packeteer/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 packeteer::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 server_name; // SNI, if the extension was present and well-formed +}; + +inline std::optional parse_tls_client_hello(std::span 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(5) + record_len) return std::nullopt; + + std::span 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(handshake[1]) << 16) | + (static_cast(handshake[2]) << 8) | + static_cast(handshake[3]); + + std::span 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(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(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(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 summarize(std::span 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 packeteer::net diff --git a/include/packeteer/net/checksum.hpp b/include/packeteer/net/checksum.hpp new file mode 100644 index 0000000..522d90a --- /dev/null +++ b/include/packeteer/net/checksum.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include + +#include "packeteer/net/ipv4.hpp" + +// RFC 1071 Internet checksum, and the IPv4/TCP/UDP verification built +// on it. Not wired into summarize_packet(): on loopback, and for many +// packets captured right as they leave the local machine, the +// transmitted checksum is legitimately 0x0000 or garbage - modern +// NICs compute it in hardware ("checksum offload") only once the frame +// actually reaches them, which is *after* most capture points see it. +// Flagging that as "BAD" by default would be noise, not signal, on +// exactly the interfaces this project has been tested against all +// session (lo, tailscale0). Wireshark makes this opt-in for the same +// reason; so does this (CLI's -c flag calls these directly). +namespace packeteer::net { + +// One's-complement sum of 16-bit big-endian words, folded back into 16 +// bits, then complemented. Used identically by IPv4's header checksum +// and, over a pseudo-header + segment instead of a plain header, by +// TCP/UDP. +inline std::uint16_t internet_checksum(std::span data) { + std::uint32_t sum = 0; + std::size_t i = 0; + for (; i + 1 < data.size(); i += 2) { + sum += (static_cast(data[i]) << 8) | data[i + 1]; + } + if (i < data.size()) { + sum += static_cast(data[i]) << 8; // odd trailing byte: high half only + } + while (sum >> 16) { + sum = (sum & 0xFFFFu) + (sum >> 16); + } + return static_cast(~sum & 0xFFFFu); +} + +// `header_bytes` must be exactly the IPv4 header as it appeared on the +// wire (IHL*4 bytes, options included, checksum field included as its +// real transmitted value - not zeroed). Summing a header that already +// contains its own valid checksum comes out to exactly 0; that's the +// verification, no need for a mutable copy with the field zeroed out. +inline bool verify_ipv4_checksum(std::span header_bytes) { + return internet_checksum(header_bytes) == 0; +} + +enum class ChecksumResult { kValid, kInvalid, kNotPresent }; + +namespace detail { + +inline std::vector build_ipv4_pseudo_header(const Ipv4Address& src, + const Ipv4Address& dst, + std::uint8_t protocol, + std::span segment) { + std::vector buf; + buf.reserve(12 + segment.size()); + buf.insert(buf.end(), src.bytes.begin(), src.bytes.end()); + buf.insert(buf.end(), dst.bytes.begin(), dst.bytes.end()); + buf.push_back(0); + buf.push_back(protocol); + std::uint16_t len = static_cast(segment.size()); + buf.push_back(static_cast(len >> 8)); + buf.push_back(static_cast(len & 0xFF)); + buf.insert(buf.end(), segment.begin(), segment.end()); + return buf; +} + +} // namespace detail + +// TCP's checksum is mandatory - always kValid or kInvalid. +inline ChecksumResult verify_tcp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, + std::span tcp_segment) { + auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoTcp, tcp_segment); + return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; +} + +// UDP's checksum is optional over IPv4 (RFC 768): a transmitted value +// of exactly 0x0000 means "no checksum was computed", not "checksum is +// zero" - that's kNotPresent, not a failure. +inline ChecksumResult verify_udp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, + std::span udp_datagram) { + if (udp_datagram.size() >= 8 && udp_datagram[6] == 0 && udp_datagram[7] == 0) { + return ChecksumResult::kNotPresent; + } + auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoUdp, udp_datagram); + return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; +} + +} // namespace packeteer::net diff --git a/include/packeteer/net/ethernet.hpp b/include/packeteer/net/ethernet.hpp new file mode 100644 index 0000000..5b851bc --- /dev/null +++ b/include/packeteer/net/ethernet.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" + +namespace packeteer::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 bytes; +}; + +struct EthernetHeader { + MacAddress dst; + MacAddress src; + std::uint16_t ethertype; +}; + +struct EthernetFrame { + EthernetHeader header; + std::span payload; +}; + +inline std::optional parse_ethernet(std::span 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 packeteer::net diff --git a/include/packeteer/net/icmp.hpp b/include/packeteer/net/icmp.hpp new file mode 100644 index 0000000..d2613a2 --- /dev/null +++ b/include/packeteer/net/icmp.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" + +// ICMPv4 (RFC 792) and ICMPv6 (RFC 4443) share the same first-4-byte +// shape (Type, Code, Checksum) but a completely different type +// namespace - the same numeric type means something different in each +// - so they get separate parse functions and separate type-name +// tables, sharing only the header struct shape. Neither protocol has +// ports, so this doesn't fit L7Registry's port-keyed dispatch at all; +// it's handled directly by protocol number in summarize.hpp instead. +namespace packeteer::net { + +struct IcmpHeader { + std::uint8_t type; + std::uint8_t code; + std::optional identifier; // echo request/reply only + std::optional sequence; // echo request/reply only +}; + +inline std::optional parse_icmpv4(std::span bytes) { + if (bytes.size() < 4) return std::nullopt; + + IcmpHeader header{}; + header.type = bytes[0]; + header.code = bytes[1]; + if ((header.type == 8 || header.type == 0) && bytes.size() >= 8) { // echo request/reply + header.identifier = read_be16(bytes, 4); + header.sequence = read_be16(bytes, 6); + } + return header; +} + +inline std::string icmpv4_type_name(std::uint8_t type) { + switch (type) { + case 0: return "Echo Reply"; + case 3: return "Destination Unreachable"; + case 4: return "Source Quench"; + case 5: return "Redirect"; + case 8: return "Echo Request"; + case 11: return "Time Exceeded"; + case 12: return "Parameter Problem"; + case 13: return "Timestamp Request"; + case 14: return "Timestamp Reply"; + default: return "type=" + std::to_string(type); + } +} + +inline std::optional parse_icmpv6(std::span bytes) { + if (bytes.size() < 4) return std::nullopt; + + IcmpHeader header{}; + header.type = bytes[0]; + header.code = bytes[1]; + if ((header.type == 128 || header.type == 129) && bytes.size() >= 8) { // echo request/reply + header.identifier = read_be16(bytes, 4); + header.sequence = read_be16(bytes, 6); + } + return header; +} + +inline std::string icmpv6_type_name(std::uint8_t type) { + switch (type) { + case 1: return "Destination Unreachable"; + case 2: return "Packet Too Big"; + case 3: return "Time Exceeded"; + case 4: return "Parameter Problem"; + case 128: return "Echo Request"; + case 129: return "Echo Reply"; + case 133: return "Router Solicitation"; + case 134: return "Router Advertisement"; + case 135: return "Neighbor Solicitation"; + case 136: return "Neighbor Advertisement"; + case 137: return "Redirect"; + default: return "type=" + std::to_string(type); + } +} + +} // namespace packeteer::net diff --git a/include/packeteer/net/ipv4.hpp b/include/packeteer/net/ipv4.hpp new file mode 100644 index 0000000..ee77c17 --- /dev/null +++ b/include/packeteer/net/ipv4.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" + +namespace packeteer::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 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 payload; +}; + +inline std::optional parse_ipv4(std::span bytes) { + if (bytes.size() < 20) return std::nullopt; + + std::uint8_t version = static_cast(bytes[0] >> 4); + std::uint8_t ihl = bytes[0] & 0x0F; + std::size_t header_len = static_cast(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 packeteer::net diff --git a/include/packeteer/net/ipv6.hpp b/include/packeteer/net/ipv6.hpp new file mode 100644 index 0000000..8c048e8 --- /dev/null +++ b/include/packeteer/net/ipv6.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" + +namespace packeteer::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 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 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 parse_ipv6(std::span bytes) { + if (bytes.size() < kIpv6HeaderLen) return std::nullopt; + + std::uint8_t version = static_cast(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((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 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 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(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(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 groups{}; + for (std::size_t i = 0; i < 8; ++i) { + groups[i] = static_cast((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 packeteer::net diff --git a/include/packeteer/net/tcp.hpp b/include/packeteer/net/tcp.hpp new file mode 100644 index 0000000..e3d9670 --- /dev/null +++ b/include/packeteer/net/tcp.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +#include "packeteer/byteio.hpp" + +namespace packeteer::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 payload; +}; + +inline std::optional parse_tcp(std::span bytes) { + if (bytes.size() < 20) return std::nullopt; + + std::uint8_t data_offset = static_cast(bytes[12] >> 4); + std::size_t header_len = static_cast(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 packeteer::net diff --git a/include/packeteer/net/tcp_reassembly.hpp b/include/packeteer/net/tcp_reassembly.hpp new file mode 100644 index 0000000..3dbf04f --- /dev/null +++ b/include/packeteer/net/tcp_reassembly.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "packeteer/net/ipv4.hpp" + +// Minimal, in-order-only TCP stream reassembly: tracks each flow's two +// directions separately, accumulating payload bytes as segments arrive +// exactly in sequence order. Out-of-order segments and retransmissions +// are dropped rather than buffered for later reordering - a real +// limitation, but a reasonable one for a learning-focused reassembler +// capturing directly on an endpoint (this project's demonstrated use +// all session: lo, wlp1s0, tailscale0), where segments mostly do +// arrive in order. A capture point far from either endpoint (e.g. a +// middlebox) would need real out-of-order buffering this doesn't do. +// +// The point: HTTP's dissector (packeteer/l7/http.hpp) only ever sees +// one segment at a time, so a request/response split across TCP +// segments - a Host: header landing in the second packet of a +// request, say - is invisible to it. Feeding the *reassembled* stream +// back through the same parse_http() lets it see what single-segment +// dissection structurally can't. +namespace packeteer::net { + +struct FlowKey { + Ipv4Address ip_a; + std::uint16_t port_a; + Ipv4Address ip_b; + std::uint16_t port_b; + + bool operator<(const FlowKey& other) const { + return std::tie(ip_a.bytes, port_a, ip_b.bytes, port_b) < + std::tie(other.ip_a.bytes, other.port_a, other.ip_b.bytes, other.port_b); + } +}; + +// Canonicalizes a (src, dst) pair into a direction-independent +// FlowKey - both directions of the same connection map to the same +// key - plus whether this segment's source was the "a" side. +inline std::pair canonicalize_flow(const Ipv4Address& src_ip, + std::uint16_t src_port, + const Ipv4Address& dst_ip, + std::uint16_t dst_port) { + bool src_is_a = std::tie(src_ip.bytes, src_port) < std::tie(dst_ip.bytes, dst_port); + FlowKey key = src_is_a ? FlowKey{src_ip, src_port, dst_ip, dst_port} + : FlowKey{dst_ip, dst_port, src_ip, src_port}; + return {key, src_is_a}; +} + +struct DirectionState { + bool syn_seen = false; + std::uint32_t next_seq = 0; + std::vector buffer; +}; + +struct FlowState { + DirectionState a_to_b; + DirectionState b_to_a; +}; + +class TcpReassembler { +public: + explicit TcpReassembler(std::size_t max_buffer_per_direction = 65536, + std::size_t max_flows = 4096) + : max_buffer_(max_buffer_per_direction), max_flows_(max_flows) {} + + // Feeds one TCP segment in. Returns a snapshot of the *sender's* + // accumulated stream so far if this segment extended it + // contiguously in order; nullopt if the segment was out of order, + // a retransmission, a control segment with no payload, or the flow + // table was full and this would be a brand new flow. Returned by + // value rather than by reference: the buffer this points at can + // grow/move on the next call, and bounded copies (max 64 KiB by + // default) are cheap enough that this isn't worth the lifetime risk. + std::optional> process_segment( + const Ipv4Address& src_ip, std::uint16_t src_port, const Ipv4Address& dst_ip, + std::uint16_t dst_port, std::uint32_t seq, std::uint8_t flags, + std::span payload) { + auto [key, src_is_a] = canonicalize_flow(src_ip, src_port, dst_ip, dst_port); + + auto it = flows_.find(key); + if (it == flows_.end()) { + if (flows_.size() >= max_flows_) return std::nullopt; // table full: drop new flows + it = flows_.emplace(key, FlowState{}).first; + } + DirectionState& dir = src_is_a ? it->second.a_to_b : it->second.b_to_a; + + constexpr std::uint8_t kSyn = 0x02; + if (flags & kSyn) { + dir.syn_seen = true; + dir.next_seq = seq + 1; // the SYN itself consumes one sequence number + return std::nullopt; + } + + // seq != dir.next_seq covers both out-of-order segments and + // retransmissions (a retransmit repeats a seq already below + // next_seq) - unsigned wraparound makes plain equality correct + // even across a sequence-number wrap, no need for RFC 1982 + // serial-number comparison for an exact-match check like this. + if (!dir.syn_seen || payload.empty() || seq != dir.next_seq) { + return std::nullopt; + } + + if (dir.buffer.size() + payload.size() <= max_buffer_) { + dir.buffer.insert(dir.buffer.end(), payload.begin(), payload.end()); + } + dir.next_seq = seq + static_cast(payload.size()); + + return dir.buffer; + } + + std::size_t flow_count() const { return flows_.size(); } + +private: + std::map flows_; + std::size_t max_buffer_; + std::size_t max_flows_; +}; + +} // namespace packeteer::net diff --git a/include/packeteer/net/udp.hpp b/include/packeteer/net/udp.hpp new file mode 100644 index 0000000..6602b96 --- /dev/null +++ b/include/packeteer/net/udp.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "packeteer/byteio.hpp" + +namespace packeteer::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 payload; +}; + +inline std::optional parse_udp(std::span 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 packeteer::net diff --git a/include/packeteer/packet_diagnostics.hpp b/include/packeteer/packet_diagnostics.hpp new file mode 100644 index 0000000..f1edeef --- /dev/null +++ b/include/packeteer/packet_diagnostics.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +#include "packeteer/l7/http.hpp" +#include "packeteer/net/checksum.hpp" +#include "packeteer/net/ethernet.hpp" +#include "packeteer/net/ipv4.hpp" +#include "packeteer/net/tcp.hpp" +#include "packeteer/net/tcp_reassembly.hpp" + +// Checksum validation and TCP stream reassembly are both deliberately +// kept out of summarize_packet()'s shared per-packet output - see +// packeteer/net/checksum.hpp and packeteer/net/tcp_reassembly.hpp for +// why each is opt-in (checksum offload false positives; reassembly's +// per-flow state and extra per-packet work). Shared between the CLI +// (-c/-a) and GUI frontends so they don't hand-roll two separate +// Ethernet/IPv4/TCP walks down to the same byte spans - the same +// reasoning packeteer::CaptureSession exists for at the setup layer. +namespace packeteer { + +inline std::string checksum_status(std::span bytes, int datalink) { + std::span ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return ""; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now + + auto ip = net::parse_ipv4(ip_bytes); + if (!ip) return ""; + + std::size_t header_len = static_cast(ip->header.ihl) * 4; + std::string out = "checksums: IP="; + out += net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; + + using net::ChecksumResult; + if (ip->header.protocol == net::kProtoTcp) { + auto result = net::verify_tcp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " TCP=ok" : " TCP=BAD"; + } else if (ip->header.protocol == net::kProtoUdp) { + auto result = net::verify_udp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " UDP=ok" + : result == ChecksumResult::kNotPresent ? " UDP=none" + : " UDP=BAD"; + } + return out; +} + +inline std::optional reassembled_http_status(std::span bytes, + int datalink, + net::TcpReassembler& reassembler) { + std::span ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return std::nullopt; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return std::nullopt; // IPv4 only, for now + + auto ip = net::parse_ipv4(ip_bytes); + if (!ip || ip->header.protocol != net::kProtoTcp) return std::nullopt; + + auto tcp = net::parse_tcp(ip->payload); + if (!tcp) return std::nullopt; + + auto reassembled = reassembler.process_segment(ip->header.src, tcp->header.src_port, + ip->header.dst, tcp->header.dst_port, + tcp->header.seq, tcp->header.flags, + tcp->payload); + if (!reassembled) return std::nullopt; + + auto http = net::parse_http(*reassembled); + if (!http) return std::nullopt; + + std::string out = "reassembled "; + out += http->is_request ? "request: " : "response: "; + out += http->method_or_version + " " + http->target_or_status; + if (http->host) out += " Host: " + *http->host; + out += " (" + std::to_string(reassembled->size()) + " bytes so far)"; + return out; +} + +} // namespace packeteer diff --git a/include/packeteer/pcapng/reader.hpp b/include/packeteer/pcapng/reader.hpp new file mode 100644 index 0000000..264c276 --- /dev/null +++ b/include/packeteer/pcapng/reader.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// 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 packeteer::pcapng { + +struct PacketRecord { + std::uint32_t interface_id; + std::uint64_t timestamp_us; + std::uint32_t original_len; + std::vector 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 next_packet() { + for (;;) { + std::array 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 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(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(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 link_type() const { return link_type_; } + +private: + static std::uint32_t get_u32(std::span b) { + return static_cast(b[0]) | (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | (static_cast(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 link_type_; +}; + +} // namespace packeteer::pcapng diff --git a/include/packeteer/pcapng/writer.hpp b/include/packeteer/pcapng/writer.hpp new file mode 100644 index 0000000..59e3c42 --- /dev/null +++ b/include/packeteer/pcapng/writer.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include + +// 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/packeteer/net decoders. +namespace packeteer::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 data, std::uint32_t original_len) { + std::uint64_t ts_us = static_cast(ts_sec) * 1'000'000ULL + ts_usec; + std::uint32_t ts_high = static_cast(ts_us >> 32); + std::uint32_t ts_low = static_cast(ts_us & 0xFFFFFFFFULL); + + std::size_t padded_len = (data.size() + 3) & ~std::size_t(3); + std::vector 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(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(v & 0xFF); + p[1] = static_cast((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((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((v >> (8 * i)) & 0xFF); + } + + void write_block(std::uint32_t type, std::span body) { + std::uint32_t total_len = static_cast(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 packeteer::pcapng diff --git a/include/packeteer/privileges.hpp b/include/packeteer/privileges.hpp new file mode 100644 index 0000000..7c7be7b --- /dev/null +++ b/include/packeteer/privileges.hpp @@ -0,0 +1,87 @@ +#pragma once + +#ifndef _WIN32 +#include +#include +#endif + +#include +#include +#include +#include +#include + +// After pcap_open_live() succeeds, the process has gotten everything +// CAP_NET_RAW exists for - running the rest of the program (decoding +// untrusted packet bytes, an interactive TUI/GUI event loop) as root +// from that point on is unnecessary exposure, and PLAN.md says as much +// directly: "Drop privileges immediately after opening the capture +// handle; use CAP_NET_RAW via file capabilities instead of running as +// root." +// +// The recommended path doesn't need this file at all: run +// `sudo setcap cap_net_raw+ep ` once, then invoke the binary +// directly, unprivileged, forever after - CAP_NET_RAW alone is enough +// for pcap_open_live(), no root required at any point. This exists for +// the case someone still runs the binary via sudo (out of habit, or +// because setcap isn't available/permitted in some environments): drop +// straight back to the invoking user immediately, so the rest of the +// process's lifetime - including any -w output file, which then ends +// up owned by that user instead of root - runs unprivileged either way. +namespace packeteer { + +// Drops from root to the user who actually invoked the program, via +// sudo's SUDO_UID/SUDO_GID (which sudo always sets). A no-op if not +// currently root, or if SUDO_UID isn't set (e.g. a genuine root login, +// not sudo - there's no "real" user to drop to in that case). +// +// setuid() to a nonzero UID also clears the process's Linux capability +// sets as a kernel-level side effect, so this covers both "running as +// root via sudo" and "root's own CAP_NET_RAW" the same way, without a +// separate libcap dependency. +// +// Returns an error message on failure. The drop is safety-critical: a +// failure here should be treated as fatal by the caller, not silently +// ignored while the process keeps running as root. +inline std::optional drop_privileges_if_root() { +#ifdef _WIN32 + return std::nullopt; // no POSIX privilege model to drop from +#else + if (geteuid() != 0) return std::nullopt; // already unprivileged + + const char* sudo_uid = std::getenv("SUDO_UID"); + const char* sudo_gid = std::getenv("SUDO_GID"); + if (sudo_uid == nullptr || sudo_gid == nullptr) { + return std::nullopt; // no safe target to drop to + } + + uid_t target_uid = static_cast(std::strtoul(sudo_uid, nullptr, 10)); + gid_t target_gid = static_cast(std::strtoul(sudo_gid, nullptr, 10)); + + // Order matters: groups and GID need root to change, so they must + // be dropped before UID - once UID is gone, so is the privilege + // to change the others. + if (setgroups(1, &target_gid) == -1) { + return "setgroups failed: " + std::string(std::strerror(errno)); + } + if (setgid(target_gid) == -1) { + return "setgid failed: " + std::string(std::strerror(errno)); + } + if (setuid(target_uid) == -1) { + return "setuid failed: " + std::string(std::strerror(errno)); + } + + // Defense in depth (standard advice from setuid-privilege-drop + // write-ups): confirm root can't be reclaimed. If the saved-UID + // was somehow left at 0, this would succeed and silently undo the + // drop - so a *successful* setuid(0) here means something is + // wrong, and is treated as the failure case. + if (setuid(0) != -1) { + return "failed to permanently drop root (setuid(0) unexpectedly succeeded)"; + } + + return std::nullopt; +#endif +} + +} // namespace packeteer diff --git a/include/packeteer/search.hpp b/include/packeteer/search.hpp new file mode 100644 index 0000000..826f184 --- /dev/null +++ b/include/packeteer/search.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +// A display filter, distinct from -f's capture filter (packeteer/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 packeteer { + +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 packeteer diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp new file mode 100644 index 0000000..77d9ec3 --- /dev/null +++ b/include/packeteer/summarize.hpp @@ -0,0 +1,275 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "packeteer/l7/dissector.hpp" +#include "packeteer/l7/dns.hpp" +#include "packeteer/l7/http.hpp" +#include "packeteer/l7/mdns.hpp" +#include "packeteer/l7/ssh.hpp" +#include "packeteer/l7/tls.hpp" +#include "packeteer/net/ethernet.hpp" +#include "packeteer/net/icmp.hpp" +#include "packeteer/net/ipv4.hpp" +#include "packeteer/net/ipv6.hpp" +#include "packeteer/net/tcp.hpp" +#include "packeteer/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 packeteer { + +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 (packeteer/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. mDNS reuses +// DNS's own parser (same wire format, different port/label) at +// essentially no extra cost. SSH is the first dissector whose *entire* +// protocol is one cleartext line before everything else encrypts -- +// unlike TLS's SNI, there's nothing further to ever add here. +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::MdnsDissector mdns_dissector; + static const net::SshDissector ssh_dissector; + static const net::L7Registry registry = [] { + net::L7Registry r; + r.add(&dns_dissector); + r.add(&http_dissector); + r.add(&tls_dissector); + r.add(&mdns_dissector); + r.add(&ssh_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 l7_summarize(std::span 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 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::kProtoIcmp) { + if (auto icmp = net::parse_icmpv4(info.payload)) { + out += " | ICMP " + net::icmpv4_type_name(icmp->type); + if (icmp->identifier) { + out += " id=" + std::to_string(*icmp->identifier) + + " seq=" + std::to_string(*icmp->sequence); + } + } + } else if (info.proto == net::kNextHeaderIcmpv6) { + if (auto icmp = net::parse_icmpv6(info.payload)) { + out += " | ICMPv6 " + net::icmpv6_type_name(icmp->type); + if (icmp->identifier) { + out += " id=" + std::to_string(*icmp->identifier) + + " seq=" + std::to_string(*icmp->sequence); + } + } else { + out += " | ICMPv6"; // truncated: at least say what it is + } + } + 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 bytes, int datalink) { + std::span 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(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 hex_dump_lines(std::span bytes) { + std::vector 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(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(c) : '.'; + } + line += '|'; + + lines.push_back(std::move(line)); + } + return lines; +} + +} // namespace packeteer diff --git a/include/wireframe/byteio.hpp b/include/wireframe/byteio.hpp deleted file mode 100644 index c37c29e..0000000 --- a/include/wireframe/byteio.hpp +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include -#include - -// 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 bytes, std::size_t offset) { - return static_cast((bytes[offset] << 8) | bytes[offset + 1]); -} - -inline std::uint32_t read_be32(std::span bytes, std::size_t offset) { - return (static_cast(bytes[offset]) << 24) | - (static_cast(bytes[offset + 1]) << 16) | - (static_cast(bytes[offset + 2]) << 8) | - static_cast(bytes[offset + 3]); -} - -} // namespace wireframe diff --git a/include/wireframe/capture_queue.hpp b/include/wireframe/capture_queue.hpp deleted file mode 100644 index 14794ba..0000000 --- a/include/wireframe/capture_queue.hpp +++ /dev/null @@ -1,98 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -// 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 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 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 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 pop() { - std::unique_lock 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 lock(mutex_); - stopped_ = true; - } - cv_.notify_all(); - } - - std::uint64_t dropped() const { - std::lock_guard lock(mutex_); - return dropped_; - } - -private: - mutable std::mutex mutex_; - std::condition_variable cv_; - std::queue 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 deleted file mode 100644 index 2e3b05a..0000000 --- a/include/wireframe/capture_session.hpp +++ /dev/null @@ -1,312 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "wireframe/capture_queue.hpp" -#include "wireframe/filter.hpp" -#include "wireframe/pcapng/reader.hpp" -#include "wireframe/pcapng/writer.hpp" -#include "wireframe/privileges.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 ): 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* 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 filter_expr; - std::optional pcapng_output_path; - std::optional 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 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; - } - - // Everything CAP_NET_RAW/root was needed for is done: the - // handle is open. Drop immediately, before the datalink check - // or -w's file is even created - the latter is also why this - // runs this early rather than at the very end of open(), since - // it means a -w output file gets created as the real user, not - // root, and doesn't need a manual chown to read back afterward. - if (auto err = drop_privileges_if_root()) { - return "failed to drop root privileges after opening the capture handle: " + *err; - } - - 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 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(&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(user); - CapturedPacket packet; - packet.ts_sec = static_cast(header->ts.tv_sec); - packet.ts_usec = static_cast(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(record.timestamp_us / 1'000'000ULL); - packet.ts_usec = static_cast(record.timestamp_us % 1'000'000ULL); - packet.original_len = record.original_len; - packet.data = std::move(record.data); - return packet; - } - - std::optional 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(datalink_)); - return std::nullopt; - } - - std::optional open_replay(const std::string& path, - const std::optional& 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(*link_type))) { - return "unsupported or missing link type in " + path; - } - - datalink_ = static_cast(*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_; - - bool is_replay_ = false; - std::FILE* replay_file_ = nullptr; - std::optional replay_reader_; - std::optional first_replay_packet_; - std::atomic replay_stop_requested_{false}; -}; - -} // namespace wireframe diff --git a/include/wireframe/filter.hpp b/include/wireframe/filter.hpp deleted file mode 100644 index 49fa4ab..0000000 --- a/include/wireframe/filter.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include - -// 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 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 deleted file mode 100644 index 9b2cc32..0000000 --- a/include/wireframe/l7/dissector.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// 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 summarize(std::span payload) const = 0; -}; - -class L7Registry { -public: - void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); } - - std::optional dissect(std::uint16_t port, - std::span payload) const { - for (const auto* dissector : dissectors_) { - if (dissector->port() == port) return dissector->summarize(payload); - } - return std::nullopt; - } - -private: - std::vector dissectors_; -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/dns.hpp b/include/wireframe/l7/dns.hpp deleted file mode 100644 index 5c1ab36..0000000 --- a/include/wireframe/l7/dns.hpp +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#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 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> read_dns_name( - std::span 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(bytes[offset + i]); - offset += len; - } - return std::make_pair(std::move(name), offset); -} - -inline std::optional parse_dns(std::span 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((flags >> 11) & 0x0F); - header.rcode = static_cast(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 summarize(std::span 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 deleted file mode 100644 index 4780b23..0000000 --- a/include/wireframe/l7/http.hpp +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#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 host; // request only, from a Host: header if present -}; - -inline std::optional parse_http(std::span payload) { - std::string_view text(reinterpret_cast(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 summarize(std::span 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/mdns.hpp b/include/wireframe/l7/mdns.hpp deleted file mode 100644 index 887d811..0000000 --- a/include/wireframe/l7/mdns.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/l7/dissector.hpp" -#include "wireframe/l7/dns.hpp" - -// mDNS (RFC 6762) reuses DNS's exact wire format - same header layout, -// same question/name encoding - just over a different port (5353, -// usually to/from the multicast address 224.0.0.251) and typically -// with many questions/answers per packet instead of DNS's usual one. -// parse_dns() already only looks at the first question, which is true -// here too; the only real difference worth a label is which protocol -// this traffic actually is, so real-world capture output doesn't read -// "DNS" for traffic that never touched a resolver. -namespace wireframe::net { - -inline constexpr std::uint16_t kMdnsPort = 5353; - -class MdnsDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kMdnsPort; } - - std::optional summarize(std::span payload) const override { - auto msg = parse_dns(payload); - if (!msg) return std::nullopt; - - // No id= field here unlike DnsDissector's summary: RFC 6762 - // 18.1 has multicast queries send it as zero, so printing it - // would just be "id=0" noise on real traffic. - std::string out = "mDNS "; - out += msg->header.is_response ? "response" : "query"; - 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/ssh.hpp b/include/wireframe/l7/ssh.hpp deleted file mode 100644 index efa471f..0000000 --- a/include/wireframe/l7/ssh.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/l7/dissector.hpp" - -// SSH's identification exchange (RFC 4253 section 4.2) is the one part -// of an SSH connection sent in the clear, before key exchange starts -// encrypting everything: both sides open with a single line of the -// form "SSH-protoversion-softwareversion[ comments]" terminated by -// CR LF (a bare LF is tolerated too, same leniency this project's HTTP -// dissector already uses). Only that first line is ever readable -- -// everything after key exchange is opaque, so this dissector only ever -// has one line to look at, on either side of the connection. -namespace wireframe::net { - -inline constexpr std::uint16_t kSshPort = 22; - -struct SshBanner { - std::string proto_version; - std::string software_version; -}; - -inline std::optional parse_ssh_banner(std::span payload) { - std::string_view text(reinterpret_cast(payload.data()), payload.size()); - if (text.substr(0, 4) != "SSH-") return std::nullopt; - - std::size_t line_end = text.find("\r\n"); - if (line_end == std::string_view::npos) { - line_end = text.find('\n'); - if (line_end == std::string_view::npos) return std::nullopt; - } - std::string_view line = text.substr(4, line_end - 4); // past "SSH-" - - std::size_t dash = line.find('-'); - if (dash == std::string_view::npos) return std::nullopt; - - SshBanner banner; - banner.proto_version = std::string(line.substr(0, dash)); - - // The software version runs up to the first space (start of an - // optional comment) or the end of the line, whichever is first. - std::string_view rest = line.substr(dash + 1); - std::size_t space = rest.find(' '); - banner.software_version = std::string(space == std::string_view::npos ? rest - : rest.substr(0, space)); - return banner; -} - -class SshDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kSshPort; } - - std::optional summarize(std::span payload) const override { - auto banner = parse_ssh_banner(payload); - if (!banner) return std::nullopt; - return "SSH " + banner->proto_version + " " + banner->software_version; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/tls.hpp b/include/wireframe/l7/tls.hpp deleted file mode 100644 index 1c6dc57..0000000 --- a/include/wireframe/l7/tls.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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 server_name; // SNI, if the extension was present and well-formed -}; - -inline std::optional parse_tls_client_hello(std::span 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(5) + record_len) return std::nullopt; - - std::span 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(handshake[1]) << 16) | - (static_cast(handshake[2]) << 8) | - static_cast(handshake[3]); - - std::span 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(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(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(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 summarize(std::span 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/checksum.hpp b/include/wireframe/net/checksum.hpp deleted file mode 100644 index 97e5254..0000000 --- a/include/wireframe/net/checksum.hpp +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "wireframe/net/ipv4.hpp" - -// RFC 1071 Internet checksum, and the IPv4/TCP/UDP verification built -// on it. Not wired into summarize_packet(): on loopback, and for many -// packets captured right as they leave the local machine, the -// transmitted checksum is legitimately 0x0000 or garbage - modern -// NICs compute it in hardware ("checksum offload") only once the frame -// actually reaches them, which is *after* most capture points see it. -// Flagging that as "BAD" by default would be noise, not signal, on -// exactly the interfaces this project has been tested against all -// session (lo, tailscale0). Wireshark makes this opt-in for the same -// reason; so does this (CLI's -c flag calls these directly). -namespace wireframe::net { - -// One's-complement sum of 16-bit big-endian words, folded back into 16 -// bits, then complemented. Used identically by IPv4's header checksum -// and, over a pseudo-header + segment instead of a plain header, by -// TCP/UDP. -inline std::uint16_t internet_checksum(std::span data) { - std::uint32_t sum = 0; - std::size_t i = 0; - for (; i + 1 < data.size(); i += 2) { - sum += (static_cast(data[i]) << 8) | data[i + 1]; - } - if (i < data.size()) { - sum += static_cast(data[i]) << 8; // odd trailing byte: high half only - } - while (sum >> 16) { - sum = (sum & 0xFFFFu) + (sum >> 16); - } - return static_cast(~sum & 0xFFFFu); -} - -// `header_bytes` must be exactly the IPv4 header as it appeared on the -// wire (IHL*4 bytes, options included, checksum field included as its -// real transmitted value - not zeroed). Summing a header that already -// contains its own valid checksum comes out to exactly 0; that's the -// verification, no need for a mutable copy with the field zeroed out. -inline bool verify_ipv4_checksum(std::span header_bytes) { - return internet_checksum(header_bytes) == 0; -} - -enum class ChecksumResult { kValid, kInvalid, kNotPresent }; - -namespace detail { - -inline std::vector build_ipv4_pseudo_header(const Ipv4Address& src, - const Ipv4Address& dst, - std::uint8_t protocol, - std::span segment) { - std::vector buf; - buf.reserve(12 + segment.size()); - buf.insert(buf.end(), src.bytes.begin(), src.bytes.end()); - buf.insert(buf.end(), dst.bytes.begin(), dst.bytes.end()); - buf.push_back(0); - buf.push_back(protocol); - std::uint16_t len = static_cast(segment.size()); - buf.push_back(static_cast(len >> 8)); - buf.push_back(static_cast(len & 0xFF)); - buf.insert(buf.end(), segment.begin(), segment.end()); - return buf; -} - -} // namespace detail - -// TCP's checksum is mandatory - always kValid or kInvalid. -inline ChecksumResult verify_tcp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, - std::span tcp_segment) { - auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoTcp, tcp_segment); - return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; -} - -// UDP's checksum is optional over IPv4 (RFC 768): a transmitted value -// of exactly 0x0000 means "no checksum was computed", not "checksum is -// zero" - that's kNotPresent, not a failure. -inline ChecksumResult verify_udp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, - std::span udp_datagram) { - if (udp_datagram.size() >= 8 && udp_datagram[6] == 0 && udp_datagram[7] == 0) { - return ChecksumResult::kNotPresent; - } - auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoUdp, udp_datagram); - return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/ethernet.hpp b/include/wireframe/net/ethernet.hpp deleted file mode 100644 index 2da4cc8..0000000 --- a/include/wireframe/net/ethernet.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#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 bytes; -}; - -struct EthernetHeader { - MacAddress dst; - MacAddress src; - std::uint16_t ethertype; -}; - -struct EthernetFrame { - EthernetHeader header; - std::span payload; -}; - -inline std::optional parse_ethernet(std::span 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/icmp.hpp b/include/wireframe/net/icmp.hpp deleted file mode 100644 index af83916..0000000 --- a/include/wireframe/net/icmp.hpp +++ /dev/null @@ -1,84 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" - -// ICMPv4 (RFC 792) and ICMPv6 (RFC 4443) share the same first-4-byte -// shape (Type, Code, Checksum) but a completely different type -// namespace - the same numeric type means something different in each -// - so they get separate parse functions and separate type-name -// tables, sharing only the header struct shape. Neither protocol has -// ports, so this doesn't fit L7Registry's port-keyed dispatch at all; -// it's handled directly by protocol number in summarize.hpp instead. -namespace wireframe::net { - -struct IcmpHeader { - std::uint8_t type; - std::uint8_t code; - std::optional identifier; // echo request/reply only - std::optional sequence; // echo request/reply only -}; - -inline std::optional parse_icmpv4(std::span bytes) { - if (bytes.size() < 4) return std::nullopt; - - IcmpHeader header{}; - header.type = bytes[0]; - header.code = bytes[1]; - if ((header.type == 8 || header.type == 0) && bytes.size() >= 8) { // echo request/reply - header.identifier = read_be16(bytes, 4); - header.sequence = read_be16(bytes, 6); - } - return header; -} - -inline std::string icmpv4_type_name(std::uint8_t type) { - switch (type) { - case 0: return "Echo Reply"; - case 3: return "Destination Unreachable"; - case 4: return "Source Quench"; - case 5: return "Redirect"; - case 8: return "Echo Request"; - case 11: return "Time Exceeded"; - case 12: return "Parameter Problem"; - case 13: return "Timestamp Request"; - case 14: return "Timestamp Reply"; - default: return "type=" + std::to_string(type); - } -} - -inline std::optional parse_icmpv6(std::span bytes) { - if (bytes.size() < 4) return std::nullopt; - - IcmpHeader header{}; - header.type = bytes[0]; - header.code = bytes[1]; - if ((header.type == 128 || header.type == 129) && bytes.size() >= 8) { // echo request/reply - header.identifier = read_be16(bytes, 4); - header.sequence = read_be16(bytes, 6); - } - return header; -} - -inline std::string icmpv6_type_name(std::uint8_t type) { - switch (type) { - case 1: return "Destination Unreachable"; - case 2: return "Packet Too Big"; - case 3: return "Time Exceeded"; - case 4: return "Parameter Problem"; - case 128: return "Echo Request"; - case 129: return "Echo Reply"; - case 133: return "Router Solicitation"; - case 134: return "Router Advertisement"; - case 135: return "Neighbor Solicitation"; - case 136: return "Neighbor Advertisement"; - case 137: return "Redirect"; - default: return "type=" + std::to_string(type); - } -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/ipv4.hpp b/include/wireframe/net/ipv4.hpp deleted file mode 100644 index f53b4f2..0000000 --- a/include/wireframe/net/ipv4.hpp +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#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 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 payload; -}; - -inline std::optional parse_ipv4(std::span bytes) { - if (bytes.size() < 20) return std::nullopt; - - std::uint8_t version = static_cast(bytes[0] >> 4); - std::uint8_t ihl = bytes[0] & 0x0F; - std::size_t header_len = static_cast(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 deleted file mode 100644 index 4b6b28a..0000000 --- a/include/wireframe/net/ipv6.hpp +++ /dev/null @@ -1,173 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#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 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 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 parse_ipv6(std::span bytes) { - if (bytes.size() < kIpv6HeaderLen) return std::nullopt; - - std::uint8_t version = static_cast(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((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 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 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(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(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 groups{}; - for (std::size_t i = 0; i < 8; ++i) { - groups[i] = static_cast((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 deleted file mode 100644 index f691a7f..0000000 --- a/include/wireframe/net/tcp.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include - -#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 payload; -}; - -inline std::optional parse_tcp(std::span bytes) { - if (bytes.size() < 20) return std::nullopt; - - std::uint8_t data_offset = static_cast(bytes[12] >> 4); - std::size_t header_len = static_cast(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/tcp_reassembly.hpp b/include/wireframe/net/tcp_reassembly.hpp deleted file mode 100644 index 90824a4..0000000 --- a/include/wireframe/net/tcp_reassembly.hpp +++ /dev/null @@ -1,125 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "wireframe/net/ipv4.hpp" - -// Minimal, in-order-only TCP stream reassembly: tracks each flow's two -// directions separately, accumulating payload bytes as segments arrive -// exactly in sequence order. Out-of-order segments and retransmissions -// are dropped rather than buffered for later reordering - a real -// limitation, but a reasonable one for a learning-focused reassembler -// capturing directly on an endpoint (this project's demonstrated use -// all session: lo, wlp1s0, tailscale0), where segments mostly do -// arrive in order. A capture point far from either endpoint (e.g. a -// middlebox) would need real out-of-order buffering this doesn't do. -// -// The point: HTTP's dissector (wireframe/l7/http.hpp) only ever sees -// one segment at a time, so a request/response split across TCP -// segments - a Host: header landing in the second packet of a -// request, say - is invisible to it. Feeding the *reassembled* stream -// back through the same parse_http() lets it see what single-segment -// dissection structurally can't. -namespace wireframe::net { - -struct FlowKey { - Ipv4Address ip_a; - std::uint16_t port_a; - Ipv4Address ip_b; - std::uint16_t port_b; - - bool operator<(const FlowKey& other) const { - return std::tie(ip_a.bytes, port_a, ip_b.bytes, port_b) < - std::tie(other.ip_a.bytes, other.port_a, other.ip_b.bytes, other.port_b); - } -}; - -// Canonicalizes a (src, dst) pair into a direction-independent -// FlowKey - both directions of the same connection map to the same -// key - plus whether this segment's source was the "a" side. -inline std::pair canonicalize_flow(const Ipv4Address& src_ip, - std::uint16_t src_port, - const Ipv4Address& dst_ip, - std::uint16_t dst_port) { - bool src_is_a = std::tie(src_ip.bytes, src_port) < std::tie(dst_ip.bytes, dst_port); - FlowKey key = src_is_a ? FlowKey{src_ip, src_port, dst_ip, dst_port} - : FlowKey{dst_ip, dst_port, src_ip, src_port}; - return {key, src_is_a}; -} - -struct DirectionState { - bool syn_seen = false; - std::uint32_t next_seq = 0; - std::vector buffer; -}; - -struct FlowState { - DirectionState a_to_b; - DirectionState b_to_a; -}; - -class TcpReassembler { -public: - explicit TcpReassembler(std::size_t max_buffer_per_direction = 65536, - std::size_t max_flows = 4096) - : max_buffer_(max_buffer_per_direction), max_flows_(max_flows) {} - - // Feeds one TCP segment in. Returns a snapshot of the *sender's* - // accumulated stream so far if this segment extended it - // contiguously in order; nullopt if the segment was out of order, - // a retransmission, a control segment with no payload, or the flow - // table was full and this would be a brand new flow. Returned by - // value rather than by reference: the buffer this points at can - // grow/move on the next call, and bounded copies (max 64 KiB by - // default) are cheap enough that this isn't worth the lifetime risk. - std::optional> process_segment( - const Ipv4Address& src_ip, std::uint16_t src_port, const Ipv4Address& dst_ip, - std::uint16_t dst_port, std::uint32_t seq, std::uint8_t flags, - std::span payload) { - auto [key, src_is_a] = canonicalize_flow(src_ip, src_port, dst_ip, dst_port); - - auto it = flows_.find(key); - if (it == flows_.end()) { - if (flows_.size() >= max_flows_) return std::nullopt; // table full: drop new flows - it = flows_.emplace(key, FlowState{}).first; - } - DirectionState& dir = src_is_a ? it->second.a_to_b : it->second.b_to_a; - - constexpr std::uint8_t kSyn = 0x02; - if (flags & kSyn) { - dir.syn_seen = true; - dir.next_seq = seq + 1; // the SYN itself consumes one sequence number - return std::nullopt; - } - - // seq != dir.next_seq covers both out-of-order segments and - // retransmissions (a retransmit repeats a seq already below - // next_seq) - unsigned wraparound makes plain equality correct - // even across a sequence-number wrap, no need for RFC 1982 - // serial-number comparison for an exact-match check like this. - if (!dir.syn_seen || payload.empty() || seq != dir.next_seq) { - return std::nullopt; - } - - if (dir.buffer.size() + payload.size() <= max_buffer_) { - dir.buffer.insert(dir.buffer.end(), payload.begin(), payload.end()); - } - dir.next_seq = seq + static_cast(payload.size()); - - return dir.buffer; - } - - std::size_t flow_count() const { return flows_.size(); } - -private: - std::map flows_; - std::size_t max_buffer_; - std::size_t max_flows_; -}; - -} // namespace wireframe::net diff --git a/include/wireframe/net/udp.hpp b/include/wireframe/net/udp.hpp deleted file mode 100644 index 07664c2..0000000 --- a/include/wireframe/net/udp.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include -#include - -#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 payload; -}; - -inline std::optional parse_udp(std::span 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/packet_diagnostics.hpp b/include/wireframe/packet_diagnostics.hpp deleted file mode 100644 index 4b9b0c6..0000000 --- a/include/wireframe/packet_diagnostics.hpp +++ /dev/null @@ -1,92 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/l7/http.hpp" -#include "wireframe/net/checksum.hpp" -#include "wireframe/net/ethernet.hpp" -#include "wireframe/net/ipv4.hpp" -#include "wireframe/net/tcp.hpp" -#include "wireframe/net/tcp_reassembly.hpp" - -// Checksum validation and TCP stream reassembly are both deliberately -// kept out of summarize_packet()'s shared per-packet output - see -// wireframe/net/checksum.hpp and wireframe/net/tcp_reassembly.hpp for -// why each is opt-in (checksum offload false positives; reassembly's -// per-flow state and extra per-packet work). Shared between the CLI -// (-c/-a) and GUI frontends so they don't hand-roll two separate -// Ethernet/IPv4/TCP walks down to the same byte spans - the same -// reasoning wireframe::CaptureSession exists for at the setup layer. -namespace wireframe { - -inline std::string checksum_status(std::span bytes, int datalink) { - std::span ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return ""; - ip_bytes = eth->payload; - } - if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now - - auto ip = net::parse_ipv4(ip_bytes); - if (!ip) return ""; - - std::size_t header_len = static_cast(ip->header.ihl) * 4; - std::string out = "checksums: IP="; - out += net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; - - using net::ChecksumResult; - if (ip->header.protocol == net::kProtoTcp) { - auto result = net::verify_tcp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); - out += result == ChecksumResult::kValid ? " TCP=ok" : " TCP=BAD"; - } else if (ip->header.protocol == net::kProtoUdp) { - auto result = net::verify_udp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); - out += result == ChecksumResult::kValid ? " UDP=ok" - : result == ChecksumResult::kNotPresent ? " UDP=none" - : " UDP=BAD"; - } - return out; -} - -inline std::optional reassembled_http_status(std::span bytes, - int datalink, - net::TcpReassembler& reassembler) { - std::span ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return std::nullopt; - ip_bytes = eth->payload; - } - if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return std::nullopt; // IPv4 only, for now - - auto ip = net::parse_ipv4(ip_bytes); - if (!ip || ip->header.protocol != net::kProtoTcp) return std::nullopt; - - auto tcp = net::parse_tcp(ip->payload); - if (!tcp) return std::nullopt; - - auto reassembled = reassembler.process_segment(ip->header.src, tcp->header.src_port, - ip->header.dst, tcp->header.dst_port, - tcp->header.seq, tcp->header.flags, - tcp->payload); - if (!reassembled) return std::nullopt; - - auto http = net::parse_http(*reassembled); - if (!http) return std::nullopt; - - std::string out = "reassembled "; - out += http->is_request ? "request: " : "response: "; - out += http->method_or_version + " " + http->target_or_status; - if (http->host) out += " Host: " + *http->host; - out += " (" + std::to_string(reassembled->size()) + " bytes so far)"; - return out; -} - -} // namespace wireframe diff --git a/include/wireframe/pcapng/reader.hpp b/include/wireframe/pcapng/reader.hpp deleted file mode 100644 index d01b431..0000000 --- a/include/wireframe/pcapng/reader.hpp +++ /dev/null @@ -1,123 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -// 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 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 next_packet() { - for (;;) { - std::array 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 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(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(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 link_type() const { return link_type_; } - -private: - static std::uint32_t get_u32(std::span b) { - return static_cast(b[0]) | (static_cast(b[1]) << 8) | - (static_cast(b[2]) << 16) | (static_cast(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 link_type_; -}; - -} // namespace wireframe::pcapng diff --git a/include/wireframe/pcapng/writer.hpp b/include/wireframe/pcapng/writer.hpp deleted file mode 100644 index 18f6022..0000000 --- a/include/wireframe/pcapng/writer.hpp +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// 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 data, std::uint32_t original_len) { - std::uint64_t ts_us = static_cast(ts_sec) * 1'000'000ULL + ts_usec; - std::uint32_t ts_high = static_cast(ts_us >> 32); - std::uint32_t ts_low = static_cast(ts_us & 0xFFFFFFFFULL); - - std::size_t padded_len = (data.size() + 3) & ~std::size_t(3); - std::vector 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(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(v & 0xFF); - p[1] = static_cast((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((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((v >> (8 * i)) & 0xFF); - } - - void write_block(std::uint32_t type, std::span body) { - std::uint32_t total_len = static_cast(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/privileges.hpp b/include/wireframe/privileges.hpp deleted file mode 100644 index 69df725..0000000 --- a/include/wireframe/privileges.hpp +++ /dev/null @@ -1,87 +0,0 @@ -#pragma once - -#ifndef _WIN32 -#include -#include -#endif - -#include -#include -#include -#include -#include - -// After pcap_open_live() succeeds, the process has gotten everything -// CAP_NET_RAW exists for - running the rest of the program (decoding -// untrusted packet bytes, an interactive TUI/GUI event loop) as root -// from that point on is unnecessary exposure, and PLAN.md says as much -// directly: "Drop privileges immediately after opening the capture -// handle; use CAP_NET_RAW via file capabilities instead of running as -// root." -// -// The recommended path doesn't need this file at all: run -// `sudo setcap cap_net_raw+ep ` once, then invoke the binary -// directly, unprivileged, forever after - CAP_NET_RAW alone is enough -// for pcap_open_live(), no root required at any point. This exists for -// the case someone still runs the binary via sudo (out of habit, or -// because setcap isn't available/permitted in some environments): drop -// straight back to the invoking user immediately, so the rest of the -// process's lifetime - including any -w output file, which then ends -// up owned by that user instead of root - runs unprivileged either way. -namespace wireframe { - -// Drops from root to the user who actually invoked the program, via -// sudo's SUDO_UID/SUDO_GID (which sudo always sets). A no-op if not -// currently root, or if SUDO_UID isn't set (e.g. a genuine root login, -// not sudo - there's no "real" user to drop to in that case). -// -// setuid() to a nonzero UID also clears the process's Linux capability -// sets as a kernel-level side effect, so this covers both "running as -// root via sudo" and "root's own CAP_NET_RAW" the same way, without a -// separate libcap dependency. -// -// Returns an error message on failure. The drop is safety-critical: a -// failure here should be treated as fatal by the caller, not silently -// ignored while the process keeps running as root. -inline std::optional drop_privileges_if_root() { -#ifdef _WIN32 - return std::nullopt; // no POSIX privilege model to drop from -#else - if (geteuid() != 0) return std::nullopt; // already unprivileged - - const char* sudo_uid = std::getenv("SUDO_UID"); - const char* sudo_gid = std::getenv("SUDO_GID"); - if (sudo_uid == nullptr || sudo_gid == nullptr) { - return std::nullopt; // no safe target to drop to - } - - uid_t target_uid = static_cast(std::strtoul(sudo_uid, nullptr, 10)); - gid_t target_gid = static_cast(std::strtoul(sudo_gid, nullptr, 10)); - - // Order matters: groups and GID need root to change, so they must - // be dropped before UID - once UID is gone, so is the privilege - // to change the others. - if (setgroups(1, &target_gid) == -1) { - return "setgroups failed: " + std::string(std::strerror(errno)); - } - if (setgid(target_gid) == -1) { - return "setgid failed: " + std::string(std::strerror(errno)); - } - if (setuid(target_uid) == -1) { - return "setuid failed: " + std::string(std::strerror(errno)); - } - - // Defense in depth (standard advice from setuid-privilege-drop - // write-ups): confirm root can't be reclaimed. If the saved-UID - // was somehow left at 0, this would succeed and silently undo the - // drop - so a *successful* setuid(0) here means something is - // wrong, and is treated as the failure case. - if (setuid(0) != -1) { - return "failed to permanently drop root (setuid(0) unexpectedly succeeded)"; - } - - return std::nullopt; -#endif -} - -} // namespace wireframe diff --git a/include/wireframe/search.hpp b/include/wireframe/search.hpp deleted file mode 100644 index 4cb9203..0000000 --- a/include/wireframe/search.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -// 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 deleted file mode 100644 index 840ddf9..0000000 --- a/include/wireframe/summarize.hpp +++ /dev/null @@ -1,275 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include - -#include "wireframe/l7/dissector.hpp" -#include "wireframe/l7/dns.hpp" -#include "wireframe/l7/http.hpp" -#include "wireframe/l7/mdns.hpp" -#include "wireframe/l7/ssh.hpp" -#include "wireframe/l7/tls.hpp" -#include "wireframe/net/ethernet.hpp" -#include "wireframe/net/icmp.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. mDNS reuses -// DNS's own parser (same wire format, different port/label) at -// essentially no extra cost. SSH is the first dissector whose *entire* -// protocol is one cleartext line before everything else encrypts -- -// unlike TLS's SNI, there's nothing further to ever add here. -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::MdnsDissector mdns_dissector; - static const net::SshDissector ssh_dissector; - static const net::L7Registry registry = [] { - net::L7Registry r; - r.add(&dns_dissector); - r.add(&http_dissector); - r.add(&tls_dissector); - r.add(&mdns_dissector); - r.add(&ssh_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 l7_summarize(std::span 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 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::kProtoIcmp) { - if (auto icmp = net::parse_icmpv4(info.payload)) { - out += " | ICMP " + net::icmpv4_type_name(icmp->type); - if (icmp->identifier) { - out += " id=" + std::to_string(*icmp->identifier) + - " seq=" + std::to_string(*icmp->sequence); - } - } - } else if (info.proto == net::kNextHeaderIcmpv6) { - if (auto icmp = net::parse_icmpv6(info.payload)) { - out += " | ICMPv6 " + net::icmpv6_type_name(icmp->type); - if (icmp->identifier) { - out += " id=" + std::to_string(*icmp->identifier) + - " seq=" + std::to_string(*icmp->sequence); - } - } else { - out += " | ICMPv6"; // truncated: at least say what it is - } - } - 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 bytes, int datalink) { - std::span 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(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 hex_dump_lines(std::span bytes) { - std::vector 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(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(c) : '.'; - } - line += '|'; - - lines.push_back(std::move(line)); - } - return lines; -} - -} // namespace wireframe diff --git a/src/afpacket_capture.cpp b/src/afpacket_capture.cpp index 877bc88..0b5696f 100644 --- a/src/afpacket_capture.cpp +++ b/src/afpacket_capture.cpp @@ -36,8 +36,8 @@ #include #include -#include "wireframe/privileges.hpp" -#include "wireframe/summarize.hpp" +#include "packeteer/privileges.hpp" +#include "packeteer/summarize.hpp" namespace { @@ -130,8 +130,8 @@ int main(int argc, char** argv) { // Everything CAP_NET_RAW was needed for is done: socket created, // ring mapped, bound to the interface. Same drop-after-open - // principle as CaptureSession (wireframe/privileges.hpp). - if (auto err = wireframe::drop_privileges_if_root()) { + // principle as CaptureSession (packeteer/privileges.hpp). + if (auto err = packeteer::drop_privileges_if_root()) { std::fprintf(stderr, "failed to drop privileges: %s\n", err->c_str()); munmap(ring, ring_size); close(sock); @@ -171,7 +171,7 @@ int main(int argc, char** argv) { reinterpret_cast(header) + header->tp_mac; std::span bytes(packet_start, header->tp_snaplen); - std::printf("%s\n", wireframe::summarize_packet(bytes, DLT_EN10MB).c_str()); + std::printf("%s\n", packeteer::summarize_packet(bytes, DLT_EN10MB).c_str()); std::fflush(stdout); // Hand the slot back to the kernel so it can reuse it for a diff --git a/src/gui_main.cpp b/src/gui_main.cpp index 8a7771a..49f40dc 100644 --- a/src/gui_main.cpp +++ b/src/gui_main.cpp @@ -1,6 +1,6 @@ // 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 +// via packeteer::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. @@ -22,11 +22,11 @@ #include #include -#include "wireframe/capture_session.hpp" -#include "wireframe/net/tcp_reassembly.hpp" -#include "wireframe/packet_diagnostics.hpp" -#include "wireframe/search.hpp" -#include "wireframe/summarize.hpp" +#include "packeteer/capture_session.hpp" +#include "packeteer/net/tcp_reassembly.hpp" +#include "packeteer/packet_diagnostics.hpp" +#include "packeteer/search.hpp" +#include "packeteer/summarize.hpp" namespace { @@ -58,11 +58,11 @@ struct SharedState { std::atomic capture_alive{true}; }; -void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, - SharedState& state, wireframe::net::TcpReassembler* reassembler) { +void consumer_loop(packeteer::CaptureSession& session, packeteer::CaptureQueue& queue, + SharedState& state, packeteer::net::TcpReassembler* reassembler) { while (auto packet = queue.pop()) { std::span bytes{packet->data}; - std::string summary = wireframe::summarize_packet(bytes, session.datalink()); + std::string summary = packeteer::summarize_packet(bytes, session.datalink()); if (auto* writer = session.pcapng_writer()) { writer->write_packet(/*interface_id=*/0, packet->ts_sec, packet->ts_usec, bytes, @@ -71,7 +71,7 @@ void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& std::optional reassembled_http; if (reassembler) { - reassembled_http = wireframe::reassembled_http_status(bytes, session.datalink(), + reassembled_http = packeteer::reassembled_http_status(bytes, session.datalink(), *reassembler); } @@ -91,7 +91,7 @@ void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& void print_usage(const char* argv0) { std::printf( - "wireframe - terminal packet capture and analysis tool (GUI)\n" + "packeteer - terminal packet capture and analysis tool (GUI)\n" "\n" "Usage: %s [options] [interface]\n" "\n" @@ -127,7 +127,7 @@ int main(int argc, char** argv) { } } - wireframe::CaptureSessionOptions options; + packeteer::CaptureSessionOptions options; bool enable_checksums = false; bool enable_reassembly = false; for (int i = 1; i < argc; ++i) { @@ -146,7 +146,7 @@ int main(int argc, char** argv) { } } - wireframe::CaptureSession session; + packeteer::CaptureSession session; if (auto err = session.open(options)) { std::fprintf(stderr, "%s\n", err->c_str()); return 1; @@ -159,7 +159,7 @@ int main(int argc, char** argv) { } SDL_Window* window = - SDL_CreateWindow("wireframe", 1000, 650, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN); + SDL_CreateWindow("packeteer", 1000, 650, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN); if (window == nullptr) { std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); SDL_Quit(); @@ -181,9 +181,9 @@ int main(int argc, char** argv) { ImGui_ImplSDL3_InitForSDLRenderer(window, renderer); ImGui_ImplSDLRenderer3_Init(renderer); - wireframe::CaptureQueue queue(4096); + packeteer::CaptureQueue queue(4096); SharedState state; - wireframe::net::TcpReassembler reassembler; + packeteer::net::TcpReassembler reassembler; std::thread capture_thread = session.start_capture_thread(queue); std::thread consumer_thread(consumer_loop, std::ref(session), std::ref(queue), std::ref(state), enable_reassembly ? &reassembler : nullptr); @@ -219,7 +219,7 @@ int main(int argc, char** argv) { ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(io.DisplaySize); - ImGui::Begin("wireframe", nullptr, + ImGui::Begin("packeteer", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse); @@ -246,7 +246,7 @@ int main(int argc, char** argv) { { std::lock_guard 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; + if (!packeteer::matches_search(state.rows[i].summary, search_term)) continue; ++shown; // ImGui derives a widget's ID from its label text by @@ -276,7 +276,7 @@ int main(int argc, char** argv) { if (selected_row >= 0 && selected_row < static_cast(state.rows.size())) { const auto& row = state.rows[selected_row]; if (enable_checksums) { - std::string status = wireframe::checksum_status(row.data, session.datalink()); + std::string status = packeteer::checksum_status(row.data, session.datalink()); if (!status.empty()) { ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s", status.c_str()); } @@ -285,7 +285,7 @@ int main(int argc, char** argv) { ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%s", row.reassembled_http->c_str()); } - for (const auto& line : wireframe::hex_dump_lines(row.data)) { + for (const auto& line : packeteer::hex_dump_lines(row.data)) { ImGui::TextUnformatted(line.c_str()); } } else { diff --git a/src/main.cpp b/src/main.cpp index 72dd267..82b07a9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,7 +14,7 @@ // 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 +// (packeteer/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. // @@ -23,12 +23,12 @@ // traffic silently dropped once the decoder only handled IPv4. // // Stage 6: -f compiles a tcpdump-style BPF expression via -// libpcap's own compiler (wireframe/filter.hpp) and installs it with +// libpcap's own compiler (packeteer/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) +// goes through packeteer::CaptureSession (packeteer/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. @@ -47,11 +47,11 @@ #include #include -#include "wireframe/capture_session.hpp" -#include "wireframe/net/tcp_reassembly.hpp" -#include "wireframe/packet_diagnostics.hpp" -#include "wireframe/search.hpp" -#include "wireframe/summarize.hpp" +#include "packeteer/capture_session.hpp" +#include "packeteer/net/tcp_reassembly.hpp" +#include "packeteer/packet_diagnostics.hpp" +#include "packeteer/search.hpp" +#include "packeteer/summarize.hpp" namespace { @@ -62,7 +62,7 @@ namespace { constexpr std::size_t kQueueCapacity = 4096; void hex_dump(std::span bytes) { - for (const auto& line : wireframe::hex_dump_lines(bytes)) { + for (const auto& line : packeteer::hex_dump_lines(bytes)) { std::printf("%s\n", line.c_str()); } std::printf("\n"); @@ -72,15 +72,15 @@ struct RenderOptions { bool verbose_hex; bool verbose_checksums; int datalink; - wireframe::pcapng::Writer* pcapng_writer; - std::string search_term; // display filter - see wireframe/search.hpp - wireframe::net::TcpReassembler* reassembler; // -a only; nullptr means disabled + packeteer::pcapng::Writer* pcapng_writer; + std::string search_term; // display filter - see packeteer/search.hpp + packeteer::net::TcpReassembler* reassembler; // -a only; nullptr means disabled }; -void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& opts) { +void render_packet(const packeteer::CapturedPacket& packet, const RenderOptions& opts) { std::span bytes{packet.data}; - std::string line = wireframe::summarize_packet(bytes, opts.datalink); + std::string line = packeteer::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 @@ -90,15 +90,15 @@ void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& packet.original_len); } - if (!wireframe::matches_search(line, opts.search_term)) return; + if (!packeteer::matches_search(line, opts.search_term)) return; if (opts.verbose_checksums) { - std::string status = wireframe::checksum_status(bytes, opts.datalink); + std::string status = packeteer::checksum_status(bytes, opts.datalink); if (!status.empty()) line += " " + status; } std::printf("%s\n", line.c_str()); if (opts.reassembler) { - if (auto status = wireframe::reassembled_http_status(bytes, opts.datalink, + if (auto status = packeteer::reassembled_http_status(bytes, opts.datalink, *opts.reassembler)) { std::printf(" [%s]\n", status->c_str()); } @@ -122,7 +122,7 @@ void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& // 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, +void run_tui(packeteer::CaptureSession& session, packeteer::CaptureQueue& queue, RenderOptions& opts) { using namespace ftxui; @@ -133,7 +133,7 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, 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 + // capture itself (packeteer/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; @@ -145,7 +145,7 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, std::thread consumer_thread([&] { while (auto packet = queue.pop()) { std::span bytes{packet->data}; - std::string line = wireframe::summarize_packet(bytes, opts.datalink); + std::string line = packeteer::summarize_packet(bytes, opts.datalink); if (opts.pcapng_writer) { opts.pcapng_writer->write_packet(/*interface_id=*/0, packet->ts_sec, @@ -182,7 +182,7 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, Elements lines; std::size_t shown = 0; for (const auto& row : rows) { - if (!wireframe::matches_search(row, search_term)) continue; + if (!packeteer::matches_search(row, search_term)) continue; lines.push_back(text(row)); ++shown; } @@ -209,8 +209,8 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, footer.push_back(text("search: " + search_term + (searching ? "_" : "")) | color(Color::Green)); } - std::string title = session.is_replay() ? ("wireframe - replaying " + session.device()) - : "wireframe - live capture"; + std::string title = session.is_replay() ? ("packeteer - replaying " + session.device()) + : "packeteer - live capture"; return vbox({ text(title) | bold | color(Color::Cyan), separator(), @@ -266,7 +266,7 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, void print_usage(const char* argv0) { std::printf( - "wireframe - terminal packet capture and analysis tool\n" + "packeteer - terminal packet capture and analysis tool\n" "\n" "Usage: %s [options] [interface]\n" "\n" @@ -314,7 +314,7 @@ int main(int argc, char** argv) { } } - wireframe::CaptureSessionOptions options; + packeteer::CaptureSessionOptions options; bool tui_mode = false; bool enable_reassembly = false; RenderOptions opts{.verbose_hex = false, @@ -346,7 +346,7 @@ int main(int argc, char** argv) { } } - wireframe::CaptureSession session; + packeteer::CaptureSession session; if (auto err = session.open(options)) { std::fprintf(stderr, "%s\n", err->c_str()); return 1; @@ -355,7 +355,7 @@ int main(int argc, char** argv) { opts.pcapng_writer = session.pcapng_writer(); session.install_signal_handlers(); - wireframe::net::TcpReassembler reassembler; + packeteer::net::TcpReassembler reassembler; if (enable_reassembly) opts.reassembler = &reassembler; if (!tui_mode) { @@ -368,7 +368,7 @@ int main(int argc, char** argv) { } } - wireframe::CaptureQueue queue(kQueueCapacity); + packeteer::CaptureQueue queue(kQueueCapacity); std::thread capture_thread = session.start_capture_thread(queue); if (tui_mode) { diff --git a/tests/test_byteio.cpp b/tests/test_byteio.cpp index 81fa741..3b31752 100644 --- a/tests/test_byteio.cpp +++ b/tests/test_byteio.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/byteio.hpp" +#include "packeteer/byteio.hpp" -using namespace wireframe; +using namespace packeteer; TEST_CASE("read_be16 reads a big-endian 16-bit value") { std::vector bytes = {0x12, 0x34}; diff --git a/tests/test_capture_queue.cpp b/tests/test_capture_queue.cpp index da2da28..7d56fe0 100644 --- a/tests/test_capture_queue.cpp +++ b/tests/test_capture_queue.cpp @@ -4,9 +4,9 @@ #include #include -#include "wireframe/capture_queue.hpp" +#include "packeteer/capture_queue.hpp" -using namespace wireframe; +using namespace packeteer; TEST_CASE("try_push/pop returns packets in FIFO order") { CaptureQueue queue(4); diff --git a/tests/test_capture_session.cpp b/tests/test_capture_session.cpp index 691131a..dc5f424 100644 --- a/tests/test_capture_session.cpp +++ b/tests/test_capture_session.cpp @@ -6,10 +6,10 @@ #include #include -#include "wireframe/capture_session.hpp" -#include "wireframe/pcapng/writer.hpp" +#include "packeteer/capture_session.hpp" +#include "packeteer/pcapng/writer.hpp" -using namespace wireframe; +using namespace packeteer; namespace { @@ -20,7 +20,7 @@ struct TempPcapngFile { std::string path; explicit TempPcapngFile(int link_type, const std::vector>& packets) { - char path_template[] = "/tmp/wireframe_test_XXXXXX"; + char path_template[] = "/tmp/packeteer_test_XXXXXX"; int fd = mkstemp(path_template); REQUIRE(fd != -1); path = path_template; diff --git a/tests/test_checksum.cpp b/tests/test_checksum.cpp index 1295499..5ebaea6 100644 --- a/tests/test_checksum.cpp +++ b/tests/test_checksum.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/net/checksum.hpp" +#include "packeteer/net/checksum.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_dns.cpp b/tests/test_dns.cpp index 9a389bb..db5de56 100644 --- a/tests/test_dns.cpp +++ b/tests/test_dns.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/l7/dns.hpp" +#include "packeteer/l7/dns.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_filter.cpp b/tests/test_filter.cpp index 1dd9373..9a0ca69 100644 --- a/tests/test_filter.cpp +++ b/tests/test_filter.cpp @@ -1,7 +1,7 @@ #include #include -#include "wireframe/filter.hpp" +#include "packeteer/filter.hpp" namespace { @@ -23,7 +23,7 @@ TEST_CASE("compile_filter accepts a valid tcpdump-style expression") { REQUIRE(dead.handle != nullptr); bpf_program prog{}; - auto err = wireframe::compile_filter(dead.handle, "tcp port 80", &prog); + auto err = packeteer::compile_filter(dead.handle, "tcp port 80", &prog); CHECK_FALSE(err.has_value()); pcap_freecode(&prog); } @@ -33,7 +33,7 @@ TEST_CASE("compile_filter accepts a compound expression") { REQUIRE(dead.handle != nullptr); bpf_program prog{}; - auto err = wireframe::compile_filter(dead.handle, "host 10.0.0.1 and not icmp", &prog); + auto err = packeteer::compile_filter(dead.handle, "host 10.0.0.1 and not icmp", &prog); CHECK_FALSE(err.has_value()); pcap_freecode(&prog); } @@ -43,7 +43,7 @@ TEST_CASE("compile_filter rejects invalid syntax with an error message") { REQUIRE(dead.handle != nullptr); bpf_program prog{}; - auto err = wireframe::compile_filter(dead.handle, "this is not a valid filter !!", &prog); + auto err = packeteer::compile_filter(dead.handle, "this is not a valid filter !!", &prog); REQUIRE(err.has_value()); CHECK_FALSE(err->empty()); } @@ -53,7 +53,7 @@ TEST_CASE("compile_filter works against DLT_RAW, not just Ethernet") { REQUIRE(dead.handle != nullptr); bpf_program prog{}; - auto err = wireframe::compile_filter(dead.handle, "udp", &prog); + auto err = packeteer::compile_filter(dead.handle, "udp", &prog); CHECK_FALSE(err.has_value()); pcap_freecode(&prog); } @@ -64,6 +64,6 @@ TEST_CASE("compile_filter rejects an Ethernet-only expression against DLT_RAW") 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); + auto err = packeteer::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 index 7ccc9ff..2fb532d 100644 --- a/tests/test_http.cpp +++ b/tests/test_http.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/l7/http.hpp" +#include "packeteer/l7/http.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { @@ -69,10 +69,10 @@ 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 bytes = to_bytes("GET /path HTTP/1.1\r\nHost: packeteer.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); + CHECK(summary->find("packeteer.test") != std::string::npos); } diff --git a/tests/test_icmp.cpp b/tests/test_icmp.cpp index 3dd713e..520e12d 100644 --- a/tests/test_icmp.cpp +++ b/tests/test_icmp.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/net/icmp.hpp" +#include "packeteer/net/icmp.hpp" -using namespace wireframe::net; +using namespace packeteer::net; TEST_CASE("parse_icmpv4 decodes an echo request with identifier/sequence") { std::vector bytes = {8, 0, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x01}; diff --git a/tests/test_ipv6.cpp b/tests/test_ipv6.cpp index 438fadc..1cce85b 100644 --- a/tests/test_ipv6.cpp +++ b/tests/test_ipv6.cpp @@ -2,10 +2,10 @@ #include -#include "wireframe/net/ipv4.hpp" // for kProtoTcp -#include "wireframe/net/ipv6.hpp" +#include "packeteer/net/ipv4.hpp" // for kProtoTcp +#include "packeteer/net/ipv6.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_mdns.cpp b/tests/test_mdns.cpp index cad77e7..b095fd7 100644 --- a/tests/test_mdns.cpp +++ b/tests/test_mdns.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/l7/mdns.hpp" +#include "packeteer/l7/mdns.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_net.cpp b/tests/test_net.cpp index 09de9b0..19667da 100644 --- a/tests/test_net.cpp +++ b/tests/test_net.cpp @@ -2,12 +2,12 @@ #include -#include "wireframe/net/ethernet.hpp" -#include "wireframe/net/ipv4.hpp" -#include "wireframe/net/tcp.hpp" -#include "wireframe/net/udp.hpp" +#include "packeteer/net/ethernet.hpp" +#include "packeteer/net/ipv4.hpp" +#include "packeteer/net/tcp.hpp" +#include "packeteer/net/udp.hpp" -using namespace wireframe::net; +using namespace packeteer::net; TEST_CASE("parse_ethernet decodes header fields and leaves the right payload") { std::vector bytes = { diff --git a/tests/test_pcapng.cpp b/tests/test_pcapng.cpp index f292d32..aa94167 100644 --- a/tests/test_pcapng.cpp +++ b/tests/test_pcapng.cpp @@ -3,10 +3,10 @@ #include #include -#include "wireframe/pcapng/reader.hpp" -#include "wireframe/pcapng/writer.hpp" +#include "packeteer/pcapng/reader.hpp" +#include "packeteer/pcapng/writer.hpp" -using namespace wireframe::pcapng; +using namespace packeteer::pcapng; TEST_CASE("pcapng writer/reader round-trip a single packet") { std::FILE* f = std::tmpfile(); diff --git a/tests/test_privileges.cpp b/tests/test_privileges.cpp index 287d654..99951b8 100644 --- a/tests/test_privileges.cpp +++ b/tests/test_privileges.cpp @@ -1,7 +1,7 @@ #include #include -#include "wireframe/privileges.hpp" +#include "packeteer/privileges.hpp" // The actual drop sequence (setuid/setgid) can only be meaningfully // exercised by literally running as root, which a unit test shouldn't @@ -14,5 +14,5 @@ // touch privileges it doesn't have. TEST_CASE("drop_privileges_if_root is a no-op when not running as root") { if (geteuid() == 0) return; // this test only makes sense unprivileged - CHECK_FALSE(wireframe::drop_privileges_if_root().has_value()); + CHECK_FALSE(packeteer::drop_privileges_if_root().has_value()); } diff --git a/tests/test_search.cpp b/tests/test_search.cpp index ed687e0..f1c7580 100644 --- a/tests/test_search.cpp +++ b/tests/test_search.cpp @@ -1,8 +1,8 @@ #include -#include "wireframe/search.hpp" +#include "packeteer/search.hpp" -using namespace wireframe; +using namespace packeteer; 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")); diff --git a/tests/test_ssh.cpp b/tests/test_ssh.cpp index 7c4e339..7201c0e 100644 --- a/tests/test_ssh.cpp +++ b/tests/test_ssh.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/l7/ssh.hpp" +#include "packeteer/l7/ssh.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp index d10053d..ac6f1c0 100644 --- a/tests/test_summarize.cpp +++ b/tests/test_summarize.cpp @@ -4,7 +4,7 @@ #include #include -#include "wireframe/summarize.hpp" +#include "packeteer/summarize.hpp" namespace { @@ -28,7 +28,7 @@ std::vector ethernet_ipv4_udp_dns_frame() { std::vector ip(20, 0); ip[0] = 0x45; ip[8] = 64; // ttl - ip[9] = wireframe::net::kProtoUdp; // proto + ip[9] = packeteer::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 @@ -62,7 +62,7 @@ std::vector ethernet_ipv4_tcp_http_frame() { std::vector ip(20, 0); ip[0] = 0x45; ip[8] = 64; // ttl - ip[9] = wireframe::net::kProtoTcp; // proto + ip[9] = packeteer::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 @@ -91,7 +91,7 @@ std::vector ethernet_ipv6_hopbyhop_tcp_frame() { tcp[13] = 0x02; // SYN std::vector hop_by_hop = { - static_cast(wireframe::net::kProtoTcp), + static_cast(packeteer::net::kProtoTcp), 0x00, // hdr_ext_len = 0 -> total length (0+1)*8 = 8 bytes 0, 0, 0, 0, 0, 0, // option padding }; @@ -101,7 +101,7 @@ std::vector ethernet_ipv6_hopbyhop_tcp_frame() { std::uint16_t payload_len = static_cast(hop_by_hop.size() + tcp.size()); ip6[4] = static_cast(payload_len >> 8); ip6[5] = static_cast(payload_len & 0xFF); - ip6[6] = wireframe::net::kNextHeaderHopByHop; + ip6[6] = packeteer::net::kNextHeaderHopByHop; ip6[7] = 64; // hop_limit ip6[23] = 0x01; // src = ::1 ip6[39] = 0x01; // dst = ::1 @@ -122,7 +122,7 @@ std::vector ethernet_ipv6_hopbyhop_tcp_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); + auto line = packeteer::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" @@ -130,7 +130,7 @@ TEST_CASE("summarize_packet walks a Hop-by-Hop extension header to reach TCP") { } 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); + auto line = packeteer::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" @@ -139,7 +139,7 @@ TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/TCP/HTTP frame end to e } 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); + auto line = packeteer::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" @@ -149,9 +149,9 @@ TEST_CASE("summarize_packet decodes a full Ethernet/IPv4/UDP/DNS frame end to en TEST_CASE("summarize_packet on DLT_RAW skips the Ethernet line entirely") { auto frame = ethernet_ipv4_udp_dns_frame(); - std::vector raw(frame.begin() + wireframe::net::kEthernetHeaderLen, frame.end()); + std::vector raw(frame.begin() + packeteer::net::kEthernetHeaderLen, frame.end()); - auto line = wireframe::summarize_packet(raw, DLT_RAW); + auto line = packeteer::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); @@ -159,7 +159,7 @@ TEST_CASE("summarize_packet on DLT_RAW skips the Ethernet line entirely") { TEST_CASE("summarize_packet reports a truncated Ethernet frame without decoding further") { std::vector bytes(10, 0); // shorter than the 14-byte header - auto line = wireframe::summarize_packet(bytes, DLT_EN10MB); + auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); CHECK(line == "[10 bytes] truncated ethernet frame"); } @@ -168,7 +168,7 @@ TEST_CASE("summarize_packet stops after the Ethernet line for a non-IP ethertype 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); + auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0806"); } @@ -176,7 +176,7 @@ TEST_CASE("hex_dump_lines produces one line per 16 bytes, with the right byte co std::vector bytes(20, 0); for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast(i); - auto lines = wireframe::hex_dump_lines(bytes); + auto lines = packeteer::hex_dump_lines(bytes); REQUIRE(lines.size() == 2); CHECK(lines[0].substr(0, 6) == "000000"); CHECK(lines[1].substr(0, 6) == "000010"); diff --git a/tests/test_tcp_reassembly.cpp b/tests/test_tcp_reassembly.cpp index b424610..192f12b 100644 --- a/tests/test_tcp_reassembly.cpp +++ b/tests/test_tcp_reassembly.cpp @@ -3,11 +3,11 @@ #include #include -#include "wireframe/l7/http.hpp" -#include "wireframe/net/tcp.hpp" -#include "wireframe/net/tcp_reassembly.hpp" +#include "packeteer/l7/http.hpp" +#include "packeteer/net/tcp.hpp" +#include "packeteer/net/tcp_reassembly.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { diff --git a/tests/test_tls.cpp b/tests/test_tls.cpp index 65784a9..7a3ad27 100644 --- a/tests/test_tls.cpp +++ b/tests/test_tls.cpp @@ -2,9 +2,9 @@ #include -#include "wireframe/l7/tls.hpp" +#include "packeteer/l7/tls.hpp" -using namespace wireframe::net; +using namespace packeteer::net; namespace { @@ -124,9 +124,9 @@ TEST_CASE("TlsSniDissector claims port 443 and its summary matches parse_tls_cli TlsSniDissector dissector; CHECK(dissector.port() == kTlsPort); - auto record = build_client_hello("wireframe.test"); + auto record = build_client_hello("packeteer.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); + CHECK(summary->find("SNI=packeteer.test") != std::string::npos); } -- cgit v1.2.3