srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-05-21 22:24:00 +0200
committersrdusr <[email protected]>2024-05-21 22:24:00 +0200
commitfbedc55d5aa861c381701c9f913b34ee7ab57ec4 (patch)
treed34c3648b61d417f2a5d8690e0eb4a76bd64c943
parente0f4c701028aa81026a17cf9ebfb36112184f4bc (diff)
downloadpacketeer-fbedc55d5aa861c381701c9f913b34ee7ab57ec4.tar.gz
packeteer-fbedc55d5aa861c381701c9f913b34ee7ab57ec4.zip
Add GUI parity for -c/-a, mDNS/SSH dissectors, and two new fuzz harnesses
GUI parity: checksum_status()/reassembled_http_status() moved out of main.cpp into a shared wireframe/packet_diagnostics.hpp so the GUI can show the same -c/-a diagnostics for the selected packet without duplicating the Ethernet/IPv4/TCP walk. Visually verified under Xvfb with the same split-segment scenario used to verify -a on the CLI. Two new L7 dissectors: mDNS (reuses parse_dns outright - RFC 6762 keeps DNS's wire format, just a different port) and SSH's cleartext identification banner. Live-verified against this machine's real sshd and a real DNS-wire-format packet sent to port 5353. Two new fuzz harnesses (fuzz_checksum, fuzz_tcp_reassembly) covering code added here that the original nine harnesses never touched. All 12 run clean across ~90M executions with no crashes. NAMES.md and PLAN.md updated with this round's decisions and naming candidates.
-rw-r--r--CMakeLists.txt4
-rw-r--r--NAMES.md39
-rw-r--r--PLAN.md60
-rw-r--r--fuzz/fuzz_checksum.cpp27
-rw-r--r--fuzz/fuzz_tcp_reassembly.cpp42
-rw-r--r--include/wireframe/l7/mdns.hpp44
-rw-r--r--include/wireframe/l7/ssh.hpp65
-rw-r--r--include/wireframe/packet_diagnostics.hpp92
-rw-r--r--include/wireframe/summarize.hpp12
-rw-r--r--src/gui_main.cpp45
-rw-r--r--src/main.cpp105
-rw-r--r--tests/test_mdns.cpp60
-rw-r--r--tests/test_ssh.cpp62
13 files changed, 553 insertions, 104 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index abe3546..264fcc4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -99,6 +99,8 @@ add_executable(wireframe_tests
tests/test_net.cpp
tests/test_ipv6.cpp
tests/test_dns.cpp
+ tests/test_mdns.cpp
+ tests/test_ssh.cpp
tests/test_http.cpp
tests/test_tls.cpp
tests/test_pcapng.cpp
@@ -147,4 +149,6 @@ if(WIREFRAME_ENABLE_FUZZING)
add_wireframe_fuzz_target(fuzz_tls)
add_wireframe_fuzz_target(fuzz_pcapng_reader)
add_wireframe_fuzz_target(fuzz_summarize)
+ add_wireframe_fuzz_target(fuzz_checksum)
+ add_wireframe_fuzz_target(fuzz_tcp_reassembly)
endif()
diff --git a/NAMES.md b/NAMES.md
index ae0e1e6..b07af22 100644
--- a/NAMES.md
+++ b/NAMES.md
@@ -102,3 +102,42 @@ No changes to the recommendation above - `frameshark`/`spanshark` (brand
lineage) or `peek`/`probe` (terse-CLI lane) are still the strongest picks.
`octet` is the one addition here worth weighing seriously: it's the most
precise single word for what the tool actually operates on.
+
+## More candidates (added after TCP reassembly, checksums, privilege
+## dropping, and a wider L7 protocol set - DNS/mDNS/HTTP/TLS SNI/SSH/ICMP)
+
+The project has since grown two angles the earlier lists didn't have
+anything for: **stitching segments back into a stream** (TCP
+reassembly, wireframe/net/tcp_reassembly.hpp) and **actively dropping
+root** the moment the capture handle is open (wireframe/privileges.hpp)
+rather than just capturing passively.
+
+- `flowtap` - "flow" is the actual industry term for what
+ TcpReassembler tracks (a 4-tuple's worth of state across many
+ packets), not just "stream"
+- `stitchtap` - literal, describes reassembly specifically; maybe too
+ literal/cute
+- `reflow` - re- (reassemble) + flow; short, but collides conceptually
+ with CSS/text "reflow", possibly confusing
+- `dropcap` - pun on dropping root/CAP_NET_RAW after opening the
+ capture handle, which doubles as an actual typography term ("drop
+ cap": an oversized first letter) - two real meanings landing on the
+ same word is rare enough to be worth serious consideration
+- `polytap` - poly- (many protocols: DNS/HTTP/TLS/mDNS/SSH/ICMP) + tap,
+ keeps the -tap suffix family from the first list
+- `layershark` / `stackshark` - extends the -shark lineage with the
+ OSI-layer angle (L2 through L7 all decoded by hand now)
+- `dissect` - plain English word, no jargon, describes exactly what
+ the tool does at every layer; downside is it's a very generic verb,
+ likely to collide with something already using it
+
+## Current standing recommendation
+
+Given how much the project now actually does - full L2-L7 decode
+(including reassembly), pcapng, filtering, checksum verification,
+privilege dropping, dual TUI/GUI frontends - a name that still reads
+as "one narrow tool" undersells it less than it used to when this list
+started. `frameshark` remains the strongest brand-lineage pick;
+`dropcap` is the strongest new candidate from this round, on the
+strength of its double meaning actually being true of the tool's own
+behavior rather than a stretch.
diff --git a/PLAN.md b/PLAN.md
index bc9ba8e..6defe1d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -30,8 +30,9 @@ unowned buffers) via a real-world capture pipeline.
3. [done] pcapng read/write
4. [done] Bounded channel + drop-on-backpressure between capture and render
5. [in progress] L7 dissector interface, add protocols incrementally --
- interface + DNS + HTTP + TLS SNI done (wireframe/l7/); more
- protocols can still be added incrementally, by design
+ interface + DNS + HTTP + TLS SNI + mDNS + SSH banner 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)
@@ -312,3 +313,58 @@ None currently open.
without needing active FIN/RST-triggered flow teardown - simpler,
and stale entries past those caps don't affect correctness, just
bounded memory use.
+- GUI parity for -c/-a: checksum_status() and reassembled_http_status()
+ moved out of main.cpp into a new shared header
+ (wireframe/packet_diagnostics.hpp) rather than duplicated into
+ gui_main.cpp - the same reasoning wireframe::CaptureSession exists
+ for at the setup layer, applied here to the diagnostics layer. GUI's
+ hex dump was already always-on for the selected row (no -x-equivalent
+ flag needed); checksum status is computed lazily when a row is
+ selected (stateless, cheap); reassembled HTTP status has to be
+ computed at consume time instead (reassembly needs in-order state
+ across packets), so PacketRow gained an
+ optional<string> reassembled_http field set once in consumer_loop.
+ Both are still opt-in via the same -c/-a flag names as the CLI, off
+ by default. Visually verified under Xvfb (python-xlib synthetic
+ click) with the same split-segment scenario used to verify -a on the
+ CLI: selecting the packet whose segment completed the request showed
+ both "checksums: IP=ok TCP=BAD" and "reassembled request: GET
+ /split-test Host: split.example.com (72 bytes so far)" together in
+ the details pane, and a second run with neither flag confirmed both
+ lines are absent by default. The TCP=BAD reading on lo in that
+ screenshot is the checksum-offload caveat working as documented, not
+ a bug - Linux's loopback receive path typically never computes a
+ real TCP checksum at all (CHECKSUM_UNNECESSARY), which is exactly the
+ false-positive scenario -c's opt-in-ness exists to guard against.
+- mDNS (wireframe/l7/mdns.hpp) and SSH banner (wireframe/l7/ssh.hpp)
+ dissectors, registered alongside DNS/HTTP/TLS in l7_registry().
+ mDNS reuses parse_dns() outright - RFC 6762 keeps DNS's exact wire
+ format, just over UDP 5353 instead of 53 - and deliberately omits
+ the id= field DNS's own summary shows, since RFC 6762 18.1 has
+ multicast queries send it as zero, which would just be "id=0" noise
+ on every real packet. SSH's identification banner (RFC 4253 4.2) is
+ the one part of an SSH connection ever sent in the clear - a single
+ line before key exchange encrypts everything else - so unlike every
+ other dissector here, there's structurally nothing further to add to
+ it later. Live-verified against real traffic: SSH against this
+ machine's actual running sshd via /dev/tcp on lo, correctly decoding
+ "SSH 2.0 OpenSSH_10.4" (matching the real installed OpenSSH version);
+ mDNS via a real DNS-wire-format query sent to 127.0.0.1:5353 (no
+ avahi/mDNS responder running on this sandboxed machine, so a
+ synthetic-but-wire-format-real packet substituted for organic
+ traffic), correctly decoding "mDNS query myhost.local type=1" with no
+ id= field present.
+- Fuzzing: two new libFuzzer harnesses added alongside the original
+ nine - fuzz_checksum (internet_checksum/verify_ipv4_checksum
+ directly, plus verify_tcp/udp_checksum_ipv4 with the first 8 input
+ bytes providing src/dst addresses) and fuzz_tcp_reassembly (unlike
+ every other harness, drives *one* TcpReassembler with a whole
+ sequence of segments parsed out of a single input, since the
+ interesting bugs in cross-call state - the flow map, per-direction
+ sequence tracking, the buffer cap - don't show up from one segment
+ alone). All 12 harnesses (the original nine plus these two) run
+ clean - no crashes, no ASan/UBSan errors, no leak/timeout artifacts
+ - across roughly 90 million total executions in a 20-second-each
+ pass; summarize's own harness also now exercises the ICMP decode path
+ added earlier this session and the new mDNS/SSH dissectors, since all
+ of that lives inside summarize_packet()'s call graph already.
diff --git a/fuzz/fuzz_checksum.cpp b/fuzz/fuzz_checksum.cpp
new file mode 100644
index 0000000..f490d1c
--- /dev/null
+++ b/fuzz/fuzz_checksum.cpp
@@ -0,0 +1,27 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/checksum.hpp"
+#include "wireframe/net/ipv4.hpp"
+
+using namespace wireframe::net;
+
+// internet_checksum() itself takes arbitrary bytes directly. The
+// verify_*_checksum_ipv4() wrappers additionally need two addresses,
+// so the first 8 bytes of input become src/dst and the rest is treated
+// as the header/segment/datagram under test - exercises the
+// pseudo-header construction (detail::build_ipv4_pseudo_header) along
+// with the checksum math itself.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ internet_checksum({data, size});
+ verify_ipv4_checksum({data, size});
+
+ if (size < 8) return 0;
+ Ipv4Address src{{data[0], data[1], data[2], data[3]}};
+ Ipv4Address dst{{data[4], data[5], data[6], data[7]}};
+ std::span<const unsigned char> rest{data + 8, size - 8};
+
+ verify_tcp_checksum_ipv4(src, dst, rest);
+ verify_udp_checksum_ipv4(src, dst, rest);
+ return 0;
+}
diff --git a/fuzz/fuzz_tcp_reassembly.cpp b/fuzz/fuzz_tcp_reassembly.cpp
new file mode 100644
index 0000000..78ca91c
--- /dev/null
+++ b/fuzz/fuzz_tcp_reassembly.cpp
@@ -0,0 +1,42 @@
+#include <cstddef>
+#include <cstdint>
+
+#include "wireframe/net/tcp_reassembly.hpp"
+
+using namespace wireframe::net;
+
+// Unlike the other fuzz harnesses, this drives *one* TcpReassembler
+// with a whole sequence of segments parsed out of a single input --
+// the interesting bugs here are in cross-call state (the flow map,
+// per-direction sequence tracking, the buffer-size cap), not in
+// decoding one segment alone. Each record is a fixed 19-byte header
+// (src ip/port, dst ip/port, seq, flags, a payload length) followed by
+// that many payload bytes; malformed/truncated trailing records are
+// simply skipped rather than treated as an error, same as any other
+// best-effort parse in this project.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ TcpReassembler reassembler;
+
+ size_t pos = 0;
+ while (pos + 19 <= size) {
+ Ipv4Address src{{data[pos], data[pos + 1], data[pos + 2], data[pos + 3]}};
+ Ipv4Address dst{{data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]}};
+ std::uint16_t src_port = static_cast<std::uint16_t>(data[pos + 8] << 8 | data[pos + 9]);
+ std::uint16_t dst_port = static_cast<std::uint16_t>(data[pos + 10] << 8 | data[pos + 11]);
+ std::uint32_t seq = static_cast<std::uint32_t>(data[pos + 12]) << 24 |
+ static_cast<std::uint32_t>(data[pos + 13]) << 16 |
+ static_cast<std::uint32_t>(data[pos + 14]) << 8 | data[pos + 15];
+ std::uint8_t flags = data[pos + 16];
+ std::uint16_t payload_len =
+ static_cast<std::uint16_t>(data[pos + 17] << 8 | data[pos + 18]);
+ pos += 19;
+
+ std::size_t available = size - pos;
+ std::size_t take = payload_len < available ? payload_len : available;
+ std::span<const unsigned char> payload{data + pos, take};
+ pos += take;
+
+ reassembler.process_segment(src, src_port, dst, dst_port, seq, flags, payload);
+ }
+ return 0;
+}
diff --git a/include/wireframe/l7/mdns.hpp b/include/wireframe/l7/mdns.hpp
new file mode 100644
index 0000000..887d811
--- /dev/null
+++ b/include/wireframe/l7/mdns.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+
+#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<std::string> summarize(std::span<const unsigned char> 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
new file mode 100644
index 0000000..efa471f
--- /dev/null
+++ b/include/wireframe/l7/ssh.hpp
@@ -0,0 +1,65 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#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<SshBanner> parse_ssh_banner(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(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<std::string> summarize(std::span<const unsigned char> 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/packet_diagnostics.hpp b/include/wireframe/packet_diagnostics.hpp
new file mode 100644
index 0000000..4b9b0c6
--- /dev/null
+++ b/include/wireframe/packet_diagnostics.hpp
@@ -0,0 +1,92 @@
+#pragma once
+
+#include <optional>
+#include <pcap.h>
+#include <span>
+#include <string>
+
+#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<const unsigned char> bytes, int datalink) {
+ std::span<const unsigned char> 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<std::size_t>(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<std::string> reassembled_http_status(std::span<const unsigned char> bytes,
+ int datalink,
+ net::TcpReassembler& reassembler) {
+ std::span<const unsigned char> 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/summarize.hpp b/include/wireframe/summarize.hpp
index e7e9ae3..840ddf9 100644
--- a/include/wireframe/summarize.hpp
+++ b/include/wireframe/summarize.hpp
@@ -11,6 +11,8 @@
#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"
@@ -57,16 +59,24 @@ inline std::string tcp_flags_to_string(std::uint8_t flags) {
// 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.
+// 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;
diff --git a/src/gui_main.cpp b/src/gui_main.cpp
index 16ee769..8a7771a 100644
--- a/src/gui_main.cpp
+++ b/src/gui_main.cpp
@@ -23,6 +23,8 @@
#include <thread>
#include "wireframe/capture_session.hpp"
+#include "wireframe/net/tcp_reassembly.hpp"
+#include "wireframe/packet_diagnostics.hpp"
#include "wireframe/search.hpp"
#include "wireframe/summarize.hpp"
@@ -31,6 +33,11 @@ namespace {
struct PacketRow {
std::string summary;
std::vector<unsigned char> data;
+ // -a only: computed once at consume time (reassembly needs
+ // in-order state across packets, unlike checksum status below,
+ // which is stateless and cheap enough to compute lazily when a row
+ // is selected instead of storing it on every row).
+ std::optional<std::string> reassembled_http;
};
constexpr std::size_t kMaxRows = 5000; // cap memory; oldest rows scroll off
@@ -52,7 +59,7 @@ struct SharedState {
};
void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue,
- SharedState& state) {
+ SharedState& state, wireframe::net::TcpReassembler* reassembler) {
while (auto packet = queue.pop()) {
std::span<const unsigned char> bytes{packet->data};
std::string summary = wireframe::summarize_packet(bytes, session.datalink());
@@ -62,8 +69,15 @@ void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue&
packet->original_len);
}
+ std::optional<std::string> reassembled_http;
+ if (reassembler) {
+ reassembled_http = wireframe::reassembled_http_status(bytes, session.datalink(),
+ *reassembler);
+ }
+
std::lock_guard<std::mutex> lock(state.mutex);
- state.rows.push_back({std::move(summary), std::move(packet->data)});
+ state.rows.push_back({std::move(summary), std::move(packet->data),
+ std::move(reassembled_http)});
if (state.rows.size() > kMaxRows) state.rows.pop_front();
++state.packet_count;
}
@@ -89,6 +103,11 @@ void print_usage(const char* argv0) {
" -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"
+ " -c Show IPv4/TCP/UDP checksum validity for the selected packet.\n"
+ " Off by default - see the CLI's -h for why (checksum offload).\n"
+ " -a Reassemble TCP streams and show HTTP requests/responses\n"
+ " joined across segments for the selected packet, when its\n"
+ " segment contributed to one. In-order segments only.\n"
" -h, --help Show this help and exit\n"
"\n"
"Examples:\n"
@@ -109,6 +128,8 @@ int main(int argc, char** argv) {
}
wireframe::CaptureSessionOptions options;
+ bool enable_checksums = false;
+ bool enable_reassembly = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) {
options.pcapng_output_path = argv[++i];
@@ -116,6 +137,10 @@ int main(int argc, char** argv) {
options.filter_expr = argv[++i];
} else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
options.replay_input_path = argv[++i];
+ } else if (std::strcmp(argv[i], "-c") == 0) {
+ enable_checksums = true;
+ } else if (std::strcmp(argv[i], "-a") == 0) {
+ enable_reassembly = true;
} else if (options.device.empty()) {
options.device = argv[i];
}
@@ -158,9 +183,10 @@ int main(int argc, char** argv) {
wireframe::CaptureQueue queue(4096);
SharedState state;
+ wireframe::net::TcpReassembler reassembler;
std::thread capture_thread = session.start_capture_thread(queue);
std::thread consumer_thread(consumer_loop, std::ref(session), std::ref(queue),
- std::ref(state));
+ std::ref(state), enable_reassembly ? &reassembler : nullptr);
int selected_row = -1;
bool quit = false;
@@ -248,7 +274,18 @@ int main(int argc, char** argv) {
{
std::lock_guard<std::mutex> lock(state.mutex);
if (selected_row >= 0 && selected_row < static_cast<int>(state.rows.size())) {
- for (const auto& line : wireframe::hex_dump_lines(state.rows[selected_row].data)) {
+ const auto& row = state.rows[selected_row];
+ if (enable_checksums) {
+ std::string status = wireframe::checksum_status(row.data, session.datalink());
+ if (!status.empty()) {
+ ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s", status.c_str());
+ }
+ }
+ if (row.reassembled_http) {
+ ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%s",
+ row.reassembled_http->c_str());
+ }
+ for (const auto& line : wireframe::hex_dump_lines(row.data)) {
ImGui::TextUnformatted(line.c_str());
}
} else {
diff --git a/src/main.cpp b/src/main.cpp
index 31fca73..72dd267 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -48,12 +48,8 @@
#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/packet_diagnostics.hpp"
#include "wireframe/search.hpp"
#include "wireframe/summarize.hpp"
@@ -72,95 +68,6 @@ 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;
@@ -185,11 +92,15 @@ 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);
+ if (opts.verbose_checksums) {
+ std::string status = wireframe::checksum_status(bytes, opts.datalink);
+ if (!status.empty()) line += " " + status;
+ }
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 (auto status = wireframe::reassembled_http_status(bytes, opts.datalink,
+ *opts.reassembler)) {
+ std::printf(" [%s]\n", status->c_str());
}
}
if (opts.verbose_hex) hex_dump(bytes);
diff --git a/tests/test_mdns.cpp b/tests/test_mdns.cpp
new file mode 100644
index 0000000..cad77e7
--- /dev/null
+++ b/tests/test_mdns.cpp
@@ -0,0 +1,60 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/mdns.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+// A typical mDNS query for a ".local" hostname, id=0 per RFC 6762
+// 18.1's convention for multicast queries.
+std::vector<unsigned char> mdns_query() {
+ return {
+ 0x00, 0x00, // id = 0
+ 0x00, 0x00, // flags: query
+ 0x00, 0x01, // qdcount = 1
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 6, 'm', 'y', 'h', 'o', 's', 't', 5, 'l', 'o', 'c', 'a', 'l', 0,
+ 0x00, 0x01, // qtype = A
+ 0x00, 0x01, // qclass = IN
+ };
+}
+
+} // namespace
+
+TEST_CASE("MdnsDissector claims port 5353") {
+ MdnsDissector dissector;
+ CHECK(dissector.port() == kMdnsPort);
+}
+
+TEST_CASE("MdnsDissector summarizes a query without an id= field") {
+ MdnsDissector dissector;
+ auto summary = dissector.summarize(mdns_query());
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 10) == "mDNS query");
+ CHECK(summary->find("myhost.local") != std::string::npos);
+ CHECK(summary->find("id=") == std::string::npos);
+}
+
+TEST_CASE("MdnsDissector summarizes a response with ancount") {
+ MdnsDissector dissector;
+ std::vector<unsigned char> bytes = {
+ 0x00, 0x00,
+ 0x84, 0x00, // flags: QR=1 (response), AA=1
+ 0x00, 0x00, // qdcount = 0 (typical for an mDNS response)
+ 0x00, 0x01, // ancount = 1
+ 0x00, 0x00, 0x00, 0x00,
+ };
+ auto summary = dissector.summarize(bytes);
+ REQUIRE(summary.has_value());
+ CHECK(summary->substr(0, 13) == "mDNS response");
+ CHECK(summary->find("ancount=1") != std::string::npos);
+}
+
+TEST_CASE("MdnsDissector::summarize returns nullopt for a truncated payload") {
+ MdnsDissector dissector;
+ std::vector<unsigned char> bytes(5, 0);
+ CHECK_FALSE(dissector.summarize(bytes).has_value());
+}
diff --git a/tests/test_ssh.cpp b/tests/test_ssh.cpp
new file mode 100644
index 0000000..7c4e339
--- /dev/null
+++ b/tests/test_ssh.cpp
@@ -0,0 +1,62 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "wireframe/l7/ssh.hpp"
+
+using namespace wireframe::net;
+
+namespace {
+
+std::vector<unsigned char> to_bytes(const std::string& s) {
+ return std::vector<unsigned char>(s.begin(), s.end());
+}
+
+} // namespace
+
+TEST_CASE("parse_ssh_banner decodes a CRLF-terminated banner with a comment") {
+ auto banner = parse_ssh_banner(to_bytes("SSH-2.0-OpenSSH_9.6 FreeBSD-20240101\r\n"));
+ REQUIRE(banner.has_value());
+ CHECK(banner->proto_version == "2.0");
+ CHECK(banner->software_version == "OpenSSH_9.6");
+}
+
+TEST_CASE("parse_ssh_banner decodes a banner with no comment") {
+ auto banner = parse_ssh_banner(to_bytes("SSH-2.0-libssh_0.10.6\r\n"));
+ REQUIRE(banner.has_value());
+ CHECK(banner->proto_version == "2.0");
+ CHECK(banner->software_version == "libssh_0.10.6");
+}
+
+TEST_CASE("parse_ssh_banner tolerates a bare LF terminator") {
+ auto banner = parse_ssh_banner(to_bytes("SSH-1.99-OpenSSH_3.9\n"));
+ REQUIRE(banner.has_value());
+ CHECK(banner->proto_version == "1.99");
+ CHECK(banner->software_version == "OpenSSH_3.9");
+}
+
+TEST_CASE("parse_ssh_banner rejects payloads without the SSH- prefix") {
+ CHECK_FALSE(parse_ssh_banner(to_bytes("not an ssh banner\r\n")).has_value());
+}
+
+TEST_CASE("parse_ssh_banner rejects a banner missing the version separator") {
+ CHECK_FALSE(parse_ssh_banner(to_bytes("SSH-nodash\r\n")).has_value());
+}
+
+TEST_CASE("parse_ssh_banner rejects an unterminated line") {
+ CHECK_FALSE(parse_ssh_banner(to_bytes("SSH-2.0-OpenSSH_9.6")).has_value());
+}
+
+TEST_CASE("SshDissector claims port 22 and its summary matches parse_ssh_banner") {
+ SshDissector dissector;
+ CHECK(dissector.port() == kSshPort);
+
+ auto summary = dissector.summarize(to_bytes("SSH-2.0-OpenSSH_9.6\r\n"));
+ REQUIRE(summary.has_value());
+ CHECK(*summary == "SSH 2.0 OpenSSH_9.6");
+}
+
+TEST_CASE("SshDissector::summarize returns nullopt for non-SSH payload") {
+ SshDissector dissector;
+ CHECK_FALSE(dissector.summarize(to_bytes("GET / HTTP/1.1\r\n")).has_value());
+}