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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
|
// Stage 2 (PLAN.md): Ethernet/IP/TCP/UDP decoders producing a live
// packet-list line per capture. The TUI itself is still an open
// question (PLAN.md), so this prints to stdout for now; -x keeps the
// stage-1 hex dump available underneath each summary.
//
// Stage 3: -w <file> writes the same capture out as pcapng alongside
// the summary, so files stay Wireshark-compatible (PLAN.md).
//
// Stage 4: capture thread -> bounded CaptureQueue -> render loop on
// the main thread (PLAN.md's architecture sketch). The capture thread
// only copies raw bytes into the queue; decoding, printing, and
// pcapng-writing all happen on the consumer side, so a slow render
// path can never block the capture thread - a full queue drops the
// packet and counts it instead.
//
// Stage 5: L7 dissectors register into an L7Registry keyed by port
// (wireframe/l7/dissector.hpp) and get consulted from summarize_packet
// once TCP/UDP decode a port number. DNS is the first one, proving the
// interface against real traffic rather than synthetic bytes.
//
// IPv6: dispatched by version nibble rather than assumed absent --
// every live-capture test so far has shown real IPv6 background
// traffic silently dropped once the decoder only handled IPv4.
//
// Stage 6: -f <expr> compiles a tcpdump-style BPF expression via
// libpcap's own compiler (wireframe/filter.hpp) and installs it with
// pcap_setfilter(), filtering in the kernel before packets ever reach
// userspace - rather than hand-rolling a second BPF parser.
//
// Device-open/datalink-validate/filter/pcapng/signal-handler setup all
// goes through wireframe::CaptureSession (wireframe/capture_session.hpp)
// - the same one gui_main.cpp uses - so the CLI/TUI and GUI frontends
// can't drift apart on that setup path.
#include <pcap.h>
#include <cstdio>
#include <cstring>
#include <deque>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <thread>
#include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>
#include "wireframe/capture_session.hpp"
#include "wireframe/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"
#include "wireframe/search.hpp"
#include "wireframe/summarize.hpp"
namespace {
// Queue capacity: how many packets can be buffered between the capture
// thread and the render loop before new packets get dropped. Sized as
// a fixed constant rather than a flag - tune later if a real workload
// needs it, not speculatively now.
constexpr std::size_t kQueueCapacity = 4096;
void hex_dump(std::span<const unsigned char> bytes) {
for (const auto& line : wireframe::hex_dump_lines(bytes)) {
std::printf("%s\n", line.c_str());
}
std::printf("\n");
}
// -c only: checksum validation isn't part of summarize_packet()'s
// shared output (see wireframe/net/checksum.hpp for why - checksum
// offload makes it noise, not signal, on most of the interfaces this
// project has actually been tested against). IPv4 only for now; this
// does its own minimal walk down to the IP/TCP/UDP byte spans the
// checksum functions need, reusing the existing decoders rather than
// duplicating their parsing logic.
std::string checksum_status(std::span<const unsigned char> bytes, int datalink) {
std::span<const unsigned char> ip_bytes;
if (datalink == DLT_RAW) {
ip_bytes = bytes;
} else {
auto eth = wireframe::net::parse_ethernet(bytes);
if (!eth || eth->header.ethertype != wireframe::net::kEthertypeIPv4) return "";
ip_bytes = eth->payload;
}
if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now
auto ip = wireframe::net::parse_ipv4(ip_bytes);
if (!ip) return "";
std::size_t header_len = static_cast<std::size_t>(ip->header.ihl) * 4;
std::string out = " checksums: IP=";
out += wireframe::net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD";
using wireframe::net::ChecksumResult;
if (ip->header.protocol == wireframe::net::kProtoTcp) {
auto result =
wireframe::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 == wireframe::net::kProtoUdp) {
auto result =
wireframe::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;
}
// -a only: TCP stream reassembly (wireframe/net/tcp_reassembly.hpp),
// re-run through the same HTTP dissector summarize_packet() already
// uses for a single segment - reassembly only helps when a message is
// actually split across packets, and HTTP is the L7 dissector in this
// project that's structured around lines/headers rather than one fixed
// datagram (DNS/TLS ClientHello are each their own single UDP datagram
// or first TCP segment already). Printed as its own line rather than
// folded into the per-packet summary: it reflects accumulated flow
// state, not just this one packet. In-order-only reassembly (see the
// header's own comment) means this can legitimately fire again on a
// later packet of the same request with an unchanged result once the
// headers are already complete - an honest simplification, not
// deduplicated further.
std::optional<std::string> reassembled_http_status(std::span<const unsigned char> bytes,
int datalink,
wireframe::net::TcpReassembler& reassembler) {
std::span<const unsigned char> ip_bytes;
if (datalink == DLT_RAW) {
ip_bytes = bytes;
} else {
auto eth = wireframe::net::parse_ethernet(bytes);
if (!eth || eth->header.ethertype != wireframe::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 = wireframe::net::parse_ipv4(ip_bytes);
if (!ip || ip->header.protocol != wireframe::net::kProtoTcp) return std::nullopt;
auto tcp = wireframe::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 = wireframe::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;
}
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
};
void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& opts) {
std::span<const unsigned char> bytes{packet.data};
std::string line = wireframe::summarize_packet(bytes, opts.datalink);
// -g is a display filter, not a capture filter: still written to
// -w regardless of whether it matches, since -w should reflect
// what was actually captured (that's -f's job), not what's shown.
if (opts.pcapng_writer) {
opts.pcapng_writer->write_packet(/*interface_id=*/0, packet.ts_sec, packet.ts_usec, bytes,
packet.original_len);
}
if (!wireframe::matches_search(line, opts.search_term)) return;
if (opts.verbose_checksums) line += checksum_status(bytes, opts.datalink);
std::printf("%s\n", line.c_str());
if (opts.reassembler) {
if (auto status = reassembled_http_status(bytes, opts.datalink, *opts.reassembler)) {
std::printf("%s\n", status->c_str());
}
}
if (opts.verbose_hex) hex_dump(bytes);
// Flush per packet: stdout is fully buffered off a tty, and this is
// a live capture tool, not a batch one.
std::fflush(stdout);
}
// TUI mode (-t): a scrolling packet list in a full-screen view, built
// with FTXUI (see NAMES.md-adjacent decision: chosen over notcurses for
// pure-C++ portability - no C build-system/dependency chain to fight
// on every platform PLAN.md targets, and genuine Windows console
// support, which notcurses lacks).
//
// A dedicated consumer thread pops from the capture queue and appends
// formatted rows to shared state; the UI thread just redraws on
// Event::Custom. 'q'/Esc triggers the same pcap_breakloop() shutdown
// path as Ctrl-C, so there's one shutdown sequence, not two: breakloop
// -> capture thread's pcap_loop returns -> queue.stop() -> consumer
// drains and calls screen.Exit() -> screen.Loop() returns.
void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue,
RenderOptions& opts) {
using namespace ftxui;
constexpr std::size_t kMaxRows = 2000; // cap memory; oldest rows scroll off
std::mutex state_mutex;
std::deque<std::string> rows;
std::uint64_t packet_count = 0;
// '/' search: a display filter over `rows`, independent of the
// capture itself (wireframe/search.hpp) - typed and read only on
// the UI thread (the consumer thread never touches it), so unlike
// `rows`/`packet_count` it doesn't need state_mutex.
bool searching = false;
std::string search_term;
bool replay_finished = false; // guarded by state_mutex, like rows/packet_count
auto screen = ScreenInteractive::Fullscreen();
std::thread consumer_thread([&] {
while (auto packet = queue.pop()) {
std::span<const unsigned char> bytes{packet->data};
std::string line = wireframe::summarize_packet(bytes, opts.datalink);
if (opts.pcapng_writer) {
opts.pcapng_writer->write_packet(/*interface_id=*/0, packet->ts_sec,
packet->ts_usec, bytes, packet->original_len);
}
{
std::lock_guard<std::mutex> lock(state_mutex);
rows.push_back(std::move(line));
if (rows.size() > kMaxRows) rows.pop_front();
++packet_count;
}
screen.PostEvent(Event::Custom);
}
// Replay reaching end-of-file on its own (stop_requested() still
// false) shouldn't close the window - the point of replaying a
// file is browsing/searching it afterward, not watching it flash
// by. An explicit stop (q/Esc below, or an external signal, both
// of which set stop_requested()) always closes, live capture
// included - that's still the same behavior as before.
if (session.is_replay() && !session.stop_requested()) {
{
std::lock_guard<std::mutex> lock(state_mutex);
replay_finished = true;
}
screen.PostEvent(Event::Custom); // one more redraw for the final state
} else {
screen.Exit();
}
});
auto renderer = Renderer([&] {
std::lock_guard<std::mutex> lock(state_mutex);
Elements lines;
std::size_t shown = 0;
for (const auto& row : rows) {
if (!wireframe::matches_search(row, search_term)) continue;
lines.push_back(text(row));
++shown;
}
std::string status = "packets: " + std::to_string(packet_count);
if (!search_term.empty()) status += " (" + std::to_string(shown) + " shown)";
if (replay_finished) status += " [replay finished]";
status += " dropped: " + std::to_string(queue.dropped()) +
(searching ? " (Enter to apply, Esc to clear)" : " (/ to search, q to quit)");
// Kernel/interface-level drops: a traffic spike can drop
// packets before libpcap ever hands them to our callback,
// which the queue-side counter above can't see.
Elements footer = {text(status) | dim};
if (auto stats = session.stats()) {
if (stats->dropped > 0 || stats->if_dropped > 0) {
std::string kernel_status = "kernel/interface dropped " +
std::to_string(stats->dropped) + "/" +
std::to_string(stats->if_dropped) + " (received " +
std::to_string(stats->received) + ")";
footer.push_back(text(kernel_status) | color(Color::Yellow));
}
}
if (searching || !search_term.empty()) {
footer.push_back(text("search: " + search_term + (searching ? "_" : "")) |
color(Color::Green));
}
std::string title = session.is_replay() ? ("wireframe - replaying " + session.device())
: "wireframe - live capture";
return vbox({
text(title) | bold | color(Color::Cyan),
separator(),
vbox(std::move(lines)) | yframe | flex,
separator(),
vbox(std::move(footer)),
}) |
border;
});
auto component = CatchEvent(renderer, [&](const Event& event) {
if (searching) {
if (event == Event::Return) {
searching = false;
return true;
}
if (event == Event::Escape) {
searching = false;
search_term.clear();
return true;
}
if (event == Event::Backspace) {
if (!search_term.empty()) search_term.pop_back();
return true;
}
if (event.is_character()) {
search_term += event.character();
return true;
}
return true; // swallow anything else while typing (don't let it fall through to quit)
}
if (event == Event::Character('/')) {
searching = true;
return true;
}
if (event == Event::Character('q') || event == Event::Escape) {
session.request_stop();
// Closes immediately rather than waiting for the capture/
// replay thread to actually finish and drain the queue --
// necessary for replay mode specifically (that thread may
// already be long gone once the user quits after browsing a
// finished replay, so nothing else would ever call this).
// main() still joins the thread properly afterward either way.
screen.Exit();
return true;
}
return false;
});
screen.Loop(component);
consumer_thread.join();
}
void print_usage(const char* argv0) {
std::printf(
"wireframe - terminal packet capture and analysis tool\n"
"\n"
"Usage: %s [options] [interface]\n"
"\n"
"If no interface is given, the first available device is used.\n"
"\n"
"Options:\n"
" -t, --tui Launch the interactive TUI instead of plain-text output\n"
" -x Show a hex dump under each summary (plain-text mode only)\n"
" -c Show IPv4/TCP/UDP checksum validity (plain-text mode only).\n"
" Off by default: checksum offload means many outbound and\n"
" loopback packets show as invalid even when nothing is\n"
" actually wrong - the NIC computes the real checksum in\n"
" hardware after most capture points already saw the packet.\n"
" -a Reassemble TCP streams and re-run HTTP parsing on the\n"
" joined bytes (plain-text mode only), catching a\n"
" request/response split across multiple segments that\n"
" single-packet HTTP dissection alone would miss. In-order\n"
" segments only - out-of-order/retransmitted segments are\n"
" dropped rather than buffered for reordering.\n"
" -w <file> Write the capture to <file> as pcapng (Wireshark-compatible)\n"
" -r <file> Replay a saved pcapng file instead of a live device\n"
" -f <expr> Kernel-level capture filter (tcpdump/BPF syntax); also\n"
" applies to what -w writes. Can't be combined with -r.\n"
" -g <term> Display filter: only show packets whose summary contains\n"
" <term> (case-insensitive). Doesn't affect -w. In TUI mode,\n"
" press '/' to search interactively instead.\n"
" -h, --help Show this help and exit\n"
"\n"
"Examples:\n"
" %s eth0 capture on eth0, print each packet\n"
" %s eth0 -t capture on eth0 in the interactive TUI\n"
" %s eth0 -f \"tcp port 443\" only capture HTTPS traffic\n"
" %s eth0 -w out.pcapng capture and save to out.pcapng\n"
" %s -r out.pcapng -t replay a saved capture in the TUI\n",
argv0, argv0, argv0, argv0, argv0, argv0);
}
} // namespace
int main(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) {
print_usage(argv[0]);
return 0;
}
}
wireframe::CaptureSessionOptions options;
bool tui_mode = false;
bool enable_reassembly = false;
RenderOptions opts{.verbose_hex = false,
.verbose_checksums = false,
.datalink = 0,
.pcapng_writer = nullptr,
.search_term = "",
.reassembler = nullptr};
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "-x") == 0) {
opts.verbose_hex = true;
} else if (std::strcmp(argv[i], "-c") == 0) {
opts.verbose_checksums = true;
} else if (std::strcmp(argv[i], "-a") == 0) {
enable_reassembly = true;
} else if (std::strcmp(argv[i], "-t") == 0 || std::strcmp(argv[i], "--tui") == 0) {
tui_mode = true;
} else if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) {
options.pcapng_output_path = argv[++i];
} else if (std::strcmp(argv[i], "-f") == 0 && i + 1 < argc) {
options.filter_expr = argv[++i];
} else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
options.replay_input_path = argv[++i];
} else if (std::strcmp(argv[i], "-g") == 0 && i + 1 < argc) {
opts.search_term = argv[++i];
} else if (options.device.empty()) {
options.device = argv[i];
}
}
wireframe::CaptureSession session;
if (auto err = session.open(options)) {
std::fprintf(stderr, "%s\n", err->c_str());
return 1;
}
opts.datalink = session.datalink();
opts.pcapng_writer = session.pcapng_writer();
session.install_signal_handlers();
wireframe::net::TcpReassembler reassembler;
if (enable_reassembly) opts.reassembler = &reassembler;
if (!tui_mode) {
if (session.is_replay()) {
std::printf("replaying %s (%s)\n", session.device().c_str(),
pcap_datalink_val_to_name(session.datalink()));
} else {
std::printf("capturing on %s (%s, ctrl-c to stop)\n", session.device().c_str(),
pcap_datalink_val_to_name(session.datalink()));
}
}
wireframe::CaptureQueue queue(kQueueCapacity);
std::thread capture_thread = session.start_capture_thread(queue);
if (tui_mode) {
run_tui(session, queue, opts);
} else {
while (auto packet = queue.pop()) {
render_packet(*packet, opts);
}
}
capture_thread.join();
if (queue.dropped() > 0) {
std::fprintf(stderr, "dropped %llu packets (render side fell behind)\n",
static_cast<unsigned long long>(queue.dropped()));
}
// Kernel-level counters, queried before the session closes its
// handle: a traffic spike can drop packets before libpcap ever
// hands them to our callback, which queue.dropped() can't see.
if (auto stats = session.stats()) {
if (stats->dropped > 0 || stats->if_dropped > 0) {
std::fprintf(stderr, "kernel/interface dropped %u/%u packets (received %u)\n",
stats->dropped, stats->if_dropped, stats->received);
}
}
return 0;
}
|