diff options
| -rw-r--r-- | CMakeLists.txt | 1 | ||||
| -rw-r--r-- | PLAN.md | 38 | ||||
| -rw-r--r-- | include/packeteer/net/arp.hpp | 73 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 29 | ||||
| -rw-r--r-- | src/main.cpp | 21 | ||||
| -rw-r--r-- | tests/test_arp.cpp | 89 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 20 |
7 files changed, 265 insertions, 6 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 0db3e85..0216e4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,7 @@ add_executable(packeteer_tests tests/main.cpp tests/test_byteio.cpp tests/test_net.cpp + tests/test_arp.cpp tests/test_ipv6.cpp tests/test_dns.cpp tests/test_mdns.cpp @@ -368,3 +368,41 @@ None currently open. 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. +- Renamed wireframe -> packeteer (packet + -eer). See NAMES.md for the + reasoning and the collisions checked before committing to it. +- ARP (packeteer/net/arp.hpp): the one real-traffic L2 protocol that + had zero treatment until now - an ARP frame's ethertype (0x0806) + just fell through summarize_packet's "not IPv4/IPv6, stop after the + Ethernet line" branch, on traffic that shows up on essentially every + real LAN capture. Scoped to Ethernet hardware addresses (hlen=6) and + IPv4 protocol addresses (plen=4) - the case that accounts for + virtually all real ARP traffic; other combinations still decode the + fixed header (hwtype/protype/opcode) without guessing at a different + address width. Summary phrasing deliberately matches tcpdump's own + "who-has X tell Y" / "X is-at Y" convention rather than inventing new + wording, since that phrasing is already how anyone reading ARP + traffic expects to see it. Verified against real traffic: flushed + this machine's ARP cache entry for its actual default gateway and + captured the resulting request/reply on wlp1s0 - "who-has + 192.168.1.1 tell 192.168.1.104 (28:39:26:71:cd:dd)" followed by + "192.168.1.1 is-at bc:07:1d:ff:54:e9", both addresses matching this + machine's real interface/gateway. +- TUI parity for -c/-a: unlike -x (hex dump, never ported to the TUI), + -c/-a were already being parsed into RenderOptions for every mode -- + main()'s arg parsing doesn't distinguish TUI from plain-text - but + run_tui()'s consumer thread had its own separate loop that never + read opts.verbose_checksums/opts.reassembler, so the flags silently + did nothing in TUI mode despite appearing to be accepted. Fixed by + mirroring plain-text render_packet()'s logic: checksum status + appended inline to the row, a reassembled-HTTP line pushed as a + second row right after, both via the same packeteer::checksum_status/ + reassembled_http_status() the CLI and GUI already share. Caught a + real bug while wiring this in, before it ever ran: pushing up to two + rows per packet against a single `if (rows.size() > kMaxRows) + pop_front()` would let the row deque grow unboundedly under + sustained -a activity, since one pop can't offset two pushes -- + changed to a while loop. Verified under tmux (capture-pane, not raw + pty - see the search-bug methodology note above) against the same + split-segment HTTP scenario used to verify -a on the CLI and GUI: + both "checksums: IP=ok TCP=..." and "[reassembled request: ... Host: + ... (72 bytes so far)]" appeared correctly inline in the packet list. diff --git a/include/packeteer/net/arp.hpp b/include/packeteer/net/arp.hpp new file mode 100644 index 0000000..470dc9d --- /dev/null +++ b/include/packeteer/net/arp.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include <algorithm> +#include <cstdint> +#include <optional> +#include <span> + +#include "packeteer/byteio.hpp" +#include "packeteer/net/ethernet.hpp" +#include "packeteer/net/ipv4.hpp" + +// RFC 826 ARP, scoped to the case that accounts for essentially all +// real traffic on a modern LAN: Ethernet hardware addresses (hlen=6) +// and IPv4 protocol addresses (plen=4). Other hardware/protocol +// combinations still decode the fixed header (hwtype/protype/opcode), +// just without sender/target addresses - there's no safe way to guess +// an address width other than what hardware_len/protocol_len actually +// say. +namespace packeteer::net { + +inline constexpr std::uint16_t kArpOpRequest = 1; +inline constexpr std::uint16_t kArpOpReply = 2; + +struct ArpHeader { + std::uint16_t hardware_type; + std::uint16_t protocol_type; + std::uint8_t hardware_len; + std::uint8_t protocol_len; + std::uint16_t opcode; +}; + +struct ArpPacket { + ArpHeader header; + // All four populated together only when hardware_len == 6 and + // protocol_len == 4; left as nullopt otherwise. + std::optional<MacAddress> sender_mac; + std::optional<Ipv4Address> sender_ip; + std::optional<MacAddress> target_mac; + std::optional<Ipv4Address> target_ip; +}; + +inline std::optional<ArpPacket> parse_arp(std::span<const unsigned char> bytes) { + if (bytes.size() < 8) return std::nullopt; + + ArpHeader header{}; + header.hardware_type = read_be16(bytes, 0); + header.protocol_type = read_be16(bytes, 2); + header.hardware_len = bytes[4]; + header.protocol_len = bytes[5]; + header.opcode = read_be16(bytes, 6); + + ArpPacket packet{header, std::nullopt, std::nullopt, std::nullopt, std::nullopt}; + + if (header.hardware_len == 6 && header.protocol_len == 4 && bytes.size() >= 28) { + MacAddress sender_mac{}; + std::copy_n(bytes.begin() + 8, 6, sender_mac.bytes.begin()); + Ipv4Address sender_ip{}; + std::copy_n(bytes.begin() + 14, 4, sender_ip.bytes.begin()); + MacAddress target_mac{}; + std::copy_n(bytes.begin() + 18, 6, target_mac.bytes.begin()); + Ipv4Address target_ip{}; + std::copy_n(bytes.begin() + 24, 4, target_ip.bytes.begin()); + + packet.sender_mac = sender_mac; + packet.sender_ip = sender_ip; + packet.target_mac = target_mac; + packet.target_ip = target_ip; + } + + return packet; +} + +} // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 77d9ec3..7ff06a4 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -14,6 +14,7 @@ #include "packeteer/l7/mdns.hpp" #include "packeteer/l7/ssh.hpp" #include "packeteer/l7/tls.hpp" +#include "packeteer/net/arp.hpp" #include "packeteer/net/ethernet.hpp" #include "packeteer/net/icmp.hpp" #include "packeteer/net/ipv4.hpp" @@ -40,6 +41,29 @@ inline std::string ipv4_to_string(const net::Ipv4Address& ip) { return buf; } +// tcpdump's own "who-has X tell Y" / "X is-at Y" phrasing - a +// deliberate match, not a coincidence, since anyone who's ever read +// ARP traffic in tcpdump/Wireshark will recognize it instantly. Target +// hardware/protocol addresses aren't shown even when present: a +// request's target MAC is unknown by definition (that's what's being +// asked), and a reply's target is just "whoever asked", which tcpdump +// itself omits from this line too. +inline std::string arp_summary(const net::ArpPacket& arp) { + if (!arp.sender_ip || !arp.sender_mac || !arp.target_ip) { + return "ARP opcode=" + std::to_string(arp.header.opcode) + + " (non-Ethernet/IPv4 addressing)"; + } + if (arp.header.opcode == net::kArpOpRequest) { + return "ARP who-has " + ipv4_to_string(*arp.target_ip) + " tell " + + ipv4_to_string(*arp.sender_ip) + " (" + mac_to_string(*arp.sender_mac) + ")"; + } + if (arp.header.opcode == net::kArpOpReply) { + return "ARP " + ipv4_to_string(*arp.sender_ip) + " is-at " + mac_to_string(*arp.sender_mac); + } + return "ARP opcode=" + std::to_string(arp.header.opcode) + " " + + ipv4_to_string(*arp.sender_ip) + " (" + mac_to_string(*arp.sender_mac) + ")"; +} + inline std::string tcp_flags_to_string(std::uint8_t flags) { using namespace net; std::string out; @@ -187,6 +211,11 @@ inline std::string summarize_packet(std::span<const unsigned char> bytes, int da std::snprintf(eth_buf, sizeof(eth_buf), " ethertype=0x%04x", eth->header.ethertype); out += eth_buf; + if (eth->header.ethertype == net::kEthertypeArp) { + if (auto arp = net::parse_arp(eth->payload)) out += " | " + arp_summary(*arp); + return out; + } + if (eth->header.ethertype != net::kEthertypeIPv4 && eth->header.ethertype != net::kEthertypeIPv6) { return out; diff --git a/src/main.cpp b/src/main.cpp index 82b07a9..833af52 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -152,10 +152,25 @@ void run_tui(packeteer::CaptureSession& session, packeteer::CaptureQueue& queue, packet->ts_usec, bytes, packet->original_len); } + if (opts.verbose_checksums) { + std::string status = packeteer::checksum_status(bytes, opts.datalink); + if (!status.empty()) line += " " + status; + } + std::optional<std::string> reassembled; + if (opts.reassembler) { + reassembled = packeteer::reassembled_http_status(bytes, opts.datalink, + *opts.reassembler); + } + { std::lock_guard<std::mutex> lock(state_mutex); rows.push_back(std::move(line)); - if (rows.size() > kMaxRows) rows.pop_front(); + if (reassembled) rows.push_back(" [" + *reassembled + "]"); + // A while loop, not if: up to two rows can be pushed per + // packet now (the summary plus an optional reassembly + // line), so a single pop_front() would let the deque + // grow past kMaxRows under sustained -a activity. + while (rows.size() > kMaxRows) rows.pop_front(); ++packet_count; } screen.PostEvent(Event::Custom); @@ -275,13 +290,13 @@ void print_usage(const char* argv0) { "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" + " -c Show IPv4/TCP/UDP checksum validity (plain-text and TUI).\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" + " joined bytes (plain-text and TUI), 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" diff --git a/tests/test_arp.cpp b/tests/test_arp.cpp new file mode 100644 index 0000000..247782b --- /dev/null +++ b/tests/test_arp.cpp @@ -0,0 +1,89 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "packeteer/net/arp.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector<unsigned char> arp_request() { + return { + 0x00, 0x01, // hardware type = Ethernet + 0x08, 0x00, // protocol type = IPv4 + 0x06, // hardware len = 6 + 0x04, // protocol len = 4 + 0x00, 0x01, // opcode = request + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x01, // sender MAC + 10, 0, 0, 1, // sender IP + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // target MAC (unknown, all zero) + 10, 0, 0, 2, // target IP + }; +} + +std::vector<unsigned char> arp_reply() { + return { + 0x00, 0x01, + 0x08, 0x00, + 0x06, + 0x04, + 0x00, 0x02, // opcode = reply + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x02, // sender MAC (the one who was asked) + 10, 0, 0, 2, // sender IP + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x01, // target MAC (the original requester) + 10, 0, 0, 1, // target IP + }; +} + +} // namespace + +TEST_CASE("parse_arp decodes a request with Ethernet/IPv4 addressing") { + auto arp = parse_arp(arp_request()); + REQUIRE(arp.has_value()); + CHECK(arp->header.opcode == kArpOpRequest); + REQUIRE(arp->sender_ip.has_value()); + REQUIRE(arp->sender_mac.has_value()); + REQUIRE(arp->target_ip.has_value()); + CHECK(arp->sender_ip->bytes == std::array<unsigned char, 4>{10, 0, 0, 1}); + CHECK(arp->target_ip->bytes == std::array<unsigned char, 4>{10, 0, 0, 2}); + CHECK(arp->sender_mac->bytes == std::array<unsigned char, 6>{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x01}); +} + +TEST_CASE("parse_arp decodes a reply") { + auto arp = parse_arp(arp_reply()); + REQUIRE(arp.has_value()); + CHECK(arp->header.opcode == kArpOpReply); + REQUIRE(arp->sender_ip.has_value()); + CHECK(arp->sender_ip->bytes == std::array<unsigned char, 4>{10, 0, 0, 2}); +} + +TEST_CASE("parse_arp rejects a truncated header") { + std::vector<unsigned char> bytes(5, 0); + CHECK_FALSE(parse_arp(bytes).has_value()); +} + +TEST_CASE("parse_arp decodes the header but skips addresses for non-Ethernet/IPv4") { + std::vector<unsigned char> bytes = { + 0x00, 0x06, // hardware type = IEEE 802 (arbitrary, just not the common case) + 0x08, 0x00, + 0x08, // hardware len = 8, not 6 + 0x04, + 0x00, 0x01, + }; + auto arp = parse_arp(bytes); + REQUIRE(arp.has_value()); + CHECK(arp->header.hardware_len == 8); + CHECK_FALSE(arp->sender_ip.has_value()); + CHECK_FALSE(arp->sender_mac.has_value()); +} + +TEST_CASE("parse_arp treats a header claiming Ethernet/IPv4 but too short to hold it as header-only") { + std::vector<unsigned char> bytes = { + 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, + 0xaa, 0xbb, 0xcc, // truncated sender MAC + }; + auto arp = parse_arp(bytes); + REQUIRE(arp.has_value()); + CHECK_FALSE(arp->sender_ip.has_value()); +} diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp index ac6f1c0..27e0dd3 100644 --- a/tests/test_summarize.cpp +++ b/tests/test_summarize.cpp @@ -163,13 +163,27 @@ TEST_CASE("summarize_packet reports a truncated Ethernet frame without decoding CHECK(line == "[10 bytes] truncated ethernet frame"); } -TEST_CASE("summarize_packet stops after the Ethernet line for a non-IP ethertype") { +TEST_CASE("summarize_packet stops after the Ethernet line for an unhandled ethertype") { std::vector<unsigned char> bytes = { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, - 0x08, 0x06, // ARP, not IPv4/IPv6 + 0x88, 0xCC, // LLDP, not IPv4/IPv6/ARP }; auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); - CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0806"); + CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x88cc"); +} + +TEST_CASE("summarize_packet decodes an ARP request end to end") { + std::vector<unsigned char> bytes = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + 0x08, 0x06, // ARP + 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 10, 0, 0, 1, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 10, 0, 0, 2, + }; + auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); + CHECK(line == + "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x0806 | " + "ARP who-has 10.0.0.2 tell 10.0.0.1 (aa:bb:cc:dd:ee:ff)"); } TEST_CASE("hex_dump_lines produces one line per 16 bytes, with the right byte count") { |