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}