diff options
| -rw-r--r-- | CMakeLists.txt | 1 | ||||
| -rw-r--r-- | PLAN.md | 23 | ||||
| -rw-r--r-- | include/packeteer/net/lldp.hpp | 92 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 6 | ||||
| -rw-r--r-- | tests/test_lldp.cpp | 98 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 24 |
6 files changed, 242 insertions, 2 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 20fc70e..3588095 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,7 @@ add_executable(packeteer_tests tests/test_net.cpp tests/test_arp.cpp tests/test_igmp.cpp + tests/test_lldp.cpp tests/test_rtcp.cpp tests/test_rtp.cpp tests/test_ipv6.cpp @@ -727,3 +727,26 @@ None currently open. triggered the fragment-id bug above) but not live-verified: this machine has no real global IPv6 connectivity to generate genuine IPv6-fragmented traffic against, only link-local addresses. +- LLDP (net/lldp.hpp), dispatched by ethertype (0x88CC) the same way + ARP is - no IP layer at all. TLV-encoded (7-bit type + 9-bit length + packed into each TLV's 2-byte header); only the three mandatory TLVs + (Chassis ID, Port ID, TTL) plus System Name - usually the single + most human-readable field in the whole frame - are rendered, while + every other TLV type is still walked over correctly so nothing after + it is lost. Chassis/Port ID's MAC-address subtype renders as + hex-colon; every other subtype (interface name, locally-assigned + string, etc.) as plain text, except "network address" (its own + AFI-prefixed encoding, not decoded specially - uncommon enough in + practice not to be worth a separate path). + Live-verified two ways given this machine is on WiFi, where LLDP + isn't relayed to wireless clients even when a real switch upstream + sends it, and no LLDP daemon (lldpd et al.) is installed here to + generate real Linux-side traffic either: first, a 15-second passive + capture confirmed no organic LLDP traffic exists on this network + segment to accidentally rely on; then a real, wire-format-correct + 802.1AB frame was sent via a raw AF_PACKET socket onto the actual + wlp1s0 NIC (not fed directly to parse_lldp() in a unit test) and + captured through the full real pipeline - libpcap capture, + Ethernet decode, TLV walk - correctly decoding "LLDP + chassis=de:ad:be:ef:00:01 port=eth0 ttl=120 name=packeteer-test-host", + an exact match for what was actually sent. diff --git a/include/packeteer/net/lldp.hpp b/include/packeteer/net/lldp.hpp new file mode 100644 index 0000000..ccf9f13 --- /dev/null +++ b/include/packeteer/net/lldp.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include <cstdint> +#include <cstdio> +#include <optional> +#include <span> +#include <string> + +#include "packeteer/byteio.hpp" + +// IEEE 802.1AB LLDP. Sent directly on Ethernet (ethertype 0x88CC), no +// IP layer at all - the same reasoning ARP is dispatched by ethertype +// in summarize.hpp rather than through anything IP-based. TLV-encoded: +// each TLV is a 2-byte header (7-bit type, 9-bit length) followed by +// that many bytes of value, terminated by an End of LLDPDU TLV (type +// 0). Only the three mandatory TLVs (Chassis ID, Port ID, TTL) plus +// System Name - usually the single most useful, human-readable field +// in the whole frame - are decoded; the rest (Port/System +// Description, Capabilities, Management Address, and any +// organizationally-specific TLVs) are walked over correctly (so +// nothing after them is missed) but not rendered. +namespace packeteer::net { + +inline constexpr std::uint16_t kEthertypeLldp = 0x88CC; + +struct LldpInfo { + std::optional<std::string> chassis_id; + std::optional<std::string> port_id; + std::optional<std::uint16_t> ttl; + std::optional<std::string> system_name; +}; + +// Chassis ID and Port ID share the same subtype+value shape. Subtype 4 +// (MAC address) is rendered as hex-colon; everything else (interface +// name/alias, component, locally-assigned string, etc.) is treated as +// ASCII text - true for every subtype except "network address" +// (subtype 5 for Chassis ID), which has its own AFI-prefixed encoding +// this doesn't attempt to decode specially and would render as +// mangled text instead. Uncommon enough in practice not to be worth a +// separate code path for a one-line summary. +inline std::string format_lldp_id(std::uint8_t subtype, std::span<const unsigned char> value) { + if (subtype == 4 && value.size() == 6) { + char buf[18]; + std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", value[0], value[1], + value[2], value[3], value[4], value[5]); + return buf; + } + return std::string(reinterpret_cast<const char*>(value.data()), value.size()); +} + +inline std::optional<LldpInfo> parse_lldp(std::span<const unsigned char> bytes) { + LldpInfo info{}; + std::size_t pos = 0; + constexpr int kMaxTlvs = 32; // real LLDPDUs rarely carry more than a handful + + for (int i = 0; i < kMaxTlvs; ++i) { + if (pos + 2 > bytes.size()) break; + std::uint16_t tlv_header = read_be16(bytes, pos); + std::uint8_t type = static_cast<std::uint8_t>(tlv_header >> 9); + std::uint16_t length = tlv_header & 0x01FF; + pos += 2; + if (pos + length > bytes.size()) break; // truncated: stop, keep what was decoded + std::span<const unsigned char> value = bytes.subspan(pos, length); + + if (type == 0) break; // End of LLDPDU + if (type == 1 && length >= 1) { + info.chassis_id = format_lldp_id(value[0], value.subspan(1)); + } else if (type == 2 && length >= 1) { + info.port_id = format_lldp_id(value[0], value.subspan(1)); + } else if (type == 3 && length == 2) { + info.ttl = read_be16(value, 0); + } else if (type == 5 && length >= 1) { + info.system_name = std::string(reinterpret_cast<const char*>(value.data()), value.size()); + } + + pos += length; + } + + // The three mandatory TLVs (802.1AB 9.2) - anything missing one + // of these isn't really a well-formed LLDPDU. + if (!info.chassis_id || !info.port_id || !info.ttl) return std::nullopt; + return info; +} + +inline std::string lldp_summary(const LldpInfo& info) { + std::string out = "LLDP chassis=" + *info.chassis_id + " port=" + *info.port_id + + " ttl=" + std::to_string(*info.ttl); + if (info.system_name) out += " name=" + *info.system_name; + return out; +} + +} // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 4bb2d24..c261083 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -28,6 +28,7 @@ #include "packeteer/net/igmp.hpp" #include "packeteer/net/ipv4.hpp" #include "packeteer/net/ipv6.hpp" +#include "packeteer/net/lldp.hpp" #include "packeteer/net/rtcp.hpp" #include "packeteer/net/rtp.hpp" #include "packeteer/net/tcp.hpp" @@ -301,6 +302,11 @@ inline std::string summarize_packet(std::span<const unsigned char> bytes, int da return out; } + if (vlan.ethertype == net::kEthertypeLldp) { + if (auto lldp = net::parse_lldp(vlan.payload)) out += " | " + net::lldp_summary(*lldp); + return out; + } + if (vlan.ethertype != net::kEthertypeIPv4 && vlan.ethertype != net::kEthertypeIPv6) { return out; } diff --git a/tests/test_lldp.cpp b/tests/test_lldp.cpp new file mode 100644 index 0000000..3d48970 --- /dev/null +++ b/tests/test_lldp.cpp @@ -0,0 +1,98 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "packeteer/net/lldp.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector<unsigned char> tlv(std::uint8_t type, const std::vector<unsigned char>& value) { + std::uint16_t header = static_cast<std::uint16_t>((type << 9) | value.size()); + std::vector<unsigned char> out = {static_cast<unsigned char>(header >> 8), + static_cast<unsigned char>(header & 0xFF)}; + out.insert(out.end(), value.begin(), value.end()); + return out; +} + +std::vector<unsigned char> lldpdu(std::optional<std::vector<unsigned char>> extra_tlv = {}) { + std::vector<unsigned char> bytes; + auto chassis_id = tlv(1, {4, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}); // subtype 4: MAC + auto port_id = tlv(2, {7, 'e', 't', 'h', '0'}); // subtype 7: locally assigned + auto ttl = tlv(3, {0x00, 0x78}); // 120 seconds + bytes.insert(bytes.end(), chassis_id.begin(), chassis_id.end()); + bytes.insert(bytes.end(), port_id.begin(), port_id.end()); + bytes.insert(bytes.end(), ttl.begin(), ttl.end()); + if (extra_tlv) bytes.insert(bytes.end(), extra_tlv->begin(), extra_tlv->end()); + auto end = tlv(0, {}); + bytes.insert(bytes.end(), end.begin(), end.end()); + return bytes; +} + +} // namespace + +TEST_CASE("parse_lldp decodes the three mandatory TLVs") { + auto msg = parse_lldp(lldpdu()); + REQUIRE(msg.has_value()); + REQUIRE(msg->chassis_id.has_value()); + CHECK(*msg->chassis_id == "aa:bb:cc:dd:ee:ff"); + REQUIRE(msg->port_id.has_value()); + CHECK(*msg->port_id == "eth0"); + REQUIRE(msg->ttl.has_value()); + CHECK(*msg->ttl == 120); + CHECK_FALSE(msg->system_name.has_value()); +} + +TEST_CASE("parse_lldp decodes the System Name TLV when present") { + auto msg = parse_lldp(lldpdu(tlv(5, {'s', 'w', 'i', 't', 'c', 'h', '1'}))); + REQUIRE(msg.has_value()); + REQUIRE(msg->system_name.has_value()); + CHECK(*msg->system_name == "switch1"); +} + +TEST_CASE("parse_lldp skips over an unrecognized TLV without losing later ones") { + // Type 6 (System Description) isn't decoded, but must not break + // parsing of the End TLV that follows it. + auto msg = parse_lldp(lldpdu(tlv(6, {'d', 'e', 's', 'c'}))); + REQUIRE(msg.has_value()); + REQUIRE(msg->chassis_id.has_value()); + REQUIRE(msg->port_id.has_value()); + REQUIRE(msg->ttl.has_value()); +} + +TEST_CASE("parse_lldp rejects a message missing a mandatory TLV") { + // Chassis ID and Port ID only, no TTL - not a well-formed LLDPDU. + std::vector<unsigned char> bytes; + auto chassis_id = tlv(1, {4, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}); + auto port_id = tlv(2, {7, 'e', 't', 'h', '0'}); + bytes.insert(bytes.end(), chassis_id.begin(), chassis_id.end()); + bytes.insert(bytes.end(), port_id.begin(), port_id.end()); + auto end = tlv(0, {}); + bytes.insert(bytes.end(), end.begin(), end.end()); + + CHECK_FALSE(parse_lldp(bytes).has_value()); +} + +TEST_CASE("parse_lldp rejects an empty buffer") { + std::vector<unsigned char> bytes; + CHECK_FALSE(parse_lldp(bytes).has_value()); +} + +TEST_CASE("format_lldp_id renders a MAC-address subtype as hex-colon") { + std::vector<unsigned char> mac = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + CHECK(format_lldp_id(4, mac) == "aa:bb:cc:dd:ee:ff"); +} + +TEST_CASE("format_lldp_id renders a non-MAC subtype as plain text") { + std::vector<unsigned char> text = {'e', 't', 'h', '0'}; + CHECK(format_lldp_id(7, text) == "eth0"); +} + +TEST_CASE("lldp_summary formats chassis/port/ttl, and name only when present") { + LldpInfo info{"aa:bb:cc:dd:ee:ff", "eth0", 120, std::nullopt}; + CHECK(lldp_summary(info) == "LLDP chassis=aa:bb:cc:dd:ee:ff port=eth0 ttl=120"); + + info.system_name = "switch1"; + CHECK(lldp_summary(info) == "LLDP chassis=aa:bb:cc:dd:ee:ff port=eth0 ttl=120 name=switch1"); +} diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp index baf4ed4..0cb0f98 100644 --- a/tests/test_summarize.cpp +++ b/tests/test_summarize.cpp @@ -166,10 +166,30 @@ TEST_CASE("summarize_packet reports a truncated Ethernet frame without decoding 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, - 0x88, 0xCC, // LLDP, not IPv4/IPv6/ARP + 0x80, 0x35, // RARP - real ethertype, just not one this project decodes }; auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); - CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x88cc"); + CHECK(line == "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x8035"); +} + +TEST_CASE("summarize_packet decodes an LLDP frame end to end") { + std::vector<unsigned char> bytes = { + 0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E, // dst: LLDP multicast + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // src mac + 0x88, 0xCC, // ethertype: LLDP + // Chassis ID TLV: subtype=4 (MAC), value=aa:bb:cc:dd:ee:ff + 0x02, 0x07, 4, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + // Port ID TLV: subtype=7 (locally assigned), value="eth0" + 0x04, 0x05, 7, 'e', 't', 'h', '0', + // TTL TLV: 120 seconds + 0x06, 0x02, 0x00, 0x78, + // End of LLDPDU + 0x00, 0x00, + }; + auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); + CHECK(line == + "ETH aa:bb:cc:dd:ee:ff -> 01:80:c2:00:00:0e ethertype=0x88cc | " + "LLDP chassis=aa:bb:cc:dd:ee:ff port=eth0 ttl=120"); } TEST_CASE("summarize_packet decodes an ARP request end to end") { |