// 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 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 // (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. // // 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 compiles a tcpdump-style BPF expression via // 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 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. #include #include #include #include #include #include #include #include #include #include #include #include #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 { // 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 bytes) { for (const auto& line : packeteer::hex_dump_lines(bytes)) { std::printf("%s\n", line.c_str()); } std::printf("\n"); } struct RenderOptions { bool verbose_hex; bool verbose_checksums; int datalink; 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 packeteer::CapturedPacket& packet, const RenderOptions& opts) { std::span bytes{packet.data}; 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 // 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 (!packeteer::matches_search(line, opts.search_term)) return; if (opts.verbose_checksums) { 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 = packeteer::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(packeteer::CaptureSession& session, packeteer::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 rows; std::uint64_t packet_count = 0; // '/' search: a display filter over `rows`, independent of the // 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; 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 bytes{packet->data}; std::string line = packeteer::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 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 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 lock(state_mutex); Elements lines; std::size_t shown = 0; for (const auto& row : rows) { if (!packeteer::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() ? ("packeteer - replaying " + session.device()) : "packeteer - 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( "packeteer - 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 Write the capture to as pcapng (Wireshark-compatible)\n" " -r Replay a saved pcapng file instead of a live device\n" " -f Kernel-level capture filter (tcpdump/BPF syntax); also\n" " applies to what -w writes. Can't be combined with -r.\n" " -g Display filter: only show packets whose summary contains\n" " (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; } } packeteer::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]; } } packeteer::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(); packeteer::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())); } } packeteer::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(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; }