1pub 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
64const LLDP_BPF_FILTER: &str = "ether proto 0x88cc";
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum TransportKind {
71 Raw,
73 Udp,
75}
76
77#[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#[derive(Clone)]
149enum FrameSink {
150 Pcap(std::sync::mpsc::Sender<Vec<u8>>),
152 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 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
191struct 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 source_mac: [u8; 6],
202 interface: Option<String>,
203}
204
205pub struct LldpServer;
206
207impl LldpServer {
208 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 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 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 #[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 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 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 #[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 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
386
387 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 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 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 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 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 Ok("0.0.0.0:0"
529 .parse()
530 .expect("0.0.0.0:0 is a valid socket address"))
531 }
532
533 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 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 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 debug!(
590 "LLDP ignoring an undecodable frame ({} bytes): {}",
591 frame.len(),
592 e
593 );
594 return;
595 }
596 };
597
598 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 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 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 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 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 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 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
833fn 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
846async 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}