srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/include/wireframe/summarize.hpp
diff options
context:
space:
mode:
Diffstat (limited to 'include/wireframe/summarize.hpp')
-rw-r--r--include/wireframe/summarize.hpp248
1 files changed, 248 insertions, 0 deletions
diff --git a/include/wireframe/summarize.hpp b/include/wireframe/summarize.hpp
new file mode 100644
index 0000000..59aa621
--- /dev/null
+++ b/include/wireframe/summarize.hpp
@@ -0,0 +1,248 @@
+#pragma once
+
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+#include <vector>
+
+#include <pcap.h>
+
+#include "wireframe/l7/dissector.hpp"
+#include "wireframe/l7/dns.hpp"
+#include "wireframe/l7/http.hpp"
+#include "wireframe/l7/tls.hpp"
+#include "wireframe/net/ethernet.hpp"
+#include "wireframe/net/ipv4.hpp"
+#include "wireframe/net/ipv6.hpp"
+#include "wireframe/net/tcp.hpp"
+#include "wireframe/net/udp.hpp"
+
+// Packet -> human-readable summary. Shared by every frontend (plain
+// CLI, TUI, GUI) so they can't drift apart on what a given packet
+// decodes to - one source of truth, not three copies to keep in sync.
+namespace wireframe {
+
+inline std::string mac_to_string(const net::MacAddress& mac) {
+ char buf[18];
+ std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", mac.bytes[0], mac.bytes[1],
+ mac.bytes[2], mac.bytes[3], mac.bytes[4], mac.bytes[5]);
+ return buf;
+}
+
+inline std::string ipv4_to_string(const net::Ipv4Address& ip) {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", ip.bytes[0], ip.bytes[1], ip.bytes[2],
+ ip.bytes[3]);
+ return buf;
+}
+
+inline std::string tcp_flags_to_string(std::uint8_t flags) {
+ using namespace net;
+ std::string out;
+ if (flags & kTcpSyn) out += 'S';
+ if (flags & kTcpAck) out += 'A';
+ if (flags & kTcpFin) out += 'F';
+ if (flags & kTcpRst) out += 'R';
+ if (flags & kTcpPsh) out += 'P';
+ if (flags & kTcpUrg) out += 'U';
+ return out.empty() ? "-" : out;
+}
+
+// Registered once. DNS (UDP) was the first L7 dissector, proving the
+// interface (wireframe/l7/dissector.hpp) is enough to add a protocol
+// without touching the L2-L4 decode path; HTTP (TCP) is the second,
+// and the first to actually exercise L7Registry's TCP-payload path --
+// DNS alone never did, since it only ever runs over UDP port 53. TLS
+// (also TCP, port 443) covers what HTTP increasingly can't: most web
+// traffic today is encrypted, and SNI is the one piece of a TLS
+// handshake still readable without decrypting anything.
+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::L7Registry registry = [] {
+ net::L7Registry r;
+ r.add(&dns_dissector);
+ r.add(&http_dissector);
+ r.add(&tls_dissector);
+ return r;
+ }();
+ return registry;
+}
+
+// Tries the destination port first (the common case: a client talking
+// to a well-known server port), then the source port (a server's
+// reply, coming from that same well-known port).
+inline std::optional<std::string> l7_summarize(std::span<const unsigned char> payload,
+ std::uint16_t src_port, std::uint16_t dst_port) {
+ if (auto summary = l7_registry().dissect(dst_port, payload)) return summary;
+ return l7_registry().dissect(src_port, payload);
+}
+
+// IPv4 and IPv6 headers carry different fields (ttl vs. hop_limit,
+// 4-byte vs. 16-byte addresses), but everything above the IP layer --
+// TCP/UDP decode plus the L7 lookup - is identical once normalized to
+// this. Keeping that dispatch in one place means TCP/UDP/L7 formatting
+// can't drift between the two IP versions.
+struct IpInfo {
+ const char* label; // "IPv4" or "IPv6"
+ std::string src_str;
+ std::string dst_str;
+ std::uint8_t ttl_or_hop_limit;
+ std::uint8_t proto;
+ std::span<const unsigned char> payload;
+};
+
+inline std::string summarize_transport_and_above(const IpInfo& info) {
+ char ip_buf[160];
+ std::snprintf(ip_buf, sizeof(ip_buf), " | %s %s -> %s ttl=%u proto=%u", info.label,
+ info.src_str.c_str(), info.dst_str.c_str(), info.ttl_or_hop_limit, info.proto);
+ std::string out = ip_buf;
+
+ if (info.proto == net::kProtoTcp) {
+ if (auto tcp = net::parse_tcp(info.payload)) {
+ char tcp_buf[128];
+ std::snprintf(tcp_buf, sizeof(tcp_buf), " | TCP %u -> %u [%s] seq=%u ack=%u win=%u",
+ tcp->header.src_port, tcp->header.dst_port,
+ tcp_flags_to_string(tcp->header.flags).c_str(), tcp->header.seq,
+ tcp->header.ack, tcp->header.window);
+ out += tcp_buf;
+ if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port)) {
+ out += " | " + *l7;
+ }
+ }
+ } else if (info.proto == net::kProtoUdp) {
+ if (auto udp = net::parse_udp(info.payload)) {
+ char udp_buf[64];
+ std::snprintf(udp_buf, sizeof(udp_buf), " | UDP %u -> %u len=%u",
+ udp->header.src_port, udp->header.dst_port, udp->header.length);
+ out += udp_buf;
+ if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port)) {
+ out += " | " + *l7;
+ }
+ }
+ } else if (info.proto == net::kNextHeaderIcmpv6) {
+ out += " | ICMPv6";
+ }
+ return out;
+}
+
+// `datalink` is the interface's actual pcap_datalink() type, not an
+// assumption: tunnel/VPN interfaces (tailscale0, wireguard, plain
+// tun/tap) hand libpcap raw IP with no link-layer header at all
+// (DLT_RAW), unlike a real NIC or even `lo` (both DLT_EN10MB on
+// Linux). Treating raw IP bytes as an Ethernet frame silently produces
+// garbage MACs and ethertypes - verified by actually capturing on
+// tailscale0 before this branch existed.
+//
+// IP version is read from the packet itself (the first nibble), not
+// inferred from ethertype/datalink: DLT_RAW has no ethertype to key
+// off at all, and even on Ethernet this keeps IPv4/IPv6 dispatch in
+// one place.
+inline std::string summarize_packet(std::span<const unsigned char> bytes, int datalink) {
+ std::span<const unsigned char> ip_bytes;
+ std::string out;
+
+ if (datalink == DLT_RAW) {
+ out = "RAW";
+ ip_bytes = bytes;
+ } else {
+ auto eth = net::parse_ethernet(bytes);
+ if (!eth) {
+ char buf[64];
+ std::snprintf(buf, sizeof(buf), "[%zu bytes] truncated ethernet frame", bytes.size());
+ return buf;
+ }
+
+ out = "ETH " + mac_to_string(eth->header.src) + " -> " + mac_to_string(eth->header.dst);
+ char eth_buf[32];
+ std::snprintf(eth_buf, sizeof(eth_buf), " ethertype=0x%04x", eth->header.ethertype);
+ out += eth_buf;
+
+ if (eth->header.ethertype != net::kEthertypeIPv4 &&
+ eth->header.ethertype != net::kEthertypeIPv6) {
+ return out;
+ }
+ ip_bytes = eth->payload;
+ }
+
+ if (ip_bytes.empty()) {
+ out += " | IP (empty payload)";
+ return out;
+ }
+ std::uint8_t version = static_cast<std::uint8_t>(ip_bytes[0] >> 4);
+
+ if (version == 4) {
+ auto ip = net::parse_ipv4(ip_bytes);
+ if (!ip) {
+ out += " | IPv4 (truncated)";
+ return out;
+ }
+ out += summarize_transport_and_above({"IPv4", ipv4_to_string(ip->header.src),
+ ipv4_to_string(ip->header.dst), ip->header.ttl,
+ ip->header.protocol, ip->payload});
+ } else if (version == 6) {
+ auto ip6 = net::parse_ipv6(ip_bytes);
+ if (!ip6) {
+ out += " | IPv6 (truncated)";
+ return out;
+ }
+ std::string src_str = net::ipv6_to_string(ip6->header.src);
+ std::string dst_str = net::ipv6_to_string(ip6->header.dst);
+
+ // next_header may name an extension header (Hop-by-Hop,
+ // Routing, Dest Options, Fragment, AH) rather than the actual
+ // transport protocol; walk through those to find it.
+ auto walked = net::walk_ipv6_extension_headers(ip6->header.next_header, ip6->payload);
+ if (walked.stopped_at_esp) {
+ char buf[160];
+ std::snprintf(buf, sizeof(buf), " | IPv6 %s -> %s ttl=%u proto=%u | ESP (encrypted)",
+ src_str.c_str(), dst_str.c_str(), ip6->header.hop_limit,
+ net::kNextHeaderEsp);
+ out += buf;
+ } else {
+ out += summarize_transport_and_above({"IPv6", src_str, dst_str, ip6->header.hop_limit,
+ walked.final_next_header, walked.payload});
+ }
+ } else {
+ out += " | IP version " + std::to_string(version) + " (unsupported)";
+ }
+ return out;
+}
+
+// One formatted line per 16 bytes: offset, hex, ASCII gutter. Returned
+// as lines rather than printed so both the CLI's -x output and a GUI
+// details pane can use the same formatting.
+inline std::vector<std::string> hex_dump_lines(std::span<const unsigned char> bytes) {
+ std::vector<std::string> lines;
+ for (std::size_t offset = 0; offset < bytes.size(); offset += 16) {
+ char offset_buf[32];
+ std::snprintf(offset_buf, sizeof(offset_buf), "%06zx ", offset);
+ std::string line = offset_buf;
+
+ std::size_t line_len = std::min<std::size_t>(16, bytes.size() - offset);
+ for (std::size_t i = 0; i < 16; ++i) {
+ if (i < line_len) {
+ char byte_buf[4];
+ std::snprintf(byte_buf, sizeof(byte_buf), "%02x ", bytes[offset + i]);
+ line += byte_buf;
+ } else {
+ line += " ";
+ }
+ if (i == 7) line += ' ';
+ }
+
+ line += " |";
+ for (std::size_t i = 0; i < line_len; ++i) {
+ unsigned char c = bytes[offset + i];
+ line += (c >= 0x20 && c < 0x7f) ? static_cast<char>(c) : '.';
+ }
+ line += '|';
+
+ lines.push_back(std::move(line));
+ }
+ return lines;
+}
+
+} // namespace wireframe