Skip to main content

netget/server/cdp/
mod.rs

1//! CDP (Cisco Discovery Protocol) server — NetGet impersonating a Cisco device to its neighbours.
2//!
3//! CDP is the classic Layer-2 reconnaissance leak: a switch announces its hostname, hardware
4//! model, IOS version, port name and **native VLAN** to anyone on the wire, unsolicited, every
5//! 60 seconds. Here the *model* authors all of it, so what a neighbour records about "the switch
6//! it is plugged into" is whatever the LLM decides to claim.
7//!
8//! # Structure
9//!
10//! * [`codec`] is a **pure** module: TLVs, the 802.3 + LLC/SNAP header and the CDP checksum, with
11//!   no I/O of any kind. That is where the protocol can actually be wrong, and it is
12//!   exhaustively tested against literal specification bytes and real captures.
13//! * This file is the transport, and it is deliberately thin. It has two implementations:
14//!   - **raw** — libpcap capture and injection on a named interface. Needs packet-capture
15//!     privilege (root, `/dev/bpf*` access on macOS/BSD, or `CAP_NET_RAW` on Linux). **Never
16//!     executed by any test.**
17//!   - **udp** — one complete 802.3 frame per datagram, replying to the datagram's sender.
18//!     Needs no privilege, and exists so the whole event → LLM → action → frame path runs in the
19//!     test suite. Selected with the `transport` startup parameter, the same way `ospf` is
20//!     driven over UDP in tests.
21//!
22//! # Silence on failure
23//!
24//! CDP is in the **deliberately-silent** class (root `CLAUDE.md`). Every frame the protocol
25//! defines is a positive assertion — "a device with this identity exists on this link" — which
26//! the neighbour caches and an operator reads back. There is no CDP error or NAK frame, so on an
27//! LLM failure a fabricated advertisement would be strictly worse than nothing: it poisons a
28//! neighbour table with a device that does not exist. The wire therefore carries nothing, no
29//! `WireFailure` text is ever interpolated into a frame, and the distinction survives only in the
30//! log, in the same shape `src/server/radius/` uses. The six tokens this file actually emits,
31//! and the whole set — grep them, do not guess, because a token that does not exist is worse
32//! than none:
33//!
34//! `decision=passive_no_policy` · `model_reject` · `model_silent` · `model_invalid_action` ·
35//! `fail_closed_llm_error_overloaded` · `fail_closed_llm_error_unavailable`
36//!
37//! Note the last two: there is no bare `fail_closed_llm_error`. The `WireFailure` category is
38//! folded into the token so that "the backend is saturated, retry" and "the backend is broken"
39//! stay greppable apart, which is the one thing an operator wants from a log line that says
40//! nothing went on the wire.
41
42pub mod actions;
43pub mod codec;
44
45use anyhow::{anyhow, Context, Result};
46use std::net::SocketAddr;
47use std::sync::Arc;
48use tokio::net::UdpSocket;
49use tokio::sync::mpsc;
50use tracing::{debug, error, info, trace, warn};
51
52use crate::llm::action_helper::call_llm;
53use crate::llm::ollama_client::OllamaClient;
54use crate::logging::emit::Log;
55use crate::protocol::Event;
56use crate::server::connection::ConnectionId;
57use crate::state::app_state::AppState;
58
59use actions::{CdpProtocol, CdpTransport, CDP_ACTION_RESULT, CDP_NEIGHBOR_ADVERTISEMENT_EVENT};
60use codec::{CdpAdvertisement, FrameHeader};
61
62/// The source MAC used by the UDP test transport when none is configured.
63///
64/// Locally administered (bit 1 of the first octet set) so it can never collide with a real
65/// Cisco OUI, while still being obviously CDP-ish to anyone reading a capture.
66const DEFAULT_UDP_SOURCE_MAC: [u8; 6] = [0x02, 0x00, 0x0c, 0xcc, 0xcc, 0x01];
67
68/// BPF filter for CDP: multicast destination, SNAP encapsulation, Cisco OUI, protocol 0x2000.
69///
70/// Offsets are into the frame: `[14:2]` is DSAP+SSAP, `[16:1]` the LLC control byte, `[17:3]`
71/// cannot be expressed in one term so the OUI is split, and `[20:2]` is the SNAP protocol id.
72/// Without a filter the capture hands *every* frame on the segment to the LLM, so a compile
73/// failure here has to refuse the start rather than fall through — the same reasoning
74/// `src/server/arp/mod.rs` records.
75const CDP_BPF_FILTER: &str = "ether dst 01:00:0c:cc:cc:cc and ether[14:2] = 0xaaaa \
76                              and ether[16:1] = 0x03 and ether[17:2] = 0x0000 \
77                              and ether[19:1] = 0x0c and ether[20:2] = 0x2000";
78
79/// Where an outgoing frame goes.
80#[derive(Clone)]
81enum CdpSink {
82    /// A live pcap handle, shared with the capture loop.
83    #[cfg(all(unix, feature = "libpcap"))]
84    Raw(Arc<std::sync::Mutex<pcap::Capture<pcap::Active>>>),
85    /// The bound UDP socket, plus the peer this frame is answering.
86    Udp(Arc<UdpSocket>, SocketAddr),
87}
88
89impl CdpSink {
90    async fn send(&self, frame: &[u8]) -> Result<()> {
91        match self {
92            #[cfg(all(unix, feature = "libpcap"))]
93            CdpSink::Raw(cap) => {
94                let mut guard = cap.lock().unwrap_or_else(|e| e.into_inner());
95                guard
96                    .sendpacket(frame)
97                    .context("pcap injection of a CDP frame failed")
98            }
99            CdpSink::Udp(socket, peer) => {
100                socket
101                    .send_to(frame, peer)
102                    .await
103                    .with_context(|| format!("sending a CDP frame to {} failed", peer))?;
104                Ok(())
105            }
106        }
107    }
108
109    fn describe(&self) -> String {
110        match self {
111            #[cfg(all(unix, feature = "libpcap"))]
112            CdpSink::Raw(_) => "pcap".to_string(),
113            CdpSink::Udp(_, peer) => format!("udp:{}", peer),
114        }
115    }
116}
117
118/// Everything the frame handler needs, so the two transports share one code path.
119struct CdpContext {
120    llm_client: OllamaClient,
121    app_state: Arc<AppState>,
122    status_tx: mpsc::UnboundedSender<String>,
123    protocol: Arc<CdpProtocol>,
124    server_id: crate::state::ServerId,
125    source_mac: [u8; 6],
126}
127
128pub struct CdpServer;
129
130impl CdpServer {
131    /// Start a CDP server.
132    ///
133    /// Returns the bound UDP address on the `udp` transport, and an unbound placeholder on the
134    /// `raw` transport (which owns no socket). Either way this only returns `Ok` once the
135    /// transport is genuinely live: the pcap handle is opened on a blocking thread and its
136    /// outcome is handed back over a oneshot, so a failure surfaces as `ServerStatus::Error`
137    /// rather than as a server that reports `Running` while capturing nothing — the defect root
138    /// `CLAUDE.md` records for ARP, DataLink and ICMP.
139    pub async fn spawn_with_llm_actions(ctx: crate::protocol::SpawnContext) -> Result<SocketAddr> {
140        let crate::protocol::SpawnContext {
141            interface,
142            host,
143            port,
144            mac_address,
145            llm_client,
146            state: app_state,
147            status_tx,
148            server_id,
149            startup_params,
150            ..
151        } = ctx;
152
153        // --- startup parameters -------------------------------------------------------------
154        //
155        // Both declared parameters are read here. `?` propagates a `StartupParamError` so an
156        // undeclared key or a wrong-typed value becomes a clean error naming the key; it must
157        // never be unwrapped (root CLAUDE.md, "Startup parameters").
158        let mut transport = CdpTransport::Raw;
159        let mut configured_mac: Option<[u8; 6]> = None;
160
161        if let Some(ref params) = startup_params {
162            if let Some(value) = params.get_optional_string("transport")? {
163                transport = CdpTransport::parse(&value)?;
164            }
165            if let Some(value) = params.get_optional_string("source_mac")? {
166                configured_mac = Some(codec::parse_mac(&value)?);
167            }
168        }
169        // The flexible-binding `mac_address` field means the same thing; an explicit
170        // `source_mac` startup parameter wins because it is the more specific request.
171        if configured_mac.is_none() {
172            if let Some(ref value) = mac_address {
173                configured_mac = Some(codec::parse_mac(value)?);
174            }
175        }
176
177        let protocol = Arc::new(CdpProtocol::new());
178
179        match transport {
180            CdpTransport::Udp => {
181                Self::spawn_udp(
182                    host.unwrap_or_else(|| "127.0.0.1".to_string()),
183                    port.unwrap_or(0),
184                    configured_mac.unwrap_or(DEFAULT_UDP_SOURCE_MAC),
185                    llm_client,
186                    app_state,
187                    status_tx,
188                    server_id,
189                    protocol,
190                )
191                .await
192            }
193            CdpTransport::Raw => {
194                let interface = interface.context(
195                    "CDP requires a network interface: pass `interface` (e.g. \"en0\"), or use \
196                     the `transport: \"udp\"` startup parameter for the unprivileged test \
197                     transport",
198                )?;
199                Self::spawn_raw(
200                    interface,
201                    configured_mac,
202                    llm_client,
203                    app_state,
204                    status_tx,
205                    server_id,
206                    protocol,
207                )
208                .await
209            }
210        }
211    }
212
213    // =============================================================================================
214    // UDP test transport
215    // =============================================================================================
216
217    /// One complete 802.3 CDP frame per datagram; replies go back to the sender.
218    ///
219    /// This is not a wire-compatible CDP transport and does not pretend to be — real CDP has no
220    /// UDP encapsulation. It exists because the raw transport cannot run without packet-capture
221    /// privilege, and everything above the framing (the event, the routing table, the LLM call,
222    /// the action executor, the codec) is transport-independent and worth testing. `ospf`
223    /// establishes the precedent of driving a privileged protocol over UDP in tests; this makes
224    /// it an explicit, declared mode rather than a test that quietly runs a different protocol.
225    #[allow(clippy::too_many_arguments)]
226    async fn spawn_udp(
227        host: String,
228        port: u16,
229        source_mac: [u8; 6],
230        llm_client: OllamaClient,
231        app_state: Arc<AppState>,
232        status_tx: mpsc::UnboundedSender<String>,
233        server_id: crate::state::ServerId,
234        protocol: Arc<CdpProtocol>,
235    ) -> Result<SocketAddr> {
236        let bind_addr = format!("{}:{}", host, port);
237        let socket = UdpSocket::bind(&bind_addr)
238            .await
239            .with_context(|| format!("CDP (udp transport) could not bind {}", bind_addr))?;
240        let local_addr = socket
241            .local_addr()
242            .context("CDP (udp transport) bound socket has no local address")?;
243        let socket = Arc::new(socket);
244
245        Log::new(Some(&status_tx)).info(format!(
246            "CDP listening on {} (udp test transport, source MAC {})",
247            local_addr,
248            codec::mac_to_string(&source_mac)
249        ));
250
251        let cdp_ctx = Arc::new(CdpContext {
252            llm_client,
253            app_state: app_state.clone(),
254            status_tx: status_tx.clone(),
255            protocol,
256            server_id,
257            source_mac,
258        });
259
260        let recv_socket = socket.clone();
261        let loop_state = app_state.clone();
262        let handle = tokio::spawn(async move {
263            let mut buffer = vec![0u8; 65535];
264            loop {
265                match recv_socket.recv_from(&mut buffer).await {
266                    Ok((n, peer)) => {
267                        let frame = buffer[..n].to_vec();
268                        let sink = CdpSink::Udp(recv_socket.clone(), peer);
269                        let cdp_ctx = cdp_ctx.clone();
270                        // Tracked, not detached: stop_server must abort this task too.
271                        let task_owner = loop_state.clone();
272                        task_owner
273                            .spawn_server_task(server_id, async move {
274                                if let Err(e) = Self::handle_frame(&frame, sink, cdp_ctx).await {
275                                    debug!("CDP frame from {} ignored: {:#}", peer, e);
276                                }
277                            })
278                            .await;
279                    }
280                    Err(e) => {
281                        error!("CDP (udp transport) receive error: {}", e);
282                        break;
283                    }
284                }
285            }
286            warn!("CDP (udp transport) receive loop terminated");
287        });
288
289        // Required for `stop_server` to actually release the socket.
290        app_state.register_server_task(server_id, handle).await;
291
292        Ok(local_addr)
293    }
294
295    // =============================================================================================
296    // Raw 802.3 transport
297    // =============================================================================================
298
299    /// libpcap capture and injection on a real interface. **Never executed by any test.**
300    #[allow(clippy::too_many_arguments)]
301    async fn spawn_raw(
302        interface: String,
303        configured_mac: Option<[u8; 6]>,
304        llm_client: OllamaClient,
305        app_state: Arc<AppState>,
306        status_tx: mpsc::UnboundedSender<String>,
307        server_id: crate::state::ServerId,
308        protocol: Arc<CdpProtocol>,
309    ) -> Result<SocketAddr> {
310        Log::new(Some(&status_tx)).info(format!("CDP capture starting on interface {}", interface));
311
312        // Opening the pcap handle is the step that needs privilege, so it must not be
313        // fire-and-forget: the outcome comes back over this oneshot and `spawn` only returns Ok
314        // once the capture is genuinely live.
315        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
316
317        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
318        // capture loop is stopped cooperatively through a flag the registered task trips when
319        // it is aborted. Same mechanism as `arp` and `isis`.
320        let stop = crate::utils::StopSignal::new();
321        let stop_in_loop = stop.clone();
322
323        let status_tx_ready = status_tx.clone();
324        let app_state_reg = app_state.clone();
325        let interface_for_thread = interface.clone();
326
327        tokio::task::spawn_blocking(move || {
328            let open = || -> Result<(pcap::Capture<pcap::Active>, [u8; 6])> {
329                let device = pcap::Device::list()
330                    .context("failed to list capture devices")?
331                    .into_iter()
332                    .find(|d| d.name == interface_for_thread)
333                    .ok_or_else(|| anyhow!("no such capture device '{}'", interface_for_thread))?;
334
335                let local_mac = match configured_mac {
336                    Some(mac) => mac,
337                    None => Self::interface_mac(&interface_for_thread).unwrap_or_else(|| {
338                        warn!(
339                            "CDP could not determine the MAC of '{}'; using {}",
340                            interface_for_thread,
341                            codec::mac_to_string(&DEFAULT_UDP_SOURCE_MAC)
342                        );
343                        DEFAULT_UDP_SOURCE_MAC
344                    }),
345                };
346
347                let mut cap = pcap::Capture::from_device(device)
348                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
349                    .and_then(|c| c.open())
350                    .with_context(|| {
351                        format!(
352                            "failed to open pcap capture on '{}' (needs root, or read access to \
353                             /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
354                            interface_for_thread
355                        )
356                    })?;
357
358                cap.filter(CDP_BPF_FILTER, true).with_context(|| {
359                    format!(
360                        "failed to apply the CDP BPF filter on '{}'. CDP rides 802.3 with an \
361                         LLC/SNAP header, so this filter cannot compile on an interface with no \
362                         Ethernet link layer — loopback (lo/lo0), tunnels and raw-IP devices \
363                         carry no CDP. Point this server at a real Ethernet or Wi-Fi interface.",
364                        interface_for_thread
365                    )
366                })?;
367
368                Ok((cap, local_mac))
369            };
370
371            let (cap, local_mac) = match open() {
372                Ok(v) => {
373                    let _ = ready_tx.send(Ok(()));
374                    v
375                }
376                Err(e) => {
377                    Log::new(Some(&status_tx))
378                        .error(format!("CDP capture startup failed: {:#}", e));
379                    let _ = ready_tx.send(Err(e));
380                    return;
381                }
382            };
383
384            info!(
385                "CDP capture live on {} as {}",
386                interface_for_thread,
387                codec::mac_to_string(&local_mac)
388            );
389
390            let cdp_ctx = Arc::new(CdpContext {
391                llm_client,
392                app_state,
393                status_tx: status_tx.clone(),
394                protocol,
395                server_id,
396                source_mac: local_mac,
397            });
398
399            let runtime = tokio::runtime::Handle::current();
400            let cap = Arc::new(std::sync::Mutex::new(cap));
401
402            loop {
403                if stop_in_loop.is_stopped() {
404                    Log::new(Some(&status_tx))
405                        .info(format!("CDP capture on {} stopping", interface_for_thread));
406                    break;
407                }
408                let mut guard = cap.lock().unwrap_or_else(|e| e.into_inner());
409                match guard.next_packet() {
410                    Ok(packet) => {
411                        let frame = packet.data.to_vec();
412                        drop(guard);
413
414                        let sink = CdpSink::Raw(cap.clone());
415                        let cdp_ctx = cdp_ctx.clone();
416                        runtime.spawn(async move {
417                            if let Err(e) = Self::handle_frame(&frame, sink, cdp_ctx).await {
418                                debug!("CDP frame ignored: {:#}", e);
419                            }
420                        });
421                    }
422                    Err(pcap::Error::TimeoutExpired) => {
423                        drop(guard);
424                        continue;
425                    }
426                    Err(e) => {
427                        drop(guard);
428                        Log::new(Some(&status_tx)).error(format!("CDP capture error: {}", e));
429                        break;
430                    }
431                }
432            }
433
434            // Shared with in-flight injection tasks; the handle closes with the last of them.
435            drop(cap);
436        });
437
438        match ready_rx.await {
439            Ok(Ok(())) => {}
440            Ok(Err(e)) => return Err(e),
441            Err(_) => {
442                return Err(anyhow!(
443                    "CDP capture task on '{}' exited before signalling readiness",
444                    interface
445                ))
446            }
447        }
448
449        // Only now that the capture is live: `stop_server` aborts this parked task, which trips
450        // `stop` and ends the blocking loop above.
451        app_state_reg
452            .register_server_task(server_id, stop.park_task())
453            .await;
454
455        Log::new(Some(&status_tx_ready)).info(format!("CDP capture active on {}", interface));
456
457        // No socket is bound. `is_bound_addr` treats this as "no listening socket".
458        Ok("0.0.0.0:0".parse().expect("literal address parses"))
459    }
460
461    /// This interface's own MAC, via pnet's cross-platform interface list.
462    ///
463    /// `pnet` is already a dependency of this feature, and using it avoids the Linux-only
464    /// `/sys/class/net` read `isis` falls back on (which returns an error on macOS, the platform
465    /// this protocol was written on).
466    fn interface_mac(name: &str) -> Option<[u8; 6]> {
467        pnet::datalink::interfaces()
468            .into_iter()
469            .find(|i| i.name == name)
470            .and_then(|i| i.mac)
471            .map(|m| [m.0, m.1, m.2, m.3, m.4, m.5])
472    }
473
474    // =============================================================================================
475    // Shared frame handling
476    // =============================================================================================
477
478    /// Decode one frame, raise the event, and put whatever the model decides on the wire.
479    async fn handle_frame(frame: &[u8], sink: CdpSink, ctx: Arc<CdpContext>) -> Result<()> {
480        let (header, payload) = codec::decode_frame(frame)?;
481        let decoded = codec::decode_payload(payload)?;
482
483        // Never advertise at our own reflection: the UDP transport echoes to a peer that may be
484        // another netget, and on a real segment our own injected frame can come back through the
485        // capture handle. Either way, answering it is an infinite loop.
486        if header.source_mac == ctx.source_mac {
487            trace!("CDP ignoring a frame from our own source MAC");
488            return Ok(());
489        }
490
491        let connection_id = ConnectionId::new(ctx.app_state.get_next_unified_id().await);
492
493        Self::register_connection(&ctx, connection_id, &header, frame.len()).await;
494
495        let ad = &decoded.advertisement;
496        Log::new(Some(&ctx.status_tx)).info(format!(
497            "CDP advertisement from {} ({}) via {}",
498            ad.device_id.as_deref().unwrap_or("<no device id>"),
499            ad.platform.as_deref().unwrap_or("unknown platform"),
500            codec::mac_to_string(&header.source_mac),
501        ));
502        if !decoded.checksum_valid() {
503            Log::new(Some(&ctx.status_tx)).warn(format!(
504                "CDP advertisement from {} carries checksum 0x{:04x} but computes 0x{:04x}",
505                codec::mac_to_string(&header.source_mac),
506                decoded.declared_checksum,
507                decoded.computed_checksum
508            ));
509        }
510
511        let event = Event::new(
512            &CDP_NEIGHBOR_ADVERTISEMENT_EVENT,
513            decoded.to_event_data(&header, &connection_id.to_string()),
514        );
515
516        // Whether to announce ourselves to a neighbour at all is a policy decision, not
517        // something the received frame determines — and a busy segment produces one
518        // advertisement per neighbour per 60s, so consulting the model with no policy
519        // configured would burn a round-trip per frame to decide nothing. With no operator
520        // instruction and no handler for this event, observe passively. Same gate as `isis` and
521        // `ospf`, and the same reasoning.
522        if !operator_wants_dynamic(&ctx.app_state, ctx.server_id, &event.event_type.id).await {
523            debug!(
524                "CDP {} from {} decision=passive_no_policy (no instruction and no handler; no \
525                 advertisement and no LLM call)",
526                event.event_type.id,
527                codec::mac_to_string(&header.source_mac)
528            );
529            Log::new(Some(&ctx.status_tx)).debug(
530                "CDP advertisement observed passively: no operator policy configured, no reply"
531                    .to_string(),
532            );
533            return Ok(());
534        }
535
536        Self::dispatch(event, connection_id, sink, header, ctx).await
537    }
538
539    /// Record the neighbour as a connection so the dashboard rail shows it.
540    async fn register_connection(
541        ctx: &CdpContext,
542        connection_id: ConnectionId,
543        header: &FrameHeader,
544        bytes: usize,
545    ) {
546        use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
547
548        let now = crate::utils::clock::Instant::now();
549        // A MAC is not a socket address and `ConnectionState` has nowhere else to put one, so
550        // the readable form goes in `protocol_info` and the address field is the unspecified
551        // one. `isis` stuffs a fabricated address into the field and it renders as nonsense.
552        let conn = ConnectionState {
553            id: connection_id,
554            remote_addr: "0.0.0.0:0".parse().expect("literal address parses"),
555            local_addr: "0.0.0.0:0".parse().expect("literal address parses"),
556            bytes_sent: 0,
557            bytes_received: bytes as u64,
558            packets_sent: 0,
559            packets_received: 1,
560            last_activity: now,
561            status: ConnectionStatus::Active,
562            status_changed_at: now,
563            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
564                "source_mac": codec::mac_to_string(&header.source_mac),
565                "destination_mac": codec::mac_to_string(&header.destination_mac),
566            })),
567        };
568        ctx.app_state
569            .add_connection_to_server(ctx.server_id, conn)
570            .await;
571        let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
572    }
573
574    /// Ask the model what to say back, and say exactly that or nothing at all.
575    async fn dispatch(
576        event: Event,
577        connection_id: ConnectionId,
578        sink: CdpSink,
579        header: FrameHeader,
580        ctx: Arc<CdpContext>,
581    ) -> Result<()> {
582        let event_id = event.event_type.id.clone();
583        let peer = codec::mac_to_string(&header.source_mac);
584
585        let result = call_llm(
586            &ctx.llm_client,
587            &ctx.app_state,
588            ctx.server_id,
589            Some(connection_id),
590            &event,
591            ctx.protocol.as_ref(),
592        )
593        .await;
594
595        let execution = match result {
596            Ok(execution) => execution,
597            Err(e) => {
598                // Nothing goes on the wire. CDP has no error frame, and its only frame type
599                // asserts that a device exists — so answering a backend outage with an
600                // advertisement would turn "netget is broken" into "this switch is real",
601                // written into the neighbour's table for `ttl` seconds. The peer simply ages us
602                // out, which is the correct outcome. Nothing derived from `e` is ever
603                // interpolated into a frame; the operator gets the whole error here.
604                let category = crate::utils::WireFailure::classify(&e);
605                let decision = if category.is_overloaded() {
606                    "fail_closed_llm_error_overloaded"
607                } else {
608                    "fail_closed_llm_error_unavailable"
609                };
610                error!(
611                    "CDP {} from {} decision={} ({}) — no advertisement emitted; CDP has no \
612                     error frame and a fabricated one would poison the neighbour's table. \
613                     LLM error: {}",
614                    event_id,
615                    peer,
616                    decision,
617                    category.text(),
618                    e
619                );
620                Log::new(Some(&ctx.status_tx)).error(format!(
621                    "✗ CDP LLM error (decision={}, no advertisement emitted): {}",
622                    decision, e
623                ));
624                return Ok(());
625            }
626        };
627
628        for message in &execution.messages {
629            Log::new(Some(&ctx.status_tx)).info(message.to_string());
630        }
631
632        let mut frames_sent = 0usize;
633        let mut explicit_refusal: Option<String> = None;
634
635        for protocol_result in &execution.protocol_results {
636            let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
637                protocol_result
638            else {
639                continue;
640            };
641            if name != CDP_ACTION_RESULT {
642                continue;
643            }
644
645            match data.get("type").and_then(|v| v.as_str()) {
646                Some("no_advertisement") => {
647                    explicit_refusal = Some(
648                        data.get("reason")
649                            .and_then(|v| v.as_str())
650                            .unwrap_or("no reason given")
651                            .to_string(),
652                    );
653                }
654                Some("send_cdp_advertisement") => {
655                    let advertisement = match CdpAdvertisement::from_action(data) {
656                        Ok(ad) => ad,
657                        Err(e) => {
658                            // A malformed answer is a failure to answer, not a licence to
659                            // invent a device. Silent on the wire, loud in the log.
660                            error!(
661                                "CDP {} from {} decision=model_invalid_action — no \
662                                 advertisement emitted: {:#}",
663                                event_id, peer, e
664                            );
665                            Log::new(Some(&ctx.status_tx))
666                                .error(format!("✗ CDP advertisement rejected: {:#}", e));
667                            continue;
668                        }
669                    };
670
671                    let payload = match codec::encode_payload(&advertisement) {
672                        Ok(p) => p,
673                        Err(e) => {
674                            error!("CDP payload encode failed: {:#}", e);
675                            continue;
676                        }
677                    };
678                    let frame = match codec::encode_frame(ctx.source_mac, &payload) {
679                        Ok(f) => f,
680                        Err(e) => {
681                            error!("CDP frame encode failed: {:#}", e);
682                            continue;
683                        }
684                    };
685
686                    match sink.send(&frame).await {
687                        Ok(()) => {
688                            frames_sent += 1;
689                            ctx.app_state
690                                .update_connection_stats(
691                                    ctx.server_id,
692                                    connection_id,
693                                    None,
694                                    Some(frame.len() as u64),
695                                    None,
696                                    Some(1),
697                                )
698                                .await;
699                            Log::new(Some(&ctx.status_tx)).info(format!(
700                                "→ CDP advertisement sent via {} ({} bytes): {}",
701                                sink.describe(),
702                                frame.len(),
703                                advertisement.summary()
704                            ));
705                            trace!("CDP sent (hex): {}", hex::encode(&frame));
706                        }
707                        Err(e) => {
708                            Log::new(Some(&ctx.status_tx))
709                                .error(format!("CDP send failed: {:#}", e));
710                        }
711                    }
712                }
713                other => {
714                    warn!("CDP ignoring unexpected action type {:?}", other);
715                }
716            }
717        }
718
719        if frames_sent == 0 {
720            // The wire looks identical in all three cases — CDP has no way to express any of
721            // them — so the decision tag in the log is the only place the difference survives.
722            // Exactly the discipline `src/server/radius/` uses for its fail-closed path.
723            let decision = match &explicit_refusal {
724                Some(_) => "model_reject",
725                None => "model_silent",
726            };
727            info!(
728                "CDP {} from {} decision={} — no advertisement emitted{}",
729                event_id,
730                peer,
731                decision,
732                explicit_refusal
733                    .as_ref()
734                    .map(|r| format!(" (reason: {})", r))
735                    .unwrap_or_default()
736            );
737            Log::new(Some(&ctx.status_tx)).info(format!(
738                "CDP answered with no advertisement: decision={}",
739                decision
740            ));
741        }
742
743        Ok(())
744    }
745}
746
747/// True when the operator opted into dynamic behaviour: a non-empty server instruction, or an
748/// event handler configured for this event.
749///
750/// With neither, the server is a passive listener and never consults the model — see the
751/// reasoning at the call site.
752async fn operator_wants_dynamic(
753    state: &AppState,
754    server_id: crate::state::ServerId,
755    event_id: &str,
756) -> bool {
757    state
758        .with_server_mut(server_id, |server| {
759            let has_instruction = !server.instruction.trim().is_empty();
760            let has_handler = server
761                .event_handler_config
762                .as_ref()
763                .map(|c| c.find_handler(event_id).is_some())
764                .unwrap_or(false);
765            has_instruction || has_handler
766        })
767        .await
768        .unwrap_or(false)
769}