srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/PLAN.md
blob: ee96d15818cae780b4e30e13b20bf009b22e4c69 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# wireframe - Packet Analyzer / Network TUI

## Overview
Terminal packet capture and analysis tool. Primary goal: learn the C++
memory model (byte layout, alignment, endianness, `std::span` over
unowned buffers) via a real-world capture pipeline.

## Stack
- Language: C++ (first of two C++ projects - build this one first)
- Capture: libpcap, or raw `AF_PACKET` with an mmap'd ring buffer to skip
  libpcap's copies
- Parsing: hand-rolled L2-L4 decoders over `std::span`, L7 dissectors as
  a small interface/vtable so protocols can be added incrementally
- Optional: `aya`-style in-kernel filtering isn't available in C++; if
  eBPF filtering is wanted later, that's a separate learning detour
- UI: TUI (library TBD - ftxui or notcurses are the usual C++ options)
- Output format: pcapng (not pcap) so interface metadata survives and
  files stay Wireshark-compatible

## Architecture sketch
- Capture thread (owns the pcap/AF_PACKET handle) -> bounded channel ->
  render/analysis thread. A traffic spike should drop packets, not
  block the UI.
- Drop privileges immediately after opening the capture handle; use
  `CAP_NET_RAW` via file capabilities instead of running as root.

## Build order
1. [done] Raw capture -> hex dump to stdout
2. [done] Ethernet/IP/TCP/UDP decoders + live packet list in TUI (-t)
3. [done] pcapng read/write
4. [done] Bounded channel + drop-on-backpressure between capture and render
5. [in progress] L7 dissector interface, add protocols incrementally --
   interface + DNS + HTTP + TLS SNI done (wireframe/l7/); more
   protocols can still be added incrementally, by design
6. [done] Filtering (-f <expr>, libpcap's own BPF compiler - see Decisions)

## Open questions
None currently open.

## Decisions
- TUI library: FTXUI (v7.0.3, fetched via CMake FetchContent). Chosen
  over notcurses for pure-C++ portability (no C build-system/dependency
  chain to fight on Gentoo/low-spec machines) and genuine native
  Windows console support, which notcurses lacks - both matter given
  this needs to work everywhere.
- GUI added as a secondary frontend - TUI stays primary (explicit
  user direction). Dear ImGui + SDL3 (v1.92.9b / release-3.4.14, both
  FetchContent, same approach as FTXUI/doctest - no system-package
  dependency, builds the same way everywhere). SDL3 over SDL2: this
  ecosystem already carries sdl2-compat as an SDL3-backed shim, so
  SDL3 is the live line, not legacy. SDL_Renderer backend, not raw
  OpenGL3 - avoids needing a separate GL function loader as another
  dependency, which matters more here than raw rendering performance
  does. src/gui_main.cpp; parity with the CLI/TUI is structural, not
  incidental - all three go through the same wireframe::CaptureSession
  (wireframe/capture_session.hpp) for device-open/datalink-validate/
  filter/pcapng/signal-handler setup, so the GUI can't silently skip a
  step (e.g. the DLT_RAW check) the way two hand-copied setups would
  eventually drift.
- Tests: doctest (v2.5.3, FetchContent), tests/ mirrors include/wireframe/.
  Every module gets unit tests as it's built, not backfilled later --
  `cmake --build build && ./build/wireframe_tests` (or `ctest`) should
  stay green at every commit.
- Filtering: libpcap's own pcap_compile()/pcap_setfilter() (tcpdump
  syntax, kernel-level via BPF), not a hand-rolled parser - the
  parser/compiler already exists, is correct, and reimplementing it has
  no bearing on this project's actual goal (the C++ memory model).
  wireframe/filter.hpp wraps compilation; testable without root via
  pcap_open_dead(). Verified live: -f "tcp port N" and -f icmp each
  correctly suppressed non-matching traffic that was actually present.
- pcap_stats(): CaptureSession::stats() surfaces kernel/interface-level
  drops (ps_recv/ps_drop/ps_ifdrop), shown in CLI/TUI/GUI whenever
  nonzero. Distinct from CaptureQueue::dropped() - verified live that
  the two really do measure different things: a short capture showed
  ps_recv=12 against only 4 packets actually rendered, i.e. packets the
  kernel had already received but that were never dispatched to our
  callback before shutdown, with queue-side drops at 0 throughout.
- Fuzzing: libFuzzer harnesses (fuzz/, clang + ASan/UBSan, opt-in via
  -DWIREFRAME_ENABLE_FUZZING=ON -DCMAKE_CXX_COMPILER=clang++, separate
  build-fuzz/ dir) for every hand-rolled decoder plus the pcapng reader
  and the full summarize_packet() pipeline - the highest-value tests
  in the repo given the project's actual goal (byte layout/alignment/
  UB on parsers over untrusted bytes), not an afterthought. Found and
  fixed a real bug on the first run: Reader::next_packet() allocated a
  block's claimed size (an untrusted 32-bit field straight from the
  file) before validating it, so a corrupted/hostile pcapng file could
  OOM the process. Fixed with a 1 MiB body-size cap (reader.hpp is
  explicitly scoped to pair with our own writer, whose packets are
  capped at a 65535 snaplen, so this is generous, not tight) and locked
  in with both a unit test and a passing re-fuzz of the exact crashing
  input. ~23M total fuzz executions across all 8 harnesses this
  session, one bug found and fixed, zero remaining crashes.
