diff options
Diffstat (limited to 'include/wireframe/summarize.hpp')
| -rw-r--r-- | include/wireframe/summarize.hpp | 248 |
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 |