Skip to main content

netget/server/lldp/
actions.rs

1//! LLDP protocol actions, events and metadata.
2//!
3//! What makes LLDP interesting to drive from a model is that **the model authors the identity**:
4//! NetGet impersonates a switch to its neighbour, and the chassis ID, system description and
5//! capability set are its to choose. Everything below is therefore expressed as structured
6//! fields the model can reason about — names, addresses, descriptions — and never as octets.
7//!
8//! The one rule that shapes the rest of this file: **an advertisement is a positive assertion.**
9//! A neighbour writes what it receives into its topology table and shows it to an operator, so
10//! there is no such thing as an error advertisement. When there is nothing to say, LLDP says
11//! nothing. See `no_advertisement`, and the failure handling in `mod.rs`.
12
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, Parameter, ParameterDefinition,
16};
17use crate::protocol::log_template::LogTemplate;
18use crate::protocol::EventType;
19use crate::state::app_state::AppState;
20use anyhow::{Context, Result};
21use serde_json::json;
22use std::sync::LazyLock;
23
24use super::codec;
25
26/// Interface a capture is opened on when the caller names none. LLDP is not observable on
27/// loopback — nothing speaks it there and libpcap rejects an Ethernet-only filter on a link
28/// type with no Ethernet header — so this default exists to produce a clear refusal rather than
29/// to be useful. Point the server at a real NIC.
30#[cfg(any(
31    target_os = "macos",
32    target_os = "ios",
33    target_os = "freebsd",
34    target_os = "openbsd",
35    target_os = "netbsd",
36    target_os = "dragonfly"
37))]
38pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
39#[cfg(not(any(
40    target_os = "macos",
41    target_os = "ios",
42    target_os = "freebsd",
43    target_os = "openbsd",
44    target_os = "netbsd",
45    target_os = "dragonfly"
46)))]
47pub const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
48
49/// Locally-administered address used as the Ethernet source when nothing better is known.
50///
51/// The `x2` in the first octet marks it locally administered, so it cannot collide with a real
52/// vendor assignment. Overridable with the `source_mac` startup parameter, and by the action.
53pub const DEFAULT_SOURCE_MAC: &str = "02:00:00:00:00:01";
54
55pub struct LldpProtocol;
56
57impl Default for LldpProtocol {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl LldpProtocol {
64    pub fn new() -> Self {
65        Self
66    }
67
68    /// Validate a `send_lldp_advertisement` action by *building the frame it describes*.
69    ///
70    /// The bytes are thrown away here — `mod.rs` owns the transport and rebuilds them with the
71    /// server's own source address — but running the encoder is what makes this a validation
72    /// rather than a hope. A chassis ID that does not match its subtype, a capability name with
73    /// a typo, or a system description past the 255-octet TLV limit fails at the action, where
74    /// the model can be told, instead of producing a frame a neighbour discards in silence.
75    fn execute_send_advertisement(&self, action: serde_json::Value) -> Result<ActionResult> {
76        let request = codec::AdvertisementRequest::from_action(&action)
77            .context("send_lldp_advertisement was refused")?;
78
79        // A frame is only transmitted by the running server, which knows its interface's
80        // address; the action carries the *description* onwards.
81        Ok(ActionResult::Custom {
82            name: LLDP_ADVERTISEMENT_RESULT.to_string(),
83            data: json!({
84                "chassis_id": request.lldpdu.chassis_id,
85                "port_id": request.lldpdu.port_id,
86                "ttl": request.lldpdu.ttl,
87                "action": action,
88            }),
89        })
90    }
91}
92
93/// The `ActionResult::Custom` name `mod.rs` looks for when deciding what to transmit.
94pub const LLDP_ADVERTISEMENT_RESULT: &str = "lldp_advertisement";
95
96/// The action name that means "deliberately say nothing".
97pub const NO_ADVERTISEMENT_ACTION: &str = "no_advertisement";
98
99impl Protocol for LldpProtocol {
100    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
101        // LLDP is interface-based, but the UDP test transport needs a host and port, so both
102        // sets of defaults are supplied and `transport` decides which are used.
103        Some(crate::protocol::BindingDefaults {
104            mac_address: None,
105            interface: Some(DEFAULT_LOOPBACK_INTERFACE.to_string()),
106            host: Some("127.0.0.1".to_string()),
107            port: Some(0),
108        })
109    }
110
111    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
112        // Every one of these is read by `LldpServer::spawn_with_llm_actions`.
113        vec![
114            ParameterDefinition {
115                name: "transport".to_string(),
116                type_hint: "string".to_string(),
117                description:
118                    "How frames reach the wire. \"raw\" (the default) captures and injects real \
119                     Ethernet frames through libpcap on the chosen interface and needs \
120                     root/CAP_NET_RAW. \"udp\" is a TESTING transport: it carries complete \
121                     Ethernet frames as UDP datagram payloads on the bound host/port so the \
122                     whole event -> handler -> frame path can be exercised without privileges. \
123                     It is not LLDP as any real neighbour speaks it — no switch will ever send \
124                     or receive these datagrams."
125                        .to_string(),
126                required: false,
127                example: json!("raw"),
128                default: None,
129            },
130            ParameterDefinition {
131                name: "udp_peer".to_string(),
132                type_hint: "string".to_string(),
133                description:
134                    "TESTING only, and only with transport=\"udp\": HOST:PORT that advertisements \
135                     are sent to. Without it, frames go back to the last peer heard from, which \
136                     is enough to answer a neighbour but not to start a periodic advertisement \
137                     before anyone has spoken."
138                        .to_string(),
139                required: false,
140                example: json!("127.0.0.1:34567"),
141                default: None,
142            },
143            ParameterDefinition {
144                name: "advertise_interval_secs".to_string(),
145                type_hint: "integer".to_string(),
146                description:
147                    "Seconds between lldp_advertise_due events, which is how NetGet advertises \
148                     itself rather than only answering neighbours. 0 (the default) disables the \
149                     timer entirely and the server is a pure listener. IEEE 802.1AB's default is \
150                     30, with a TTL of 120."
151                        .to_string(),
152                required: false,
153                example: json!(30),
154                default: None,
155            },
156            ParameterDefinition {
157                name: "source_mac".to_string(),
158                type_hint: "string".to_string(),
159                description: format!(
160                    "Ethernet source address for frames this server transmits, used when the \
161                     action does not name one. Defaults to the interface's own address where it \
162                     can be read, and to {DEFAULT_SOURCE_MAC} (locally administered) otherwise — \
163                     which is always the case for transport=\"udp\", where there is no interface."
164                ),
165                required: false,
166                example: json!("02:00:00:00:00:01"),
167                default: None,
168            },
169        ]
170    }
171
172    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
173        // LLDP has no user-triggered verbs of its own: transmitting is driven either by a
174        // received advertisement or by the advertise timer, and both are network events.
175        Vec::new()
176    }
177
178    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
179        vec![send_lldp_advertisement_action(), no_advertisement_action()]
180    }
181
182    fn protocol_name(&self) -> &'static str {
183        "LLDP"
184    }
185
186    fn get_event_types(&self) -> Vec<EventType> {
187        get_lldp_event_types()
188    }
189
190    fn stack_name(&self) -> &'static str {
191        "ETH>LLDP"
192    }
193
194    fn keywords(&self) -> Vec<&'static str> {
195        vec!["lldp", "link layer discovery", "802.1ab"]
196    }
197
198    fn startup_privilege_requirement(
199        &self,
200
201        startup_params: Option<&serde_json::Value>,
202    ) -> crate::protocol::metadata::PrivilegeRequirement {
203        // The UDP test transport carries the same frames over an ordinary socket.
204
205        crate::llm::actions::protocol_trait::unless_udp_transport(
206            self.metadata().privilege_requirement,
207            startup_params,
208        )
209    }
210
211    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
212        use crate::protocol::metadata::{
213            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
214        };
215
216        ProtocolMetadataV2::builder()
217            .state(DevelopmentState::Experimental)
218            // libpcap capture/injection, never a SOCK_RAW. RawSockets would refuse to start on
219            // a host with /dev/bpf* access but no root - the "don't claim more than you need"
220            // rule that ospf got wrong by declaring Root when it wanted CAP_NET_RAW.
221            .privilege_requirement(PrivilegeRequirement::PacketCapture)
222            .connectionless()
223            // Deliberately silent: LLDP has no error frame. An LLDPDU asserts chassis, port and
224            // system identity to every neighbour, which stores it in its MIB for the TTL, so a
225            // fabricated one is a false neighbour entry on every station that hears it.
226            .deliberately_silent()
227            .implementation(
228                "Hand-written IEEE 802.1AB TLV codec (src/server/lldp/codec.rs), pure and \
229                 transport-free; libpcap capture/injection on EtherType 0x88CC for the real \
230                 transport, plus a UDP test transport that carries whole Ethernet frames.",
231            )
232            .llm_control(
233                "The entire advertised identity: chassis ID and subtype, port ID and subtype, \
234                 TTL, port/system descriptions, system capabilities and management address. \
235                 NetGet impersonates whatever device the model describes.",
236            )
237            .e2e_testing(
238                "tests/server/lldp/codec_test.rs encodes against hand-computed IEEE 802.1AB \
239                 bytes and decodes literal frames, including the mandatory/capability/management \
240                 TLVs of a real-world Extreme Summit300-48 capture. \
241                 tests/server/lldp/e2e_test.rs drives the full event -> handler/LLM -> frame \
242                 path over the UDP test transport in-process, including the silence case. \
243                 Nothing has ever run the pcap transport. \
244                 NO THIRD-PARTY PEER, and this was investigated rather than assumed \
245                 (September 2026): `lldpcli` is installed on this machine and CANNOT be \
246                 pointed at NetGet. It is not an LLDP speaker at all — lldpcli(8) is the \
247                 control program for a local `lldpd` daemon and reaches it over the unix \
248                 socket /opt/homebrew/var/run/lldpd.socket, which is its only transport (-u \
249                 changes the socket path; there is no host or port option, because there is no \
250                 wire protocol on that side). The real peer would have to be `lldpd` itself, \
251                 which needs root, a real Ethernet interface and NetGet's libpcap transport — \
252                 and a test needing root could only be #[ignore]d or skip-gated, neither of \
253                 which is evidence. See src/server/lldp/CLAUDE.md for the feth-pair experiment \
254                 that would earn Beta.",
255            )
256            .notes(
257                "PROVEN: the TLV codec, against literal IEEE 802.1AB-2016 byte layouts and a \
258                 real-world capture, in both directions. NOT PROVEN: the raw-Ethernet transport, \
259                 which needs root/CAP_NET_RAW and has never been executed anywhere — no frame \
260                 this code produced has reached a real LLDP neighbour, and no third-party LLDP \
261                 peer is runnable in the environment that tests it. Deliberately silent on LLM \
262                 failure: every LLDP frame asserts that a device with a given identity exists on \
263                 this link, so a fabricated one poisons a neighbour's topology table. The \
264                 failure is recorded with a decision= tag in the log instead. See \
265                 src/server/lldp/CLAUDE.md for the feth-pair experiment that would earn Beta.",
266            )
267            .build()
268    }
269
270    fn description(&self) -> &'static str {
271        "LLDP (IEEE 802.1AB) link-layer discovery — advertise a device identity to neighbours"
272    }
273
274    fn example_prompt(&self) -> &'static str {
275        "Be an LLDP agent on en0 impersonating a Cisco Catalyst switch: answer every neighbour \
276         advertisement with our own on port GigabitEthernet0/1"
277    }
278
279    fn group_name(&self) -> &'static str {
280        "Core"
281    }
282
283    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
284        use crate::llm::actions::StartupExamples;
285        StartupExamples::new(
286            // LLM-driven: the model invents the identity per neighbour.
287            json!({
288                "type": "open_server",
289                "base_stack": "lldp",
290                "interface": "en0",
291                "instruction": "You are an LLDP agent impersonating an Extreme Summit300-48 \
292                                switch. Answer each neighbour advertisement with our own on \
293                                port 1/1, capabilities bridge and router, TTL 120."
294            }),
295            // Script: a deterministic identity, no LLM call per neighbour.
296            json!({
297                "type": "open_server",
298                "base_stack": "lldp",
299                "interface": "en0",
300                "startup_params": {"advertise_interval_secs": 30},
301                "event_handlers": [{
302                    "event_pattern": "lldp_*",
303                    "handler": {
304                        "type": "script",
305                        "language": "python",
306                        "code": "respond([{'type': 'send_lldp_advertisement', 'chassis_id': '02:00:00:00:00:01', 'chassis_id_subtype': 'mac_address', 'port_id': 'ge-0/0/1', 'port_id_subtype': 'interface_name', 'ttl': 120, 'system_name': 'netget-lab', 'capabilities': ['bridge', 'router']}])"
307                    }
308                }]
309            }),
310            // Static: one fixed identity, and explicit silence for the timer.
311            json!({
312                "type": "open_server",
313                "base_stack": "lldp",
314                "interface": "en0",
315                "event_handlers": [{
316                    "event_pattern": "lldp_neighbor_advertisement",
317                    "handler": {
318                        "type": "static",
319                        "actions": [{
320                            "type": "send_lldp_advertisement",
321                            "chassis_id": "02:00:00:00:00:01",
322                            "chassis_id_subtype": "mac_address",
323                            "port_id": "1/1",
324                            "port_id_subtype": "interface_name",
325                            "ttl": 120,
326                            "system_name": "netget-lab",
327                            "system_description": "NetGet LLDP agent",
328                            "capabilities": ["bridge", "router"]
329                        }]
330                    }
331                }]
332            }),
333        )
334    }
335}
336
337impl Server for LldpProtocol {
338    fn spawn(
339        &self,
340        ctx: crate::protocol::SpawnContext,
341    ) -> std::pin::Pin<
342        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
343    > {
344        Box::pin(async move {
345            use super::LldpServer;
346            let listen_addr = ctx
347                .socket_addr()
348                .unwrap_or_else(|| ctx.legacy_listen_addr());
349            LldpServer::spawn_with_llm_actions(
350                ctx.interface.clone(),
351                listen_addr,
352                ctx.llm_client,
353                ctx.state,
354                ctx.status_tx,
355                ctx.server_id,
356                ctx.startup_params,
357            )
358            .await
359        })
360    }
361
362    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
363        let action_type = action
364            .get("type")
365            .and_then(|v| v.as_str())
366            .context("Missing 'type' field in action")?;
367
368        match action_type {
369            "send_lldp_advertisement" => self.execute_send_advertisement(action),
370            // Silence, explicitly chosen. It is a real answer and is logged as
371            // `decision=model_reject`, which is what distinguishes it from a model that said
372            // nothing and from a backend that failed.
373            NO_ADVERTISEMENT_ACTION => Ok(ActionResult::NoAction),
374            other => Err(anyhow::anyhow!("Unknown LLDP action: {}", other)),
375        }
376    }
377}
378
379// ---------------------------------------------------------------------------------------------
380// Action definitions
381// ---------------------------------------------------------------------------------------------
382
383fn subtype_list(kind: codec::IdKind) -> String {
384    let table = match kind {
385        codec::IdKind::Chassis => codec::CHASSIS_ID_SUBTYPES,
386        codec::IdKind::Port => codec::PORT_ID_SUBTYPES,
387    };
388    table
389        .iter()
390        .map(|(_, n)| *n)
391        .collect::<Vec<_>>()
392        .join(" | ")
393}
394
395fn capability_list() -> String {
396    codec::SYSTEM_CAPABILITIES
397        .iter()
398        .map(|(_, n)| *n)
399        .collect::<Vec<_>>()
400        .join(", ")
401}
402
403fn send_lldp_advertisement_action() -> ActionDefinition {
404    ActionDefinition {
405        name: "send_lldp_advertisement".to_string(),
406        description:
407            "Transmit one LLDP advertisement describing the device NetGet is impersonating. \
408             Every field is structured — there is no way to send raw TLV octets, deliberately. \
409             Remember what an advertisement means: the neighbour records this identity in its \
410             topology table and shows it to an operator, so send one only when you intend to \
411             claim that a device with these details exists on this link. Use no_advertisement \
412             otherwise."
413                .to_string(),
414        parameters: vec![
415            Parameter {
416                name: "chassis_id".to_string(),
417                type_hint: "string".to_string(),
418                description:
419                    "Identifier of the whole device: a MAC address (00:1b:21:3c:4d:5e), an IP \
420                     address, or free text, matching chassis_id_subtype."
421                        .to_string(),
422                required: true,
423            },
424            Parameter {
425                name: "chassis_id_subtype".to_string(),
426                type_hint: "string".to_string(),
427                description: format!(
428                    "How chassis_id should be read: {}. Defaults to mac_address or \
429                     network_address when the value looks like one, and to 'local' otherwise. \
430                     Note the numbering differs from port_id_subtype.",
431                    subtype_list(codec::IdKind::Chassis)
432                ),
433                required: false,
434            },
435            Parameter {
436                name: "port_id".to_string(),
437                type_hint: "string".to_string(),
438                description:
439                    "Identifier of the port this advertisement is sent from: an interface name \
440                     (GigabitEthernet0/1), a MAC address, or free text."
441                        .to_string(),
442                required: true,
443            },
444            Parameter {
445                name: "port_id_subtype".to_string(),
446                type_hint: "string".to_string(),
447                description: format!(
448                    "How port_id should be read: {}. Same defaulting as chassis_id_subtype.",
449                    subtype_list(codec::IdKind::Port)
450                ),
451                required: false,
452            },
453            Parameter {
454                name: "ttl".to_string(),
455                type_hint: "integer".to_string(),
456                description:
457                    "Seconds the neighbour should keep this entry, 0-65535 (default 120). 0 is a \
458                     shutdown notice: it tells the neighbour to delete us immediately."
459                        .to_string(),
460                required: false,
461            },
462            Parameter {
463                name: "port_description".to_string(),
464                type_hint: "string".to_string(),
465                description: "Human-readable port description, up to 255 octets.".to_string(),
466                required: false,
467            },
468            Parameter {
469                name: "system_name".to_string(),
470                type_hint: "string".to_string(),
471                description: "The device's administratively assigned name (its hostname)."
472                    .to_string(),
473                required: false,
474            },
475            Parameter {
476                name: "system_description".to_string(),
477                type_hint: "string".to_string(),
478                description:
479                    "Free text describing the device — typically vendor, model and firmware \
480                     version. This is the field network reconnaissance reads, so choose it as \
481                     deliberately as the rest."
482                        .to_string(),
483                required: false,
484            },
485            Parameter {
486                name: "capabilities".to_string(),
487                type_hint: "array".to_string(),
488                description: format!(
489                    "Capabilities the device supports, as names: {}. Used for capabilities_enabled \
490                     too when that is omitted.",
491                    capability_list()
492                ),
493                required: false,
494            },
495            Parameter {
496                name: "capabilities_enabled".to_string(),
497                type_hint: "array".to_string(),
498                description: "Subset of 'capabilities' currently switched on. Omit to claim that \
499                     everything supported is enabled."
500                    .to_string(),
501                required: false,
502            },
503            Parameter {
504                name: "management_address".to_string(),
505                type_hint: "string".to_string(),
506                description:
507                    "Address the device is managed on: an IPv4, IPv6 or MAC address. The family \
508                     is deduced from the notation."
509                        .to_string(),
510                required: false,
511            },
512            Parameter {
513                name: "management_interface_number".to_string(),
514                type_hint: "integer".to_string(),
515                description: "ifIndex the management address lives on (default 0).".to_string(),
516                required: false,
517            },
518            Parameter {
519                name: "source_mac".to_string(),
520                type_hint: "string".to_string(),
521                description:
522                    "Ethernet source address of the frame. Defaults to the server's interface \
523                     address; set it only to impersonate a specific device at layer 2."
524                        .to_string(),
525                required: false,
526            },
527            Parameter {
528                name: "destination_mac".to_string(),
529                type_hint: "string".to_string(),
530                description:
531                    "Ethernet destination (default 01:80:c2:00:00:0e, the nearest-bridge group \
532                     address every LLDP agent transmits to). Change it only for a directed \
533                     frame to one neighbour."
534                        .to_string(),
535                required: false,
536            },
537        ],
538        example: json!({
539            "type": "send_lldp_advertisement",
540            "chassis_id": "00:1b:21:3c:4d:5e",
541            "chassis_id_subtype": "mac_address",
542            "port_id": "GigabitEthernet0/1",
543            "port_id_subtype": "interface_name",
544            "ttl": 120,
545            "port_description": "Uplink to core",
546            "system_name": "edge-sw-01",
547            "system_description": "Cisco IOS Software, C2960 Software, Version 15.0(2)SE11",
548            "capabilities": ["bridge", "router"],
549            "capabilities_enabled": ["bridge"],
550            "management_address": "192.0.2.10"
551        }),
552        log_template: Some(
553            LogTemplate::new()
554                .with_info("-> LLDP {system_name} chassis={chassis_id} port={port_id} ttl={ttl}")
555                .with_debug(
556                    "LLDP send_lldp_advertisement: chassis={chassis_id} ({chassis_id_subtype}) \
557                     port={port_id} ({port_id_subtype}) ttl={ttl}",
558                )
559                .with_trace("LLDP advertisement: {json_pretty(.)}"),
560        ),
561    }
562}
563
564fn no_advertisement_action() -> ActionDefinition {
565    ActionDefinition {
566        name: NO_ADVERTISEMENT_ACTION.to_string(),
567        description:
568            "Say nothing. LLDP has no error or refusal frame — every frame it defines asserts \
569             that a device exists — so this is how a decision not to advertise is expressed. It \
570             is recorded in the log as decision=model_reject, which is what distinguishes it \
571             from having produced no answer at all."
572                .to_string(),
573        parameters: vec![Parameter {
574            name: "reason".to_string(),
575            type_hint: "string".to_string(),
576            description: "Why nothing is being advertised. Logged, never transmitted.".to_string(),
577            required: false,
578        }],
579        example: json!({
580            "type": "no_advertisement",
581            "reason": "This neighbour is not on a link we advertise to"
582        }),
583        log_template: Some(
584            LogTemplate::new()
585                .with_info("LLDP staying silent: {reason}")
586                .with_debug("LLDP no_advertisement: {reason}"),
587        ),
588    }
589}
590
591// ---------------------------------------------------------------------------------------------
592// Event types
593// ---------------------------------------------------------------------------------------------
594
595/// A neighbour's advertisement arrived and has been decoded.
596pub static LLDP_NEIGHBOR_ADVERTISEMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
597    EventType::new(
598        "lldp_neighbor_advertisement",
599        "An LLDP neighbour advertised itself on this link. Every field is already decoded from \
600         its TLVs.",
601        json!({
602            "type": "send_lldp_advertisement",
603            "chassis_id": "02:00:00:00:00:01",
604            "chassis_id_subtype": "mac_address",
605            "port_id": "1/1",
606            "port_id_subtype": "interface_name",
607            "ttl": 120,
608            "system_name": "netget-lab",
609            "capabilities": ["bridge", "router"]
610        }),
611    )
612    .with_parameters(vec![
613        Parameter {
614            name: "source_mac".to_string(),
615            type_hint: "string".to_string(),
616            description: "Ethernet source address the advertisement came from".to_string(),
617            required: true,
618        },
619        Parameter {
620            name: "chassis_id".to_string(),
621            type_hint: "string".to_string(),
622            description: "The neighbour's chassis identifier, in the notation its subtype implies"
623                .to_string(),
624            required: true,
625        },
626        Parameter {
627            name: "chassis_id_subtype".to_string(),
628            type_hint: "string".to_string(),
629            description: "How to read chassis_id (mac_address, network_address, local, ...)"
630                .to_string(),
631            required: true,
632        },
633        Parameter {
634            name: "port_id".to_string(),
635            type_hint: "string".to_string(),
636            description: "The neighbour's port identifier".to_string(),
637            required: true,
638        },
639        Parameter {
640            name: "port_id_subtype".to_string(),
641            type_hint: "string".to_string(),
642            description: "How to read port_id".to_string(),
643            required: true,
644        },
645        Parameter {
646            name: "ttl".to_string(),
647            type_hint: "integer".to_string(),
648            description: "Seconds the neighbour asks us to keep its entry; 0 means it is leaving"
649                .to_string(),
650            required: true,
651        },
652        Parameter {
653            name: "system_name".to_string(),
654            type_hint: "string".to_string(),
655            description: "The neighbour's hostname, if it sent one".to_string(),
656            required: false,
657        },
658        Parameter {
659            name: "system_description".to_string(),
660            type_hint: "string".to_string(),
661            description: "Vendor/model/firmware text, if it sent any".to_string(),
662            required: false,
663        },
664        Parameter {
665            name: "capabilities".to_string(),
666            type_hint: "array".to_string(),
667            description: "Capability names the neighbour supports".to_string(),
668            required: false,
669        },
670        Parameter {
671            name: "capabilities_enabled".to_string(),
672            type_hint: "array".to_string(),
673            description: "Capability names the neighbour has switched on".to_string(),
674            required: false,
675        },
676        Parameter {
677            name: "management_address".to_string(),
678            type_hint: "string".to_string(),
679            description: "Address the neighbour says it is managed on".to_string(),
680            required: false,
681        },
682    ])
683    .with_log_template(
684        LogTemplate::new()
685            .with_info("LLDP neighbour {system_name} ({chassis_id}) on port {port_id}")
686            .with_debug(
687                "LLDP advertisement from {source_mac}: chassis={chassis_id} port={port_id} \
688                 ttl={ttl}",
689            )
690            .with_trace("LLDP neighbour: {json_pretty(.)}"),
691    )
692    .with_actions(vec![
693        send_lldp_advertisement_action(),
694        no_advertisement_action(),
695    ])
696    .with_alternative_example(json!({
697        "type": "no_advertisement",
698        "reason": "Listening only; this link is not one we advertise on"
699    }))
700});
701
702/// The advertise timer fired: it is time to announce ourselves, unprompted.
703pub static LLDP_ADVERTISE_DUE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
704    EventType::new(
705        "lldp_advertise_due",
706        "The periodic advertise timer fired (advertise_interval_secs). This is how NetGet \
707         announces itself rather than only answering neighbours; nothing was received.",
708        json!({
709            "type": "send_lldp_advertisement",
710            "chassis_id": "02:00:00:00:00:01",
711            "chassis_id_subtype": "mac_address",
712            "port_id": "1/1",
713            "port_id_subtype": "interface_name",
714            "ttl": 120,
715            "system_name": "netget-lab",
716            "system_description": "NetGet LLDP agent",
717            "capabilities": ["bridge", "router"]
718        }),
719    )
720    .with_parameters(vec![
721        Parameter {
722            name: "interval_secs".to_string(),
723            type_hint: "integer".to_string(),
724            description: "The configured interval between these events".to_string(),
725            required: true,
726        },
727        Parameter {
728            name: "sequence".to_string(),
729            type_hint: "integer".to_string(),
730            description: "How many times the timer has fired since the server started".to_string(),
731            required: true,
732        },
733        Parameter {
734            name: "interface".to_string(),
735            type_hint: "string".to_string(),
736            description: "Interface the advertisement would be transmitted on".to_string(),
737            required: false,
738        },
739    ])
740    .with_log_template(
741        LogTemplate::new()
742            .with_info("LLDP advertise timer #{sequence}")
743            .with_debug("LLDP advertise_due: sequence={sequence} interval={interval_secs}s"),
744    )
745    .with_actions(vec![
746        send_lldp_advertisement_action(),
747        no_advertisement_action(),
748    ])
749    .with_alternative_example(json!({
750        "type": "no_advertisement",
751        "reason": "Nothing to announce yet"
752    }))
753});
754
755pub fn get_lldp_event_types() -> Vec<EventType> {
756    vec![
757        LLDP_NEIGHBOR_ADVERTISEMENT_EVENT.clone(),
758        LLDP_ADVERTISE_DUE_EVENT.clone(),
759    ]
760}