srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-31 23:29:00 +0200
committersrdusr <[email protected]>2026-05-31 23:29:00 +0200
commit8c8708e43aeae394787d0d1aa71ee22dca635bbe (patch)
treed1e168d081ecf52fd9a62802a214d4263824331b
parent719b7f439c1c8c76d0573b34daa329061c9ad8a6 (diff)
downloadpacketeer-8c8708e43aeae394787d0d1aa71ee22dca635bbe.tar.gz
packeteer-8c8708e43aeae394787d0d1aa71ee22dca635bbe.zip
Add LLDP - dispatched by ethertype, no IP layer at all
Real switches broadcast this every ~30s, but this project had zero treatment for it (0x88CC was previously the test suite's own example of an "unhandled ethertype"). Dispatched by ethertype the same way ARP is, since LLDP sits directly on Ethernet. TLV-encoded; only the three mandatory TLVs (Chassis ID, Port ID, TTL) plus System Name are rendered, while every other TLV is still walked over correctly so nothing after it is lost. Live-verified two ways, since this machine is on WiFi (LLDP isn't relayed to wireless clients even when a real switch sends it) with no LLDP daemon installed to generate traffic locally either: a 15-second passive capture confirmed no organic LLDP traffic exists to accidentally rely on, then a real 802.1AB frame was sent via a raw AF_PACKET socket onto the actual NIC (not fed directly to parse_lldp() in a unit test) and captured through the full pipeline, decoding correctly.
-rw-r--r--CMakeLists.txt1
-rw-r--r--PLAN.md23
-rw-r--r--include/packeteer/net/lldp.hpp92
-rw-r--r--include/packeteer/summarize.hpp6
-rw-r--r--tests/test_lldp.cpp98
-rw-r--r--tests/test_summarize.cpp24
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
diff --git a/PLAN.md b/PLAN.md
index 64b49e3..f63e477 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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") {