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