Skip to main content

netget/server/lldp/
codec.rs

1//! Pure LLDP (IEEE 802.1AB) frame codec.
2//!
3//! Everything in this file is a **pure function over plain values**: it opens no socket, reads
4//! no configuration and touches no global state. That is deliberate. The LLDP transport is raw
5//! Ethernet and needs `CAP_NET_RAW` / `/dev/bpf*`, which no test in this repository has, so the
6//! transport can never be executed here — but the frame format can be, and is, checked against
7//! literal bytes from the specification and from a real capture
8//! (`tests/server/lldp/codec_test.rs`).
9//!
10//! This is the `bluetooth_ble_beacon` split the root `CLAUDE.md` describes: payload construction
11//! is pure and exhaustively tested; the platform transport is a thin layer over it that has
12//! never run. `metadata().notes` says both.
13//!
14//! # Frame layout
15//!
16//! ```text
17//! | dst MAC (6) | src MAC (6) | 0x88CC (2) | LLDPDU ... |
18//! ```
19//!
20//! An LLDPDU is a sequence of TLVs. Each TLV starts with two octets holding a 7-bit type and a
21//! 9-bit length:
22//!
23//! ```text
24//!  0                   1
25//!  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
26//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
27//! |     type (7)  |   length (9)  |
28//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
29//! ```
30//!
31//! IEEE 802.1AB-2016 §8.5 requires the first three TLVs, in this order: Chassis ID (1),
32//! Port ID (2), Time To Live (3). Optional TLVs follow, and End Of LLDPDU (type 0, length 0)
33//! terminates. Chassis ID and Port ID each carry a **leading subtype octet** that decides how
34//! the rest of the value is read — a MAC address, a network address or text — and getting that
35//! wrong is the classic way to produce a frame a neighbour silently discards.
36//!
37//! # No bytes cross the LLM boundary
38//!
39//! Every identifier is carried in and out of here as a **string in its natural notation** —
40//! `"00:01:30:f9:ad:a0"` for a MAC, `"192.0.2.1"` for an address, `"1/1"` for an interface
41//! name — and every subtype and capability as a **name**. Models cannot reliably produce or
42//! read a TLV blob, and a `tlv_hex` parameter would defeat the entire point of the protocol
43//! (see the action & event design rules in the root `CLAUDE.md`).
44
45use anyhow::{anyhow, bail, Context, Result};
46use serde_json::{json, Map, Value};
47
48/// EtherType assigned to LLDP (IEEE 802.1AB §8.1).
49pub const LLDP_ETHERTYPE: u16 = 0x88CC;
50
51/// The nearest-bridge group address every LLDP agent transmits to (802.1AB Table 7-1).
52pub const LLDP_MULTICAST_MAC: [u8; 6] = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x0E];
53
54/// Destination MAC | source MAC | EtherType.
55pub const ETHERNET_HEADER_LEN: usize = 14;
56
57// ---------------------------------------------------------------------------------------------
58// TLV types (802.1AB-2016 Table 8-1)
59// ---------------------------------------------------------------------------------------------
60
61pub const TLV_END_OF_LLDPDU: u8 = 0;
62pub const TLV_CHASSIS_ID: u8 = 1;
63pub const TLV_PORT_ID: u8 = 2;
64pub const TLV_TIME_TO_LIVE: u8 = 3;
65pub const TLV_PORT_DESCRIPTION: u8 = 4;
66pub const TLV_SYSTEM_NAME: u8 = 5;
67pub const TLV_SYSTEM_DESCRIPTION: u8 = 6;
68pub const TLV_SYSTEM_CAPABILITIES: u8 = 7;
69pub const TLV_MANAGEMENT_ADDRESS: u8 = 8;
70
71/// The 9-bit length field caps any single TLV value.
72const MAX_TLV_VALUE: usize = 511;
73
74/// 802.1AB caps identifiers and the text TLVs at 255 octets, well below the 9-bit field.
75const MAX_STRING_TLV_VALUE: usize = 255;
76
77/// Chassis ID subtypes, 802.1AB-2016 Table 8-2.
78pub const CHASSIS_ID_SUBTYPES: &[(u8, &str)] = &[
79    (1, "chassis_component"),
80    (2, "interface_alias"),
81    (3, "port_component"),
82    (4, "mac_address"),
83    (5, "network_address"),
84    (6, "interface_name"),
85    (7, "local"),
86];
87
88/// Port ID subtypes, 802.1AB-2016 Table 8-3. **Not the same numbering as Chassis ID** — a MAC
89/// address is 4 for a chassis and 3 for a port, which is the single easiest thing to get wrong
90/// in this protocol.
91pub const PORT_ID_SUBTYPES: &[(u8, &str)] = &[
92    (1, "interface_alias"),
93    (2, "port_component"),
94    (3, "mac_address"),
95    (4, "network_address"),
96    (5, "interface_name"),
97    (6, "agent_circuit_id"),
98    (7, "local"),
99];
100
101/// System capability bits, 802.1AB-2016 Table 8-4.
102pub const SYSTEM_CAPABILITIES: &[(u16, &str)] = &[
103    (0x0001, "other"),
104    (0x0002, "repeater"),
105    (0x0004, "bridge"),
106    (0x0008, "wlan_access_point"),
107    (0x0010, "router"),
108    (0x0020, "telephone"),
109    (0x0040, "docsis_cable_device"),
110    (0x0080, "station_only"),
111    (0x0100, "c_vlan_component"),
112    (0x0200, "s_vlan_component"),
113    (0x0400, "two_port_mac_relay"),
114];
115
116/// The IANA address family numbers LLDP management addresses use in practice.
117const ADDRESS_FAMILIES: &[(u8, &str)] = &[(1, "ipv4"), (2, "ipv6"), (6, "mac_address")];
118
119/// Which side of the frame an identifier belongs to. The two use different subtype numbers for
120/// the same thing, so the value encoder has to know which table it is reading.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum IdKind {
123    Chassis,
124    Port,
125}
126
127impl IdKind {
128    fn table(self) -> &'static [(u8, &'static str)] {
129        match self {
130            IdKind::Chassis => CHASSIS_ID_SUBTYPES,
131            IdKind::Port => PORT_ID_SUBTYPES,
132        }
133    }
134
135    fn mac_subtype(self) -> u8 {
136        match self {
137            IdKind::Chassis => 4,
138            IdKind::Port => 3,
139        }
140    }
141
142    fn network_address_subtype(self) -> u8 {
143        match self {
144            IdKind::Chassis => 5,
145            IdKind::Port => 4,
146        }
147    }
148
149    fn label(self) -> &'static str {
150        match self {
151            IdKind::Chassis => "chassis_id",
152            IdKind::Port => "port_id",
153        }
154    }
155}
156
157/// Look a subtype name up in the relevant table.
158pub fn subtype_code(kind: IdKind, name: &str) -> Option<u8> {
159    let wanted = name.trim().to_ascii_lowercase();
160    kind.table()
161        .iter()
162        .find(|(_, n)| *n == wanted)
163        .map(|(code, _)| *code)
164}
165
166/// Render a subtype number as its name, or `"reserved_<n>"` for one the spec does not define.
167pub fn subtype_name(kind: IdKind, code: u8) -> String {
168    kind.table()
169        .iter()
170        .find(|(c, _)| *c == code)
171        .map(|(_, n)| (*n).to_string())
172        .unwrap_or_else(|| format!("reserved_{code}"))
173}
174
175/// Split a capability bitmask into names. Bits with no assigned meaning are reported as
176/// `reserved_bit_<n>` rather than dropped — a neighbour that sets one is telling us something,
177/// even if the spec has not named it yet.
178pub fn capability_names(bits: u16) -> Vec<String> {
179    let mut out = Vec::new();
180    for i in 0..16 {
181        let bit = 1u16 << i;
182        if bits & bit == 0 {
183            continue;
184        }
185        match SYSTEM_CAPABILITIES.iter().find(|(b, _)| *b == bit) {
186            Some((_, name)) => out.push((*name).to_string()),
187            None => out.push(format!("reserved_bit_{i}")),
188        }
189    }
190    out
191}
192
193/// Turn capability names back into a bitmask. An unrecognised name is an error, not a silently
194/// dropped bit: a model that asks to advertise `"swtich"` must be told, or it will believe it
195/// claimed to be a bridge.
196pub fn capability_bits(names: &[String]) -> Result<u16> {
197    let mut bits = 0u16;
198    for name in names {
199        let wanted = name.trim().to_ascii_lowercase();
200        let found = SYSTEM_CAPABILITIES
201            .iter()
202            .find(|(_, n)| *n == wanted)
203            .map(|(b, _)| *b);
204        match found {
205            Some(b) => bits |= b,
206            None => bail!(
207                "'{}' is not an LLDP system capability. Valid names: {}",
208                name,
209                SYSTEM_CAPABILITIES
210                    .iter()
211                    .map(|(_, n)| *n)
212                    .collect::<Vec<_>>()
213                    .join(", ")
214            ),
215        }
216    }
217    Ok(bits)
218}
219
220/// Parse `aa:bb:cc:dd:ee:ff` (or the `-`/`.`-free spellings) into six octets.
221pub fn parse_mac(text: &str) -> Result<[u8; 6]> {
222    let cleaned: String = text
223        .chars()
224        .filter(|c| !matches!(c, ':' | '-' | '.' | ' '))
225        .collect();
226    if cleaned.len() != 12 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
227        bail!("'{text}' is not a MAC address (expected 6 hex octets, e.g. 00:01:30:f9:ad:a0)");
228    }
229    let mut out = [0u8; 6];
230    for (i, byte) in out.iter_mut().enumerate() {
231        *byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
232            .with_context(|| format!("'{text}' is not a MAC address"))?;
233    }
234    Ok(out)
235}
236
237/// Render six octets as lowercase colon-separated hex.
238pub fn format_mac(mac: &[u8; 6]) -> String {
239    format!(
240        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
241        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
242    )
243}
244
245// ---------------------------------------------------------------------------------------------
246// The decoded / to-be-encoded LLDPDU
247// ---------------------------------------------------------------------------------------------
248
249/// One management address, as carried by TLV type 8.
250///
251/// The trailing object identifier is decoded far enough to validate the TLV's length and then
252/// discarded: it is an SNMP OID, of no use to a model, and nothing here can produce one.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct ManagementAddress {
255    /// IANA address family number: 1 = IPv4, 2 = IPv6, 6 = MAC.
256    pub family: u8,
257    /// The address in its natural notation for that family.
258    pub address: String,
259    /// Interface numbering subtype: 1 = unknown, 2 = ifIndex, 3 = system port number.
260    pub interface_numbering_subtype: u8,
261    /// The interface number itself.
262    pub interface_number: u32,
263}
264
265impl ManagementAddress {
266    /// Build one from an address string, deducing the family from its notation.
267    pub fn new(address: &str, interface_number: u32) -> Result<Self> {
268        let trimmed = address.trim();
269        let family = if trimmed.parse::<std::net::Ipv4Addr>().is_ok() {
270            1
271        } else if trimmed.parse::<std::net::Ipv6Addr>().is_ok() {
272            2
273        } else if parse_mac(trimmed).is_ok() {
274            6
275        } else {
276            bail!(
277                "'{address}' is not a management address: expected an IPv4 address, an IPv6 \
278                 address or a MAC address"
279            );
280        };
281        Ok(Self {
282            family,
283            address: trimmed.to_string(),
284            // ifIndex. The alternative (1, "unknown") tells a neighbour nothing, and every
285            // capture of real equipment uses 2 whether or not the index is meaningful.
286            interface_numbering_subtype: 2,
287            interface_number,
288        })
289    }
290
291    /// The family as a name, for event data.
292    pub fn family_name(&self) -> String {
293        ADDRESS_FAMILIES
294            .iter()
295            .find(|(f, _)| *f == self.family)
296            .map(|(_, n)| (*n).to_string())
297            .unwrap_or_else(|| format!("iana_family_{}", self.family))
298    }
299
300    fn encode_value(&self) -> Result<Vec<u8>> {
301        let addr_bytes: Vec<u8> = match self.family {
302            1 => self
303                .address
304                .parse::<std::net::Ipv4Addr>()
305                .with_context(|| format!("'{}' is not an IPv4 address", self.address))?
306                .octets()
307                .to_vec(),
308            2 => self
309                .address
310                .parse::<std::net::Ipv6Addr>()
311                .with_context(|| format!("'{}' is not an IPv6 address", self.address))?
312                .octets()
313                .to_vec(),
314            6 => parse_mac(&self.address)?.to_vec(),
315            other => bail!("unsupported management address family {other}"),
316        };
317
318        let mut value = Vec::with_capacity(addr_bytes.len() + 8);
319        // "Management address string length" counts the family octet plus the address.
320        value.push((addr_bytes.len() + 1) as u8);
321        value.push(self.family);
322        value.extend_from_slice(&addr_bytes);
323        value.push(self.interface_numbering_subtype);
324        value.extend_from_slice(&self.interface_number.to_be_bytes());
325        // OID string length. Zero: we have no OID to advertise and inventing one would be a
326        // claim about an SNMP MIB that does not exist.
327        value.push(0);
328        Ok(value)
329    }
330
331    fn decode_value(value: &[u8]) -> Result<Self> {
332        if value.len() < 9 {
333            bail!(
334                "management address TLV is {} octets, needs at least 9",
335                value.len()
336            );
337        }
338        let addr_string_len = value[0] as usize;
339        if addr_string_len == 0 {
340            bail!("management address TLV declares a zero-length address string");
341        }
342        if value.len() < 1 + addr_string_len + 6 {
343            bail!(
344                "management address TLV declares a {addr_string_len}-octet address string but \
345                 carries only {} octets after it",
346                value.len() - 1
347            );
348        }
349        let family = value[1];
350        let addr = &value[2..1 + addr_string_len];
351        let address = match family {
352            1 if addr.len() == 4 => {
353                std::net::Ipv4Addr::new(addr[0], addr[1], addr[2], addr[3]).to_string()
354            }
355            2 if addr.len() == 16 => {
356                let mut octets = [0u8; 16];
357                octets.copy_from_slice(addr);
358                std::net::Ipv6Addr::from(octets).to_string()
359            }
360            6 if addr.len() == 6 => {
361                let mut mac = [0u8; 6];
362                mac.copy_from_slice(addr);
363                format_mac(&mac)
364            }
365            // An address family we do not model. Rendering it as hex here would put bytes in
366            // front of the model, so it is reported by length instead and the caller decides.
367            other => bail!(
368                "management address family {other} with a {}-octet address is not supported",
369                addr.len()
370            ),
371        };
372
373        let rest = &value[1 + addr_string_len..];
374        Ok(Self {
375            family,
376            address,
377            interface_numbering_subtype: rest[0],
378            interface_number: u32::from_be_bytes([rest[1], rest[2], rest[3], rest[4]]),
379        })
380    }
381}
382
383/// A decoded LLDPDU: the three mandatory TLVs plus whichever optional ones were present.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct Lldpdu {
386    pub chassis_id_subtype: u8,
387    pub chassis_id: String,
388    pub port_id_subtype: u8,
389    pub port_id: String,
390    pub ttl: u16,
391    pub port_description: Option<String>,
392    pub system_name: Option<String>,
393    pub system_description: Option<String>,
394    /// (supported, enabled) from the System Capabilities TLV.
395    pub capabilities: Option<(u16, u16)>,
396    pub management_address: Option<ManagementAddress>,
397}
398
399impl Lldpdu {
400    /// The mandatory three, with everything optional left out.
401    pub fn minimal(
402        chassis_id_subtype: u8,
403        chassis_id: impl Into<String>,
404        port_id_subtype: u8,
405        port_id: impl Into<String>,
406        ttl: u16,
407    ) -> Self {
408        Self {
409            chassis_id_subtype,
410            chassis_id: chassis_id.into(),
411            port_id_subtype,
412            port_id: port_id.into(),
413            ttl,
414            port_description: None,
415            system_name: None,
416            system_description: None,
417            capabilities: None,
418            management_address: None,
419        }
420    }
421
422    /// Serialise to LLDPDU octets (no Ethernet header).
423    pub fn encode(&self) -> Result<Vec<u8>> {
424        let mut out = Vec::with_capacity(64);
425
426        push_tlv(
427            &mut out,
428            TLV_CHASSIS_ID,
429            &encode_id_value(IdKind::Chassis, self.chassis_id_subtype, &self.chassis_id)?,
430        )?;
431        push_tlv(
432            &mut out,
433            TLV_PORT_ID,
434            &encode_id_value(IdKind::Port, self.port_id_subtype, &self.port_id)?,
435        )?;
436        push_tlv(&mut out, TLV_TIME_TO_LIVE, &self.ttl.to_be_bytes())?;
437
438        if let Some(text) = &self.port_description {
439            push_text_tlv(
440                &mut out,
441                TLV_PORT_DESCRIPTION,
442                "port_description",
443                text,
444                false,
445            )?;
446        }
447        if let Some(text) = &self.system_name {
448            push_text_tlv(&mut out, TLV_SYSTEM_NAME, "system_name", text, false)?;
449        }
450        if let Some(text) = &self.system_description {
451            push_text_tlv(
452                &mut out,
453                TLV_SYSTEM_DESCRIPTION,
454                "system_description",
455                text,
456                true,
457            )?;
458        }
459        if let Some((supported, enabled)) = self.capabilities {
460            let mut value = Vec::with_capacity(4);
461            value.extend_from_slice(&supported.to_be_bytes());
462            value.extend_from_slice(&enabled.to_be_bytes());
463            push_tlv(&mut out, TLV_SYSTEM_CAPABILITIES, &value)?;
464        }
465        if let Some(mgmt) = &self.management_address {
466            push_tlv(&mut out, TLV_MANAGEMENT_ADDRESS, &mgmt.encode_value()?)?;
467        }
468
469        // End Of LLDPDU: type 0, length 0, i.e. two zero octets.
470        push_tlv(&mut out, TLV_END_OF_LLDPDU, &[])?;
471        Ok(out)
472    }
473
474    /// Parse LLDPDU octets (no Ethernet header).
475    ///
476    /// Enforces 802.1AB §8.5: the first three TLVs must be Chassis ID, Port ID and TTL, in that
477    /// order. Unknown and organisationally-specific TLVs are skipped; a duplicate optional TLV
478    /// keeps the first occurrence, as a receiver must.
479    pub fn decode(data: &[u8]) -> Result<Self> {
480        let mut chassis: Option<(u8, String)> = None;
481        let mut port: Option<(u8, String)> = None;
482        let mut ttl: Option<u16> = None;
483        let mut port_description = None;
484        let mut system_name = None;
485        let mut system_description = None;
486        let mut capabilities = None;
487        let mut management_address = None;
488
489        let mut offset = 0usize;
490        let mut index = 0usize;
491
492        while offset < data.len() {
493            if offset + 2 > data.len() {
494                bail!("truncated TLV header at octet {offset}");
495            }
496            let header = u16::from_be_bytes([data[offset], data[offset + 1]]);
497            let tlv_type = (header >> 9) as u8;
498            let tlv_len = (header & 0x01FF) as usize;
499            offset += 2;
500
501            if offset + tlv_len > data.len() {
502                bail!(
503                    "TLV type {tlv_type} declares {tlv_len} octets but only {} remain",
504                    data.len() - offset
505                );
506            }
507            let value = &data[offset..offset + tlv_len];
508            offset += tlv_len;
509
510            match (index, tlv_type) {
511                (0, TLV_CHASSIS_ID) => {
512                    chassis = Some(decode_id_value(IdKind::Chassis, value)?);
513                }
514                (1, TLV_PORT_ID) => {
515                    port = Some(decode_id_value(IdKind::Port, value)?);
516                }
517                (2, TLV_TIME_TO_LIVE) => {
518                    if value.len() != 2 {
519                        bail!("Time To Live TLV must be 2 octets, got {}", value.len());
520                    }
521                    ttl = Some(u16::from_be_bytes([value[0], value[1]]));
522                }
523                (0, other) => {
524                    bail!("first TLV must be Chassis ID (type {TLV_CHASSIS_ID}), got type {other}")
525                }
526                (1, other) => {
527                    bail!("second TLV must be Port ID (type {TLV_PORT_ID}), got type {other}")
528                }
529                (2, other) => bail!(
530                    "third TLV must be Time To Live (type {TLV_TIME_TO_LIVE}), got type {other}"
531                ),
532                (_, TLV_END_OF_LLDPDU) => break,
533                (_, TLV_PORT_DESCRIPTION) => {
534                    port_description.get_or_insert_with(|| text_of(value));
535                }
536                (_, TLV_SYSTEM_NAME) => {
537                    // An identifier, and one that is logged unquoted — see
538                    // `identifier_text_of`. The two *description* TLVs above and below use
539                    // `text_of` because a real System Description is a multi-line banner.
540                    system_name.get_or_insert_with(|| identifier_text_of(value));
541                }
542                (_, TLV_SYSTEM_DESCRIPTION) => {
543                    system_description.get_or_insert_with(|| text_of(value));
544                }
545                (_, TLV_SYSTEM_CAPABILITIES) => {
546                    if value.len() != 4 {
547                        bail!(
548                            "System Capabilities TLV must be 4 octets, got {}",
549                            value.len()
550                        );
551                    }
552                    capabilities.get_or_insert((
553                        u16::from_be_bytes([value[0], value[1]]),
554                        u16::from_be_bytes([value[2], value[3]]),
555                    ));
556                }
557                (_, TLV_MANAGEMENT_ADDRESS) => {
558                    // A management address we cannot render is not fatal to the whole PDU: the
559                    // mandatory TLVs are still intact and the neighbour is still worth
560                    // reporting. Drop the address, keep the advertisement.
561                    if management_address.is_none() {
562                        management_address = ManagementAddress::decode_value(value).ok();
563                    }
564                }
565                // Organisationally specific (127) and anything else the spec adds later.
566                (_, _) => {}
567            }
568
569            index += 1;
570        }
571
572        let (chassis_id_subtype, chassis_id) =
573            chassis.ok_or_else(|| anyhow!("LLDPDU has no Chassis ID TLV"))?;
574        let (port_id_subtype, port_id) =
575            port.ok_or_else(|| anyhow!("LLDPDU has no Port ID TLV"))?;
576        let ttl = ttl.ok_or_else(|| anyhow!("LLDPDU has no Time To Live TLV"))?;
577
578        Ok(Self {
579            chassis_id_subtype,
580            chassis_id,
581            port_id_subtype,
582            port_id,
583            ttl,
584            port_description,
585            system_name,
586            system_description,
587            capabilities,
588            management_address,
589        })
590    }
591
592    /// Everything the model is shown about a neighbour: names and natural notation, no octets.
593    ///
594    /// Optional fields the neighbour did not send are omitted rather than sent as `null`, so an
595    /// event handler can test presence with a plain `in` / `get`.
596    pub fn to_event_data(&self) -> Map<String, Value> {
597        let mut map = Map::new();
598        map.insert("chassis_id".into(), json!(self.chassis_id));
599        map.insert(
600            "chassis_id_subtype".into(),
601            json!(subtype_name(IdKind::Chassis, self.chassis_id_subtype)),
602        );
603        map.insert(
604            "chassis_id_subtype_code".into(),
605            json!(self.chassis_id_subtype),
606        );
607        map.insert("port_id".into(), json!(self.port_id));
608        map.insert(
609            "port_id_subtype".into(),
610            json!(subtype_name(IdKind::Port, self.port_id_subtype)),
611        );
612        map.insert("port_id_subtype_code".into(), json!(self.port_id_subtype));
613        map.insert("ttl".into(), json!(self.ttl));
614
615        if let Some(v) = &self.port_description {
616            map.insert("port_description".into(), json!(v));
617        }
618        if let Some(v) = &self.system_name {
619            map.insert("system_name".into(), json!(v));
620        }
621        if let Some(v) = &self.system_description {
622            map.insert("system_description".into(), json!(v));
623        }
624        if let Some((supported, enabled)) = self.capabilities {
625            map.insert("capabilities".into(), json!(capability_names(supported)));
626            map.insert(
627                "capabilities_enabled".into(),
628                json!(capability_names(enabled)),
629            );
630        }
631        if let Some(mgmt) = &self.management_address {
632            map.insert("management_address".into(), json!(mgmt.address));
633            map.insert(
634                "management_address_family".into(),
635                json!(mgmt.family_name()),
636            );
637            map.insert(
638                "management_interface_number".into(),
639                json!(mgmt.interface_number),
640            );
641        }
642        map
643    }
644}
645
646/// A full LLDP Ethernet frame, decoded.
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct LldpFrame {
649    pub destination_mac: [u8; 6],
650    pub source_mac: [u8; 6],
651    pub lldpdu: Lldpdu,
652}
653
654/// Wrap an LLDPDU in its Ethernet header.
655pub fn encode_frame(
656    destination_mac: [u8; 6],
657    source_mac: [u8; 6],
658    lldpdu: &Lldpdu,
659) -> Result<Vec<u8>> {
660    let body = lldpdu.encode()?;
661    let mut frame = Vec::with_capacity(ETHERNET_HEADER_LEN + body.len());
662    frame.extend_from_slice(&destination_mac);
663    frame.extend_from_slice(&source_mac);
664    frame.extend_from_slice(&LLDP_ETHERTYPE.to_be_bytes());
665    frame.extend_from_slice(&body);
666    Ok(frame)
667}
668
669/// Parse an Ethernet frame carrying an LLDPDU.
670///
671/// The EtherType is checked: the BPF filter already restricts capture to 0x88CC, but the UDP
672/// test transport carries whole frames from an arbitrary sender and must not trust them.
673pub fn decode_frame(frame: &[u8]) -> Result<LldpFrame> {
674    if frame.len() < ETHERNET_HEADER_LEN {
675        bail!(
676            "frame is {} octets, shorter than an Ethernet header",
677            frame.len()
678        );
679    }
680    let ethertype = u16::from_be_bytes([frame[12], frame[13]]);
681    if ethertype != LLDP_ETHERTYPE {
682        bail!("EtherType is 0x{ethertype:04x}, not LLDP's 0x88CC");
683    }
684    let mut destination_mac = [0u8; 6];
685    destination_mac.copy_from_slice(&frame[0..6]);
686    let mut source_mac = [0u8; 6];
687    source_mac.copy_from_slice(&frame[6..12]);
688
689    Ok(LldpFrame {
690        destination_mac,
691        source_mac,
692        lldpdu: Lldpdu::decode(&frame[ETHERNET_HEADER_LEN..])?,
693    })
694}
695
696// ---------------------------------------------------------------------------------------------
697// Action <-> LLDPDU
698// ---------------------------------------------------------------------------------------------
699
700/// What a `send_lldp_advertisement` action asks for, once validated.
701///
702/// `source_mac` is optional because the server knows its own interface address and the model
703/// usually should not have to; `destination_mac` defaults to the nearest-bridge group address,
704/// which is where an LLDP agent transmits.
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct AdvertisementRequest {
707    pub lldpdu: Lldpdu,
708    pub source_mac: Option<[u8; 6]>,
709    pub destination_mac: [u8; 6],
710}
711
712impl AdvertisementRequest {
713    /// Validate a `send_lldp_advertisement` action into a frame-ready request.
714    ///
715    /// This is the *only* place action JSON is interpreted, and it is deliberately strict: an
716    /// advertisement is a positive assertion about a device on the link, so a field the model
717    /// got wrong must fail here rather than reach a neighbour's topology table half-formed.
718    pub fn from_action(action: &Value) -> Result<Self> {
719        let chassis_id = required_str(action, "chassis_id")?;
720        let port_id = required_str(action, "port_id")?;
721
722        let chassis_id_subtype = named_subtype(
723            IdKind::Chassis,
724            action.get("chassis_id_subtype").and_then(Value::as_str),
725            &chassis_id,
726        )?;
727        let port_id_subtype = named_subtype(
728            IdKind::Port,
729            action.get("port_id_subtype").and_then(Value::as_str),
730            &port_id,
731        )?;
732
733        let ttl = match action.get("ttl") {
734            None | Some(Value::Null) => 120u16,
735            Some(v) => {
736                let n = v
737                    .as_u64()
738                    .ok_or_else(|| anyhow!("'ttl' must be a whole number of seconds, got {v}"))?;
739                u16::try_from(n).map_err(|_| anyhow!("'ttl' must be 0-65535 seconds, got {n}"))?
740            }
741        };
742
743        let supported = string_array(action, "capabilities")?;
744        let enabled = string_array(action, "capabilities_enabled")?;
745        let capabilities = match (supported, enabled) {
746            (None, None) => None,
747            (Some(s), None) => {
748                let bits = capability_bits(&s)?;
749                // Nothing was said about which are switched on. Claiming the full supported
750                // set is what real equipment advertises, and the alternative (enabled = 0)
751                // would describe a device that supports being a bridge and is not one.
752                Some((bits, bits))
753            }
754            (None, Some(e)) => {
755                let bits = capability_bits(&e)?;
756                Some((bits, bits))
757            }
758            (Some(s), Some(e)) => Some((capability_bits(&s)?, capability_bits(&e)?)),
759        };
760
761        let management_address = match action.get("management_address") {
762            None | Some(Value::Null) => None,
763            Some(v) => {
764                let addr = v
765                    .as_str()
766                    .ok_or_else(|| anyhow!("'management_address' must be a string, got {v}"))?;
767                let iface = match action.get("management_interface_number") {
768                    None | Some(Value::Null) => 0u32,
769                    Some(n) => u32::try_from(n.as_u64().ok_or_else(|| {
770                        anyhow!("'management_interface_number' must be a whole number, got {n}")
771                    })?)
772                    .map_err(|_| anyhow!("'management_interface_number' must fit in 32 bits"))?,
773                };
774                Some(ManagementAddress::new(addr, iface)?)
775            }
776        };
777
778        let lldpdu = Lldpdu {
779            chassis_id_subtype,
780            chassis_id,
781            port_id_subtype,
782            port_id,
783            ttl,
784            port_description: optional_str(action, "port_description")?,
785            system_name: optional_str(action, "system_name")?,
786            system_description: optional_str(action, "system_description")?,
787            capabilities,
788            management_address,
789        };
790
791        let source_mac = match action.get("source_mac") {
792            None | Some(Value::Null) => None,
793            Some(v) => Some(parse_mac(v.as_str().ok_or_else(|| {
794                anyhow!("'source_mac' must be a MAC address string, got {v}")
795            })?)?),
796        };
797        let destination_mac = match action.get("destination_mac") {
798            None | Some(Value::Null) => LLDP_MULTICAST_MAC,
799            Some(v) => parse_mac(v.as_str().ok_or_else(|| {
800                anyhow!("'destination_mac' must be a MAC address string, got {v}")
801            })?)?,
802        };
803
804        // Encoding here is what makes this a validation rather than a hope: a value too long for
805        // its TLV, or an address that does not match its subtype, fails now.
806        lldpdu.encode()?;
807
808        Ok(Self {
809            lldpdu,
810            source_mac,
811            destination_mac,
812        })
813    }
814
815    /// Produce the frame, using `fallback_source` when the action named no source MAC.
816    pub fn to_frame(&self, fallback_source: [u8; 6]) -> Result<Vec<u8>> {
817        encode_frame(
818            self.destination_mac,
819            self.source_mac.unwrap_or(fallback_source),
820            &self.lldpdu,
821        )
822    }
823}
824
825// ---------------------------------------------------------------------------------------------
826// Internals
827// ---------------------------------------------------------------------------------------------
828
829fn push_tlv(out: &mut Vec<u8>, tlv_type: u8, value: &[u8]) -> Result<()> {
830    if value.len() > MAX_TLV_VALUE {
831        bail!(
832            "TLV type {tlv_type} value is {} octets; the 9-bit length field caps it at \
833             {MAX_TLV_VALUE}",
834            value.len()
835        );
836    }
837    let header = ((tlv_type as u16) << 9) | value.len() as u16;
838    out.extend_from_slice(&header.to_be_bytes());
839    out.extend_from_slice(value);
840    Ok(())
841}
842
843/// Push a text TLV, refusing a control character unless the field is one a real device sends
844/// multi-line.
845///
846/// `multiline_is_real` is true only for System Description, which is `sysDescr` — for IOS, a
847/// multi-line banner. Refusing a newline there would make the most realistic value the model can
848/// send unencodable, and that field is interpolated into no log line. Every other text TLV is an
849/// identifier or an `ifDescr` and is refused; see [`reject_control_characters`].
850fn push_text_tlv(
851    out: &mut Vec<u8>,
852    tlv_type: u8,
853    field: &str,
854    text: &str,
855    multiline_is_real: bool,
856) -> Result<()> {
857    if !multiline_is_real {
858        reject_control_characters(field, text)?;
859    }
860    let bytes = text.as_bytes();
861    if bytes.len() > MAX_STRING_TLV_VALUE {
862        bail!(
863            "'{field}' is {} octets; IEEE 802.1AB caps it at {MAX_STRING_TLV_VALUE}",
864            bytes.len()
865        );
866    }
867    push_tlv(out, tlv_type, bytes)
868}
869
870/// Refuse a control character in anything that becomes a text TLV.
871///
872/// **An LLDP text TLV is an entry in somebody's neighbour table.** A system name, port
873/// description or chassis ID is copied verbatim into `show lldp neighbors`, into an NMS's
874/// topology display and — on this side — into the `lldp_neighbor_advertisement` log line, none
875/// of which quote what they print. A newline in a `system_name` therefore does not merely look
876/// odd: it forges a *second* neighbour entry in every one of those displays, which is the whole
877/// point of impersonating a switch. Nothing in 802.1AB's text TLVs needs a control character,
878/// so refusing is free.
879///
880/// Refused rather than stripped, deliberately. On this side the model authored the string and
881/// can be told; silently rewriting its answer would make the frame disagree with the decision
882/// the log records. [`text_of`] takes the opposite exit for the same reason: a *neighbour*
883/// cannot be told, and dropping it would hide it.
884fn reject_control_characters(field: &str, text: &str) -> Result<()> {
885    if let Some((index, bad)) = text.char_indices().find(|(_, c)| c.is_control()) {
886        bail!(
887            "'{field}' contains a control character (U+{:04X}) at byte {index}. LLDP text is \
888             copied verbatim into a neighbour's topology table and into log lines, so a newline \
889             or NUL there forges a whole neighbour entry. Send printable text only.",
890            bad as u32
891        );
892    }
893    Ok(())
894}
895
896/// Encode a Chassis ID or Port ID value: the leading subtype octet, then the identifier in
897/// whatever representation that subtype selects.
898fn encode_id_value(kind: IdKind, subtype: u8, value: &str) -> Result<Vec<u8>> {
899    if !kind.table().iter().any(|(c, _)| *c == subtype) {
900        bail!(
901            "{} subtype {subtype} is not defined by IEEE 802.1AB. Valid subtypes: {}",
902            kind.label(),
903            kind.table()
904                .iter()
905                .map(|(c, n)| format!("{n} ({c})"))
906                .collect::<Vec<_>>()
907                .join(", ")
908        );
909    }
910
911    let mut out = vec![subtype];
912    if subtype == kind.mac_subtype() {
913        out.extend_from_slice(&parse_mac(value).with_context(|| {
914            format!("{} subtype 'mac_address' needs a MAC address", kind.label())
915        })?);
916    } else if subtype == kind.network_address_subtype() {
917        // 802.1AB §8.5.2.3: an IANA address family octet, then the address.
918        if let Ok(v4) = value.trim().parse::<std::net::Ipv4Addr>() {
919            out.push(1);
920            out.extend_from_slice(&v4.octets());
921        } else if let Ok(v6) = value.trim().parse::<std::net::Ipv6Addr>() {
922            out.push(2);
923            out.extend_from_slice(&v6.octets());
924        } else {
925            bail!(
926                "{} subtype 'network_address' needs an IP address, got '{value}'",
927                kind.label()
928            );
929        }
930    } else {
931        if value.is_empty() {
932            bail!("{} must not be empty", kind.label());
933        }
934        // Every remaining subtype carries free text, and a chassis or port ID is the key a
935        // neighbour's table is indexed on — the worst possible place for an embedded newline.
936        reject_control_characters(kind.label(), value)?;
937        out.extend_from_slice(value.as_bytes());
938    }
939
940    if out.len() > MAX_STRING_TLV_VALUE {
941        bail!(
942            "{} is {} octets including its subtype; IEEE 802.1AB caps it at \
943             {MAX_STRING_TLV_VALUE}",
944            kind.label(),
945            out.len()
946        );
947    }
948    Ok(out)
949}
950
951/// The inverse of [`encode_id_value`].
952fn decode_id_value(kind: IdKind, value: &[u8]) -> Result<(u8, String)> {
953    if value.is_empty() {
954        bail!("{} TLV carries no subtype octet", kind.label());
955    }
956    let subtype = value[0];
957    let body = &value[1..];
958    if body.is_empty() {
959        bail!("{} TLV carries a subtype and no identifier", kind.label());
960    }
961
962    let text = if subtype == kind.mac_subtype() {
963        if body.len() != 6 {
964            bail!(
965                "{} subtype 'mac_address' must carry 6 octets, got {}",
966                kind.label(),
967                body.len()
968            );
969        }
970        let mut mac = [0u8; 6];
971        mac.copy_from_slice(body);
972        format_mac(&mac)
973    } else if subtype == kind.network_address_subtype() {
974        match (body[0], body.len()) {
975            (1, 5) => std::net::Ipv4Addr::new(body[1], body[2], body[3], body[4]).to_string(),
976            (2, 17) => {
977                let mut octets = [0u8; 16];
978                octets.copy_from_slice(&body[1..]);
979                std::net::Ipv6Addr::from(octets).to_string()
980            }
981            (family, len) => bail!(
982                "{} subtype 'network_address' has IANA family {family} with {} address octets, \
983                 which is not IPv4 or IPv6",
984                kind.label(),
985                len - 1
986            ),
987        }
988    } else {
989        // A Chassis or Port ID is the key a neighbour table is indexed on, and both appear
990        // unquoted in this protocol's own log line.
991        identifier_text_of(body)
992    };
993
994    Ok((subtype, text))
995}
996
997/// TLV text is "not null terminated" ASCII/UTF-8 per 802.1AB. Lossy is right here: a neighbour
998/// that sends invalid UTF-8 should still be reported, not dropped.
999///
1000/// Kept **verbatim** apart from the UTF-8 replacement. This is what the two description TLVs go
1001/// through, and a real System Description is `sysDescr` — for IOS, a multi-line banner. Folding
1002/// its newlines away would destroy the single most useful thing a recon operator reads off the
1003/// wire, and neither description is interpolated into a log line (see [`identifier_text_of`]
1004/// for the ones that are).
1005fn text_of(value: &[u8]) -> String {
1006    String::from_utf8_lossy(value)
1007        .trim_end_matches('\0')
1008        .to_string()
1009}
1010
1011/// [`text_of`], plus control characters replaced by a space — for the fields that are
1012/// **identifiers**.
1013///
1014/// System Name, Chassis ID and Port ID come straight back out in
1015/// `LLDP neighbour {system_name} ({chassis_id}) on port {port_id}`, and
1016/// `src/protocol/log_template.rs` quotes nothing. A neighbour that puts a newline in its System
1017/// Name would otherwise forge a whole extra log line — and a forged neighbour in a topology
1018/// display is the entire point of impersonating a switch. 802.1AB's System Name is `sysName`,
1019/// an administratively assigned node name, and a Chassis or Port ID is a table key; none of the
1020/// three is ever legitimately multi-line, so nothing real is lost. A neighbour cannot be asked
1021/// to resend, which is why this strips where [`reject_control_characters`] refuses.
1022fn identifier_text_of(value: &[u8]) -> String {
1023    crate::utils::sanitize::line_field(&text_of(value))
1024}
1025
1026fn required_str(action: &Value, key: &str) -> Result<String> {
1027    match action.get(key) {
1028        Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
1029        Some(Value::String(_)) => bail!("'{key}' must not be empty"),
1030        Some(other) => bail!("'{key}' must be a string, got {other}"),
1031        None => bail!("'{key}' is required"),
1032    }
1033}
1034
1035fn optional_str(action: &Value, key: &str) -> Result<Option<String>> {
1036    match action.get(key) {
1037        None | Some(Value::Null) => Ok(None),
1038        Some(Value::String(s)) => Ok(Some(s.clone())),
1039        Some(other) => bail!("'{key}' must be a string, got {other}"),
1040    }
1041}
1042
1043fn string_array(action: &Value, key: &str) -> Result<Option<Vec<String>>> {
1044    match action.get(key) {
1045        None | Some(Value::Null) => Ok(None),
1046        Some(Value::Array(items)) => {
1047            let mut out = Vec::with_capacity(items.len());
1048            for item in items {
1049                match item {
1050                    Value::String(s) => out.push(s.clone()),
1051                    other => bail!("'{key}' must be an array of strings, found {other}"),
1052                }
1053            }
1054            Ok(Some(out))
1055        }
1056        Some(other) => bail!("'{key}' must be an array of strings, got {other}"),
1057    }
1058}
1059
1060/// Resolve a subtype name, or infer one from the value's shape when none was given.
1061///
1062/// Inference exists because the subtype is the field a model is most likely to omit, and the
1063/// wrong default is worse than a guess from the value: sending a MAC address under the
1064/// `interface_name` subtype produces a frame whose chassis ID reads as the literal text
1065/// `"00:1b:21:..."` in a neighbour's table.
1066fn named_subtype(kind: IdKind, name: Option<&str>, value: &str) -> Result<u8> {
1067    match name {
1068        Some(name) => subtype_code(kind, name).ok_or_else(|| {
1069            anyhow!(
1070                "'{}_subtype' value '{name}' is not an IEEE 802.1AB subtype. Valid names: {}",
1071                kind.label(),
1072                kind.table()
1073                    .iter()
1074                    .map(|(_, n)| *n)
1075                    .collect::<Vec<_>>()
1076                    .join(", ")
1077            )
1078        }),
1079        None if parse_mac(value).is_ok() => Ok(kind.mac_subtype()),
1080        None if value.trim().parse::<std::net::IpAddr>().is_ok() => {
1081            Ok(kind.network_address_subtype())
1082        }
1083        // "locally assigned" is the honest default for free text: it claims nothing about what
1084        // the string means, which is exactly the situation when nobody said.
1085        None => Ok(7),
1086    }
1087}