- HTTP L7 dissector (wireframe/l7/http.hpp): best-effort single-segment
  request/status-line parse (+ Host: header for requests), same scope
  DNS already has - no TCP stream reassembly, so a message split
  across packets is only partially visible. This is the first
  registered dissector to actually exercise L7Registry's TCP-payload
  path; DNS alone never did, since it only runs over UDP. Verified live
  against a real HTTP request/response (curl -> python http.server on
  port 80): both directions decoded correctly, including the
  dst-port-then-src-port fallback in l7_summarize (request matches on
  dst_port=80, response matches on src_port=80). Fuzzed separately
  (fuzz_http.cpp, 5.3M runs, no crashes) since the string_view request-
  line/header scanning is new hand-rolled logic distinct from anything
  fuzz_summarize's binary-format parsers already cover.
- TLS SNI L7 dissector (wireframe/l7/tls.hpp): parses a ClientHello's
  record/handshake/extensions structure (nested TLVs, every length
  bounds-checked against attacker-influenced fields at every level --
  the most structurally complex hand-rolled parser in the project) to
  extract the SNI extension. Answers what HTTP alone increasingly
  can't: most web traffic is TLS-encrypted, and the server name is the
  one thing still readable in cleartext, in every TLS version, before
  encryption starts. Same single-segment scope as DNS/HTTP. Verified
  against real, unsolicited internet traffic captured live on wlp1s0
  (not loopback/synthetic) - correctly extracted a genuine SNI from a
  real ClientHello. Fuzzed the hardest of any target so far given the
  nesting depth: fuzz_tls.cpp, 25.7M runs, no crashes.
- IPv6 extension headers: walk_ipv6_extension_headers() (ipv6.hpp)
  walks Hop-by-Hop, Routing, Destination Options, Fragment, and AH to
  find the real transport protocol underneath them, so e.g. TCP wrapped
  in a Hop-by-Hop options header is decoded instead of silently
  stopping. ESP is a deliberate hard stop, not an oversight: its own
  next-header field lives in a trailer after the encrypted payload, at
  an offset unknowable without decrypting first - reported as
  "ESP (encrypted)" rather than guessed at. parse_ipv6() itself stays
  an unconditional decode of just the fixed 40-byte header; the walk is
  a separate, composable function summarize.hpp calls, so parse_ipv6's
  existing tests didn't need to change. Verified end-to-end (a
  Hop-by-Hop-wrapped TCP frame decodes through to the TCP layer via
  summarize_packet, not just the walker in isolation) and fuzzed
  (extended fuzz_ipv6.cpp, 6.3M runs; fuzz_summarize.cpp indirectly
  covers it too, 4.3M more) - no crashes. This was the last item on
  the known-gaps list; none remain.
- Post-capture search: wireframe/search.hpp's matches_search() is a
  display filter, deliberately distinct from -f's capture filter --
  -f decides what's captured (and written to -w); search decides what's
  shown, without touching either, same distinction Wireshark draws
  between a capture filter and a display filter. CLI: -g <term> (only
  suppresses what's printed; -w output is unaffected). TUI: '/' opens
  live-filtered search (Enter keeps the filter and returns to
  browsing, Esc clears it), verified interactively via a real terminal
  (tmux capture-pane) - typing, backspace, both Enter and Esc paths,
  and confirmed 'q' still quits correctly afterward. GUI: a search box
  next to the capture-info line, using io.WantCaptureKeyboard to route
  Esc to "clear the search" while the box has focus vs. "quit the app"
  otherwise - verified visually via Xvfb, including the focused/
  unfocused Esc distinction actually working both ways.
  One real methodology lesson from building this: an initial pty-based
  interactive test of the TUI (raw-byte capture, regex-matched against
  unparsed ANSI escape sequences) appeared to show a redraw bug --
  typing "abc" only ever displayed "a". Chasing it added an unnecessary
  PostEvent "fix" before re-verifying under tmux (which properly
  resolves escape sequences via a real terminal emulator) showed the
  original code was correct all along; the first test method just
  wasn't reliable enough to trust. The PostEvent change was reverted --
  correct code, not narrowly-passing code, was the actual goal.
- Replay mode (-r <file>): reads a previously-saved pcapng file back
  through the exact same CaptureQueue/render/search pipeline as a live
  capture - the render/consumer side only ever talks to a
  CaptureQueue, so it can't tell whether packets are arriving from
  pcap_loop or being read back from disk. All in CaptureSession, so
  every frontend gets it for free rather than needing a second code
  path. Reader gained link_type() (the datalink from the file's IDB,
  previously discarded) so replayed packets decode with the correct
  DLT_EN10MB/DLT_RAW branch instead of an assumption; CaptureQueue
  gained a blocking push() alongside the existing drop-on-full
  try_push(), because a live capture thread can't be allowed to stall
  but a file has no real-time pressure forcing a drop - dropping from
  what's supposed to be a faithful replay of a fixed record would
  defeat the point of replaying it. -f (capture filter) is rejected
  outright when combined with -r, with an actionable error pointing at
  -g, rather than silently ignored.
  Verified live end-to-end, not just via unit tests: captured real
  traffic with -w on both DLT_EN10MB (lo) and DLT_RAW (tailscale0),
  replayed each file with -r with no root/live device needed, and the
  output matched the original capture exactly, including L7 dissection
  (DNS) surviving the round-trip. The replay thread finishing (not
  killed via request_stop()) means the file is exhausted, not that the
  user wants to quit - CaptureSession::stop_requested() distinguishes
  the two, and TUI/GUI both leave the window open on natural
  end-of-file (the point of replaying into an interactive frontend is
  browsing/searching afterward, not watching it flash by), closing only
  on an explicit 'q'/Esc/window-close or an external signal. Verified
  interactively in both: tmux capture-pane confirmed the TUI stays open
  with "[replay finished]" shown, search still works against the
  now-static list, and 'q' closes it; Xvfb confirmed the same for the
  GUI, including a live process check across a multi-second wait to
  rule out a delayed auto-close.