diff options
| -rw-r--r-- | CMakeLists.txt | 14 | ||||
| -rw-r--r-- | PLAN.md | 126 | ||||
| -rw-r--r-- | include/wireframe/capture_session.hpp | 11 | ||||
| -rw-r--r-- | include/wireframe/net/checksum.hpp | 91 | ||||
| -rw-r--r-- | include/wireframe/net/icmp.hpp | 84 | ||||
| -rw-r--r-- | include/wireframe/net/tcp_reassembly.hpp | 125 | ||||
| -rw-r--r-- | include/wireframe/privileges.hpp | 87 | ||||
| -rw-r--r-- | include/wireframe/summarize.hpp | 19 | ||||
| -rw-r--r-- | src/afpacket_capture.cpp | 186 | ||||
| -rw-r--r-- | src/gui_main.cpp | 30 | ||||
| -rw-r--r-- | src/main.cpp | 166 | ||||
| -rw-r--r-- | tests/test_checksum.cpp | 136 | ||||
| -rw-r--r-- | tests/test_icmp.cpp | 85 | ||||
| -rw-r--r-- | tests/test_privileges.cpp | 18 | ||||
| -rw-r--r-- | tests/test_tcp_reassembly.cpp | 176 |
15 files changed, 1351 insertions, 3 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 211738a..abe3546 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,16 @@ target_link_libraries(wireframe PRIVATE ftxui::screen ) +# Standalone AF_PACKET/mmap ring buffer demo (see the file's own header +# comment for why this is separate from CaptureSession). AF_PACKET is a +# Linux-specific socket family, unlike the portable libpcap path the +# rest of this project uses - only built on Linux. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(wireframe_afpacket_demo src/afpacket_capture.cpp) + target_include_directories(wireframe_afpacket_demo PRIVATE include) + target_link_libraries(wireframe_afpacket_demo PRIVATE pcap) +endif() + # GUI (secondary to the TUI - see PLAN.md Decisions). Dear ImGui + # SDL3, same FetchContent approach as FTXUI/doctest: no dependency on # a system package, so it builds the same way on every platform this @@ -97,6 +107,10 @@ add_executable(wireframe_tests tests/test_summarize.cpp tests/test_capture_session.cpp tests/test_search.cpp + tests/test_privileges.cpp + tests/test_icmp.cpp + tests/test_checksum.cpp + tests/test_tcp_reassembly.cpp ) target_include_directories(wireframe_tests PRIVATE include) target_link_libraries(wireframe_tests PRIVATE doctest::doctest Threads::Threads pcap) @@ -33,6 +33,8 @@ unowned buffers) via a real-world capture pipeline. interface + DNS + HTTP + TLS SNI done (wireframe/l7/); more protocols can still be added incrementally, by design 6. [done] Filtering (-f <expr>, libpcap's own BPF compiler - see Decisions) +7. [done] Drop privileges after opening the capture handle (see Decisions) +8. [done] TCP stream reassembly, opt-in via -a (see Decisions) ## Open questions None currently open. @@ -186,3 +188,127 @@ None currently open. now-static list, and 'q' closes it; Xvfb confirmed the same for the GUI, including a live process check across a multi-second wait to rule out a delayed auto-close. +- Privilege dropping (wireframe/privileges.hpp): after pcap_open_live() + succeeds - the only operation that actually needs CAP_NET_RAW - and + before the datalink check or a -w file is even created, drop from + root to the invoking user via sudo's SUDO_UID/SUDO_GID. setuid() to a + nonzero UID clears the process's Linux capability sets as a kernel + side effect too, so this covers both "ran via sudo" and "root's own + CAP_NET_RAW" without a separate libcap dependency, and as a side + benefit means -w's output file ends up owned by the real user, not + root (previously needed a manual chown after every capture - every + live test earlier this session did). Recommended usage skips this + path entirely: `sudo setcap cap_net_raw+ep <binary>` once, then run + unprivileged forever after, matching PLAN.md's original "use + CAP_NET_RAW via file capabilities instead of running as root." + Only the non-root no-op path is unit-testable without a test process + permanently dropping its own privileges mid-suite, which would be a + surprising thing for a unit test to do - so the real drop sequence + was verified live instead: running via sudo, /proc/<pid>/status + showed Uid go from 0 to the real UID and CapEff/CapPrm both go to + zero within about a second of startup, with capture continuing to + work correctly afterward (proving the already-open fd keeps working + regardless of the process's current privilege level, which is the + whole point of "drop after open"). Separately verified the + setcap-without-sudo path works with zero privilege escalation at any + point in the process's life. +- AF_PACKET/mmap ring buffer (src/afpacket_capture.cpp, wireframe_afpacket_demo, + Linux-only): PLAN.md's originally-listed alternative capture backend, + built as a standalone artifact rather than swapped into CaptureSession + - the existing pipeline has real, tested value riding on libpcap's + APIs (pcap_setfilter, pcap_stats, pcap_datalink) that a raw-socket + path would need to reimplement from scratch at every one of + CaptureSession's already-verified call sites, real risk to 90 passing + tests for a copy-avoidance benefit modern libpcap on Linux already + gets much of internally. TPACKET_V2 (simpler one-frame-per-slot + layout than V3's block-batching) mmap'd directly into the process, + packets read via std::span pointing straight into that kernel-shared + mapping - no read()/recv(), no buffer of our own, genuinely zero + copies between the NIC and summarize_packet() seeing the bytes. Reuses + drop_privileges_if_root() (same principle, same code, right after the + ring is mapped and bound). Verified against real traffic on both lo + and the physical wlp1s0 interface - full TCP handshakes, DNS, mDNS, + ICMPv6 all decoded correctly across a large volume of genuine + traffic, no crashes, no leaked sockets/mappings after exit, tests and + the rest of the build entirely unaffected by its addition. +- ICMP decoding (wireframe/net/icmp.hpp): previously every ICMPv4 + packet just showed "proto=1" with nothing further - no dissector + existed at all - despite ICMP being most of this session's own test + traffic (every ping). ICMPv6 was labeled but not decoded either. + ICMPv4 and ICMPv6 share the same first-4-byte shape (type/code/ + checksum) but a completely different type namespace - the same + number means something different in each (ICMPv4 type 8 is Echo + Request; ICMPv6's Echo Request is 128, and its own type 8 isn't + defined at all) - so they get separate parse functions and type-name + tables, not one shared by number. Neither protocol has ports, so this + doesn't fit L7Registry's port-keyed dispatch; both are handled + directly by IP protocol number in summarize_transport_and_above + instead. Verified live against real ping traffic on both lo (proto=1) + and ::1 (proto=58) - request/reply pairs decoded correctly on both, + including matching identifier/sequence numbers between each request + and its reply. +- -h/--help: both wireframe and wireframe_gui now print real usage + text (each binary's actual flag set - the GUI never had -x/-t/-g, + so its help doesn't claim it does) and exit 0 before touching a + device or any privilege at all. Previously -x -t -w -f -g -r all + existed with zero discoverability outside reading the source. +- Checksum validation (wireframe/net/checksum.hpp): RFC 1071 Internet + checksum, plus IPv4-header/TCP/UDP verification built on it (IPv6 + checksums use a different pseudo-header and different optionality + rules - not done here, a reasonable follow-on if wanted). UDP's + checksum is optional over IPv4: a transmitted value of exactly + 0x0000 means "not computed", reported as its own kNotPresent state, + not folded into invalid. Deliberately not part of summarize_packet's + shared output - opt-in via the CLI's -c flag only (same + plain-text-mode-only precedent -x/hex-dump already set), because + checksum offload means many outbound and loopback packets can + legitimately show an invalid checksum with nothing actually wrong: + the NIC computes the real one in hardware during DMA, which is often + after the capture point already saw the packet. Wireshark makes this + opt-in for the same reason. + internet_checksum() itself is verified against RFC 1071's own worked + example (an external reference value, not derived from this code), + not just internal self-consistency. Live-tested on lo and the + physical wlp1s0 - both showed IP=ok/UDP=ok/TCP=ok throughout; `ethtool + -k wlp1s0` shows tx-checksumming off on this machine's driver, which + is exactly why (no hardware offload means the kernel computes real + checksums in software) - so the "offload causes false BAD" case this + feature exists to route around couldn't be reproduced on this + specific sandboxed machine's NIC, but that's a property of this + hardware, not a gap in the reasoning: most real NICs ship tx-checksum + offload on by default, which is exactly the scenario -c's + opt-in-ness is meant to keep from reading as false positives. +- TCP stream reassembly (wireframe/net/tcp_reassembly.hpp): in-order-only + - out-of-order segments and retransmissions are dropped, not buffered + for later reordering. A real limitation, but an honest one for a + learning tool captured directly on an endpoint (lo/wlp1s0/tailscale0, + everything this project has actually run against), where segments + mostly do arrive in order; a capture point far from either endpoint + (a middlebox) would need real reorder buffering this doesn't attempt. + Deliberately kept out of summarize_packet()'s shared signature and the + TUI/GUI consumer loops - adding a TcpReassembler& parameter there + would ripple into every call site and both frontends' render paths, + risking the (at the time) 108 passing tests for a single opt-in + feature. Instead it's CLI-only, opt-in via -a, same + plain-text-mode-only precedent -x/-c already set: a separate + TcpReassembler instance lives in main(), and render_packet() does its + own minimal Ethernet/IPv4/TCP walk (mirroring checksum_status()) to + feed segments in and, when new contiguous bytes come back, re-runs + parse_http() (wireframe/l7/http.hpp) against the joined stream and + prints the result as a distinct "[reassembled ...]" line, not folded + into the per-packet summary. Deliberately calls parse_http() directly + rather than going through L7Registry, so it isn't gated to port 80 the + way the shared per-packet summary is - a deliberate difference, not + an oversight. Live-verified against real split traffic: a Python + client sent an HTTP request's request-line and its Host: header in + two separate sendall() calls 0.3s apart with TCP_NODELAY set (to stop + the kernel coalescing them back into one segment), captured on lo. + The first segment's reassembled view showed the request line with no + Host: (correct - it hadn't arrived yet); only once the second + segment landed did Host: appear, confirming the two segments were + actually joined rather than the dissector getting lucky on one + segment alone. Buffers are capped per direction (64 KiB default) and + the flow table is capped in total flow count, both to bound memory + without needing active FIN/RST-triggered flow teardown - simpler, + and stale entries past those caps don't affect correctness, just + bounded memory use. diff --git a/include/wireframe/capture_session.hpp b/include/wireframe/capture_session.hpp index 50764a8..2e3b05a 100644 --- a/include/wireframe/capture_session.hpp +++ b/include/wireframe/capture_session.hpp @@ -14,6 +14,7 @@ #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 @@ -99,6 +100,16 @@ public: 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_ + ": " + diff --git a/include/wireframe/net/checksum.hpp b/include/wireframe/net/checksum.hpp new file mode 100644 index 0000000..97e5254 --- /dev/null +++ b/include/wireframe/net/checksum.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include <cstdint> +#include <span> +#include <vector> + +#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<const unsigned char> data) { + std::uint32_t sum = 0; + std::size_t i = 0; + for (; i + 1 < data.size(); i += 2) { + sum += (static_cast<std::uint32_t>(data[i]) << 8) | data[i + 1]; + } + if (i < data.size()) { + sum += static_cast<std::uint32_t>(data[i]) << 8; // odd trailing byte: high half only + } + while (sum >> 16) { + sum = (sum & 0xFFFFu) + (sum >> 16); + } + return static_cast<std::uint16_t>(~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<const unsigned char> header_bytes) { + return internet_checksum(header_bytes) == 0; +} + +enum class ChecksumResult { kValid, kInvalid, kNotPresent }; + +namespace detail { + +inline std::vector<unsigned char> build_ipv4_pseudo_header(const Ipv4Address& src, + const Ipv4Address& dst, + std::uint8_t protocol, + std::span<const unsigned char> segment) { + std::vector<unsigned char> 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<std::uint16_t>(segment.size()); + buf.push_back(static_cast<unsigned char>(len >> 8)); + buf.push_back(static_cast<unsigned char>(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<const unsigned char> 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<const unsigned char> 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/icmp.hpp b/include/wireframe/net/icmp.hpp new file mode 100644 index 0000000..af83916 --- /dev/null +++ b/include/wireframe/net/icmp.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include <cstdint> +#include <optional> +#include <span> +#include <string> + +#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<std::uint16_t> identifier; // echo request/reply only + std::optional<std::uint16_t> sequence; // echo request/reply only +}; + +inline std::optional<IcmpHeader> parse_icmpv4(std::span<const unsigned char> 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<IcmpHeader> parse_icmpv6(std::span<const unsigned char> 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/tcp_reassembly.hpp b/include/wireframe/net/tcp_reassembly.hpp new file mode 100644 index 0000000..90824a4 --- /dev/null +++ b/include/wireframe/net/tcp_reassembly.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include <cstdint> +#include <map> +#include <optional> +#include <span> +#include <tuple> +#include <vector> + +#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<FlowKey, bool> 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<unsigned char> 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<std::vector<unsigned char>> 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<const unsigned char> 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<std::uint32_t>(payload.size()); + + return dir.buffer; + } + + std::size_t flow_count() const { return flows_.size(); } + +private: + std::map<FlowKey, FlowState> flows_; + std::size_t max_buffer_; + std::size_t max_flows_; +}; + +} // namespace wireframe::net diff --git a/include/wireframe/privileges.hpp b/include/wireframe/privileges.hpp new file mode 100644 index 0000000..69df725 --- /dev/null +++ b/include/wireframe/privileges.hpp @@ -0,0 +1,87 @@ +#pragma once + +#ifndef _WIN32 +#include <grp.h> +#include <unistd.h> +#endif + +#include <cerrno> +#include <cstdlib> +#include <cstring> +#include <optional> +#include <string> + +// 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 <binary>` 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<std::string> 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<uid_t>(std::strtoul(sudo_uid, nullptr, 10)); + gid_t target_gid = static_cast<gid_t>(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/summarize.hpp b/include/wireframe/summarize.hpp index 59aa621..e7e9ae3 100644 --- a/include/wireframe/summarize.hpp +++ b/include/wireframe/summarize.hpp @@ -13,6 +13,7 @@ #include "wireframe/l7/http.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" @@ -122,8 +123,24 @@ inline std::string summarize_transport_and_above(const IpInfo& info) { 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) { - out += " | ICMPv6"; + 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; } diff --git a/src/afpacket_capture.cpp b/src/afpacket_capture.cpp new file mode 100644 index 0000000..877bc88 --- /dev/null +++ b/src/afpacket_capture.cpp @@ -0,0 +1,186 @@ +// AF_PACKET + PACKET_RX_RING: capture without libpcap's internal buffer +// copy, using a memory-mapped ring buffer shared directly with the +// kernel. This demonstrates PLAN.md's originally-listed alternative +// capture backend ("libpcap, or raw AF_PACKET with an mmap'd ring +// buffer to skip libpcap's copies") as a focused, standalone artifact. +// +// Deliberately NOT wired into CaptureSession/the main pipeline: doing +// that would mean reimplementing filtering (SO_ATTACH_FILTER instead +// of pcap_setfilter), kernel stats (raw sockopts instead of +// pcap_stats), and datalink detection (ARPHRD_* mapping instead of +// pcap_datalink) at every one of CaptureSession's already-tested call +// sites - real risk to working, verified functionality for a +// copy-avoidance benefit modern libpcap on Linux already gets much of +// internally. What this file actually explores - a std::span reading +// packet bytes directly out of kernel-shared mapped memory, with zero +// copies between the NIC and this process at all - is a more direct +// exploration of this project's actual point (the C++ memory model) +// than anything routed through libpcap's own abstraction, and doesn't +// need to touch the rest of the tool to demonstrate that. +// +// Linux-only: AF_PACKET is a Linux-specific socket family, unlike the +// portable libpcap path the rest of this project uses. + +#include <linux/if_ether.h> +#include <linux/if_packet.h> +#include <net/if.h> +#include <poll.h> +#include <sys/mman.h> +#include <sys/socket.h> +#include <unistd.h> + +#include <atomic> +#include <cerrno> +#include <csignal> +#include <cstdio> +#include <cstring> +#include <span> + +#include "wireframe/privileges.hpp" +#include "wireframe/summarize.hpp" + +namespace { + +// TPACKET_V2: a simpler one-frame-per-slot layout than TPACKET_V3's +// block-batching, still genuinely mmap'd and zero-copy. The right +// complexity level for demonstrating the technique clearly, not for +// maximizing throughput. +constexpr std::size_t kFrameSize = 2048; // room for a max-size Ethernet frame + header + padding +constexpr std::size_t kFramesPerBlock = 2; +constexpr std::size_t kBlockSize = kFrameSize * kFramesPerBlock; // must be a page-size multiple +constexpr std::size_t kBlockCount = 64; +constexpr std::size_t kFrameCount = kFramesPerBlock * kBlockCount; + +std::atomic<bool> g_stop{false}; +void handle_stop_signal(int) { g_stop.store(true); } + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s <interface>\n", argv[0]); + return 1; + } + const char* ifname = argv[1]; + + long page_size = sysconf(_SC_PAGESIZE); + if (page_size <= 0 || kBlockSize % static_cast<std::size_t>(page_size) != 0) { + std::fprintf(stderr, + "kBlockSize (%zu) isn't a multiple of this system's page size (%ld) - " + "TPACKET_V2 requires it to be\n", + kBlockSize, page_size); + return 1; + } + + int sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + if (sock == -1) { + std::fprintf(stderr, "socket(AF_PACKET) failed: %s\n", std::strerror(errno)); + return 1; + } + + int version = TPACKET_V2; + if (setsockopt(sock, SOL_PACKET, PACKET_VERSION, &version, sizeof(version)) == -1) { + std::fprintf(stderr, "setsockopt(PACKET_VERSION) failed: %s\n", std::strerror(errno)); + close(sock); + return 1; + } + + tpacket_req req{}; + req.tp_block_size = kBlockSize; + req.tp_block_nr = kBlockCount; + req.tp_frame_size = kFrameSize; + req.tp_frame_nr = kFrameCount; + if (setsockopt(sock, SOL_PACKET, PACKET_RX_RING, &req, sizeof(req)) == -1) { + std::fprintf(stderr, "setsockopt(PACKET_RX_RING) failed: %s\n", std::strerror(errno)); + close(sock); + return 1; + } + + std::size_t ring_size = req.tp_block_size * req.tp_block_nr; + // This mapping *is* the ring buffer: the kernel writes captured + // frames into these same pages, and every packet read below is a + // pointer straight into this mapping - no read()/recv() call, no + // buffer of our own, no copy of the packet data at any point + // between the NIC and summarize_packet() seeing it. + void* ring = mmap(nullptr, ring_size, PROT_READ | PROT_WRITE, MAP_SHARED, sock, 0); + if (ring == MAP_FAILED) { + std::fprintf(stderr, "mmap failed: %s\n", std::strerror(errno)); + close(sock); + return 1; + } + + unsigned int ifindex = if_nametoindex(ifname); + if (ifindex == 0) { + std::fprintf(stderr, "if_nametoindex(%s) failed: %s\n", ifname, std::strerror(errno)); + munmap(ring, ring_size); + close(sock); + return 1; + } + + sockaddr_ll addr{}; + addr.sll_family = AF_PACKET; + addr.sll_protocol = htons(ETH_P_ALL); + addr.sll_ifindex = static_cast<int>(ifindex); + if (bind(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) { + std::fprintf(stderr, "bind failed: %s\n", std::strerror(errno)); + munmap(ring, ring_size); + close(sock); + return 1; + } + + // Everything CAP_NET_RAW was needed for is done: socket created, + // ring mapped, bound to the interface. Same drop-after-open + // principle as CaptureSession (wireframe/privileges.hpp). + if (auto err = wireframe::drop_privileges_if_root()) { + std::fprintf(stderr, "failed to drop privileges: %s\n", err->c_str()); + munmap(ring, ring_size); + close(sock); + return 1; + } + + std::signal(SIGINT, handle_stop_signal); + std::signal(SIGTERM, handle_stop_signal); + + std::printf( + "capturing on %s via AF_PACKET/mmap ring buffer (%zu frames x %zu bytes, ctrl-c to " + "stop)\n", + ifname, kFrameCount, kFrameSize); + + std::size_t frame_index = 0; + while (!g_stop.load()) { + // The status byte at the start of each slot is how the kernel + // and this process hand a frame back and forth without ever + // copying the packet itself: TP_STATUS_KERNEL means "not + // written yet, keep waiting"; the kernel flips it once a + // packet lands, and only then are these bytes safe to read. + auto* header = reinterpret_cast<tpacket2_hdr*>(static_cast<unsigned char*>(ring) + + frame_index * kFrameSize); + + if (header->tp_status == TP_STATUS_KERNEL) { + pollfd pfd{}; + pfd.fd = sock; + pfd.events = POLLIN; + poll(&pfd, 1, /*timeout_ms=*/200); // bounded so g_stop is still checked promptly + continue; + } + + // tp_mac is the offset from the start of this header to the + // start of the actual frame data - still inside the same + // mmap'd page, never copied elsewhere. + const auto* packet_start = + reinterpret_cast<const unsigned char*>(header) + header->tp_mac; + std::span<const unsigned char> bytes(packet_start, header->tp_snaplen); + + std::printf("%s\n", wireframe::summarize_packet(bytes, DLT_EN10MB).c_str()); + std::fflush(stdout); + + // Hand the slot back to the kernel so it can reuse it for a + // future packet - the mirror image of the status flip above. + header->tp_status = TP_STATUS_KERNEL; + frame_index = (frame_index + 1) % kFrameCount; + } + + munmap(ring, ring_size); + close(sock); + return 0; +} diff --git a/src/gui_main.cpp b/src/gui_main.cpp index d5d4518..16ee769 100644 --- a/src/gui_main.cpp +++ b/src/gui_main.cpp @@ -75,9 +75,39 @@ void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& } } +void print_usage(const char* argv0) { + std::printf( + "wireframe - 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 <file> Write the capture to <file> as pcapng (Wireshark-compatible)\n" + " -r <file> Replay a saved pcapng file instead of a live device\n" + " -f <expr> Kernel-level capture filter (tcpdump/BPF syntax); also\n" + " applies to what -w writes. Can't be combined with -r.\n" + " -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; + } + } + wireframe::CaptureSessionOptions options; for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) { diff --git a/src/main.cpp b/src/main.cpp index 3a3e925..31fca73 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -48,6 +48,12 @@ #include <ftxui/dom/elements.hpp> #include "wireframe/capture_session.hpp" +#include "wireframe/l7/http.hpp" +#include "wireframe/net/checksum.hpp" +#include "wireframe/net/ethernet.hpp" +#include "wireframe/net/ipv4.hpp" +#include "wireframe/net/tcp.hpp" +#include "wireframe/net/tcp_reassembly.hpp" #include "wireframe/search.hpp" #include "wireframe/summarize.hpp" @@ -66,11 +72,102 @@ void hex_dump(std::span<const unsigned char> bytes) { std::printf("\n"); } +// -c only: checksum validation isn't part of summarize_packet()'s +// shared output (see wireframe/net/checksum.hpp for why - checksum +// offload makes it noise, not signal, on most of the interfaces this +// project has actually been tested against). IPv4 only for now; this +// does its own minimal walk down to the IP/TCP/UDP byte spans the +// checksum functions need, reusing the existing decoders rather than +// duplicating their parsing logic. +std::string checksum_status(std::span<const unsigned char> bytes, int datalink) { + std::span<const unsigned char> ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = wireframe::net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != wireframe::net::kEthertypeIPv4) return ""; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now + + auto ip = wireframe::net::parse_ipv4(ip_bytes); + if (!ip) return ""; + + std::size_t header_len = static_cast<std::size_t>(ip->header.ihl) * 4; + std::string out = " checksums: IP="; + out += wireframe::net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; + + using wireframe::net::ChecksumResult; + if (ip->header.protocol == wireframe::net::kProtoTcp) { + auto result = + wireframe::net::verify_tcp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " TCP=ok" : " TCP=BAD"; + } else if (ip->header.protocol == wireframe::net::kProtoUdp) { + auto result = + wireframe::net::verify_udp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " UDP=ok" + : result == ChecksumResult::kNotPresent ? " UDP=none" + : " UDP=BAD"; + } + return out; +} + +// -a only: TCP stream reassembly (wireframe/net/tcp_reassembly.hpp), +// re-run through the same HTTP dissector summarize_packet() already +// uses for a single segment - reassembly only helps when a message is +// actually split across packets, and HTTP is the L7 dissector in this +// project that's structured around lines/headers rather than one fixed +// datagram (DNS/TLS ClientHello are each their own single UDP datagram +// or first TCP segment already). Printed as its own line rather than +// folded into the per-packet summary: it reflects accumulated flow +// state, not just this one packet. In-order-only reassembly (see the +// header's own comment) means this can legitimately fire again on a +// later packet of the same request with an unchanged result once the +// headers are already complete - an honest simplification, not +// deduplicated further. +std::optional<std::string> reassembled_http_status(std::span<const unsigned char> bytes, + int datalink, + wireframe::net::TcpReassembler& reassembler) { + std::span<const unsigned char> ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = wireframe::net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != wireframe::net::kEthertypeIPv4) return std::nullopt; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return std::nullopt; // IPv4 only, for now + + auto ip = wireframe::net::parse_ipv4(ip_bytes); + if (!ip || ip->header.protocol != wireframe::net::kProtoTcp) return std::nullopt; + + auto tcp = wireframe::net::parse_tcp(ip->payload); + if (!tcp) return std::nullopt; + + auto reassembled = reassembler.process_segment(ip->header.src, tcp->header.src_port, + ip->header.dst, tcp->header.dst_port, + tcp->header.seq, tcp->header.flags, + tcp->payload); + if (!reassembled) return std::nullopt; + + auto http = wireframe::net::parse_http(*reassembled); + if (!http) return std::nullopt; + + std::string out = " [reassembled "; + out += http->is_request ? "request] " : "response] "; + out += http->method_or_version + " " + http->target_or_status; + if (http->host) out += " Host: " + *http->host; + out += " (" + std::to_string(reassembled->size()) + " bytes so far)"; + return out; +} + struct RenderOptions { bool verbose_hex; + bool verbose_checksums; int datalink; wireframe::pcapng::Writer* pcapng_writer; std::string search_term; // display filter - see wireframe/search.hpp + wireframe::net::TcpReassembler* reassembler; // -a only; nullptr means disabled }; void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& opts) { @@ -88,7 +185,13 @@ void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& if (!wireframe::matches_search(line, opts.search_term)) return; + if (opts.verbose_checksums) line += checksum_status(bytes, opts.datalink); std::printf("%s\n", line.c_str()); + if (opts.reassembler) { + if (auto status = reassembled_http_status(bytes, opts.datalink, *opts.reassembler)) { + std::printf("%s\n", status->c_str()); + } + } if (opts.verbose_hex) hex_dump(bytes); // Flush per packet: stdout is fully buffered off a tty, and this is @@ -250,17 +353,73 @@ void run_tui(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, consumer_thread.join(); } +void print_usage(const char* argv0) { + std::printf( + "wireframe - terminal packet capture and analysis tool\n" + "\n" + "Usage: %s [options] [interface]\n" + "\n" + "If no interface is given, the first available device is used.\n" + "\n" + "Options:\n" + " -t, --tui Launch the interactive TUI instead of plain-text output\n" + " -x Show a hex dump under each summary (plain-text mode only)\n" + " -c Show IPv4/TCP/UDP checksum validity (plain-text mode only).\n" + " Off by default: checksum offload means many outbound and\n" + " loopback packets show as invalid even when nothing is\n" + " actually wrong - the NIC computes the real checksum in\n" + " hardware after most capture points already saw the packet.\n" + " -a Reassemble TCP streams and re-run HTTP parsing on the\n" + " joined bytes (plain-text mode only), catching a\n" + " request/response split across multiple segments that\n" + " single-packet HTTP dissection alone would miss. In-order\n" + " segments only - out-of-order/retransmitted segments are\n" + " dropped rather than buffered for reordering.\n" + " -w <file> Write the capture to <file> as pcapng (Wireshark-compatible)\n" + " -r <file> Replay a saved pcapng file instead of a live device\n" + " -f <expr> Kernel-level capture filter (tcpdump/BPF syntax); also\n" + " applies to what -w writes. Can't be combined with -r.\n" + " -g <term> Display filter: only show packets whose summary contains\n" + " <term> (case-insensitive). Doesn't affect -w. In TUI mode,\n" + " press '/' to search interactively instead.\n" + " -h, --help Show this help and exit\n" + "\n" + "Examples:\n" + " %s eth0 capture on eth0, print each packet\n" + " %s eth0 -t capture on eth0 in the interactive TUI\n" + " %s eth0 -f \"tcp port 443\" only capture HTTPS traffic\n" + " %s eth0 -w out.pcapng capture and save to out.pcapng\n" + " %s -r out.pcapng -t replay a saved capture in the TUI\n", + argv0, argv0, argv0, argv0, argv0, argv0); +} + } // namespace int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) { + print_usage(argv[0]); + return 0; + } + } + wireframe::CaptureSessionOptions options; bool tui_mode = false; - RenderOptions opts{ - .verbose_hex = false, .datalink = 0, .pcapng_writer = nullptr, .search_term = ""}; + bool enable_reassembly = false; + RenderOptions opts{.verbose_hex = false, + .verbose_checksums = false, + .datalink = 0, + .pcapng_writer = nullptr, + .search_term = "", + .reassembler = nullptr}; for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "-x") == 0) { opts.verbose_hex = true; + } else if (std::strcmp(argv[i], "-c") == 0) { + opts.verbose_checksums = true; + } else if (std::strcmp(argv[i], "-a") == 0) { + enable_reassembly = true; } else if (std::strcmp(argv[i], "-t") == 0 || std::strcmp(argv[i], "--tui") == 0) { tui_mode = true; } else if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) { @@ -285,6 +444,9 @@ int main(int argc, char** argv) { opts.pcapng_writer = session.pcapng_writer(); session.install_signal_handlers(); + wireframe::net::TcpReassembler reassembler; + if (enable_reassembly) opts.reassembler = &reassembler; + if (!tui_mode) { if (session.is_replay()) { std::printf("replaying %s (%s)\n", session.device().c_str(), diff --git a/tests/test_checksum.cpp b/tests/test_checksum.cpp new file mode 100644 index 0000000..1295499 --- /dev/null +++ b/tests/test_checksum.cpp @@ -0,0 +1,136 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/net/checksum.hpp" + +using namespace wireframe::net; + +namespace { + +// Mirrors checksum.hpp's own detail::build_ipv4_pseudo_header, kept +// separate here deliberately: constructing expected test vectors using +// the exact same private helper the code under test uses would make +// these tests circular. A few duplicated lines of test-only setup is +// the honest cost of testing independently. +std::vector<unsigned char> pseudo_header(const std::array<unsigned char, 4>& src, + const std::array<unsigned char, 4>& dst, + unsigned char protocol, + std::span<const unsigned char> segment) { + std::vector<unsigned char> buf; + buf.insert(buf.end(), src.begin(), src.end()); + buf.insert(buf.end(), dst.begin(), dst.end()); + buf.push_back(0); + buf.push_back(protocol); + std::uint16_t len = static_cast<std::uint16_t>(segment.size()); + buf.push_back(static_cast<unsigned char>(len >> 8)); + buf.push_back(static_cast<unsigned char>(len & 0xFF)); + buf.insert(buf.end(), segment.begin(), segment.end()); + return buf; +} + +} // namespace + +TEST_CASE("internet_checksum matches RFC 1071's own worked example") { + // The RFC's example data (0001 f203 f4f5 f6f7) computes to checksum + // 220d - an external reference, not derived from this code. + std::vector<unsigned char> data = {0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7}; + CHECK(internet_checksum(data) == 0x220d); +} + +TEST_CASE("internet_checksum of data with its own valid checksum appended is zero") { + // Direct consequence of the RFC 1071 example: appending that + // checksum as one more word should sum to all-ones, complementing + // to exactly zero - this is the actual verification technique + // verify_ipv4_checksum() etc. rely on. + std::vector<unsigned char> data = {0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7, 0x22, 0x0d}; + CHECK(internet_checksum(data) == 0); +} + +TEST_CASE("internet_checksum handles an odd-length buffer (trailing byte padded high)") { + std::vector<unsigned char> data = {0x00, 0x01, 0xf2}; // 3 bytes: one word + one odd byte + // 0x0001 + 0xf200 (odd byte in the high half) = 0xf201; ~0xf201 = 0x0dfe + CHECK(internet_checksum(data) == 0x0dfe); +} + +TEST_CASE("verify_ipv4_checksum accepts a header with a correctly computed checksum") { + std::vector<unsigned char> header(20, 0); + header[0] = 0x45; + header[8] = 64; // ttl + header[9] = kProtoTcp; + header[12] = 10; header[13] = 0; header[14] = 0; header[15] = 1; + header[16] = 10; header[17] = 0; header[18] = 0; header[19] = 2; + // checksum field (bytes 10-11) computed with itself still zeroed + std::uint16_t csum = internet_checksum(header); + header[10] = static_cast<unsigned char>(csum >> 8); + header[11] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_ipv4_checksum(header)); +} + +TEST_CASE("verify_ipv4_checksum rejects a header corrupted after the checksum was computed") { + std::vector<unsigned char> header(20, 0); + header[0] = 0x45; + header[9] = kProtoTcp; + std::uint16_t csum = internet_checksum(header); + header[10] = static_cast<unsigned char>(csum >> 8); + header[11] = static_cast<unsigned char>(csum & 0xFF); + + header[15] ^= 0xFF; // flip a source-address byte after the fact + CHECK_FALSE(verify_ipv4_checksum(header)); +} + +TEST_CASE("verify_tcp_checksum_ipv4 accepts a segment with a correctly computed checksum") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> tcp(20, 0); + tcp[0] = 0; tcp[1] = 80; // src port + tcp[2] = 0x01; tcp[3] = 0xbb; // dst port 443 + tcp[12] = 5 << 4; // data_offset = 5 + + auto buf = pseudo_header(src, dst, kProtoTcp, tcp); + std::uint16_t csum = internet_checksum(buf); + tcp[16] = static_cast<unsigned char>(csum >> 8); + tcp[17] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_tcp_checksum_ipv4({src}, {dst}, tcp) == ChecksumResult::kValid); +} + +TEST_CASE("verify_tcp_checksum_ipv4 rejects a segment corrupted after the checksum was computed") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> tcp(20, 0); + tcp[12] = 5 << 4; + auto buf = pseudo_header(src, dst, kProtoTcp, tcp); + std::uint16_t csum = internet_checksum(buf); + tcp[16] = static_cast<unsigned char>(csum >> 8); + tcp[17] = static_cast<unsigned char>(csum & 0xFF); + + tcp[0] ^= 0xFF; // corrupt the source port after the fact + CHECK(verify_tcp_checksum_ipv4({src}, {dst}, tcp) == ChecksumResult::kInvalid); +} + +TEST_CASE("verify_udp_checksum_ipv4 treats a transmitted checksum of 0x0000 as not present") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + std::vector<unsigned char> udp = {0x00, 0x35, 0x00, 0x35, 0x00, 0x08, 0x00, 0x00}; // csum=0 + CHECK(verify_udp_checksum_ipv4({src}, {dst}, udp) == ChecksumResult::kNotPresent); +} + +TEST_CASE("verify_udp_checksum_ipv4 accepts a datagram with a correctly computed checksum") { + std::array<unsigned char, 4> src = {10, 0, 0, 1}; + std::array<unsigned char, 4> dst = {10, 0, 0, 2}; + + std::vector<unsigned char> udp = {0x00, 0x35, 0x00, 0x35, 0x00, 0x08, 0x00, 0x00}; + auto buf = pseudo_header(src, dst, kProtoUdp, udp); + std::uint16_t csum = internet_checksum(buf); + // A computed checksum of exactly 0 is itself sent as 0xFFFF per + // RFC 768, to keep it distinguishable from "no checksum" - not + // exercised by this test's specific values, but worth the note. + udp[6] = static_cast<unsigned char>(csum >> 8); + udp[7] = static_cast<unsigned char>(csum & 0xFF); + + CHECK(verify_udp_checksum_ipv4({src}, {dst}, udp) == ChecksumResult::kValid); +} diff --git a/tests/test_icmp.cpp b/tests/test_icmp.cpp new file mode 100644 index 0000000..3dd713e --- /dev/null +++ b/tests/test_icmp.cpp @@ -0,0 +1,85 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/net/icmp.hpp" + +using namespace wireframe::net; + +TEST_CASE("parse_icmpv4 decodes an echo request with identifier/sequence") { + std::vector<unsigned char> bytes = {8, 0, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x01}; + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 8); + CHECK(icmp->code == 0); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 0x1c05); + REQUIRE(icmp->sequence.has_value()); + CHECK(*icmp->sequence == 1); +} + +TEST_CASE("parse_icmpv4 decodes an echo reply the same way as a request") { + std::vector<unsigned char> bytes = {0, 0, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02}; + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 0); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 1); +} + +TEST_CASE("parse_icmpv4 decodes a non-echo type without an identifier/sequence") { + std::vector<unsigned char> bytes = {3, 1, 0x00, 0x00}; // dest unreachable, host unreachable + auto icmp = parse_icmpv4(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 3); + CHECK(icmp->code == 1); + CHECK_FALSE(icmp->identifier.has_value()); +} + +TEST_CASE("parse_icmpv4 rejects a buffer shorter than the fixed header") { + std::vector<unsigned char> bytes(3, 0); + CHECK_FALSE(parse_icmpv4(bytes).has_value()); +} + +TEST_CASE("icmpv4_type_name covers known types and falls back for unknown ones") { + CHECK(icmpv4_type_name(8) == "Echo Request"); + CHECK(icmpv4_type_name(0) == "Echo Reply"); + CHECK(icmpv4_type_name(3) == "Destination Unreachable"); + CHECK(icmpv4_type_name(200) == "type=200"); +} + +TEST_CASE("parse_icmpv6 decodes an echo request with identifier/sequence") { + std::vector<unsigned char> bytes = {128, 0, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x01}; + auto icmp = parse_icmpv6(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 128); + REQUIRE(icmp->identifier.has_value()); + CHECK(*icmp->identifier == 0x1c05); +} + +TEST_CASE("parse_icmpv6 decodes a non-echo type (e.g. Neighbor Solicitation) without id/seq") { + std::vector<unsigned char> bytes = {135, 0, 0x00, 0x00}; + auto icmp = parse_icmpv6(bytes); + REQUIRE(icmp.has_value()); + CHECK(icmp->type == 135); + CHECK_FALSE(icmp->identifier.has_value()); +} + +TEST_CASE("icmpv6_type_name covers known types and falls back for unknown ones") { + CHECK(icmpv6_type_name(128) == "Echo Request"); + CHECK(icmpv6_type_name(135) == "Neighbor Solicitation"); + CHECK(icmpv6_type_name(134) == "Router Advertisement"); + CHECK(icmpv6_type_name(250) == "type=250"); +} + +TEST_CASE("the same type number means something different in each protocol's table") { + // The whole reason these are two separate tables, not one shared by + // number: ICMPv4's echo request is type 8, but ICMPv6's type 8 + // isn't in its table at all (echo request is 128 there instead). + CHECK(icmpv4_type_name(8) == "Echo Request"); + CHECK(icmpv6_type_name(8) == "type=8"); + // And type 4 means "Parameter Problem" in ICMPv6 but is unmapped + // (falls back) in the ICMPv4 table. + CHECK(icmpv6_type_name(4) == "Parameter Problem"); + CHECK(icmpv4_type_name(4) == "Source Quench"); +} diff --git a/tests/test_privileges.cpp b/tests/test_privileges.cpp new file mode 100644 index 0000000..287d654 --- /dev/null +++ b/tests/test_privileges.cpp @@ -0,0 +1,18 @@ +#include <doctest/doctest.h> +#include <unistd.h> + +#include "wireframe/privileges.hpp" + +// The actual drop sequence (setuid/setgid) can only be meaningfully +// exercised by literally running as root, which a unit test shouldn't +// do - permanently dropping the test runner's own privileges mid-suite +// would be a real, surprising side effect, not a safe thing to assert +// on. That path is verified live instead (running the real binary via +// sudo and checking the dropped-to UID actually took effect - see +// PLAN.md). This only covers the no-op path any non-root test run +// takes, which is still worth locking in: it must never attempt to +// touch privileges it doesn't have. +TEST_CASE("drop_privileges_if_root is a no-op when not running as root") { + if (geteuid() == 0) return; // this test only makes sense unprivileged + CHECK_FALSE(wireframe::drop_privileges_if_root().has_value()); +} diff --git a/tests/test_tcp_reassembly.cpp b/tests/test_tcp_reassembly.cpp new file mode 100644 index 0000000..b424610 --- /dev/null +++ b/tests/test_tcp_reassembly.cpp @@ -0,0 +1,176 @@ +#include <doctest/doctest.h> + +#include <string> +#include <vector> + +#include "wireframe/l7/http.hpp" +#include "wireframe/net/tcp.hpp" +#include "wireframe/net/tcp_reassembly.hpp" + +using namespace wireframe::net; + +namespace { + +Ipv4Address addr(unsigned char a, unsigned char b, unsigned char c, unsigned char d) { + return Ipv4Address{{a, b, c, d}}; +} + +std::vector<unsigned char> to_bytes(const std::string& s) { + return std::vector<unsigned char>(s.begin(), s.end()); +} + +} // namespace + +TEST_CASE("TcpReassembler ignores payload before SYN is seen") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + auto data = to_bytes("data before syn"); + auto result = r.process_segment(client, 40000, server, 80, 1000, 0, data); + CHECK_FALSE(result.has_value()); +} + +TEST_CASE("TcpReassembler joins two in-order segments into one contiguous buffer") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + // SYN: seq 1000, consumes seq 1000 itself, next data starts at 1001. + auto syn = r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK_FALSE(syn.has_value()); + + auto part1 = to_bytes("GET /index.html HTTP/1.1\r\n"); + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpPsh | kTcpAck, part1); + REQUIRE(r1.has_value()); + CHECK(r1->size() == part1.size()); + + auto part2 = to_bytes("Host: example.com\r\n\r\n"); + std::uint32_t seq2 = 1001 + static_cast<std::uint32_t>(part1.size()); + auto r2 = r.process_segment(client, 40000, server, 80, seq2, kTcpPsh | kTcpAck, part2); + REQUIRE(r2.has_value()); + + std::string joined(r2->begin(), r2->end()); + CHECK(joined == "GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"); + + auto http = parse_http(*r2); + REQUIRE(http.has_value()); + CHECK(http->is_request); + CHECK(http->method_or_version == "GET"); + CHECK(http->target_or_status == "/index.html"); + REQUIRE(http->host.has_value()); + CHECK(*http->host == "example.com"); +} + +TEST_CASE("TcpReassembler drops an out-of-order segment rather than buffering it") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("first "); + r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + + // Skip ahead instead of continuing at 1001 + part1.size(): out of order. + auto part3 = to_bytes("third "); + auto result = r.process_segment(client, 40000, server, 80, 9999, kTcpAck, part3); + CHECK_FALSE(result.has_value()); +} + +TEST_CASE("TcpReassembler drops a retransmitted (already-seen) segment") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("hello"); + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + REQUIRE(r1.has_value()); + + // Same seq again: a retransmission, not new data. + auto retransmit = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + CHECK_FALSE(retransmit.has_value()); +} + +TEST_CASE("TcpReassembler tracks each direction of a flow independently") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + r.process_segment(server, 80, client, 40000, 5000, kTcpSyn | kTcpAck, {}); + + auto request = to_bytes("GET / HTTP/1.1\r\n\r\n"); + auto req_result = r.process_segment(client, 40000, server, 80, 1001, kTcpPsh | kTcpAck, + request); + REQUIRE(req_result.has_value()); + CHECK(std::string(req_result->begin(), req_result->end()) == "GET / HTTP/1.1\r\n\r\n"); + + auto response = to_bytes("HTTP/1.1 200 OK\r\n\r\n"); + auto resp_result = r.process_segment(server, 80, client, 40000, 5001, kTcpPsh | kTcpAck, + response); + REQUIRE(resp_result.has_value()); + CHECK(std::string(resp_result->begin(), resp_result->end()) == "HTTP/1.1 200 OK\r\n\r\n"); + + // Requesting side's buffer should be untouched by the response. + CHECK(std::string(req_result->begin(), req_result->end()) == "GET / HTTP/1.1\r\n\r\n"); +} + +TEST_CASE("TcpReassembler canonicalizes both directions of a connection to the same flow") { + TcpReassembler r; + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK(r.flow_count() == 1); + + // A segment in the reverse direction of the *same* connection must + // not create a second flow entry. + r.process_segment(server, 80, client, 40000, 5000, kTcpSyn | kTcpAck, {}); + CHECK(r.flow_count() == 1); +} + +TEST_CASE("TcpReassembler caps buffered bytes per direction and stops growing past the limit") { + TcpReassembler r(/*max_buffer_per_direction=*/10, /*max_flows=*/16); + auto client = addr(10, 0, 0, 1); + auto server = addr(10, 0, 0, 2); + + r.process_segment(client, 40000, server, 80, 1000, kTcpSyn, {}); + + auto part1 = to_bytes("12345"); // 5 bytes, fits + auto r1 = r.process_segment(client, 40000, server, 80, 1001, kTcpAck, part1); + REQUIRE(r1.has_value()); + CHECK(r1->size() == 5); + + // Next 5 bytes would land exactly at the 10-byte cap. + auto part2 = to_bytes("67890"); + auto r2 = r.process_segment(client, 40000, server, 80, 1006, kTcpAck, part2); + REQUIRE(r2.has_value()); + CHECK(r2->size() == 10); + + // A further segment would exceed the cap: sequence tracking still + // advances (so future in-order segments aren't misjudged), but the + // buffer itself does not grow past max_buffer_. + auto part3 = to_bytes("overflow"); + auto r3 = r.process_segment(client, 40000, server, 80, 1011, kTcpAck, part3); + REQUIRE(r3.has_value()); + CHECK(r3->size() == 10); +} + +TEST_CASE("TcpReassembler caps the number of tracked flows") { + TcpReassembler r(/*max_buffer_per_direction=*/1024, /*max_flows=*/1); + auto server = addr(10, 0, 0, 2); + + auto client1 = addr(10, 0, 0, 1); + r.process_segment(client1, 40000, server, 80, 1000, kTcpSyn, {}); + CHECK(r.flow_count() == 1); + + // A second, distinct flow should be refused: table is full. + auto client2 = addr(10, 0, 0, 3); + auto data = to_bytes("x"); + auto result = r.process_segment(client2, 40000, server, 80, 2000, kTcpSyn, data); + CHECK_FALSE(result.has_value()); + CHECK(r.flow_count() == 1); +} |