// GUI frontend (secondary to the TUI - see PLAN.md Decisions). Same // capture/decode/filter/pcapng pipeline as main.cpp's CLI/TUI modes, // 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. // // Dear ImGui + SDL3 (see CMakeLists.txt for the toolkit decision). #include #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 { struct PacketRow { std::string summary; std::vector data; // -a only: computed once at consume time (reassembly needs // in-order state across packets, unlike checksum status below, // which is stateless and cheap enough to compute lazily when a row // is selected instead of storing it on every row). std::optional reassembled_http; }; constexpr std::size_t kMaxRows = 5000; // cap memory; oldest rows scroll off struct SharedState { std::mutex mutex; std::deque 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 capture_alive{true}; }; 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 = 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, packet->original_len); } std::optional reassembled_http; if (reassembler) { reassembled_http = packeteer::reassembled_http_status(bytes, session.datalink(), *reassembler); } std::lock_guard lock(state.mutex); state.rows.push_back({std::move(summary), std::move(packet->data), std::move(reassembled_http)}); if (state.rows.size() > kMaxRows) state.rows.pop_front(); ++state.packet_count; } if (session.is_replay() && !session.stop_requested()) { std::lock_guard lock(state.mutex); state.replay_finished = true; } else { state.capture_alive.store(false); } } void print_usage(const char* argv0) { std::printf( "packeteer - terminal packet capture and analysis tool (GUI)\n" "\n" "Usage: %s [options] [interface]\n" "\n" "If no interface is given, the first available device is used.\n" "Search is available interactively in the window itself.\n" "\n" "Options:\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" " -c Show IPv4/TCP/UDP checksum validity for the selected packet.\n" " Off by default - see the CLI's -h for why (checksum offload).\n" " -a Reassemble TCP streams and show HTTP requests/responses\n" " joined across segments for the selected packet, when its\n" " segment contributed to one. In-order segments only.\n" " -h, --help Show this help and exit\n" "\n" "Examples:\n" " %s eth0\n" " %s eth0 -f \"tcp port 443\"\n" " %s -r out.pcapng\n", 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 enable_checksums = false; bool enable_reassembly = false; 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 (std::strcmp(argv[i], "-c") == 0) { enable_checksums = true; } else if (std::strcmp(argv[i], "-a") == 0) { enable_reassembly = true; } 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; } 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("packeteer", 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); packeteer::CaptureQueue queue(4096); SharedState state; 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); 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("packeteer", 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 lock(state.mutex); for (std::size_t i = 0; i < state.rows.size(); ++i) { if (!packeteer::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(i)); bool is_selected = (selected_row == static_cast(i)); if (ImGui::Selectable(state.rows[i].summary.c_str(), is_selected)) { selected_row = static_cast(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 lock(state.mutex); if (selected_row >= 0 && selected_row < static_cast(state.rows.size())) { const auto& row = state.rows[selected_row]; if (enable_checksums) { 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()); } } if (row.reassembled_http) { ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%s", row.reassembled_http->c_str()); } for (const auto& line : packeteer::hex_dump_lines(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 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(state.packet_count), finished, static_cast(queue.dropped())); } else { ImGui::Text("packets: %llu (%zu shown)%s dropped: %llu (Esc to clear search)", static_cast(state.packet_count), shown, finished, static_cast(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(queue.dropped())); } ImGui_ImplSDLRenderer3_Shutdown(); ImGui_ImplSDL3_Shutdown(); ImGui::DestroyContext(); SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; }