Skip to main content

netget/server/lldp/
mod.rs

1//! LLDP (IEEE 802.1AB) server — NetGet as a switch on somebody else's link.
2//!
3//! LLDP is a one-way announcement protocol: every agent periodically transmits a description of
4//! itself to the nearest-bridge group address `01:80:C2:00:00:0E` under EtherType `0x88CC`, and
5//! every agent records what it hears in a table an operator reads. There is no request, no
6//! response and no acknowledgement. What makes it worth driving from a model is that **the
7//! identity is authored, not derived**: chassis ID, system description and capability set are
8//! choices, and they are exactly what network reconnaissance reads off a link.
9//!
10//! # Two transports, one codec
11//!
12//! The frame format lives in [`codec`] and is a pure function over plain values. This module is
13//! the thin layer that moves those bytes:
14//!
15//! * **`transport: "raw"`** (the default) — libpcap capture and injection on a real interface,
16//!   filtered to `ether proto 0x88cc`. Needs root, `/dev/bpf*` access on macOS/BSD, or
17//!   `CAP_NET_RAW` on Linux. **This code has never been executed**; see `CLAUDE.md`.
18//! * **`transport: "udp"`** — a testing transport that carries complete Ethernet frames as UDP
19//!   datagram payloads, so the whole event → handler/LLM → frame path runs unprivileged. No
20//!   real neighbour speaks it. This is the same accommodation `ospf` makes for its raw socket,
21//!   made explicit as a declared parameter rather than left to the test file.
22//!
23//! # Failure is silence, and the log is where the difference lives
24//!
25//! LLDP is in the deliberately-silent class the root `CLAUDE.md` describes. Every frame the
26//! protocol defines is a *positive assertion* that a device with a given identity exists on this
27//! link, and a neighbour writes it straight into its topology table. There is no error frame to
28//! send, and fabricating an advertisement to signal "netget is broken" would put a device on the
29//! network map that does not exist. So an LLM failure transmits **nothing**, and no `WireFailure`
30//! text ever reaches the wire.
31//!
32//! On the wire all four outcomes are identical, so they are separated in the log by a
33//! `decision=` tag, the way `src/server/radius/` separates its cases:
34//!
35//! | Tag | Meaning |
36//! |---|---|
37//! | `decision=no_policy` | No instruction and no handler — nothing is advertised and no LLM call is made |
38//! | `decision=model_reject` | The model answered `no_advertisement` — a real decision |
39//! | `decision=model_silent` | The model returned nothing usable |
40//! | `decision=fail_closed_overloaded` | The call failed and the backend is saturated (retryable) |
41//! | `decision=fail_closed_llm_error` | The call failed otherwise |
42
43pub mod actions;
44pub mod codec;
45
46use anyhow::{anyhow, bail, Context, Result};
47use std::net::SocketAddr;
48use std::sync::{Arc, Mutex};
49use tokio::net::UdpSocket;
50use tokio::sync::mpsc;
51use tracing::{debug, error, info, trace, warn};
52
53use crate::llm::action_helper::call_llm;
54use crate::llm::ollama_client::OllamaClient;
55use crate::protocol::Event;
56use crate::server::connection::ConnectionId;
57use crate::state::app_state::AppState;
58use crate::{console_debug, console_error, console_info, console_trace};
59use actions::{
60    LldpProtocol, LLDP_ADVERTISEMENT_RESULT, LLDP_ADVERTISE_DUE_EVENT,
61    LLDP_NEIGHBOR_ADVERTISEMENT_EVENT, NO_ADVERTISEMENT_ACTION,
62};
63
64/// BPF expression restricting capture to LLDP. Anything wider hands every frame on the segment
65/// to the model, so a failure to compile it has to refuse the start rather than fall through.
66const LLDP_BPF_FILTER: &str = "ether proto 0x88cc";
67
68/// Which way frames travel.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum TransportKind {
71    /// Real Ethernet through libpcap. Privileged, and never executed in this repository.
72    Raw,
73    /// Whole Ethernet frames inside UDP datagrams. Testing only.
74    Udp,
75}
76
77/// Everything the startup parameters configure. Every field here is read.
78#[derive(Debug, Clone)]
79struct LldpConfig {
80    transport: TransportKind,
81    udp_peer: Option<SocketAddr>,
82    advertise_interval_secs: u64,
83    source_mac: Option<[u8; 6]>,
84}
85
86impl LldpConfig {
87    fn from_params(params: Option<&crate::protocol::StartupParams>) -> Result<Self> {
88        let Some(params) = params else {
89            return Ok(Self {
90                transport: TransportKind::Raw,
91                udp_peer: None,
92                advertise_interval_secs: 0,
93                source_mac: None,
94            });
95        };
96
97        let transport = match params.get_optional_string("transport")?.as_deref() {
98            None | Some("raw") => TransportKind::Raw,
99            Some("udp") => TransportKind::Udp,
100            Some(other) => bail!(
101                "transport must be \"raw\" (real Ethernet via libpcap) or \"udp\" (testing \
102                 transport carrying Ethernet frames in datagrams), got '{other}'"
103            ),
104        };
105
106        let udp_peer = match params.get_optional_string("udp_peer")? {
107            None => None,
108            Some(text) => {
109                if transport != TransportKind::Udp {
110                    bail!(
111                        "udp_peer was given but transport is \"raw\", where it would do nothing. \
112                         Set transport to \"udp\" or drop udp_peer."
113                    );
114                }
115                Some(text.parse::<SocketAddr>().with_context(|| {
116                    format!("udp_peer must be HOST:PORT (e.g. 127.0.0.1:34567), got '{text}'")
117                })?)
118            }
119        };
120
121        let advertise_interval_secs = match params.get_optional_i64("advertise_interval_secs")? {
122            None => 0,
123            Some(v) if (0..=86_400).contains(&v) => v as u64,
124            Some(v) => bail!(
125                "advertise_interval_secs must be between 0 (timer disabled) and 86400, got {v}"
126            ),
127        };
128
129        let source_mac = match params.get_optional_string("source_mac")? {
130            None => None,
131            Some(text) => Some(codec::parse_mac(&text)?),
132        };
133
134        Ok(Self {
135            transport,
136            udp_peer,
137            advertise_interval_secs,
138            source_mac,
139        })
140    }
141}
142
143/// Where a built frame goes.
144///
145/// Cloned into each per-frame task rather than shared behind an `Arc`: `std::sync::mpsc::Sender`
146/// is `Send` but its `Sync`ness is not something to rely on, and cloning a channel handle costs
147/// nothing.
148#[derive(Clone)]
149enum FrameSink {
150    /// Hand the frame to the blocking pcap injection thread.
151    Pcap(std::sync::mpsc::Sender<Vec<u8>>),
152    /// Send the frame as a UDP datagram, to the configured peer or to whoever spoke last.
153    Udp {
154        socket: Arc<UdpSocket>,
155        configured_peer: Option<SocketAddr>,
156        last_peer: Arc<Mutex<Option<SocketAddr>>>,
157    },
158}
159
160impl FrameSink {
161    async fn send(&self, frame: Vec<u8>) -> Result<usize> {
162        match self {
163            FrameSink::Pcap(tx) => {
164                let len = frame.len();
165                tx.send(frame)
166                    .map_err(|_| anyhow!("pcap injection thread has gone away"))?;
167                Ok(len)
168            }
169            FrameSink::Udp {
170                socket,
171                configured_peer,
172                last_peer,
173            } => {
174                // The guard is dropped before the await: never hold a lock across I/O.
175                let peer = match configured_peer {
176                    Some(p) => Some(*p),
177                    None => *last_peer.lock().expect("lldp peer mutex poisoned"),
178                };
179                let peer = peer.ok_or_else(|| {
180                    anyhow!(
181                        "no UDP peer to transmit to: nothing has been received yet and no \
182                         udp_peer startup parameter was given"
183                    )
184                })?;
185                Ok(socket.send_to(&frame, peer).await?)
186            }
187        }
188    }
189}
190
191/// The parts of the running server every per-frame task needs.
192struct LldpContext {
193    llm_client: OllamaClient,
194    app_state: Arc<AppState>,
195    status_tx: mpsc::UnboundedSender<String>,
196    protocol: Arc<LldpProtocol>,
197    server_id: crate::state::ServerId,
198    /// Ethernet source address used when an action names none. Also the address whose own
199    /// frames are ignored on receive, so an injected advertisement captured back off the wire
200    /// cannot trigger another one.
201    source_mac: [u8; 6],
202    interface: Option<String>,
203}
204
205pub struct LldpServer;
206
207impl LldpServer {
208    /// Start an LLDP agent.
209    ///
210    /// Returns `Err` — never a server left sitting in `Running` — when the capture handle cannot
211    /// be opened, the BPF filter cannot be compiled, the UDP socket cannot be bound, or a
212    /// startup parameter is unusable. ARP, DataLink and ICMP each shipped the fire-and-forget
213    /// version of this and were each fixed separately; IS-IS was missed three times.
214    pub async fn spawn_with_llm_actions(
215        interface: Option<String>,
216        listen_addr: SocketAddr,
217        llm_client: OllamaClient,
218        app_state: Arc<AppState>,
219        status_tx: mpsc::UnboundedSender<String>,
220        server_id: crate::state::ServerId,
221        startup_params: Option<crate::protocol::StartupParams>,
222    ) -> Result<SocketAddr> {
223        // Parameters first: an unusable one must fail before anything is bound or opened.
224        let config = LldpConfig::from_params(startup_params.as_ref())?;
225
226        match config.transport {
227            TransportKind::Raw => {
228                let interface = interface.ok_or_else(|| {
229                    anyhow!(
230                        "LLDP transport \"raw\" needs an interface to capture on (pass \
231                         `interface`), or use transport \"udp\" for an unprivileged test run"
232                    )
233                })?;
234                let source_mac = config
235                    .source_mac
236                    .or_else(|| interface_mac(&interface))
237                    .unwrap_or_else(|| {
238                        codec::parse_mac(actions::DEFAULT_SOURCE_MAC)
239                            .expect("DEFAULT_SOURCE_MAC is a literal MAC address")
240                    });
241                Self::spawn_raw(
242                    interface, config, source_mac, llm_client, app_state, status_tx, server_id,
243                )
244                .await
245            }
246            TransportKind::Udp => {
247                // No interface exists, so there is no address to inherit: the configured value
248                // or the locally-administered default is all there is.
249                let source_mac = config.source_mac.unwrap_or_else(|| {
250                    codec::parse_mac(actions::DEFAULT_SOURCE_MAC)
251                        .expect("DEFAULT_SOURCE_MAC is a literal MAC address")
252                });
253                Self::spawn_udp(
254                    listen_addr,
255                    interface,
256                    config,
257                    source_mac,
258                    llm_client,
259                    app_state,
260                    status_tx,
261                    server_id,
262                )
263                .await
264            }
265        }
266    }
267
268    /// The testing transport: whole Ethernet frames carried in UDP datagrams.
269    #[allow(clippy::too_many_arguments)]
270    async fn spawn_udp(
271        listen_addr: SocketAddr,
272        interface: Option<String>,
273        config: LldpConfig,
274        source_mac: [u8; 6],
275        llm_client: OllamaClient,
276        app_state: Arc<AppState>,
277        status_tx: mpsc::UnboundedSender<String>,
278        server_id: crate::state::ServerId,
279    ) -> Result<SocketAddr> {
280        // Awaited, so a bind failure is returned rather than logged from a detached task.
281        let socket = Arc::new(UdpSocket::bind(listen_addr).await.with_context(|| {
282            format!("failed to bind the LLDP UDP test transport to {listen_addr}")
283        })?);
284        let local_addr = socket.local_addr()?;
285
286        console_info!(
287            status_tx,
288            "LLDP listening on {} (transport=udp — a TEST transport carrying Ethernet frames in \
289             datagrams; no real LLDP neighbour speaks it)",
290            local_addr
291        );
292
293        let ctx = Arc::new(LldpContext {
294            llm_client,
295            app_state: app_state.clone(),
296            status_tx: status_tx.clone(),
297            protocol: Arc::new(LldpProtocol::new()),
298            server_id,
299            source_mac,
300            interface,
301        });
302
303        let sink = FrameSink::Udp {
304            socket: socket.clone(),
305            configured_peer: config.udp_peer,
306            last_peer: Arc::new(Mutex::new(None)),
307        };
308
309        let receive_ctx = ctx.clone();
310        let receive_sink = sink.clone();
311        let loop_state = app_state.clone();
312        let receive_handle = tokio::spawn(async move {
313            let mut buffer = vec![0u8; 65535];
314            loop {
315                match socket.recv_from(&mut buffer).await {
316                    Ok((n, peer)) => {
317                        if let FrameSink::Udp { last_peer, .. } = &receive_sink {
318                            *last_peer.lock().expect("lldp peer mutex poisoned") = Some(peer);
319                        }
320                        let frame = buffer[..n].to_vec();
321                        let ctx = receive_ctx.clone();
322                        let sink = receive_sink.clone();
323                        // Tracked, not detached: stop_server must abort this task too.
324                        let task_owner = loop_state.clone();
325                        task_owner
326                            .spawn_server_task(server_id, async move {
327                                Self::handle_frame(&frame, Some(peer), local_addr, ctx, sink).await;
328                            })
329                            .await;
330                    }
331                    Err(e) => {
332                        console_error!(receive_ctx.status_tx, "LLDP UDP receive error: {}", e);
333                        break;
334                    }
335                }
336            }
337        });
338
339        app_state
340            .register_server_task(server_id, receive_handle)
341            .await;
342
343        Self::spawn_advertise_timer(&config, ctx, sink, app_state, server_id).await;
344
345        Ok(local_addr)
346    }
347
348    /// The real transport: libpcap capture and injection on an Ethernet interface.
349    ///
350    /// **Never executed anywhere.** It compiles, and it is written to the same shape as `arp`
351    /// and `isis`, but no test in this repository has the privilege to open a capture handle.
352    #[allow(clippy::too_many_arguments)]
353    async fn spawn_raw(
354        interface: String,
355        config: LldpConfig,
356        source_mac: [u8; 6],
357        llm_client: OllamaClient,
358        app_state: Arc<AppState>,
359        status_tx: mpsc::UnboundedSender<String>,
360        server_id: crate::state::ServerId,
361    ) -> Result<SocketAddr> {
362        use pcap::{Capture, Device};
363
364        console_info!(
365            status_tx,
366            "LLDP starting capture on interface {} (source MAC {})",
367            interface,
368            codec::format_mac(&source_mac)
369        );
370
371        let ctx = Arc::new(LldpContext {
372            llm_client,
373            app_state: app_state.clone(),
374            status_tx: status_tx.clone(),
375            protocol: Arc::new(LldpProtocol::new()),
376            server_id,
377            source_mac,
378            interface: Some(interface.clone()),
379        });
380
381        // Opening the capture is the privileged step, so it must not be fire-and-forget: the
382        // outcome comes back over a oneshot and this function only returns Ok once the handle
383        // is genuinely open. A server reporting Running while capturing nothing is worse than
384        // one that refuses to start.
385        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
386
387        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
388        // capture loop is stopped cooperatively.
389        let stop = crate::utils::StopSignal::new();
390        let stop_in_loop = stop.clone();
391
392        let (packet_tx, packet_rx) = std::sync::mpsc::channel::<Vec<u8>>();
393        let sink = FrameSink::Pcap(packet_tx.clone());
394
395        let loop_interface = interface.clone();
396        let loop_ctx = ctx.clone();
397        let loop_sink = sink.clone();
398        tokio::task::spawn_blocking(move || {
399            let open = || -> Result<(Capture<pcap::Active>, Capture<pcap::Active>)> {
400                let device = Device::list()
401                    .context("failed to list network devices")?
402                    .into_iter()
403                    .find(|d| d.name == loop_interface)
404                    .ok_or_else(|| anyhow!("no such capture device '{}'", loop_interface))?;
405
406                let mut cap_rx = Capture::from_device(device.clone())
407                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
408                    .and_then(|c| c.open())
409                    .with_context(|| {
410                        format!(
411                            "failed to open pcap capture on '{}' (needs root, or read access to \
412                             /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
413                            loop_interface
414                        )
415                    })?;
416
417                // `ether proto` is an Ethernet-only keyword. On a link type with no Ethernet
418                // header — loopback (DLT_NULL/DLT_LOOP), tunnels, raw-IP devices — libpcap
419                // compiles it to "expression rejects all packets" and errors. That refusal is
420                // correct: LLDP cannot exist there. The message has to say so, because
421                // libpcap's own names the optimiser rather than the problem. Same trap `arp`
422                // documents.
423                cap_rx.filter(LLDP_BPF_FILTER, true).with_context(|| {
424                    format!(
425                        "failed to apply the '{LLDP_BPF_FILTER}' BPF filter on '{}'. LLDP is an \
426                         Ethernet-only protocol, so this fails on any interface with no Ethernet \
427                         link layer — loopback (lo/lo0), tunnels and raw-IP devices carry no LLDP \
428                         and libpcap rejects the filter outright. Point this server at a real \
429                         Ethernet interface, or use transport \"udp\" for an unprivileged test.",
430                        loop_interface
431                    )
432                })?;
433
434                let cap_tx = Capture::from_device(device)
435                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
436                    .and_then(|c| c.open())
437                    .with_context(|| {
438                        format!(
439                            "failed to open pcap injection handle on '{}'",
440                            loop_interface
441                        )
442                    })?;
443
444                Ok((cap_rx, cap_tx))
445            };
446
447            let (mut cap_rx, mut cap_tx) = match open() {
448                Ok(handles) => {
449                    let _ = ready_tx.send(Ok(()));
450                    handles
451                }
452                Err(e) => {
453                    console_error!(loop_ctx.status_tx, "LLDP capture startup failed: {:#}", e);
454                    let _ = ready_tx.send(Err(e));
455                    return;
456                }
457            };
458
459            // Injection runs on its own thread: `sendpacket` blocks, and the capture loop must
460            // not stall behind it.
461            std::thread::spawn(move || {
462                while let Ok(frame) = packet_rx.recv() {
463                    if let Err(e) = cap_tx.sendpacket(frame) {
464                        error!("LLDP failed to inject frame: {}", e);
465                    }
466                }
467            });
468
469            let runtime = tokio::runtime::Handle::current();
470
471            loop {
472                if stop_in_loop.is_stopped() {
473                    console_info!(
474                        loop_ctx.status_tx,
475                        "LLDP capture on {} stopping",
476                        loop_interface
477                    );
478                    break;
479                }
480                match cap_rx.next_packet() {
481                    Ok(packet) => {
482                        let frame = packet.data.to_vec();
483                        let ctx = loop_ctx.clone();
484                        let sink = loop_sink.clone();
485                        runtime.spawn(async move {
486                            let nowhere: SocketAddr = "0.0.0.0:0"
487                                .parse()
488                                .expect("0.0.0.0:0 is a valid socket address");
489                            Self::handle_frame(&frame, None, nowhere, ctx, sink).await;
490                        });
491                    }
492                    // The 1000ms read timeout is what bounds how long a stop takes to notice
493                    // on an idle interface.
494                    Err(pcap::Error::TimeoutExpired) => continue,
495                    Err(e) => {
496                        console_error!(loop_ctx.status_tx, "LLDP capture error: {}", e);
497                        break;
498                    }
499                }
500            }
501
502            // Dropping the last sender ends the injection thread, closing its handle with it.
503            drop(packet_tx);
504            drop(cap_rx);
505        });
506
507        match ready_rx.await {
508            Ok(Ok(())) => {}
509            Ok(Err(e)) => return Err(e),
510            Err(_) => {
511                return Err(anyhow!(
512                    "LLDP capture task on '{}' exited before signalling readiness",
513                    interface
514                ))
515            }
516        }
517
518        app_state
519            .register_server_task(server_id, stop.park_task())
520            .await;
521
522        console_info!(status_tx, "LLDP capture active on {}", interface);
523
524        Self::spawn_advertise_timer(&config, ctx, sink, app_state, server_id).await;
525
526        // Nothing is bound to an address: LLDP has no port. `server_startup` records this and
527        // the dashboard shows the interface instead.
528        Ok("0.0.0.0:0"
529            .parse()
530            .expect("0.0.0.0:0 is a valid socket address"))
531    }
532
533    /// Start the periodic `lldp_advertise_due` timer, if one was configured.
534    ///
535    /// Registered like every other task: a timer that outlives `stop_server` keeps advertising
536    /// an identity for a server the operator has closed. (BGP's keepalive did exactly that.)
537    async fn spawn_advertise_timer(
538        config: &LldpConfig,
539        ctx: Arc<LldpContext>,
540        sink: FrameSink,
541        app_state: Arc<AppState>,
542        server_id: crate::state::ServerId,
543    ) {
544        if config.advertise_interval_secs == 0 {
545            debug!("LLDP advertise timer disabled (advertise_interval_secs = 0)");
546            return;
547        }
548
549        let interval_secs = config.advertise_interval_secs;
550        let handle = tokio::spawn(async move {
551            let mut sequence: u64 = 0;
552            let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
553            // The first tick is immediate; consume it so the first advertisement happens one
554            // interval in rather than racing the server's own startup log.
555            ticker.tick().await;
556            loop {
557                ticker.tick().await;
558                sequence += 1;
559                let n = sequence;
560                let event = Event::new(
561                    &LLDP_ADVERTISE_DUE_EVENT,
562                    serde_json::json!({
563                        "interval_secs": interval_secs,
564                        "sequence": n,
565                        "interface": ctx.interface,
566                    }),
567                );
568                Self::dispatch_event(event, None, ctx.clone(), sink.clone()).await;
569            }
570        });
571
572        app_state.register_server_task(server_id, handle).await;
573    }
574
575    /// Decode one received frame and hand the neighbour to the model.
576    async fn handle_frame(
577        frame: &[u8],
578        peer: Option<SocketAddr>,
579        local_addr: SocketAddr,
580        ctx: Arc<LldpContext>,
581        sink: FrameSink,
582    ) {
583        let decoded = match codec::decode_frame(frame) {
584            Ok(decoded) => decoded,
585            Err(e) => {
586                // Not an error worth an operator's attention: on a real segment the capture
587                // filter already restricts this to 0x88CC, and on the UDP transport anyone can
588                // send anything.
589                debug!(
590                    "LLDP ignoring an undecodable frame ({} bytes): {}",
591                    frame.len(),
592                    e
593                );
594                return;
595            }
596        };
597
598        // Our own advertisement, captured back off the wire. Answering it would advertise
599        // again, and again.
600        if decoded.source_mac == ctx.source_mac {
601            trace!(
602                "LLDP ignoring our own frame from {}",
603                codec::format_mac(&decoded.source_mac)
604            );
605            return;
606        }
607
608        let source_mac = codec::format_mac(&decoded.source_mac);
609        let destination_mac = codec::format_mac(&decoded.destination_mac);
610
611        console_debug!(
612            ctx.status_tx,
613            "LLDP advertisement from {} ({} bytes)",
614            source_mac,
615            frame.len()
616        );
617        console_trace!(
618            ctx.status_tx,
619            "LLDP neighbour {}: chassis={} port={} ttl={}",
620            source_mac,
621            decoded.lldpdu.chassis_id,
622            decoded.lldpdu.port_id,
623            decoded.lldpdu.ttl
624        );
625
626        // Connectionless bookkeeping: one entry per neighbour heard from, which the 10-second
627        // idle sweep reaps (metadata declares `.connectionless()`).
628        let connection_id = ConnectionId::new(ctx.app_state.get_next_unified_id().await);
629        {
630            use crate::state::server::{
631                ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
632            };
633            let now = crate::utils::clock::Instant::now();
634            let nowhere: SocketAddr = "0.0.0.0:0"
635                .parse()
636                .expect("0.0.0.0:0 is a valid socket address");
637            let conn = ServerConnectionState {
638                id: connection_id,
639                remote_addr: peer.unwrap_or(nowhere),
640                local_addr,
641                bytes_sent: 0,
642                bytes_received: frame.len() as u64,
643                packets_sent: 0,
644                packets_received: 1,
645                last_activity: now,
646                status: ConnectionStatus::Active,
647                status_changed_at: now,
648                protocol_info: ProtocolConnectionInfo::empty(),
649            };
650            ctx.app_state
651                .add_connection_to_server(ctx.server_id, conn)
652                .await;
653            let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
654        }
655
656        let mut data = decoded.lldpdu.to_event_data();
657        data.insert("source_mac".into(), serde_json::json!(source_mac));
658        data.insert("destination_mac".into(), serde_json::json!(destination_mac));
659        data.insert(
660            "connection_id".into(),
661            serde_json::json!(connection_id.to_string()),
662        );
663
664        let event = Event::new(
665            &LLDP_NEIGHBOR_ADVERTISEMENT_EVENT,
666            serde_json::Value::Object(data),
667        );
668
669        Self::dispatch_event(event, Some(connection_id), ctx, sink).await;
670    }
671
672    /// Ask the operator's policy (handler, script or model) what to advertise, and transmit
673    /// whatever it chooses — or nothing, which is a complete answer here.
674    async fn dispatch_event(
675        event: Event,
676        connection_id: Option<ConnectionId>,
677        ctx: Arc<LldpContext>,
678        sink: FrameSink,
679    ) {
680        let event_id = event.event_type.id.clone();
681
682        // What to advertise is *policy*, and there is no advertisement that can be derived from
683        // a received one — an identity is invented, not computed. With no operator policy the
684        // spec-safe answer is to listen and say nothing, and to do that WITHOUT an LLM
685        // round-trip per captured frame. `arp` and `ospf` gate on the same condition for the
686        // same reason.
687        if !operator_wants_dynamic(&ctx.app_state, ctx.server_id, &event_id).await {
688            debug!(
689                "LLDP {} decision=no_policy: no instruction and no handler, nothing advertised \
690                 and no LLM call",
691                event_id
692            );
693            let _ = ctx.status_tx.send(format!(
694                "LLDP {} decision=no_policy: listening only (no instruction or handler)",
695                event_id
696            ));
697            return;
698        }
699
700        match call_llm(
701            &ctx.llm_client,
702            &ctx.app_state,
703            ctx.server_id,
704            connection_id,
705            &event,
706            ctx.protocol.as_ref(),
707        )
708        .await
709        {
710            Ok(result) => {
711                for message in &result.messages {
712                    info!("{}", message);
713                    let _ = ctx.status_tx.send(format!("[INFO] {}", message));
714                }
715
716                let mut sent = 0usize;
717                for protocol_result in &result.protocol_results {
718                    let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
719                        protocol_result
720                    else {
721                        continue;
722                    };
723                    if name != LLDP_ADVERTISEMENT_RESULT {
724                        continue;
725                    }
726
727                    let Some(action) = data.get("action") else {
728                        warn!("LLDP advertisement result carried no action payload");
729                        continue;
730                    };
731
732                    // Re-parsed here rather than carried as bytes because the *server* owns the
733                    // source address: the model names an identity, not a frame.
734                    let request = match codec::AdvertisementRequest::from_action(action) {
735                        Ok(request) => request,
736                        Err(e) => {
737                            console_error!(
738                                ctx.status_tx,
739                                "LLDP cannot build advertisement: {:#}",
740                                e
741                            );
742                            continue;
743                        }
744                    };
745                    let frame = match request.to_frame(ctx.source_mac) {
746                        Ok(frame) => frame,
747                        Err(e) => {
748                            console_error!(
749                                ctx.status_tx,
750                                "LLDP cannot encode advertisement: {:#}",
751                                e
752                            );
753                            continue;
754                        }
755                    };
756
757                    match sink.send(frame).await {
758                        Ok(n) => {
759                            sent += 1;
760                            console_debug!(
761                                ctx.status_tx,
762                                "LLDP advertised {} ({} on port {}, {} bytes)",
763                                request
764                                    .lldpdu
765                                    .system_name
766                                    .clone()
767                                    .unwrap_or_else(|| "unnamed".to_string()),
768                                request.lldpdu.chassis_id,
769                                request.lldpdu.port_id,
770                                n
771                            );
772                        }
773                        Err(e) => {
774                            console_error!(ctx.status_tx, "LLDP transmit failed: {:#}", e);
775                        }
776                    }
777                }
778
779                if sent == 0 {
780                    // Nothing went out. On the wire that is indistinguishable from a passive
781                    // listener, so the tag is the only place the difference survives.
782                    let rejected = result.raw_actions.iter().any(|a| {
783                        a.get("type").and_then(|t| t.as_str()) == Some(NO_ADVERTISEMENT_ACTION)
784                    });
785                    let decision = if rejected {
786                        "model_reject"
787                    } else {
788                        "model_silent"
789                    };
790                    info!(
791                        "LLDP {} decision={}: nothing advertised (LLDP has no error frame; \
792                         silence is the protocol-correct answer)",
793                        event_id, decision
794                    );
795                    let _ = ctx.status_tx.send(format!(
796                        "LLDP {} decision={}: nothing advertised",
797                        event_id, decision
798                    ));
799                }
800            }
801            Err(e) => {
802                // Fail closed, and closed for LLDP means silence. The only frame this server
803                // can emit asserts that a device with a given identity exists on this link, and
804                // that identity is exactly what the failed call was supposed to decide.
805                // Fabricating one writes a device that does not exist into a neighbour's
806                // topology table — far worse than the neighbour simply not hearing from us,
807                // which its own TTL already handles. Nothing derived from `e` reaches the wire.
808                let category = crate::utils::WireFailure::classify(&e);
809                let decision = if category.is_overloaded() {
810                    "fail_closed_overloaded"
811                } else {
812                    "fail_closed_llm_error"
813                };
814                error!(
815                    "LLDP {} decision={}: nothing advertised, LLDP has no failure frame ({}): {}",
816                    event_id,
817                    decision,
818                    category.text(),
819                    e
820                );
821                let _ = ctx.status_tx.send(format!(
822                    "✗ LLDP {} decision={}: nothing advertised ({}): {}",
823                    event_id,
824                    decision,
825                    category.text(),
826                    e
827                ));
828            }
829        }
830    }
831}
832
833/// The interface's own hardware address, where the platform will tell us.
834///
835/// `pnet::datalink` reads this the same way on every OS it supports, which is why it is used
836/// here rather than `/sys/class/net/<if>/address` — that is Linux-only and is why `isis` sends
837/// from a locally-administered placeholder everywhere else.
838fn interface_mac(interface: &str) -> Option<[u8; 6]> {
839    pnet::datalink::interfaces()
840        .into_iter()
841        .find(|i| i.name == interface)
842        .and_then(|i| i.mac)
843        .map(|mac| [mac.0, mac.1, mac.2, mac.3, mac.4, mac.5])
844}
845
846/// True when the operator opted into dynamic behaviour: a non-empty server instruction, or an
847/// event handler configured for this event. False means the static default applies — for LLDP,
848/// advertise nothing, because with no configured identity there is nothing honest to claim.
849async fn operator_wants_dynamic(
850    state: &AppState,
851    server_id: crate::state::ServerId,
852    event_id: &str,
853) -> bool {
854    state
855        .with_server_mut(server_id, |server| {
856            let has_instruction = !server.instruction.trim().is_empty();
857            let has_handler = server
858                .event_handler_config
859                .as_ref()
860                .map(|c| c.find_handler(event_id).is_some())
861                .unwrap_or(false);
862            has_instruction || has_handler
863        })
864        .await
865        .unwrap_or(false)
866}