diff options
| author | srdusr <[email protected]> | 2024-05-14 01:42:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-05-14 01:42:00 +0200 |
| commit | 08332a4195956611db80a2cfe3710d760cbd6acf (patch) | |
| tree | 0cb5cdf9fdcfdd8dc8c129a33575ad9b182d5c01 /src | |
| download | packeteer-08332a4195956611db80a2cfe3710d760cbd6acf.tar.gz packeteer-08332a4195956611db80a2cfe3710d760cbd6acf.zip | |
Initial commit: wireframe packet capture/analysis tool
Terminal packet capture and analysis tool built to learn the C++
memory model (byte layout, alignment, endianness, std::span over
unowned buffers) via a real capture pipeline.
- Hand-rolled L2-L4 decoders (Ethernet, IPv4, IPv6 with extension
header walking, TCP, UDP) over std::span, no struct-casting
- L7 dissector interface with DNS, HTTP, and TLS SNI implementations
- pcapng read/write for Wireshark-compatible capture files
- Bounded capture queue: drop-on-backpressure for live capture,
blocking push for faithful file replay
- Kernel-level BPF filtering (-f) and a separate display-only search
(-g / interactive) that doesn't touch what's captured
- Replay mode (-r) reads a saved pcapng file back through the same
pipeline as live capture, no root or live device needed
- pcap_stats() surfaces kernel/interface drops invisible to the
capture queue's own counter
- Three frontends sharing one CaptureSession setup path: CLI, TUI
(FTXUI, primary), GUI (Dear ImGui + SDL3, secondary)
- 89 unit tests (doctest) plus 9 libFuzzer harnesses covering every
hand-rolled parser; fuzzing found and fixed a real OOM in the
pcapng reader (unbounded allocation from an untrusted length field)
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui_main.cpp | 280 | ||||
| -rw-r--r-- | src/main.cpp | 326 |
2 files changed, 606 insertions, 0 deletions
diff --git a/src/gui_main.cpp b/src/gui_main.cpp new file mode 100644 index 0000000..d5d4518 --- /dev/null +++ b/src/gui_main.cpp @@ -0,0 +1,280 @@ +// GUI frontend (secondary to the TUI - see PLAN.md Decisions). Same +// capture/decode/filter/pcapng pipeline as main.cpp's CLI/TUI modes, +// via wireframe::CaptureSession - not a hand-copied setup path, so it +// can't drift on datalink validation, filter errors, or the +// pcap_breakloop() shutdown hook the way two independent +// implementations eventually would. +// +// Dear ImGui + SDL3 (see CMakeLists.txt for the toolkit decision). + +#include <SDL3/SDL.h> +#include <imgui.h> +#include <imgui_impl_sdl3.h> +#include <imgui_impl_sdlrenderer3.h> + +#include <atomic> +#include <cstdio> +#include <cstring> +#include <deque> +#include <mutex> +#include <optional> +#include <span> +#include <string> +#include <thread> + +#include "wireframe/capture_session.hpp" +#include "wireframe/search.hpp" +#include "wireframe/summarize.hpp" + +namespace { + +struct PacketRow { + std::string summary; + std::vector<unsigned char> data; +}; + +constexpr std::size_t kMaxRows = 5000; // cap memory; oldest rows scroll off + +struct SharedState { + std::mutex mutex; + std::deque<PacketRow> rows; + std::uint64_t packet_count = 0; + bool replay_finished = false; // guarded by mutex, like rows/packet_count + // Set once the consumer loop drains and exits from an explicit stop + // (the window's quit action or an external SIGINT/SIGTERM) - but + // NOT when a replay simply reaches end-of-file on its own, since the + // point of replaying a file is browsing/searching it afterward, not + // watching it flash by and vanish. The render loop watches this so + // an external signal still closes the whole app in every other case + // - not just the capture, leaving a frozen window behind - the + // same single shutdown path run_tui() uses. + std::atomic<bool> capture_alive{true}; +}; + +void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, + SharedState& state) { + while (auto packet = queue.pop()) { + std::span<const unsigned char> bytes{packet->data}; + std::string summary = wireframe::summarize_packet(bytes, session.datalink()); + + if (auto* writer = session.pcapng_writer()) { + writer->write_packet(/*interface_id=*/0, packet->ts_sec, packet->ts_usec, bytes, + packet->original_len); + } + + std::lock_guard<std::mutex> lock(state.mutex); + state.rows.push_back({std::move(summary), std::move(packet->data)}); + if (state.rows.size() > kMaxRows) state.rows.pop_front(); + ++state.packet_count; + } + if (session.is_replay() && !session.stop_requested()) { + std::lock_guard<std::mutex> lock(state.mutex); + state.replay_finished = true; + } else { + state.capture_alive.store(false); + } +} + +} // namespace + +int main(int argc, char** argv) { + wireframe::CaptureSessionOptions options; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) { + options.pcapng_output_path = argv[++i]; + } else if (std::strcmp(argv[i], "-f") == 0 && i + 1 < argc) { + options.filter_expr = argv[++i]; + } else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) { + options.replay_input_path = argv[++i]; + } else if (options.device.empty()) { + options.device = argv[i]; + } + } + + wireframe::CaptureSession session; + if (auto err = session.open(options)) { + std::fprintf(stderr, "%s\n", err->c_str()); + return 1; + } + session.install_signal_handlers(); + + if (!SDL_Init(SDL_INIT_VIDEO)) { + std::fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); + return 1; + } + + SDL_Window* window = + SDL_CreateWindow("wireframe", 1000, 650, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN); + if (window == nullptr) { + std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr); + if (renderer == nullptr) { + std::fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError()); + SDL_DestroyWindow(window); + SDL_Quit(); + return 1; + } + SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); + SDL_ShowWindow(window); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; // no layout file: this is a fixed, simple layout + ImGui_ImplSDL3_InitForSDLRenderer(window, renderer); + ImGui_ImplSDLRenderer3_Init(renderer); + + wireframe::CaptureQueue queue(4096); + SharedState state; + std::thread capture_thread = session.start_capture_thread(queue); + std::thread consumer_thread(consumer_loop, std::ref(session), std::ref(queue), + std::ref(state)); + + int selected_row = -1; + bool quit = false; + char search_buf[256] = {}; + ImGuiIO& io = ImGui::GetIO(); + while (!quit && state.capture_alive.load()) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + ImGui_ImplSDL3_ProcessEvent(&event); + if (event.type == SDL_EVENT_QUIT) { + quit = true; + session.request_stop(); + } + // io.WantCaptureKeyboard reflects whether an ImGui widget + // (the search box) held keyboard focus as of the last + // completed frame - without this check, Escape would quit + // the whole app while the user is just trying to clear a + // search term, instead of doing what the TUI's Escape does + // in the same context (clear the term, stay open). + if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE && + !io.WantCaptureKeyboard) { + quit = true; + session.request_stop(); + } + } + + ImGui_ImplSDLRenderer3_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + ImGui::NewFrame(); + + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(io.DisplaySize); + ImGui::Begin("wireframe", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse); + + if (session.is_replay()) { + ImGui::Text("replaying %s (%s)", session.device().c_str(), + pcap_datalink_val_to_name(session.datalink())); + } else { + ImGui::Text("capturing on %s (%s)", session.device().c_str(), + pcap_datalink_val_to_name(session.datalink())); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(300); + ImGui::InputTextWithHint("##search", "search (display filter, not capture filter)", + search_buf, sizeof(search_buf)); + if (ImGui::IsItemFocused() && ImGui::IsKeyPressed(ImGuiKey_Escape)) { + search_buf[0] = '\0'; // same behavior as the TUI's Esc-while-searching + } + std::string search_term(search_buf); + ImGui::Separator(); + + float details_height = 160.0f; + std::size_t shown = 0; + ImGui::BeginChild("packet_list", ImVec2(0, -details_height - 8), ImGuiChildFlags_Borders); + { + std::lock_guard<std::mutex> lock(state.mutex); + for (std::size_t i = 0; i < state.rows.size(); ++i) { + if (!wireframe::matches_search(state.rows[i].summary, search_term)) continue; + ++shown; + + // ImGui derives a widget's ID from its label text by + // default; two rows with identical summary text (e.g. + // repeated ICMP lines) would otherwise collide on the + // same ID. PushID(index) makes each row's ID unique + // regardless of what text it displays. + ImGui::PushID(static_cast<int>(i)); + bool is_selected = (selected_row == static_cast<int>(i)); + if (ImGui::Selectable(state.rows[i].summary.c_str(), is_selected)) { + selected_row = static_cast<int>(i); + } + ImGui::PopID(); + } + // Auto-scroll to the newest row unless the user has scrolled up + // to look at something (a manual scroll leaves the view short + // of the max, which is what we check here). + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) { + ImGui::SetScrollHereY(1.0f); + } + } + ImGui::EndChild(); + + ImGui::BeginChild("packet_details", ImVec2(0, details_height), ImGuiChildFlags_Borders); + { + std::lock_guard<std::mutex> lock(state.mutex); + if (selected_row >= 0 && selected_row < static_cast<int>(state.rows.size())) { + for (const auto& line : wireframe::hex_dump_lines(state.rows[selected_row].data)) { + ImGui::TextUnformatted(line.c_str()); + } + } else { + ImGui::TextDisabled("select a packet to see its hex dump"); + } + } + ImGui::EndChild(); + + ImGui::Separator(); + { + std::lock_guard<std::mutex> lock(state.mutex); + const char* finished = state.replay_finished ? " [replay finished]" : ""; + if (search_term.empty()) { + ImGui::Text("packets: %llu%s dropped: %llu (Esc to quit)", + static_cast<unsigned long long>(state.packet_count), finished, + static_cast<unsigned long long>(queue.dropped())); + } else { + ImGui::Text("packets: %llu (%zu shown)%s dropped: %llu (Esc to clear search)", + static_cast<unsigned long long>(state.packet_count), shown, finished, + static_cast<unsigned long long>(queue.dropped())); + } + } + // Kernel/interface-level drops: a traffic spike can drop + // packets before libpcap ever hands them to our callback, + // which the queue-side counter above can't see. + if (auto stats = session.stats()) { + if (stats->dropped > 0 || stats->if_dropped > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f), + "kernel/interface dropped %u/%u (received %u)", stats->dropped, + stats->if_dropped, stats->received); + } + } + + ImGui::End(); + + ImGui::Render(); + SDL_SetRenderDrawColor(renderer, 30, 30, 30, 255); + SDL_RenderClear(renderer); + ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), renderer); + SDL_RenderPresent(renderer); + } + + session.request_stop(); + capture_thread.join(); + consumer_thread.join(); + + if (queue.dropped() > 0) { + std::fprintf(stderr, "dropped %llu packets (render side fell behind)\n", + static_cast<unsigned long long>(queue.dropped())); + } + + ImGui_ImplSDLRenderer3_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + SDL_Quit(); + return 0; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..3a3e925 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,326 @@ +// Stage 2 (PLAN.md): Ethernet/IP/TCP/UDP decoders producing a live +// packet-list line per capture. The TUI itself is still an open +// question (PLAN.md), so this prints to stdout for now; -x keeps the +// stage-1 hex dump available underneath each summary. +// +// Stage 3: -w <file> writes the same capture out as pcapng alongside +// the summary, so files stay Wireshark-compatible (PLAN.md). +// +// Stage 4: capture thread -> bounded CaptureQueue -> render loop on +// the main thread (PLAN.md's architecture sketch). The capture thread +// only copies raw bytes into the queue; decoding, printing, and +// pcapng-writing all happen on the consumer side, so a slow render +// path can never block the capture thread - a full queue drops the +// packet and counts it instead. +// +// Stage 5: L7 dissectors register into an L7Registry keyed by port +// (wireframe/l7/dissector.hpp) and get consulted from summarize_packet +// once TCP/UDP decode a port number. DNS is the first one, proving the +// interface against real traffic rather than synthetic bytes. +// +// IPv6: dispatched by version nibble rather than assumed absent -- +// every live-capture test so far has shown real IPv6 background +// traffic silently dropped once the decoder only handled IPv4. +// +// Stage 6: -f <expr> compiles a tcpdump-style BPF expression via +// libpcap's own compiler (wireframe/filter.hpp) and installs it with +// pcap_setfilter(), filtering in the kernel before packets ever reach +// userspace - rather than hand-rolling a second BPF parser. +// +// Device-open/datalink-validate/filter/pcapng/signal-handler setup all +// goes through wireframe::CaptureSession (wireframe/capture_session.hpp) +// - the same one gui_main.cpp uses - so the CLI/TUI and GUI frontends +// can't drift apart on that setup path. + +#include <pcap.h> + +#include <cstdio> +#include <cstring> +#include <deque> +#include <mutex> +#include <optional> +#include <span> +#include <string> +#include <thread> + +#include <ftxui/component/component.hpp> +#include <ftxui/component/screen_interactive.hpp> +#include <ftxui/dom/elements.hpp> + +#include "wireframe/capture_session.hpp" +#include "wireframe/search.hpp" +#include "wireframe/summarize.hpp" + +namespace { + +// Queue capacity: how many packets can be buffered between the capture +// thread and the render loop before new packets get dropped. Sized as +// a fixed constant rather than a flag - tune later if a real workload +// needs it, not speculatively now. +constexpr std::size_t kQueueCapacity = 4096; + +void hex_dump(std::span<const unsigned char> bytes) { + for (const auto& line : wireframe::hex_dump_lines(bytes)) { + std::printf("%s\n", line.c_str()); + } + std::printf("\n"); +} + +struct RenderOptions { + bool verbose_hex; + int datalink; + wireframe::pcapng::Writer* pcapng_writer; + std::string search_term; // display filter - see wireframe/search.hpp +}; + +void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& opts) { + std::span<const unsigned char> bytes{packet.data}; + + std::string line = wireframe::summarize_packet(bytes, opts.datalink); + + // -g is a display filter, not a capture filter: still written to + // -w regardless of whether it matches, since -w should reflect + // what was actually captured (that's -f's job), not what's shown. + if (opts.pcapng_writer) { + opts.pcapng_writer->write_packet(/*interface_id=*/0, packet.ts_sec, packet.ts_usec, bytes, + packet.original_len); + } + + if (!wireframe::matches_search(line, opts.search_term)) return; + + std::printf("%s\n", line.c_str()); + if (opts.verbose_hex) hex_dump(bytes); + + // Flush per packet: stdout is fully buffered off a tty, and this is + // a live capture tool, not a batch one. + std::fflush(stdout); +} + +// TUI mode (-t): a scrolling packet list in a full-screen view, built +// with FTXUI (see NAMES.md-adjacent decision: chosen over notcurses for +// pure-C++ portability - no C build-system/dependency chain to fight +// on every platform PLAN.md targets, and genuine Windows console +// support, which notcurses lacks). +// +// A dedicated consumer thread pops from the capture queue and appends +// formatted rows to shared state; the UI thread just redraws on +// Event::Custom. 'q'/Esc triggers the same pcap_breakloop() shutdown +// path as Ctrl-C, so there's one shutdown sequence, not two: breakloop +// -> capture thread's pcap_loop returns -> queue.stop() -> consumer +// drains and calls screen.Exit() -> screen.Loop() returns. +void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, + RenderOptions& opts) { + using namespace ftxui; + + constexpr std::size_t kMaxRows = 2000; // cap memory; oldest rows scroll off + + std::mutex state_mutex; + std::deque<std::string> rows; + std::uint64_t packet_count = 0; + + // '/' search: a display filter over `rows`, independent of the + // capture itself (wireframe/search.hpp) - typed and read only on + // the UI thread (the consumer thread never touches it), so unlike + // `rows`/`packet_count` it doesn't need state_mutex. + bool searching = false; + std::string search_term; + bool replay_finished = false; // guarded by state_mutex, like rows/packet_count + + auto screen = ScreenInteractive::Fullscreen(); + + std::thread consumer_thread([&] { + while (auto packet = queue.pop()) { + std::span<const unsigned char> bytes{packet->data}; + std::string line = wireframe::summarize_packet(bytes, opts.datalink); + + if (opts.pcapng_writer) { + opts.pcapng_writer->write_packet(/*interface_id=*/0, packet->ts_sec, + packet->ts_usec, bytes, packet->original_len); + } + + { + std::lock_guard<std::mutex> lock(state_mutex); + rows.push_back(std::move(line)); + if (rows.size() > kMaxRows) rows.pop_front(); + ++packet_count; + } + screen.PostEvent(Event::Custom); + } + // Replay reaching end-of-file on its own (stop_requested() still + // false) shouldn't close the window - the point of replaying a + // file is browsing/searching it afterward, not watching it flash + // by. An explicit stop (q/Esc below, or an external signal, both + // of which set stop_requested()) always closes, live capture + // included - that's still the same behavior as before. + if (session.is_replay() && !session.stop_requested()) { + { + std::lock_guard<std::mutex> lock(state_mutex); + replay_finished = true; + } + screen.PostEvent(Event::Custom); // one more redraw for the final state + } else { + screen.Exit(); + } + }); + + auto renderer = Renderer([&] { + std::lock_guard<std::mutex> lock(state_mutex); + Elements lines; + std::size_t shown = 0; + for (const auto& row : rows) { + if (!wireframe::matches_search(row, search_term)) continue; + lines.push_back(text(row)); + ++shown; + } + + std::string status = "packets: " + std::to_string(packet_count); + if (!search_term.empty()) status += " (" + std::to_string(shown) + " shown)"; + if (replay_finished) status += " [replay finished]"; + status += " dropped: " + std::to_string(queue.dropped()) + + (searching ? " (Enter to apply, Esc to clear)" : " (/ to search, q to quit)"); + // Kernel/interface-level drops: a traffic spike can drop + // packets before libpcap ever hands them to our callback, + // which the queue-side counter above can't see. + Elements footer = {text(status) | dim}; + if (auto stats = session.stats()) { + if (stats->dropped > 0 || stats->if_dropped > 0) { + std::string kernel_status = "kernel/interface dropped " + + std::to_string(stats->dropped) + "/" + + std::to_string(stats->if_dropped) + " (received " + + std::to_string(stats->received) + ")"; + footer.push_back(text(kernel_status) | color(Color::Yellow)); + } + } + if (searching || !search_term.empty()) { + footer.push_back(text("search: " + search_term + (searching ? "_" : "")) | + color(Color::Green)); + } + std::string title = session.is_replay() ? ("wireframe - replaying " + session.device()) + : "wireframe - live capture"; + return vbox({ + text(title) | bold | color(Color::Cyan), + separator(), + vbox(std::move(lines)) | yframe | flex, + separator(), + vbox(std::move(footer)), + }) | + border; + }); + + auto component = CatchEvent(renderer, [&](const Event& event) { + if (searching) { + if (event == Event::Return) { + searching = false; + return true; + } + if (event == Event::Escape) { + searching = false; + search_term.clear(); + return true; + } + if (event == Event::Backspace) { + if (!search_term.empty()) search_term.pop_back(); + return true; + } + if (event.is_character()) { + search_term += event.character(); + return true; + } + return true; // swallow anything else while typing (don't let it fall through to quit) + } + if (event == Event::Character('/')) { + searching = true; + return true; + } + if (event == Event::Character('q') || event == Event::Escape) { + session.request_stop(); + // Closes immediately rather than waiting for the capture/ + // replay thread to actually finish and drain the queue -- + // necessary for replay mode specifically (that thread may + // already be long gone once the user quits after browsing a + // finished replay, so nothing else would ever call this). + // main() still joins the thread properly afterward either way. + screen.Exit(); + return true; + } + return false; + }); + + screen.Loop(component); + consumer_thread.join(); +} + +} // namespace + +int main(int argc, char** argv) { + wireframe::CaptureSessionOptions options; + bool tui_mode = false; + RenderOptions opts{ + .verbose_hex = false, .datalink = 0, .pcapng_writer = nullptr, .search_term = ""}; + + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "-x") == 0) { + opts.verbose_hex = true; + } else if (std::strcmp(argv[i], "-t") == 0 || std::strcmp(argv[i], "--tui") == 0) { + tui_mode = true; + } else if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) { + options.pcapng_output_path = argv[++i]; + } else if (std::strcmp(argv[i], "-f") == 0 && i + 1 < argc) { + options.filter_expr = argv[++i]; + } else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) { + options.replay_input_path = argv[++i]; + } else if (std::strcmp(argv[i], "-g") == 0 && i + 1 < argc) { + opts.search_term = argv[++i]; + } else if (options.device.empty()) { + options.device = argv[i]; + } + } + + wireframe::CaptureSession session; + if (auto err = session.open(options)) { + std::fprintf(stderr, "%s\n", err->c_str()); + return 1; + } + opts.datalink = session.datalink(); + opts.pcapng_writer = session.pcapng_writer(); + session.install_signal_handlers(); + + if (!tui_mode) { + if (session.is_replay()) { + std::printf("replaying %s (%s)\n", session.device().c_str(), + pcap_datalink_val_to_name(session.datalink())); + } else { + std::printf("capturing on %s (%s, ctrl-c to stop)\n", session.device().c_str(), + pcap_datalink_val_to_name(session.datalink())); + } + } + + wireframe::CaptureQueue queue(kQueueCapacity); + std::thread capture_thread = session.start_capture_thread(queue); + + if (tui_mode) { + run_tui(session, queue, opts); + } else { + while (auto packet = queue.pop()) { + render_packet(*packet, opts); + } + } + + capture_thread.join(); + + if (queue.dropped() > 0) { + std::fprintf(stderr, "dropped %llu packets (render side fell behind)\n", + static_cast<unsigned long long>(queue.dropped())); + } + // Kernel-level counters, queried before the session closes its + // handle: a traffic spike can drop packets before libpcap ever + // hands them to our callback, which queue.dropped() can't see. + if (auto stats = session.stats()) { + if (stats->dropped > 0 || stats->if_dropped > 0) { + std::fprintf(stderr, "kernel/interface dropped %u/%u packets (received %u)\n", + stats->dropped, stats->if_dropped, stats->received); + } + } + + return 0; +} |