From b565d7d9c47ca1ec5af0effd828431ee96027d60 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Mon, 27 May 2024 22:00:00 +0200 Subject: Rename project from wireframe to packeteer Decided on the name after weighing alternatives in NAMES.md: packeteer (packet + -eer, "one who wields packets") fit the project's actual scope better than the wire/frame pun once it had grown into full L2-L7 dissection, reassembly, checksums, privilege dropping, and dual TUI/GUI frontends. No existing packet-capture project uses the name; the one real-world collision (Packeteer, Inc., a networking company acquired and folded into Blue Coat/Symantec by 2008) is long defunct. Mechanical rename throughout: CMake project/target names, the wireframe:: namespace and include/wireframe/ directory (git mv, history preserved), every #include path, CLI/GUI help text, and the project's own working directory. NAMES.md rewritten to record the decision instead of leaving stale self-referential etymology behind from the blind rename pass. Verified after every step: full rebuild (all four targets, no warnings) and the full test suite (128/128 cases, 366/366 assertions) both from a fresh reconfigure and again after the directory move. --- include/wireframe/byteio.hpp | 22 --- include/wireframe/capture_queue.hpp | 98 ---------- include/wireframe/capture_session.hpp | 312 ------------------------------- include/wireframe/filter.hpp | 36 ---- include/wireframe/l7/dissector.hpp | 45 ----- include/wireframe/l7/dns.hpp | 108 ----------- include/wireframe/l7/http.hpp | 113 ----------- include/wireframe/l7/mdns.hpp | 44 ----- include/wireframe/l7/ssh.hpp | 65 ------- include/wireframe/l7/tls.hpp | 139 -------------- include/wireframe/net/checksum.hpp | 91 --------- include/wireframe/net/ethernet.hpp | 44 ----- include/wireframe/net/icmp.hpp | 84 --------- include/wireframe/net/ipv4.hpp | 56 ------ include/wireframe/net/ipv6.hpp | 173 ----------------- include/wireframe/net/tcp.hpp | 54 ------ include/wireframe/net/tcp_reassembly.hpp | 125 ------------- include/wireframe/net/udp.hpp | 35 ---- include/wireframe/packet_diagnostics.hpp | 92 --------- include/wireframe/pcapng/reader.hpp | 123 ------------ include/wireframe/pcapng/writer.hpp | 94 ---------- include/wireframe/privileges.hpp | 87 --------- include/wireframe/search.hpp | 26 --- include/wireframe/summarize.hpp | 275 --------------------------- 24 files changed, 2341 deletions(-) delete mode 100644 include/wireframe/byteio.hpp delete mode 100644 include/wireframe/capture_queue.hpp delete mode 100644 include/wireframe/capture_session.hpp delete mode 100644 include/wireframe/filter.hpp delete mode 100644 include/wireframe/l7/dissector.hpp delete mode 100644 include/wireframe/l7/dns.hpp delete mode 100644 include/wireframe/l7/http.hpp delete mode 100644 include/wireframe/l7/mdns.hpp delete mode 100644 include/wireframe/l7/ssh.hpp delete mode 100644 include/wireframe/l7/tls.hpp delete mode 100644 include/wireframe/net/checksum.hpp delete mode 100644 include/wireframe/net/ethernet.hpp delete mode 100644 include/wireframe/net/icmp.hpp delete mode 100644 include/wireframe/net/ipv4.hpp delete mode 100644 include/wireframe/net/ipv6.hpp delete mode 100644 include/wireframe/net/tcp.hpp delete mode 100644 include/wireframe/net/tcp_reassembly.hpp delete mode 100644 include/wireframe/net/udp.hpp delete mode 100644 include/wireframe/packet_diagnostics.hpp delete mode 100644 include/wireframe/pcapng/reader.hpp delete mode 100644 include/wireframe/pcapng/writer.hpp delete mode 100644 include/wireframe/privileges.hpp delete mode 100644 include/wireframe/search.hpp delete mode 100644 include/wireframe/summarize.hpp (limited to 'include/wireframe') diff --git a/include/wireframe/byteio.hpp b/include/wireframe/byteio.hpp deleted file mode 100644 index c37c29e..0000000 --- a/include/wireframe/byteio.hpp +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include -#include - -// Manual big-endian reads instead of reinterpret_cast onto a packed -// struct: network buffers from pcap aren't guaranteed aligned for -// multi-byte integer types, so casting would be undefined behavior. -namespace wireframe { - -inline std::uint16_t read_be16(std::span bytes, std::size_t offset) { - return static_cast((bytes[offset] << 8) | bytes[offset + 1]); -} - -inline std::uint32_t read_be32(std::span bytes, std::size_t offset) { - return (static_cast(bytes[offset]) << 24) | - (static_cast(bytes[offset + 1]) << 16) | - (static_cast(bytes[offset + 2]) << 8) | - static_cast(bytes[offset + 3]); -} - -} // namespace wireframe diff --git a/include/wireframe/capture_queue.hpp b/include/wireframe/capture_queue.hpp deleted file mode 100644 index 14794ba..0000000 --- a/include/wireframe/capture_queue.hpp +++ /dev/null @@ -1,98 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -// Bounded queue between the capture thread and the render/analysis -// thread (PLAN.md's architecture sketch). Owns a copy of each packet's -// bytes since the buffer libpcap hands the callback is only valid for -// the duration of that call. -namespace wireframe { - -struct CapturedPacket { - std::uint32_t ts_sec; - std::uint32_t ts_usec; - std::uint32_t original_len; - std::vector data; // caplen bytes -}; - -// Single-producer / single-consumer. Two producer-side push variants -// for two different producers with different constraints: a live -// capture thread can't be allowed to stall (PLAN.md is explicit that a -// traffic spike should drop packets, not block), but a replay-from-file -// producer has no such real-time pressure, and dropping from a fixed -// historical record would defeat the point of "faithfully replaying -// what was captured" - so it blocks for room instead. -class CaptureQueue { -public: - explicit CaptureQueue(std::size_t capacity) : capacity_(capacity) {} - - // Never blocks: drops the packet and counts it if the queue is full. - bool try_push(CapturedPacket&& packet) { - { - std::lock_guard lock(mutex_); - if (queue_.size() >= capacity_) { - ++dropped_; - return false; - } - queue_.push(std::move(packet)); - } - cv_.notify_all(); - return true; - } - - // Blocks until there's room, then pushes. Returns false without - // pushing if stop() is called while waiting - the consumer side is - // going away, so nothing will ever pop it. - bool push(CapturedPacket&& packet) { - { - std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { return queue_.size() < capacity_ || stopped_; }); - if (stopped_) return false; - queue_.push(std::move(packet)); - } - cv_.notify_all(); - return true; - } - - // Blocks until a packet is available. Returns nullopt only once - // stop() has been called and the queue has fully drained - so a - // consumer loop on pop() processes everything queued before the - // capture side stopped, rather than discarding it. - std::optional pop() { - std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { return !queue_.empty() || stopped_; }); - if (queue_.empty()) return std::nullopt; - CapturedPacket packet = std::move(queue_.front()); - queue_.pop(); - cv_.notify_all(); // wake a push() blocked on room, if any - return packet; - } - - void stop() { - { - std::lock_guard lock(mutex_); - stopped_ = true; - } - cv_.notify_all(); - } - - std::uint64_t dropped() const { - std::lock_guard lock(mutex_); - return dropped_; - } - -private: - mutable std::mutex mutex_; - std::condition_variable cv_; - std::queue queue_; - std::size_t capacity_; - bool stopped_ = false; - std::uint64_t dropped_ = 0; -}; - -} // namespace wireframe diff --git a/include/wireframe/capture_session.hpp b/include/wireframe/capture_session.hpp deleted file mode 100644 index 2e3b05a..0000000 --- a/include/wireframe/capture_session.hpp +++ /dev/null @@ -1,312 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "wireframe/capture_queue.hpp" -#include "wireframe/filter.hpp" -#include "wireframe/pcapng/reader.hpp" -#include "wireframe/pcapng/writer.hpp" -#include "wireframe/privileges.hpp" - -// Device-open -> datalink-validate -> filter/pcapng-setup -> signal-hook -// pipeline, shared by every frontend (CLI, TUI, GUI). Centralized so a -// new frontend can't silently skip a step the others rely on - e.g. -// the DLT_RAW/DLT_EN10MB check that summarize_packet() depends on, or -// the pcap_breakloop() shutdown hook that keeps a -w pcapng file from -// being truncated on Ctrl-C (see main.cpp's history: both were real -// bugs before this was centralized). -// -// Also covers replay mode (-r ): reading a previously-saved -// pcapng file back through the exact same queue/render/search pipeline -// as a live capture, so every frontend gets it for free rather than -// needing a second code path. The render/consumer side only ever talks -// to a CaptureQueue - it has no way to tell whether packets are -// arriving from a live pcap_loop or being read back from disk. -namespace wireframe { - -namespace detail { -inline pcap_t* g_capture_handle = nullptr; -inline std::atomic* g_replay_stop_flag = nullptr; -inline void handle_stop_signal(int) { - if (g_capture_handle != nullptr) pcap_breakloop(g_capture_handle); - if (g_replay_stop_flag != nullptr) g_replay_stop_flag->store(true); -} -} // namespace detail - -struct CaptureSessionOptions { - std::string device; // empty = pick the first device via pcap_findalldevs - std::optional filter_expr; - std::optional pcapng_output_path; - std::optional replay_input_path; // -r: read from this pcapng file, not a live device -}; - -inline bool is_supported_datalink(int datalink) { - return datalink == DLT_EN10MB || datalink == DLT_RAW; -} - -// Kernel/NIC-level counters, distinct from CaptureQueue::dropped(): -// the queue can only count packets libpcap already handed to our -// callback. A traffic spike can drop packets in the kernel's capture -// buffer before that ever happens - invisible without this. Not -// meaningful in replay mode (stats() returns nullopt there). -struct CaptureStats { - unsigned int received; // ps_recv - unsigned int dropped; // ps_drop: kernel buffer had no room - unsigned int if_dropped; // ps_ifdrop: dropped by the interface/driver -}; - -class CaptureSession { -public: - ~CaptureSession() { close(); } - - CaptureSession() = default; - CaptureSession(const CaptureSession&) = delete; - CaptureSession& operator=(const CaptureSession&) = delete; - - // Returns an error message on failure. The session remains safe to - // destroy (or close()) regardless of how far setup got. - std::optional open(const CaptureSessionOptions& options) { - if (options.replay_input_path) { - if (options.filter_expr) { - return std::string( - "-f (capture filter) isn't supported with -r (replay); use -g to filter " - "what's displayed instead"); - } - return open_replay(*options.replay_input_path, options.pcapng_output_path); - } - - char errbuf[PCAP_ERRBUF_SIZE]; - - if (options.device.empty()) { - if (pcap_findalldevs(&all_devices_, errbuf) == -1 || all_devices_ == nullptr) { - return std::string("no capture device found: ") + errbuf; - } - device_ = all_devices_->name; - } else { - device_ = options.device; - } - - handle_ = pcap_open_live(device_.c_str(), /*snaplen=*/65535, /*promisc=*/0, - /*to_ms=*/1000, errbuf); - if (handle_ == nullptr) { - return std::string("pcap_open_live failed: ") + errbuf; - } - - // Everything CAP_NET_RAW/root was needed for is done: the - // handle is open. Drop immediately, before the datalink check - // or -w's file is even created - the latter is also why this - // runs this early rather than at the very end of open(), since - // it means a -w output file gets created as the real user, not - // root, and doesn't need a manual chown to read back afterward. - if (auto err = drop_privileges_if_root()) { - return "failed to drop root privileges after opening the capture handle: " + *err; - } - - datalink_ = pcap_datalink(handle_); - if (!is_supported_datalink(datalink_)) { - return std::string("unsupported datalink type on ") + device_ + ": " + - pcap_datalink_val_to_name(datalink_) + " (" + - pcap_datalink_val_to_description(datalink_) + ")"; - } - - if (options.filter_expr) { - bpf_program program{}; - if (auto err = compile_filter(handle_, *options.filter_expr, &program)) { - return "invalid filter '" + *options.filter_expr + "': " + *err; - } - if (pcap_setfilter(handle_, &program) == -1) { - std::string err = std::string("pcap_setfilter failed: ") + pcap_geterr(handle_); - pcap_freecode(&program); - return err; - } - pcap_freecode(&program); // bytecode is copied into the kernel by pcap_setfilter - } - - if (options.pcapng_output_path) { - if (auto err = open_pcapng_writer(*options.pcapng_output_path)) return err; - } - - return std::nullopt; - } - - // pcap_loop() blocks in a read/poll waiting for the next packet, so - // a plain "stop requested" flag wouldn't unblock it promptly. - // pcap_breakloop() is documented as signal-safe and is what - // actually interrupts that wait. Replay mode has no handle to - // breakloop, so it's interrupted via g_replay_stop_flag instead -- - // both are armed here so one signal handler covers either mode. - void install_signal_handlers() { - detail::g_capture_handle = handle_; - detail::g_replay_stop_flag = &replay_stop_requested_; - std::signal(SIGINT, detail::handle_stop_signal); - std::signal(SIGTERM, detail::handle_stop_signal); - } - - void request_stop() { - if (handle_ != nullptr) pcap_breakloop(handle_); - replay_stop_requested_.store(true); - } - - // True once a stop has been explicitly requested - via - // request_stop() or an external SIGINT/SIGTERM (the signal handler - // sets the same flag). Lets a frontend tell "the producer stopped - // because someone asked it to" apart from "the producer ran out of - // data on its own" (replay reaching end-of-file), which call for - // different UI behavior: the former should close the window, the - // latter should leave it open so what's already loaded can still be - // browsed. - bool stop_requested() const { return replay_stop_requested_.load(); } - - // Must be called before close()/the destructor - pcap_stats() - // needs a still-open handle. Safe to call after request_stop(), - // since breakloop only stops pcap_loop(), it doesn't close handle_. - // Always nullopt in replay mode (handle_ is never set there). - std::optional stats() const { - if (handle_ == nullptr) return std::nullopt; - pcap_stat stat{}; - if (pcap_stats(handle_, &stat) == -1) return std::nullopt; - return CaptureStats{stat.ps_recv, stat.ps_drop, stat.ps_ifdrop}; - } - - // Capture-thread side: copy each packet into the queue and return - // immediately. No decoding, printing, or file I/O here - that's - // every frontend's own consumer-side job. - // - // Live mode drops on backpressure (try_push, via capture_callback) - // since a traffic spike can't be paused. Replay mode blocks instead - // (push): a file has no real-time pressure forcing a drop, and - // dropping from what's supposed to be a faithful replay of a fixed - // historical record would defeat the point of replaying it. - std::thread start_capture_thread(CaptureQueue& queue) { - if (is_replay_) { - return std::thread([this, &queue] { - queue.push(to_captured_packet(std::move(*first_replay_packet_))); - while (!replay_stop_requested_.load()) { - auto record = replay_reader_->next_packet(); - if (!record) break; - if (!queue.push(to_captured_packet(std::move(*record)))) break; - } - queue.stop(); - }); - } - return std::thread([this, &queue] { - pcap_loop(handle_, /*count=*/-1, capture_callback, - reinterpret_cast(&queue)); - queue.stop(); - }); - } - - void close() { - if (handle_ != nullptr) { - pcap_close(handle_); - handle_ = nullptr; - } - if (all_devices_ != nullptr) { - pcap_freealldevs(all_devices_); - all_devices_ = nullptr; - } - if (pcapng_file_ != nullptr) { - std::fclose(pcapng_file_); - pcapng_file_ = nullptr; - } - if (replay_file_ != nullptr) { - std::fclose(replay_file_); - replay_file_ = nullptr; - } - } - - pcap_t* handle() const { return handle_; } - const std::string& device() const { return device_; } - int datalink() const { return datalink_; } - bool is_replay() const { return is_replay_; } - pcapng::Writer* pcapng_writer() { return pcapng_writer_ ? &*pcapng_writer_ : nullptr; } - -private: - static void capture_callback(unsigned char* user, const pcap_pkthdr* header, - const unsigned char* raw) { - auto* queue = reinterpret_cast(user); - CapturedPacket packet; - packet.ts_sec = static_cast(header->ts.tv_sec); - packet.ts_usec = static_cast(header->ts.tv_usec); - packet.original_len = header->len; - packet.data.assign(raw, raw + header->caplen); - queue->try_push(std::move(packet)); - } - - static CapturedPacket to_captured_packet(pcapng::PacketRecord&& record) { - CapturedPacket packet; - packet.ts_sec = static_cast(record.timestamp_us / 1'000'000ULL); - packet.ts_usec = static_cast(record.timestamp_us % 1'000'000ULL); - packet.original_len = record.original_len; - packet.data = std::move(record.data); - return packet; - } - - std::optional open_pcapng_writer(const std::string& path) { - pcapng_file_ = std::fopen(path.c_str(), "wb"); - if (pcapng_file_ == nullptr) { - return "failed to open " + path + " for writing: " + std::strerror(errno); - } - pcapng_writer_.emplace(pcapng_file_); - pcapng_writer_->write_section_header(); - pcapng_writer_->write_interface_description(65535, - static_cast(datalink_)); - return std::nullopt; - } - - std::optional open_replay(const std::string& path, - const std::optional& pcapng_output_path) { - replay_file_ = std::fopen(path.c_str(), "rb"); - if (replay_file_ == nullptr) { - return "failed to open " + path + " for reading: " + std::strerror(errno); - } - - replay_reader_.emplace(replay_file_); - // Reading the first packet is also what makes the reader consume - // the SHB/IDB blocks that precede it, which is what populates - // link_type() below - there's no separate "just read the - // header" step, so the packet itself is kept, not discarded. - first_replay_packet_ = replay_reader_->next_packet(); - if (!first_replay_packet_) { - return "no packets found in " + path + " (empty, or not a valid pcapng file)"; - } - - auto link_type = replay_reader_->link_type(); - if (!link_type || !is_supported_datalink(static_cast(*link_type))) { - return "unsupported or missing link type in " + path; - } - - datalink_ = static_cast(*link_type); - device_ = path; - is_replay_ = true; - - if (pcapng_output_path) { - if (auto err = open_pcapng_writer(*pcapng_output_path)) return err; - } - - return std::nullopt; - } - - pcap_t* handle_ = nullptr; - pcap_if_t* all_devices_ = nullptr; - std::string device_; - int datalink_ = 0; - std::FILE* pcapng_file_ = nullptr; - std::optional pcapng_writer_; - - bool is_replay_ = false; - std::FILE* replay_file_ = nullptr; - std::optional replay_reader_; - std::optional first_replay_packet_; - std::atomic replay_stop_requested_{false}; -}; - -} // namespace wireframe diff --git a/include/wireframe/filter.hpp b/include/wireframe/filter.hpp deleted file mode 100644 index 49fa4ab..0000000 --- a/include/wireframe/filter.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include - -// Thin wrapper around libpcap's BPF filter compiler. tcpdump-style -// filter syntax ("tcp port 80", "host 10.0.0.1 and not icmp") already -// has a correct, well-tested parser and compiler in libpcap itself -- -// hand-rolling a second one would be a large, separate project with no -// bearing on this one's actual goal (the C++ memory model), so this -// wraps the existing implementation instead of reinventing it. -namespace wireframe { - -// Compiles `expression` against `handle`'s linktype/snaplen into -// `out`. `handle` can be a real, already-open capture handle, or a -// throwaway one from pcap_open_dead() - pcap_compile() only needs the -// handle to know the linktype and to report errors via pcap_geterr(), -// it doesn't require an active capture. That's what makes this -// testable without root or a real interface. -// -// Returns nullopt on success (with `out` filled in and owned by the -// caller - pcap_freecode(out) once it's no longer needed, including -// after a successful pcap_setfilter()). Returns pcap's error message -// on failure, and leaves `out` unmodified. -inline std::optional compile_filter(pcap_t* handle, const std::string& expression, - bpf_program* out) { - if (pcap_compile(handle, out, expression.c_str(), /*optimize=*/1, PCAP_NETMASK_UNKNOWN) == - -1) { - return std::string(pcap_geterr(handle)); - } - return std::nullopt; -} - -} // namespace wireframe diff --git a/include/wireframe/l7/dissector.hpp b/include/wireframe/l7/dissector.hpp deleted file mode 100644 index 9b2cc32..0000000 --- a/include/wireframe/l7/dissector.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// Small interface/vtable for L7 dissectors (PLAN.md's architecture -// sketch), so protocols can be registered and added incrementally -// without touching the L2-L4 decode path or main.cpp's dispatch logic. -namespace wireframe::net { - -class L7Dissector { -public: - virtual ~L7Dissector() = default; - - // The transport port this dissector claims (e.g. 53 for DNS). A - // single fixed port is enough for the protocols in scope so far; - // dissectors needing a port range or heuristic sniffing can widen - // this later without changing the registry's shape. - virtual std::uint16_t port() const = 0; - - // A one-line summary of the payload, or nullopt if it doesn't look - // like this protocol (e.g. truncated/malformed). - virtual std::optional summarize(std::span payload) const = 0; -}; - -class L7Registry { -public: - void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); } - - std::optional dissect(std::uint16_t port, - std::span payload) const { - for (const auto* dissector : dissectors_) { - if (dissector->port() == port) return dissector->summarize(payload); - } - return std::nullopt; - } - -private: - std::vector dissectors_; -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/dns.hpp b/include/wireframe/l7/dns.hpp deleted file mode 100644 index 5c1ab36..0000000 --- a/include/wireframe/l7/dns.hpp +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" -#include "wireframe/l7/dissector.hpp" - -// Hand-rolled DNS message parsing: header + the first question record. -// Answer/authority/additional records aren't decoded (not needed for a -// one-line summary), so name-compression pointers there are never -// followed - a pointer in the question section itself is rejected -// rather than chased, keeping this a pure forward scan with no risk of -// a pointer loop. -namespace wireframe::net { - -inline constexpr std::uint16_t kDnsPort = 53; - -struct DnsHeader { - std::uint16_t id; - bool is_response; - std::uint8_t opcode; - std::uint8_t rcode; - std::uint16_t qdcount; - std::uint16_t ancount; -}; - -struct DnsQuestion { - std::string name; - std::uint16_t qtype; -}; - -struct DnsMessage { - DnsHeader header; - std::optional question; // first question only -}; - -// Reads a (possibly multi-label) dotted name starting at offset. -// Returns the name and the offset just past it, or nullopt on -// truncation or a compression pointer (0xC0 prefix - valid in -// answer/authority records, not supported here). -inline std::optional> read_dns_name( - std::span bytes, std::size_t offset) { - std::string name; - while (true) { - if (offset >= bytes.size()) return std::nullopt; - std::uint8_t len = bytes[offset]; - if (len == 0) { - ++offset; - break; - } - if ((len & 0xC0) == 0xC0) return std::nullopt; // compression pointer: unsupported - ++offset; - if (offset + len > bytes.size()) return std::nullopt; - if (!name.empty()) name += '.'; - for (std::uint8_t i = 0; i < len; ++i) name += static_cast(bytes[offset + i]); - offset += len; - } - return std::make_pair(std::move(name), offset); -} - -inline std::optional parse_dns(std::span bytes) { - if (bytes.size() < 12) return std::nullopt; - - DnsHeader header{}; - header.id = read_be16(bytes, 0); - std::uint16_t flags = read_be16(bytes, 2); - header.is_response = (flags & 0x8000) != 0; - header.opcode = static_cast((flags >> 11) & 0x0F); - header.rcode = static_cast(flags & 0x0F); - header.qdcount = read_be16(bytes, 4); - header.ancount = read_be16(bytes, 6); - - DnsMessage msg{header, std::nullopt}; - if (header.qdcount >= 1) { - if (auto result = read_dns_name(bytes, 12)) { - auto& [name, next_offset] = *result; - if (next_offset + 4 <= bytes.size()) { - msg.question = DnsQuestion{std::move(name), read_be16(bytes, next_offset)}; - } - } - } - return msg; -} - -class DnsDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kDnsPort; } - - std::optional summarize(std::span payload) const override { - auto msg = parse_dns(payload); - if (!msg) return std::nullopt; - - std::string out = "DNS "; - out += msg->header.is_response ? "response" : "query"; - out += " id=" + std::to_string(msg->header.id); - if (msg->header.is_response) out += " ancount=" + std::to_string(msg->header.ancount); - if (msg->question) { - out += " " + msg->question->name + " type=" + std::to_string(msg->question->qtype); - } - return out; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/http.hpp b/include/wireframe/l7/http.hpp deleted file mode 100644 index 4780b23..0000000 --- a/include/wireframe/l7/http.hpp +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/l7/dissector.hpp" - -// Best-effort, single-segment HTTP/1.x request/status-line parsing (plus -// the Host: header for requests). No TCP stream reassembly, so a -// message split across multiple packets is only partially visible here -// - the same scope DNS already has (single UDP datagram, no -// reassembly). Good enough for a one-line summary, not a full dissector. -namespace wireframe::net { - -inline constexpr std::uint16_t kHttpPort = 80; - -struct HttpMessage { - bool is_request; - std::string method_or_version; // request: method (GET); response: "HTTP/1.1" - std::string target_or_status; // request: target path; response: status code - std::optional host; // request only, from a Host: header if present -}; - -inline std::optional parse_http(std::span payload) { - std::string_view text(reinterpret_cast(payload.data()), payload.size()); - - std::size_t line_end = text.find("\r\n"); - std::size_t term_len = 2; - if (line_end == std::string_view::npos) { - line_end = text.find('\n'); - term_len = 1; - if (line_end == std::string_view::npos) return std::nullopt; - } - std::string_view first_line = text.substr(0, line_end); - - std::size_t sp1 = first_line.find(' '); - if (sp1 == std::string_view::npos) return std::nullopt; - std::size_t sp2 = first_line.find(' ', sp1 + 1); - if (sp2 == std::string_view::npos) return std::nullopt; - - std::string_view field1 = first_line.substr(0, sp1); - std::string_view field2 = first_line.substr(sp1 + 1, sp2 - sp1 - 1); - - HttpMessage msg; - - if (field1.substr(0, 5) == "HTTP/") { - msg.is_request = false; - msg.method_or_version = std::string(field1); - msg.target_or_status = std::string(field2); - return msg; - } - - static constexpr std::string_view kMethods[] = {"GET", "POST", "PUT", "DELETE", - "HEAD", "OPTIONS", "PATCH", "CONNECT", - "TRACE"}; - bool known_method = false; - for (auto method : kMethods) { - if (field1 == method) { - known_method = true; - break; - } - } - if (!known_method) return std::nullopt; - - msg.is_request = true; - msg.method_or_version = std::string(field1); - msg.target_or_status = std::string(field2); - - // Best-effort Host: header scan, bounded by whatever this one - // packet contains and terminated at the first blank line (end of - // headers) or the end of the payload - never loops past text.size(). - std::size_t pos = line_end + term_len; - while (pos < text.size()) { - std::size_t next_end = text.find("\r\n", pos); - std::size_t header_len = (next_end == std::string_view::npos) ? text.size() - pos - : next_end - pos; - std::string_view header_line = text.substr(pos, header_len); - if (header_line.empty()) break; // blank line: end of headers - - if (header_line.size() > 5 && - (header_line.substr(0, 5) == "Host:" || header_line.substr(0, 5) == "host:")) { - std::size_t value_start = 5; - while (value_start < header_line.size() && header_line[value_start] == ' ') { - ++value_start; - } - msg.host = std::string(header_line.substr(value_start)); - } - - if (next_end == std::string_view::npos) break; - pos = next_end + 2; - } - - return msg; -} - -class HttpDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kHttpPort; } - - std::optional summarize(std::span payload) const override { - auto msg = parse_http(payload); - if (!msg) return std::nullopt; - - std::string out = "HTTP " + msg->method_or_version + " " + msg->target_or_status; - if (msg->host) out += " Host: " + *msg->host; - return out; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/mdns.hpp b/include/wireframe/l7/mdns.hpp deleted file mode 100644 index 887d811..0000000 --- a/include/wireframe/l7/mdns.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/l7/dissector.hpp" -#include "wireframe/l7/dns.hpp" - -// mDNS (RFC 6762) reuses DNS's exact wire format - same header layout, -// same question/name encoding - just over a different port (5353, -// usually to/from the multicast address 224.0.0.251) and typically -// with many questions/answers per packet instead of DNS's usual one. -// parse_dns() already only looks at the first question, which is true -// here too; the only real difference worth a label is which protocol -// this traffic actually is, so real-world capture output doesn't read -// "DNS" for traffic that never touched a resolver. -namespace wireframe::net { - -inline constexpr std::uint16_t kMdnsPort = 5353; - -class MdnsDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kMdnsPort; } - - std::optional summarize(std::span payload) const override { - auto msg = parse_dns(payload); - if (!msg) return std::nullopt; - - // No id= field here unlike DnsDissector's summary: RFC 6762 - // 18.1 has multicast queries send it as zero, so printing it - // would just be "id=0" noise on real traffic. - std::string out = "mDNS "; - out += msg->header.is_response ? "response" : "query"; - if (msg->header.is_response) out += " ancount=" + std::to_string(msg->header.ancount); - if (msg->question) { - out += " " + msg->question->name + " type=" + std::to_string(msg->question->qtype); - } - return out; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/ssh.hpp b/include/wireframe/l7/ssh.hpp deleted file mode 100644 index efa471f..0000000 --- a/include/wireframe/l7/ssh.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/l7/dissector.hpp" - -// SSH's identification exchange (RFC 4253 section 4.2) is the one part -// of an SSH connection sent in the clear, before key exchange starts -// encrypting everything: both sides open with a single line of the -// form "SSH-protoversion-softwareversion[ comments]" terminated by -// CR LF (a bare LF is tolerated too, same leniency this project's HTTP -// dissector already uses). Only that first line is ever readable -- -// everything after key exchange is opaque, so this dissector only ever -// has one line to look at, on either side of the connection. -namespace wireframe::net { - -inline constexpr std::uint16_t kSshPort = 22; - -struct SshBanner { - std::string proto_version; - std::string software_version; -}; - -inline std::optional parse_ssh_banner(std::span payload) { - std::string_view text(reinterpret_cast(payload.data()), payload.size()); - if (text.substr(0, 4) != "SSH-") return std::nullopt; - - std::size_t line_end = text.find("\r\n"); - if (line_end == std::string_view::npos) { - line_end = text.find('\n'); - if (line_end == std::string_view::npos) return std::nullopt; - } - std::string_view line = text.substr(4, line_end - 4); // past "SSH-" - - std::size_t dash = line.find('-'); - if (dash == std::string_view::npos) return std::nullopt; - - SshBanner banner; - banner.proto_version = std::string(line.substr(0, dash)); - - // The software version runs up to the first space (start of an - // optional comment) or the end of the line, whichever is first. - std::string_view rest = line.substr(dash + 1); - std::size_t space = rest.find(' '); - banner.software_version = std::string(space == std::string_view::npos ? rest - : rest.substr(0, space)); - return banner; -} - -class SshDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kSshPort; } - - std::optional summarize(std::span payload) const override { - auto banner = parse_ssh_banner(payload); - if (!banner) return std::nullopt; - return "SSH " + banner->proto_version + " " + banner->software_version; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/l7/tls.hpp b/include/wireframe/l7/tls.hpp deleted file mode 100644 index 1c6dc57..0000000 --- a/include/wireframe/l7/tls.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" -#include "wireframe/l7/dissector.hpp" - -// TLS ClientHello -> SNI extension parsing. Most web traffic is TLS -// today, so HTTP alone covers a shrinking fraction of it - SNI is what -// makes a packet analyzer useful against that traffic without -// decrypting anything: the server name is sent in cleartext in the -// ClientHello, before any encryption starts, in every TLS version this -// parses (the ClientHello/extension wire format hasn't changed across -// versions - only what happens after it has). -// -// Same scope as the other L7 dissectors: single-segment, best-effort. -// A ClientHello padded across multiple TCP segments (large cookie/PSK -// extensions, unusual but possible) is only partially visible here. -// Every length field is bounds-checked against what's actually left in -// the buffer before use - this is exactly the kind of nested, -// attacker-influenced TLV structure the project's decoders are meant -// to get right. -namespace wireframe::net { - -inline constexpr std::uint16_t kTlsPort = 443; -inline constexpr std::uint8_t kTlsContentTypeHandshake = 0x16; -inline constexpr std::uint8_t kTlsHandshakeTypeClientHello = 0x01; -inline constexpr std::uint16_t kTlsExtensionServerName = 0x0000; - -struct TlsClientHello { - std::optional server_name; // SNI, if the extension was present and well-formed -}; - -inline std::optional parse_tls_client_hello(std::span bytes) { - // Record header: ContentType(1) ProtocolVersion(2) Length(2) - if (bytes.size() < 5) return std::nullopt; - if (bytes[0] != kTlsContentTypeHandshake) return std::nullopt; - std::uint16_t record_len = read_be16(bytes, 3); - if (bytes.size() < static_cast(5) + record_len) return std::nullopt; - - std::span handshake = bytes.subspan(5); - - // Handshake header: HandshakeType(1) Length(3, 24-bit BE) - if (handshake.size() < 4) return std::nullopt; - if (handshake[0] != kTlsHandshakeTypeClientHello) return std::nullopt; - std::uint32_t hs_len = (static_cast(handshake[1]) << 16) | - (static_cast(handshake[2]) << 8) | - static_cast(handshake[3]); - - std::span body = handshake.subspan(4); - if (body.size() < hs_len) return std::nullopt; - body = body.first(hs_len); // never read past the declared handshake body - - std::size_t offset = 0; - - // client_version(2) + random(32) - if (body.size() < offset + 34) return std::nullopt; - offset += 34; - - // legacy_session_id: length(1) + data - if (body.size() < offset + 1) return std::nullopt; - std::uint8_t session_id_len = body[offset]; - offset += 1; - if (body.size() < offset + session_id_len) return std::nullopt; - offset += session_id_len; - - // cipher_suites: length(2) + data - if (body.size() < offset + 2) return std::nullopt; - std::uint16_t cipher_suites_len = read_be16(body, offset); - offset += 2; - if (body.size() < static_cast(offset) + cipher_suites_len) return std::nullopt; - offset += cipher_suites_len; - - // legacy_compression_methods: length(1) + data - if (body.size() < offset + 1) return std::nullopt; - std::uint8_t compression_len = body[offset]; - offset += 1; - if (body.size() < offset + compression_len) return std::nullopt; - offset += compression_len; - - TlsClientHello hello; - if (offset == body.size()) return hello; // no extensions block: no SNI, still a valid hello - - // extensions: length(2) + data - if (body.size() < offset + 2) return std::nullopt; - std::uint16_t extensions_len = read_be16(body, offset); - offset += 2; - if (body.size() < static_cast(offset) + extensions_len) return std::nullopt; - std::size_t extensions_end = offset + extensions_len; - - while (offset + 4 <= extensions_end) { - std::uint16_t ext_type = read_be16(body, offset); - std::uint16_t ext_len = read_be16(body, offset + 2); - std::size_t ext_data_start = offset + 4; - std::size_t ext_data_end = ext_data_start + ext_len; - if (ext_data_end > extensions_end) break; // malformed: stop, keep what we have - - if (ext_type == kTlsExtensionServerName && ext_len >= 2) { - // ServerNameList: list_len(2) + entries; only the first - // entry is used, matching every real client's behavior of - // sending exactly one host_name entry. - std::uint16_t list_len = read_be16(body, ext_data_start); - std::size_t list_start = ext_data_start + 2; - std::size_t list_end = list_start + list_len; - if (list_end <= ext_data_end && list_start + 3 <= list_end) { - std::uint8_t name_type = body[list_start]; - std::uint16_t name_len = read_be16(body, list_start + 1); - std::size_t name_start = list_start + 3; - if (name_type == 0 && name_start + name_len <= list_end) { - hello.server_name = std::string( - reinterpret_cast(body.data() + name_start), name_len); - } - } - } - - offset = ext_data_end; - } - - return hello; -} - -class TlsSniDissector : public L7Dissector { -public: - std::uint16_t port() const override { return kTlsPort; } - - std::optional summarize(std::span payload) const override { - auto hello = parse_tls_client_hello(payload); - if (!hello) return std::nullopt; - - std::string out = "TLS ClientHello"; - if (hello->server_name) out += " SNI=" + *hello->server_name; - return out; - } -}; - -} // namespace wireframe::net diff --git a/include/wireframe/net/checksum.hpp b/include/wireframe/net/checksum.hpp deleted file mode 100644 index 97e5254..0000000 --- a/include/wireframe/net/checksum.hpp +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "wireframe/net/ipv4.hpp" - -// RFC 1071 Internet checksum, and the IPv4/TCP/UDP verification built -// on it. Not wired into summarize_packet(): on loopback, and for many -// packets captured right as they leave the local machine, the -// transmitted checksum is legitimately 0x0000 or garbage - modern -// NICs compute it in hardware ("checksum offload") only once the frame -// actually reaches them, which is *after* most capture points see it. -// Flagging that as "BAD" by default would be noise, not signal, on -// exactly the interfaces this project has been tested against all -// session (lo, tailscale0). Wireshark makes this opt-in for the same -// reason; so does this (CLI's -c flag calls these directly). -namespace wireframe::net { - -// One's-complement sum of 16-bit big-endian words, folded back into 16 -// bits, then complemented. Used identically by IPv4's header checksum -// and, over a pseudo-header + segment instead of a plain header, by -// TCP/UDP. -inline std::uint16_t internet_checksum(std::span data) { - std::uint32_t sum = 0; - std::size_t i = 0; - for (; i + 1 < data.size(); i += 2) { - sum += (static_cast(data[i]) << 8) | data[i + 1]; - } - if (i < data.size()) { - sum += static_cast(data[i]) << 8; // odd trailing byte: high half only - } - while (sum >> 16) { - sum = (sum & 0xFFFFu) + (sum >> 16); - } - return static_cast(~sum & 0xFFFFu); -} - -// `header_bytes` must be exactly the IPv4 header as it appeared on the -// wire (IHL*4 bytes, options included, checksum field included as its -// real transmitted value - not zeroed). Summing a header that already -// contains its own valid checksum comes out to exactly 0; that's the -// verification, no need for a mutable copy with the field zeroed out. -inline bool verify_ipv4_checksum(std::span header_bytes) { - return internet_checksum(header_bytes) == 0; -} - -enum class ChecksumResult { kValid, kInvalid, kNotPresent }; - -namespace detail { - -inline std::vector build_ipv4_pseudo_header(const Ipv4Address& src, - const Ipv4Address& dst, - std::uint8_t protocol, - std::span segment) { - std::vector buf; - buf.reserve(12 + segment.size()); - buf.insert(buf.end(), src.bytes.begin(), src.bytes.end()); - buf.insert(buf.end(), dst.bytes.begin(), dst.bytes.end()); - buf.push_back(0); - buf.push_back(protocol); - std::uint16_t len = static_cast(segment.size()); - buf.push_back(static_cast(len >> 8)); - buf.push_back(static_cast(len & 0xFF)); - buf.insert(buf.end(), segment.begin(), segment.end()); - return buf; -} - -} // namespace detail - -// TCP's checksum is mandatory - always kValid or kInvalid. -inline ChecksumResult verify_tcp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, - std::span tcp_segment) { - auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoTcp, tcp_segment); - return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; -} - -// UDP's checksum is optional over IPv4 (RFC 768): a transmitted value -// of exactly 0x0000 means "no checksum was computed", not "checksum is -// zero" - that's kNotPresent, not a failure. -inline ChecksumResult verify_udp_checksum_ipv4(const Ipv4Address& src, const Ipv4Address& dst, - std::span udp_datagram) { - if (udp_datagram.size() >= 8 && udp_datagram[6] == 0 && udp_datagram[7] == 0) { - return ChecksumResult::kNotPresent; - } - auto buf = detail::build_ipv4_pseudo_header(src, dst, kProtoUdp, udp_datagram); - return internet_checksum(buf) == 0 ? ChecksumResult::kValid : ChecksumResult::kInvalid; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/ethernet.hpp b/include/wireframe/net/ethernet.hpp deleted file mode 100644 index 2da4cc8..0000000 --- a/include/wireframe/net/ethernet.hpp +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" - -namespace wireframe::net { - -inline constexpr std::size_t kEthernetHeaderLen = 14; -inline constexpr std::uint16_t kEthertypeIPv4 = 0x0800; -inline constexpr std::uint16_t kEthertypeIPv6 = 0x86DD; -inline constexpr std::uint16_t kEthertypeArp = 0x0806; - -struct MacAddress { - std::array bytes; -}; - -struct EthernetHeader { - MacAddress dst; - MacAddress src; - std::uint16_t ethertype; -}; - -struct EthernetFrame { - EthernetHeader header; - std::span payload; -}; - -inline std::optional parse_ethernet(std::span bytes) { - if (bytes.size() < kEthernetHeaderLen) return std::nullopt; - - EthernetHeader header{}; - std::copy_n(bytes.begin(), 6, header.dst.bytes.begin()); - std::copy_n(bytes.begin() + 6, 6, header.src.bytes.begin()); - header.ethertype = read_be16(bytes, 12); - - return EthernetFrame{header, bytes.subspan(kEthernetHeaderLen)}; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/icmp.hpp b/include/wireframe/net/icmp.hpp deleted file mode 100644 index af83916..0000000 --- a/include/wireframe/net/icmp.hpp +++ /dev/null @@ -1,84 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" - -// ICMPv4 (RFC 792) and ICMPv6 (RFC 4443) share the same first-4-byte -// shape (Type, Code, Checksum) but a completely different type -// namespace - the same numeric type means something different in each -// - so they get separate parse functions and separate type-name -// tables, sharing only the header struct shape. Neither protocol has -// ports, so this doesn't fit L7Registry's port-keyed dispatch at all; -// it's handled directly by protocol number in summarize.hpp instead. -namespace wireframe::net { - -struct IcmpHeader { - std::uint8_t type; - std::uint8_t code; - std::optional identifier; // echo request/reply only - std::optional sequence; // echo request/reply only -}; - -inline std::optional parse_icmpv4(std::span bytes) { - if (bytes.size() < 4) return std::nullopt; - - IcmpHeader header{}; - header.type = bytes[0]; - header.code = bytes[1]; - if ((header.type == 8 || header.type == 0) && bytes.size() >= 8) { // echo request/reply - header.identifier = read_be16(bytes, 4); - header.sequence = read_be16(bytes, 6); - } - return header; -} - -inline std::string icmpv4_type_name(std::uint8_t type) { - switch (type) { - case 0: return "Echo Reply"; - case 3: return "Destination Unreachable"; - case 4: return "Source Quench"; - case 5: return "Redirect"; - case 8: return "Echo Request"; - case 11: return "Time Exceeded"; - case 12: return "Parameter Problem"; - case 13: return "Timestamp Request"; - case 14: return "Timestamp Reply"; - default: return "type=" + std::to_string(type); - } -} - -inline std::optional parse_icmpv6(std::span bytes) { - if (bytes.size() < 4) return std::nullopt; - - IcmpHeader header{}; - header.type = bytes[0]; - header.code = bytes[1]; - if ((header.type == 128 || header.type == 129) && bytes.size() >= 8) { // echo request/reply - header.identifier = read_be16(bytes, 4); - header.sequence = read_be16(bytes, 6); - } - return header; -} - -inline std::string icmpv6_type_name(std::uint8_t type) { - switch (type) { - case 1: return "Destination Unreachable"; - case 2: return "Packet Too Big"; - case 3: return "Time Exceeded"; - case 4: return "Parameter Problem"; - case 128: return "Echo Request"; - case 129: return "Echo Reply"; - case 133: return "Router Solicitation"; - case 134: return "Router Advertisement"; - case 135: return "Neighbor Solicitation"; - case 136: return "Neighbor Advertisement"; - case 137: return "Redirect"; - default: return "type=" + std::to_string(type); - } -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/ipv4.hpp b/include/wireframe/net/ipv4.hpp deleted file mode 100644 index f53b4f2..0000000 --- a/include/wireframe/net/ipv4.hpp +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" - -namespace wireframe::net { - -inline constexpr std::uint8_t kProtoIcmp = 1; -inline constexpr std::uint8_t kProtoTcp = 6; -inline constexpr std::uint8_t kProtoUdp = 17; - -struct Ipv4Address { - std::array bytes; -}; - -struct Ipv4Header { - std::uint8_t version; - std::uint8_t ihl; // header length in 32-bit words - std::uint16_t total_length; - std::uint8_t ttl; - std::uint8_t protocol; - Ipv4Address src; - Ipv4Address dst; -}; - -struct Ipv4Packet { - Ipv4Header header; - std::span payload; -}; - -inline std::optional parse_ipv4(std::span bytes) { - if (bytes.size() < 20) return std::nullopt; - - std::uint8_t version = static_cast(bytes[0] >> 4); - std::uint8_t ihl = bytes[0] & 0x0F; - std::size_t header_len = static_cast(ihl) * 4; - if (version != 4 || header_len < 20 || bytes.size() < header_len) return std::nullopt; - - Ipv4Header header{}; - header.version = version; - header.ihl = ihl; - header.total_length = read_be16(bytes, 2); - header.ttl = bytes[8]; - header.protocol = bytes[9]; - std::copy_n(bytes.begin() + 12, 4, header.src.bytes.begin()); - std::copy_n(bytes.begin() + 16, 4, header.dst.bytes.begin()); - - return Ipv4Packet{header, bytes.subspan(header_len)}; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/ipv6.hpp b/include/wireframe/net/ipv6.hpp deleted file mode 100644 index 4b6b28a..0000000 --- a/include/wireframe/net/ipv6.hpp +++ /dev/null @@ -1,173 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "wireframe/byteio.hpp" - -namespace wireframe::net { - -inline constexpr std::size_t kIpv6HeaderLen = 40; -inline constexpr std::uint8_t kNextHeaderHopByHop = 0; -inline constexpr std::uint8_t kNextHeaderRouting = 43; -inline constexpr std::uint8_t kNextHeaderFragment = 44; -inline constexpr std::uint8_t kNextHeaderEsp = 50; -inline constexpr std::uint8_t kNextHeaderAh = 51; -inline constexpr std::uint8_t kNextHeaderIcmpv6 = 58; -inline constexpr std::uint8_t kNextHeaderDestOptions = 60; - -struct Ipv6Address { - std::array bytes; -}; - -struct Ipv6Header { - std::uint8_t version; - std::uint8_t traffic_class; - std::uint32_t flow_label; - std::uint16_t payload_length; - std::uint8_t next_header; // transport protocol, or an extension header type - std::uint8_t hop_limit; - Ipv6Address src; - Ipv6Address dst; -}; - -struct Ipv6Packet { - Ipv6Header header; - std::span payload; -}; - -// Only the fixed 40-byte header is decoded here - header.next_header -// may name an extension header rather than a transport protocol. -// walk_ipv6_extension_headers() (below) resolves that; parse_ipv6() -// itself stays a direct, unconditional decode of exactly the fixed -// header, nothing more. -inline std::optional parse_ipv6(std::span bytes) { - if (bytes.size() < kIpv6HeaderLen) return std::nullopt; - - std::uint8_t version = static_cast(bytes[0] >> 4); - if (version != 6) return std::nullopt; - - Ipv6Header header{}; - header.version = version; - std::uint32_t first_word = read_be32(bytes, 0); - header.traffic_class = static_cast((first_word >> 20) & 0xFF); - header.flow_label = first_word & 0x000FFFFF; - header.payload_length = read_be16(bytes, 4); - header.next_header = bytes[6]; - header.hop_limit = bytes[7]; - std::copy_n(bytes.begin() + 8, 16, header.src.bytes.begin()); - std::copy_n(bytes.begin() + 24, 16, header.dst.bytes.begin()); - - return Ipv6Packet{header, bytes.subspan(kIpv6HeaderLen)}; -} - -struct Ipv6ExtensionWalkResult { - std::uint8_t final_next_header; // a transport protocol, or an extension type we stopped at - std::span payload; // bytes after every extension header walked - bool stopped_at_esp; // true if ESP was hit - see walk_ipv6_extension_headers() -}; - -// Walks Hop-by-Hop, Routing, Destination Options, Fragment, and AH -// extension headers to find the real transport protocol underneath -// them, so e.g. TCP/UDP wrapped in a Hop-by-Hop options header is still -// decoded instead of silently stopping at "next_header=0". Each header -// carries its own length, so this never needs to understand a header -// type's *meaning* to skip over it correctly - only Hop-by-Hop/ -// Routing/Dest-Options (length in 8-byte units from a trailing byte), -// Fragment (fixed 8 bytes), and AH (length in 4-byte units, RFC 4302) -// have different encodings, all handled explicitly below. -// -// ESP is a hard stop, not a bug: its own next-header field lives in a -// trailer *after* the encrypted payload, at an offset this code has no -// way to know without decrypting first. Reported as stopped_at_esp -// rather than guessed at. -// -// Bounded to a handful of iterations as defense in depth against a -// hostile/corrupt chain - not strictly needed for termination (every -// header is at least 8 bytes, so payload.size() strictly decreases -// each iteration and the loop can't actually run forever), but a -// pathological chain of many tiny headers would otherwise still cost -// real work for no legitimate reason. -inline Ipv6ExtensionWalkResult walk_ipv6_extension_headers(std::uint8_t next_header, - std::span payload) { - constexpr int kMaxExtensionHeaders = 8; - - for (int i = 0; i < kMaxExtensionHeaders; ++i) { - if (next_header == kNextHeaderEsp) { - return {next_header, payload, /*stopped_at_esp=*/true}; - } - - std::size_t ext_len; - if (next_header == kNextHeaderFragment) { - if (payload.size() < 8) return {next_header, payload, false}; - ext_len = 8; - } else if (next_header == kNextHeaderAh) { - if (payload.size() < 2) return {next_header, payload, false}; - ext_len = (static_cast(payload[1]) + 2) * 4; - } else if (next_header == kNextHeaderHopByHop || next_header == kNextHeaderRouting || - next_header == kNextHeaderDestOptions) { - if (payload.size() < 2) return {next_header, payload, false}; - ext_len = (static_cast(payload[1]) + 1) * 8; - } else { - break; // TCP/UDP/ICMPv6/anything else we don't chain through: stop here - } - - if (payload.size() < ext_len) return {next_header, payload, false}; // truncated: stop - - std::uint8_t this_next_header = payload[0]; - payload = payload.subspan(ext_len); - next_header = this_next_header; - } - - return {next_header, payload, false}; -} - -// RFC 5952 canonical text form: lowercase hex, and the longest run of -// two-or-more consecutive zero groups (leftmost wins a tie) collapsed to -// "::". A lone zero group is left as "0", not compressed, per 5952 4.2.2. -inline std::string ipv6_to_string(const Ipv6Address& addr) { - std::array groups{}; - for (std::size_t i = 0; i < 8; ++i) { - groups[i] = static_cast((addr.bytes[i * 2] << 8) | addr.bytes[i * 2 + 1]); - } - - int best_start = -1; - int best_len = 0; - int cur_start = -1; - int cur_len = 0; - for (int i = 0; i < 8; ++i) { - if (groups[i] == 0) { - if (cur_start < 0) cur_start = i; - ++cur_len; - if (cur_len > best_len) { - best_start = cur_start; - best_len = cur_len; - } - } else { - cur_start = -1; - cur_len = 0; - } - } - if (best_len < 2) best_start = -1; // don't compress a lone zero group - - std::string out; - char buf[6]; - for (int i = 0; i < 8; ++i) { - if (i == best_start) { - out += "::"; - i += best_len - 1; // the for-loop's ++i advances past the run - continue; - } - if (!out.empty() && out.back() != ':') out += ':'; - std::snprintf(buf, sizeof(buf), "%x", groups[i]); - out += buf; - } - return out; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/tcp.hpp b/include/wireframe/net/tcp.hpp deleted file mode 100644 index f691a7f..0000000 --- a/include/wireframe/net/tcp.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "wireframe/byteio.hpp" - -namespace wireframe::net { - -// Lower 6 bits of the flags byte: URG ACK PSH RST SYN FIN. CWR/ECE (the -// top 2 bits) are masked off - not needed for now. -inline constexpr std::uint8_t kTcpFin = 0x01; -inline constexpr std::uint8_t kTcpSyn = 0x02; -inline constexpr std::uint8_t kTcpRst = 0x04; -inline constexpr std::uint8_t kTcpPsh = 0x08; -inline constexpr std::uint8_t kTcpAck = 0x10; -inline constexpr std::uint8_t kTcpUrg = 0x20; - -struct TcpHeader { - std::uint16_t src_port; - std::uint16_t dst_port; - std::uint32_t seq; - std::uint32_t ack; - std::uint8_t data_offset; // header length in 32-bit words - std::uint8_t flags; - std::uint16_t window; -}; - -struct TcpSegment { - TcpHeader header; - std::span payload; -}; - -inline std::optional parse_tcp(std::span bytes) { - if (bytes.size() < 20) return std::nullopt; - - std::uint8_t data_offset = static_cast(bytes[12] >> 4); - std::size_t header_len = static_cast(data_offset) * 4; - if (header_len < 20 || bytes.size() < header_len) return std::nullopt; - - TcpHeader header{}; - header.src_port = read_be16(bytes, 0); - header.dst_port = read_be16(bytes, 2); - header.seq = read_be32(bytes, 4); - header.ack = read_be32(bytes, 8); - header.data_offset = data_offset; - header.flags = bytes[13] & 0x3F; - header.window = read_be16(bytes, 14); - - return TcpSegment{header, bytes.subspan(header_len)}; -} - -} // namespace wireframe::net diff --git a/include/wireframe/net/tcp_reassembly.hpp b/include/wireframe/net/tcp_reassembly.hpp deleted file mode 100644 index 90824a4..0000000 --- a/include/wireframe/net/tcp_reassembly.hpp +++ /dev/null @@ -1,125 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "wireframe/net/ipv4.hpp" - -// Minimal, in-order-only TCP stream reassembly: tracks each flow's two -// directions separately, accumulating payload bytes as segments arrive -// exactly in sequence order. Out-of-order segments and retransmissions -// are dropped rather than buffered for later reordering - a real -// limitation, but a reasonable one for a learning-focused reassembler -// capturing directly on an endpoint (this project's demonstrated use -// all session: lo, wlp1s0, tailscale0), where segments mostly do -// arrive in order. A capture point far from either endpoint (e.g. a -// middlebox) would need real out-of-order buffering this doesn't do. -// -// The point: HTTP's dissector (wireframe/l7/http.hpp) only ever sees -// one segment at a time, so a request/response split across TCP -// segments - a Host: header landing in the second packet of a -// request, say - is invisible to it. Feeding the *reassembled* stream -// back through the same parse_http() lets it see what single-segment -// dissection structurally can't. -namespace wireframe::net { - -struct FlowKey { - Ipv4Address ip_a; - std::uint16_t port_a; - Ipv4Address ip_b; - std::uint16_t port_b; - - bool operator<(const FlowKey& other) const { - return std::tie(ip_a.bytes, port_a, ip_b.bytes, port_b) < - std::tie(other.ip_a.bytes, other.port_a, other.ip_b.bytes, other.port_b); - } -}; - -// Canonicalizes a (src, dst) pair into a direction-independent -// FlowKey - both directions of the same connection map to the same -// key - plus whether this segment's source was the "a" side. -inline std::pair canonicalize_flow(const Ipv4Address& src_ip, - std::uint16_t src_port, - const Ipv4Address& dst_ip, - std::uint16_t dst_port) { - bool src_is_a = std::tie(src_ip.bytes, src_port) < std::tie(dst_ip.bytes, dst_port); - FlowKey key = src_is_a ? FlowKey{src_ip, src_port, dst_ip, dst_port} - : FlowKey{dst_ip, dst_port, src_ip, src_port}; - return {key, src_is_a}; -} - -struct DirectionState { - bool syn_seen = false; - std::uint32_t next_seq = 0; - std::vector buffer; -}; - -struct FlowState { - DirectionState a_to_b; - DirectionState b_to_a; -}; - -class TcpReassembler { -public: - explicit TcpReassembler(std::size_t max_buffer_per_direction = 65536, - std::size_t max_flows = 4096) - : max_buffer_(max_buffer_per_direction), max_flows_(max_flows) {} - - // Feeds one TCP segment in. Returns a snapshot of the *sender's* - // accumulated stream so far if this segment extended it - // contiguously in order; nullopt if the segment was out of order, - // a retransmission, a control segment with no payload, or the flow - // table was full and this would be a brand new flow. Returned by - // value rather than by reference: the buffer this points at can - // grow/move on the next call, and bounded copies (max 64 KiB by - // default) are cheap enough that this isn't worth the lifetime risk. - std::optional> process_segment( - const Ipv4Address& src_ip, std::uint16_t src_port, const Ipv4Address& dst_ip, - std::uint16_t dst_port, std::uint32_t seq, std::uint8_t flags, - std::span payload) { - auto [key, src_is_a] = canonicalize_flow(src_ip, src_port, dst_ip, dst_port); - - auto it = flows_.find(key); - if (it == flows_.end()) { - if (flows_.size() >= max_flows_) return std::nullopt; // table full: drop new flows - it = flows_.emplace(key, FlowState{}).first; - } - DirectionState& dir = src_is_a ? it->second.a_to_b : it->second.b_to_a; - - constexpr std::uint8_t kSyn = 0x02; - if (flags & kSyn) { - dir.syn_seen = true; - dir.next_seq = seq + 1; // the SYN itself consumes one sequence number - return std::nullopt; - } - - // seq != dir.next_seq covers both out-of-order segments and - // retransmissions (a retransmit repeats a seq already below - // next_seq) - unsigned wraparound makes plain equality correct - // even across a sequence-number wrap, no need for RFC 1982 - // serial-number comparison for an exact-match check like this. - if (!dir.syn_seen || payload.empty() || seq != dir.next_seq) { - return std::nullopt; - } - - if (dir.buffer.size() + payload.size() <= max_buffer_) { - dir.buffer.insert(dir.buffer.end(), payload.begin(), payload.end()); - } - dir.next_seq = seq + static_cast(payload.size()); - - return dir.buffer; - } - - std::size_t flow_count() const { return flows_.size(); } - -private: - std::map flows_; - std::size_t max_buffer_; - std::size_t max_flows_; -}; - -} // namespace wireframe::net diff --git a/include/wireframe/net/udp.hpp b/include/wireframe/net/udp.hpp deleted file mode 100644 index 07664c2..0000000 --- a/include/wireframe/net/udp.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "wireframe/byteio.hpp" - -namespace wireframe::net { - -inline constexpr std::size_t kUdpHeaderLen = 8; - -struct UdpHeader { - std::uint16_t src_port; - std::uint16_t dst_port; - std::uint16_t length; -}; - -struct UdpDatagram { - UdpHeader header; - std::span payload; -}; - -inline std::optional parse_udp(std::span bytes) { - if (bytes.size() < kUdpHeaderLen) return std::nullopt; - - UdpHeader header{}; - header.src_port = read_be16(bytes, 0); - header.dst_port = read_be16(bytes, 2); - header.length = read_be16(bytes, 4); - - return UdpDatagram{header, bytes.subspan(kUdpHeaderLen)}; -} - -} // namespace wireframe::net diff --git a/include/wireframe/packet_diagnostics.hpp b/include/wireframe/packet_diagnostics.hpp deleted file mode 100644 index 4b9b0c6..0000000 --- a/include/wireframe/packet_diagnostics.hpp +++ /dev/null @@ -1,92 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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" - -// Checksum validation and TCP stream reassembly are both deliberately -// kept out of summarize_packet()'s shared per-packet output - see -// wireframe/net/checksum.hpp and wireframe/net/tcp_reassembly.hpp for -// why each is opt-in (checksum offload false positives; reassembly's -// per-flow state and extra per-packet work). Shared between the CLI -// (-c/-a) and GUI frontends so they don't hand-roll two separate -// Ethernet/IPv4/TCP walks down to the same byte spans - the same -// reasoning wireframe::CaptureSession exists for at the setup layer. -namespace wireframe { - -inline std::string checksum_status(std::span bytes, int datalink) { - std::span ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return ""; - ip_bytes = eth->payload; - } - if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now - - auto ip = net::parse_ipv4(ip_bytes); - if (!ip) return ""; - - std::size_t header_len = static_cast(ip->header.ihl) * 4; - std::string out = "checksums: IP="; - out += net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; - - using net::ChecksumResult; - if (ip->header.protocol == net::kProtoTcp) { - auto result = 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 == net::kProtoUdp) { - auto result = 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; -} - -inline std::optional reassembled_http_status(std::span bytes, - int datalink, - net::TcpReassembler& reassembler) { - std::span ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != 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 = net::parse_ipv4(ip_bytes); - if (!ip || ip->header.protocol != net::kProtoTcp) return std::nullopt; - - auto tcp = 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 = 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; -} - -} // namespace wireframe diff --git a/include/wireframe/pcapng/reader.hpp b/include/wireframe/pcapng/reader.hpp deleted file mode 100644 index d01b431..0000000 --- a/include/wireframe/pcapng/reader.hpp +++ /dev/null @@ -1,123 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -// Minimal pcapng reader, paired with writer.hpp: reads Enhanced Packet -// Blocks sequentially, skipping the Section Header Block, Interface -// Description Block, and any other block type transparently. -// -// Assumes little-endian block encoding (checked against the Section -// Header Block's byte-order magic, not just assumed) since that's what -// writer.hpp emits and what pcapng writers on this class of hardware -// (tcpdump, dumpcap) produce. A big-endian file is out of scope - this -// pairs with our own writer, not general pcapng interop. -namespace wireframe::pcapng { - -struct PacketRecord { - std::uint32_t interface_id; - std::uint64_t timestamp_us; - std::uint32_t original_len; - std::vector data; -}; - -class Reader { -public: - explicit Reader(std::FILE* file) : file_(file) {} - - // Returns the next packet, or nullopt once the file is exhausted or - // a malformed/unsupported block is hit - treated as end of stream - // rather than a hard error, to keep this reader small. - std::optional next_packet() { - for (;;) { - std::array field{}; - if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt; - std::uint32_t type = get_u32(field); - - if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt; - std::uint32_t total_len = get_u32(field); - if (total_len < 12) return std::nullopt; - - std::size_t body_len = total_len - 12; - // total_len is an untrusted 32-bit value straight from the - // file; without a cap, a corrupted/hostile file can claim - // a multi-gigabyte block and OOM the process on the - // allocation below before a single byte is even read to - // check whether the file actually contains that much data - // (found by fuzzing fuzz_pcapng_reader.cpp - real crash, - // not theoretical). Bounded well above any block our own - // writer produces (packets capped at a 65535 snaplen; this - // reader is explicitly scoped to pair with that writer, - // not arbitrary pcapng interop). - if (body_len > kMaxBlockBodyLen) return std::nullopt; - std::vector body(body_len); - if (body_len > 0 && std::fread(body.data(), 1, body_len, file_) != body_len) { - return std::nullopt; - } - - if (std::fread(field.data(), 1, 4, file_) != 4) return std::nullopt; - if (get_u32(field) != total_len) return std::nullopt; // corrupt trailer - - if (type == kBlockTypeShb) { - if (body_len < 4 || get_u32({body.data(), 4}) != kByteOrderMagic) { - return std::nullopt; // not little-endian, or malformed - } - continue; - } - if (type == kBlockTypeIdb) { - // LinkType is the first 2 bytes of the IDB body (see - // writer.hpp's write_interface_description). Only the - // first IDB is captured - correct for a file our own - // writer produced, which only ever writes one - // interface, matching this reader's documented scope. - if (!link_type_ && body_len >= 2) { - link_type_ = static_cast(body[0] | (body[1] << 8)); - } - continue; - } - if (type != kBlockTypeEpb) continue; // anything else: skip - - if (body_len < 20) return std::nullopt; - - PacketRecord record; - record.interface_id = get_u32({body.data() + 0, 4}); - std::uint32_t ts_high = get_u32({body.data() + 4, 4}); - std::uint32_t ts_low = get_u32({body.data() + 8, 4}); - record.timestamp_us = (static_cast(ts_high) << 32) | ts_low; - std::uint32_t caplen = get_u32({body.data() + 12, 4}); - record.original_len = get_u32({body.data() + 16, 4}); - - if (body_len < 20 + caplen) return std::nullopt; - record.data.assign(body.begin() + 20, body.begin() + 20 + caplen); - return record; - } - } - - // The interface's link type, learned from the Interface - // Description Block once next_packet() has read past it (which - // happens before it ever returns the first EPB, so this is - // populated by the time the first successful next_packet() call - // returns). nullopt if no IDB has been seen yet. - std::optional link_type() const { return link_type_; } - -private: - static std::uint32_t get_u32(std::span b) { - return static_cast(b[0]) | (static_cast(b[1]) << 8) | - (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); - } - - static constexpr std::uint32_t kBlockTypeShb = 0x0A0D0D0A; - static constexpr std::uint32_t kBlockTypeIdb = 0x00000001; - static constexpr std::uint32_t kBlockTypeEpb = 0x00000006; - static constexpr std::uint32_t kByteOrderMagic = 0x1A2B3C4D; - static constexpr std::size_t kMaxBlockBodyLen = 1 << 20; // 1 MiB - - std::FILE* file_; - std::optional link_type_; -}; - -} // namespace wireframe::pcapng diff --git a/include/wireframe/pcapng/writer.hpp b/include/wireframe/pcapng/writer.hpp deleted file mode 100644 index 18f6022..0000000 --- a/include/wireframe/pcapng/writer.hpp +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -// Minimal pcapng writer: one Section Header Block, one Interface -// Description Block, then an Enhanced Packet Block per captured packet. -// Per-block Options are skipped entirely - they're optional in the -// spec, and a block with none simply omits that section, so this stays -// a valid, Wireshark-readable file without needing to hand-encode TLVs. -// -// Multi-byte fields are written little-endian by hand (matching the -// 0x1A2B3C4D byte-order magic below) rather than via struct-casting, -// for the same alignment/UB reasons as the src/wireframe/net decoders. -namespace wireframe::pcapng { - -inline constexpr std::uint32_t kBlockTypeShb = 0x0A0D0D0A; -inline constexpr std::uint32_t kBlockTypeIdb = 0x00000001; -inline constexpr std::uint32_t kBlockTypeEpb = 0x00000006; -inline constexpr std::uint32_t kByteOrderMagic = 0x1A2B3C4D; -inline constexpr std::uint16_t kLinkTypeEthernet = 1; - -class Writer { -public: - explicit Writer(std::FILE* file) : file_(file) {} - - void write_section_header() { - std::uint8_t body[16]; - put_u32(body + 0, kByteOrderMagic); - put_u16(body + 4, 1); // major version - put_u16(body + 6, 0); // minor version - put_u64(body + 8, 0xFFFFFFFFFFFFFFFFULL); // section length: unknown - write_block(kBlockTypeShb, {body, sizeof(body)}); - } - - void write_interface_description(std::uint32_t snaplen, std::uint16_t link_type) { - std::uint8_t body[8]; - put_u16(body + 0, link_type); - put_u16(body + 2, 0); // reserved - put_u32(body + 4, snaplen); - write_block(kBlockTypeIdb, {body, sizeof(body)}); - } - - void write_packet(std::uint32_t interface_id, std::uint32_t ts_sec, std::uint32_t ts_usec, - std::span data, std::uint32_t original_len) { - std::uint64_t ts_us = static_cast(ts_sec) * 1'000'000ULL + ts_usec; - std::uint32_t ts_high = static_cast(ts_us >> 32); - std::uint32_t ts_low = static_cast(ts_us & 0xFFFFFFFFULL); - - std::size_t padded_len = (data.size() + 3) & ~std::size_t(3); - std::vector body(20 + padded_len, 0); // tail is padding, stays zero - put_u32(body.data() + 0, interface_id); - put_u32(body.data() + 4, ts_high); - put_u32(body.data() + 8, ts_low); - put_u32(body.data() + 12, static_cast(data.size())); - put_u32(body.data() + 16, original_len); - std::copy(data.begin(), data.end(), body.begin() + 20); - - write_block(kBlockTypeEpb, body); - } - -private: - static void put_u16(std::uint8_t* p, std::uint16_t v) { - p[0] = static_cast(v & 0xFF); - p[1] = static_cast((v >> 8) & 0xFF); - } - - static void put_u32(std::uint8_t* p, std::uint32_t v) { - for (int i = 0; i < 4; ++i) p[i] = static_cast((v >> (8 * i)) & 0xFF); - } - - static void put_u64(std::uint8_t* p, std::uint64_t v) { - for (int i = 0; i < 8; ++i) p[i] = static_cast((v >> (8 * i)) & 0xFF); - } - - void write_block(std::uint32_t type, std::span body) { - std::uint32_t total_len = static_cast(8 + body.size() + 4); - std::uint8_t type_buf[4]; - std::uint8_t len_buf[4]; - put_u32(type_buf, type); - put_u32(len_buf, total_len); - std::fwrite(type_buf, 1, 4, file_); - std::fwrite(len_buf, 1, 4, file_); - std::fwrite(body.data(), 1, body.size(), file_); - std::fwrite(len_buf, 1, 4, file_); - } - - std::FILE* file_; -}; - -} // namespace wireframe::pcapng diff --git a/include/wireframe/privileges.hpp b/include/wireframe/privileges.hpp deleted file mode 100644 index 69df725..0000000 --- a/include/wireframe/privileges.hpp +++ /dev/null @@ -1,87 +0,0 @@ -#pragma once - -#ifndef _WIN32 -#include -#include -#endif - -#include -#include -#include -#include -#include - -// After pcap_open_live() succeeds, the process has gotten everything -// CAP_NET_RAW exists for - running the rest of the program (decoding -// untrusted packet bytes, an interactive TUI/GUI event loop) as root -// from that point on is unnecessary exposure, and PLAN.md says as much -// directly: "Drop privileges immediately after opening the capture -// handle; use CAP_NET_RAW via file capabilities instead of running as -// root." -// -// The recommended path doesn't need this file at all: run -// `sudo setcap cap_net_raw+ep ` once, then invoke the binary -// directly, unprivileged, forever after - CAP_NET_RAW alone is enough -// for pcap_open_live(), no root required at any point. This exists for -// the case someone still runs the binary via sudo (out of habit, or -// because setcap isn't available/permitted in some environments): drop -// straight back to the invoking user immediately, so the rest of the -// process's lifetime - including any -w output file, which then ends -// up owned by that user instead of root - runs unprivileged either way. -namespace wireframe { - -// Drops from root to the user who actually invoked the program, via -// sudo's SUDO_UID/SUDO_GID (which sudo always sets). A no-op if not -// currently root, or if SUDO_UID isn't set (e.g. a genuine root login, -// not sudo - there's no "real" user to drop to in that case). -// -// setuid() to a nonzero UID also clears the process's Linux capability -// sets as a kernel-level side effect, so this covers both "running as -// root via sudo" and "root's own CAP_NET_RAW" the same way, without a -// separate libcap dependency. -// -// Returns an error message on failure. The drop is safety-critical: a -// failure here should be treated as fatal by the caller, not silently -// ignored while the process keeps running as root. -inline std::optional drop_privileges_if_root() { -#ifdef _WIN32 - return std::nullopt; // no POSIX privilege model to drop from -#else - if (geteuid() != 0) return std::nullopt; // already unprivileged - - const char* sudo_uid = std::getenv("SUDO_UID"); - const char* sudo_gid = std::getenv("SUDO_GID"); - if (sudo_uid == nullptr || sudo_gid == nullptr) { - return std::nullopt; // no safe target to drop to - } - - uid_t target_uid = static_cast(std::strtoul(sudo_uid, nullptr, 10)); - gid_t target_gid = static_cast(std::strtoul(sudo_gid, nullptr, 10)); - - // Order matters: groups and GID need root to change, so they must - // be dropped before UID - once UID is gone, so is the privilege - // to change the others. - if (setgroups(1, &target_gid) == -1) { - return "setgroups failed: " + std::string(std::strerror(errno)); - } - if (setgid(target_gid) == -1) { - return "setgid failed: " + std::string(std::strerror(errno)); - } - if (setuid(target_uid) == -1) { - return "setuid failed: " + std::string(std::strerror(errno)); - } - - // Defense in depth (standard advice from setuid-privilege-drop - // write-ups): confirm root can't be reclaimed. If the saved-UID - // was somehow left at 0, this would succeed and silently undo the - // drop - so a *successful* setuid(0) here means something is - // wrong, and is treated as the failure case. - if (setuid(0) != -1) { - return "failed to permanently drop root (setuid(0) unexpectedly succeeded)"; - } - - return std::nullopt; -#endif -} - -} // namespace wireframe diff --git a/include/wireframe/search.hpp b/include/wireframe/search.hpp deleted file mode 100644 index 4cb9203..0000000 --- a/include/wireframe/search.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -// A display filter, distinct from -f's capture filter (wireframe/filter.hpp): -// -f decides what's captured - and, combined with -w, what's written to -// disk. This decides what's shown, without touching either. Same -// distinction Wireshark draws between a capture filter and a display -// filter, just without the display filter's expression language - a -// plain case-insensitive substring match over the packet's summary line -// is enough for "find the packets mentioning this host/port", which is -// the actual use case. -namespace wireframe { - -inline bool matches_search(const std::string& haystack, const std::string& needle) { - if (needle.empty()) return true; - auto it = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), - [](unsigned char a, unsigned char b) { - return std::tolower(a) == std::tolower(b); - }); - return it != haystack.end(); -} - -} // namespace wireframe diff --git a/include/wireframe/summarize.hpp b/include/wireframe/summarize.hpp deleted file mode 100644 index 840ddf9..0000000 --- a/include/wireframe/summarize.hpp +++ /dev/null @@ -1,275 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include - -#include "wireframe/l7/dissector.hpp" -#include "wireframe/l7/dns.hpp" -#include "wireframe/l7/http.hpp" -#include "wireframe/l7/mdns.hpp" -#include "wireframe/l7/ssh.hpp" -#include "wireframe/l7/tls.hpp" -#include "wireframe/net/ethernet.hpp" -#include "wireframe/net/icmp.hpp" -#include "wireframe/net/ipv4.hpp" -#include "wireframe/net/ipv6.hpp" -#include "wireframe/net/tcp.hpp" -#include "wireframe/net/udp.hpp" - -// Packet -> human-readable summary. Shared by every frontend (plain -// CLI, TUI, GUI) so they can't drift apart on what a given packet -// decodes to - one source of truth, not three copies to keep in sync. -namespace wireframe { - -inline std::string mac_to_string(const net::MacAddress& mac) { - char buf[18]; - std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", mac.bytes[0], mac.bytes[1], - mac.bytes[2], mac.bytes[3], mac.bytes[4], mac.bytes[5]); - return buf; -} - -inline std::string ipv4_to_string(const net::Ipv4Address& ip) { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", ip.bytes[0], ip.bytes[1], ip.bytes[2], - ip.bytes[3]); - return buf; -} - -inline std::string tcp_flags_to_string(std::uint8_t flags) { - using namespace net; - std::string out; - if (flags & kTcpSyn) out += 'S'; - if (flags & kTcpAck) out += 'A'; - if (flags & kTcpFin) out += 'F'; - if (flags & kTcpRst) out += 'R'; - if (flags & kTcpPsh) out += 'P'; - if (flags & kTcpUrg) out += 'U'; - return out.empty() ? "-" : out; -} - -// Registered once. DNS (UDP) was the first L7 dissector, proving the -// interface (wireframe/l7/dissector.hpp) is enough to add a protocol -// without touching the L2-L4 decode path; HTTP (TCP) is the second, -// and the first to actually exercise L7Registry's TCP-payload path -- -// DNS alone never did, since it only ever runs over UDP port 53. TLS -// (also TCP, port 443) covers what HTTP increasingly can't: most web -// traffic today is encrypted, and SNI is the one piece of a TLS -// handshake still readable without decrypting anything. mDNS reuses -// DNS's own parser (same wire format, different port/label) at -// essentially no extra cost. SSH is the first dissector whose *entire* -// protocol is one cleartext line before everything else encrypts -- -// unlike TLS's SNI, there's nothing further to ever add here. -inline const net::L7Registry& l7_registry() { - static const net::DnsDissector dns_dissector; - static const net::HttpDissector http_dissector; - static const net::TlsSniDissector tls_dissector; - static const net::MdnsDissector mdns_dissector; - static const net::SshDissector ssh_dissector; - static const net::L7Registry registry = [] { - net::L7Registry r; - r.add(&dns_dissector); - r.add(&http_dissector); - r.add(&tls_dissector); - r.add(&mdns_dissector); - r.add(&ssh_dissector); - return r; - }(); - return registry; -} - -// Tries the destination port first (the common case: a client talking -// to a well-known server port), then the source port (a server's -// reply, coming from that same well-known port). -inline std::optional l7_summarize(std::span payload, - std::uint16_t src_port, std::uint16_t dst_port) { - if (auto summary = l7_registry().dissect(dst_port, payload)) return summary; - return l7_registry().dissect(src_port, payload); -} - -// IPv4 and IPv6 headers carry different fields (ttl vs. hop_limit, -// 4-byte vs. 16-byte addresses), but everything above the IP layer -- -// TCP/UDP decode plus the L7 lookup - is identical once normalized to -// this. Keeping that dispatch in one place means TCP/UDP/L7 formatting -// can't drift between the two IP versions. -struct IpInfo { - const char* label; // "IPv4" or "IPv6" - std::string src_str; - std::string dst_str; - std::uint8_t ttl_or_hop_limit; - std::uint8_t proto; - std::span payload; -}; - -inline std::string summarize_transport_and_above(const IpInfo& info) { - char ip_buf[160]; - std::snprintf(ip_buf, sizeof(ip_buf), " | %s %s -> %s ttl=%u proto=%u", info.label, - info.src_str.c_str(), info.dst_str.c_str(), info.ttl_or_hop_limit, info.proto); - std::string out = ip_buf; - - if (info.proto == net::kProtoTcp) { - if (auto tcp = net::parse_tcp(info.payload)) { - char tcp_buf[128]; - std::snprintf(tcp_buf, sizeof(tcp_buf), " | TCP %u -> %u [%s] seq=%u ack=%u win=%u", - tcp->header.src_port, tcp->header.dst_port, - tcp_flags_to_string(tcp->header.flags).c_str(), tcp->header.seq, - tcp->header.ack, tcp->header.window); - out += tcp_buf; - if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port)) { - out += " | " + *l7; - } - } - } else if (info.proto == net::kProtoUdp) { - if (auto udp = net::parse_udp(info.payload)) { - char udp_buf[64]; - std::snprintf(udp_buf, sizeof(udp_buf), " | UDP %u -> %u len=%u", - udp->header.src_port, udp->header.dst_port, udp->header.length); - out += udp_buf; - if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port)) { - out += " | " + *l7; - } - } - } else if (info.proto == net::kProtoIcmp) { - if (auto icmp = net::parse_icmpv4(info.payload)) { - out += " | ICMP " + net::icmpv4_type_name(icmp->type); - if (icmp->identifier) { - out += " id=" + std::to_string(*icmp->identifier) + - " seq=" + std::to_string(*icmp->sequence); - } - } - } else if (info.proto == net::kNextHeaderIcmpv6) { - if (auto icmp = net::parse_icmpv6(info.payload)) { - out += " | ICMPv6 " + net::icmpv6_type_name(icmp->type); - if (icmp->identifier) { - out += " id=" + std::to_string(*icmp->identifier) + - " seq=" + std::to_string(*icmp->sequence); - } - } else { - out += " | ICMPv6"; // truncated: at least say what it is - } - } - return out; -} - -// `datalink` is the interface's actual pcap_datalink() type, not an -// assumption: tunnel/VPN interfaces (tailscale0, wireguard, plain -// tun/tap) hand libpcap raw IP with no link-layer header at all -// (DLT_RAW), unlike a real NIC or even `lo` (both DLT_EN10MB on -// Linux). Treating raw IP bytes as an Ethernet frame silently produces -// garbage MACs and ethertypes - verified by actually capturing on -// tailscale0 before this branch existed. -// -// IP version is read from the packet itself (the first nibble), not -// inferred from ethertype/datalink: DLT_RAW has no ethertype to key -// off at all, and even on Ethernet this keeps IPv4/IPv6 dispatch in -// one place. -inline std::string summarize_packet(std::span bytes, int datalink) { - std::span ip_bytes; - std::string out; - - if (datalink == DLT_RAW) { - out = "RAW"; - ip_bytes = bytes; - } else { - auto eth = net::parse_ethernet(bytes); - if (!eth) { - char buf[64]; - std::snprintf(buf, sizeof(buf), "[%zu bytes] truncated ethernet frame", bytes.size()); - return buf; - } - - out = "ETH " + mac_to_string(eth->header.src) + " -> " + mac_to_string(eth->header.dst); - char eth_buf[32]; - std::snprintf(eth_buf, sizeof(eth_buf), " ethertype=0x%04x", eth->header.ethertype); - out += eth_buf; - - if (eth->header.ethertype != net::kEthertypeIPv4 && - eth->header.ethertype != net::kEthertypeIPv6) { - return out; - } - ip_bytes = eth->payload; - } - - if (ip_bytes.empty()) { - out += " | IP (empty payload)"; - return out; - } - std::uint8_t version = static_cast(ip_bytes[0] >> 4); - - if (version == 4) { - auto ip = net::parse_ipv4(ip_bytes); - if (!ip) { - out += " | IPv4 (truncated)"; - return out; - } - out += summarize_transport_and_above({"IPv4", ipv4_to_string(ip->header.src), - ipv4_to_string(ip->header.dst), ip->header.ttl, - ip->header.protocol, ip->payload}); - } else if (version == 6) { - auto ip6 = net::parse_ipv6(ip_bytes); - if (!ip6) { - out += " | IPv6 (truncated)"; - return out; - } - std::string src_str = net::ipv6_to_string(ip6->header.src); - std::string dst_str = net::ipv6_to_string(ip6->header.dst); - - // next_header may name an extension header (Hop-by-Hop, - // Routing, Dest Options, Fragment, AH) rather than the actual - // transport protocol; walk through those to find it. - auto walked = net::walk_ipv6_extension_headers(ip6->header.next_header, ip6->payload); - if (walked.stopped_at_esp) { - char buf[160]; - std::snprintf(buf, sizeof(buf), " | IPv6 %s -> %s ttl=%u proto=%u | ESP (encrypted)", - src_str.c_str(), dst_str.c_str(), ip6->header.hop_limit, - net::kNextHeaderEsp); - out += buf; - } else { - out += summarize_transport_and_above({"IPv6", src_str, dst_str, ip6->header.hop_limit, - walked.final_next_header, walked.payload}); - } - } else { - out += " | IP version " + std::to_string(version) + " (unsupported)"; - } - return out; -} - -// One formatted line per 16 bytes: offset, hex, ASCII gutter. Returned -// as lines rather than printed so both the CLI's -x output and a GUI -// details pane can use the same formatting. -inline std::vector hex_dump_lines(std::span bytes) { - std::vector lines; - for (std::size_t offset = 0; offset < bytes.size(); offset += 16) { - char offset_buf[32]; - std::snprintf(offset_buf, sizeof(offset_buf), "%06zx ", offset); - std::string line = offset_buf; - - std::size_t line_len = std::min(16, bytes.size() - offset); - for (std::size_t i = 0; i < 16; ++i) { - if (i < line_len) { - char byte_buf[4]; - std::snprintf(byte_buf, sizeof(byte_buf), "%02x ", bytes[offset + i]); - line += byte_buf; - } else { - line += " "; - } - if (i == 7) line += ' '; - } - - line += " |"; - for (std::size_t i = 0; i < line_len; ++i) { - unsigned char c = bytes[offset + i]; - line += (c >= 0x20 && c < 0x7f) ? static_cast(c) : '.'; - } - line += '|'; - - lines.push_back(std::move(line)); - } - return lines; -} - -} // namespace wireframe -- cgit v1.2.3