Skip to main content

netget/server/isis/
mod.rs

1//! IS-IS (Intermediate System to Intermediate System) routing protocol server
2//!
3//! Implementation of ISO/IEC 10589 and RFC 1195 (IS-IS over IP).
4//! Operates at Layer 2 using raw sockets/pcap for true IS-IS protocol operation.
5//! The LLM controls routing decisions, neighbor adjacencies, and LSP generation.
6
7pub mod actions;
8
9use crate::server::connection::ConnectionId;
10use anyhow::{Context, Result};
11use bytes::Bytes;
12use pcap::{Capture, Device};
13use std::sync::Arc;
14use tokio::sync::mpsc;
15use tracing::{debug, error, info, trace, warn};
16
17use crate::llm::action_helper::call_llm;
18use crate::llm::ollama_client::OllamaClient;
19use crate::logging::emit::Log;
20use crate::protocol::Event;
21use crate::server::IsisProtocol;
22use crate::state::app_state::AppState;
23use crate::{console_debug, console_error, console_info, console_trace};
24use actions::ISIS_HELLO_EVENT;
25
26// IS-IS Constants (ISO/IEC 10589, RFC 1195)
27const ISIS_INTRADOMAIN_ROUTING_PROTOCOL_DISCRIMINATOR: u8 = 0x83;
28const ISIS_HEADER_LEN: usize = 8; // Common header length
29
30// IS-IS PDU Types (RFC 1195 Section 4)
31const ISIS_HELLO_LAN_L1: u8 = 15; // Level 1 LAN Hello
32const ISIS_HELLO_LAN_L2: u8 = 16; // Level 2 LAN Hello
33const ISIS_HELLO_P2P: u8 = 17; // Point-to-Point Hello
34const ISIS_LSP_L1: u8 = 18; // Level 1 Link State PDU
35const ISIS_LSP_L2: u8 = 20; // Level 2 Link State PDU
36const ISIS_CSNP_L1: u8 = 24; // Level 1 Complete Sequence Number PDU
37const ISIS_CSNP_L2: u8 = 25; // Level 2 Complete Sequence Number PDU
38const ISIS_PSNP_L1: u8 = 26; // Level 1 Partial Sequence Number PDU
39const ISIS_PSNP_L2: u8 = 27; // Level 2 Partial Sequence Number PDU
40
41// IS-IS Multicast MAC Addresses
42const ISIS_ALL_L1_IS: [u8; 6] = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x14]; // All Level 1 ISs
43const ISIS_ALL_L2_IS: [u8; 6] = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x15]; // All Level 2 ISs
44
45// LLC/SNAP headers for IS-IS over Ethernet
46const LLC_DSAP_ISO: u8 = 0xFE; // ISO CLNS
47const LLC_SSAP_ISO: u8 = 0xFE;
48const LLC_CTRL: u8 = 0x03; // Unnumbered Information
49
50// IS-IS TLV Types (commonly used)
51const ISIS_TLV_AREA_ADDRESSES: u8 = 1;
52#[allow(dead_code)]
53const ISIS_TLV_NEIGHBORS: u8 = 6;
54#[allow(dead_code)]
55const ISIS_TLV_PADDING: u8 = 8;
56#[allow(dead_code)]
57const ISIS_TLV_LSP_ENTRIES: u8 = 9;
58const ISIS_TLV_PROTOCOLS_SUPPORTED: u8 = 129;
59const ISIS_TLV_IP_INTERFACE_ADDRESSES: u8 = 132;
60#[allow(dead_code)]
61const ISIS_TLV_IP_INTERNAL_REACHABILITY: u8 = 128;
62#[allow(dead_code)]
63const ISIS_TLV_EXTENDED_REACHABILITY: u8 = 22;
64const ISIS_TLV_HOSTNAME: u8 = 137;
65
66/// IS-IS server that handles routing protocol operations with LLM at Layer 2
67pub struct IsisServer;
68
69impl IsisServer {
70    /// List available network interfaces
71    pub fn list_devices() -> Result<Vec<Device>> {
72        Device::list().context("Failed to list network devices")
73    }
74
75    /// Find a device by name
76    pub fn find_device(name: &str) -> Result<Device> {
77        let devices = Self::list_devices()?;
78        devices
79            .into_iter()
80            .find(|d| d.name == name)
81            .ok_or_else(|| anyhow::anyhow!("Device '{}' not found", name))
82    }
83
84    /// Spawn IS-IS server with integrated LLM actions (Layer 2 with pcap)
85    pub async fn spawn_with_llm_actions(
86        interface: String,
87        llm_client: OllamaClient,
88        app_state: Arc<AppState>,
89        status_tx: mpsc::UnboundedSender<String>,
90        server_id: crate::state::ServerId,
91        startup_params: Option<crate::protocol::StartupParams>,
92    ) -> Result<String> {
93        console_info!(
94            status_tx,
95            "IS-IS server starting on interface: {}",
96            interface
97        );
98
99        // Extract configuration from startup params
100        let (system_id, area_id, level) = if let Some(ref params) = startup_params {
101            let sys_id = params
102                .get_optional_string("system_id")?
103                .unwrap_or_else(|| "0000.0000.0001".to_string());
104            let area = params
105                .get_optional_string("area_id")?
106                .unwrap_or_else(|| "49.0001".to_string());
107            let lvl = params
108                .get_optional_string("level")?
109                .unwrap_or_else(|| "level-2".to_string());
110
111            Log::new(Some(&status_tx)).info(format!(
112                "IS-IS configured: system_id={}, area={}, level={}",
113                sys_id, area, lvl
114            ));
115            (sys_id, area, lvl)
116        } else {
117            // Defaults
118            (
119                "0000.0000.0001".to_string(),
120                "49.0001".to_string(),
121                "level-2".to_string(),
122            )
123        };
124
125        let protocol = Arc::new(IsisProtocol::new());
126
127        // Retained by this function; the capture task takes ownership of `status_tx`.
128        let status_tx_ready = status_tx.clone();
129
130        // pcap is blocking, so we run it in a blocking task.
131        //
132        // Opening the pcap handle is the step that requires privileges (root, or read/write
133        // access to /dev/bpf* on macOS and the BSDs, or CAP_NET_RAW on Linux). It therefore
134        // MUST NOT be fire-and-forget: the outcome is handed back over a oneshot and this
135        // function only returns Ok once the capture is genuinely live, so a failure surfaces as
136        // ServerStatus::Error instead of a server that reports Running while capturing nothing.
137        // ARP, DataLink and ICMP were fixed for exactly this; IS-IS was missed at the time.
138        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
139
140        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
141        // capture loop is stopped cooperatively: it polls this flag every iteration, and the
142        // task registered with `register_server_task` below trips it when `stop_server` aborts
143        // it. Without this the capture kept running (and kept calling the LLM) after the server
144        // was stopped, until the process exited. See `crate::utils::shutdown`.
145        let stop = crate::utils::StopSignal::new();
146        let stop_in_loop = stop.clone();
147        // Retained by this function; the capture task takes ownership of `app_state`.
148        let app_state_reg = app_state.clone();
149
150        let interface_clone = interface.clone();
151        let protocol_clone = protocol.clone();
152        tokio::task::spawn_blocking(move || {
153            let open_capture = || -> Result<(Capture<pcap::Active>, [u8; 6])> {
154                let device = Self::find_device(&interface_clone)
155                    .with_context(|| format!("no such capture device '{}'", interface_clone))?;
156
157                // Get device MAC address (needed for source MAC in responses). Not fatal:
158                // a locally administered address still produces valid frames.
159                let local_mac = match Self::get_interface_mac(&interface_clone) {
160                    Ok(mac) => mac,
161                    Err(e) => {
162                        warn!("Failed to get interface MAC address: {}, using default", e);
163                        [0x02, 0x00, 0x00, 0x00, 0x00, 0x01] // Local admin MAC
164                    }
165                };
166
167                // Open capture for IS-IS packets
168                let mut cap = Capture::from_device(device)
169                    .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
170                    .and_then(|c| c.open())
171                    .with_context(|| {
172                        format!(
173                            "failed to open pcap capture on '{}' (needs root, or \
174                             read access to /dev/bpf* on macOS/BSD, or CAP_NET_RAW on Linux)",
175                            interface_clone
176                        )
177                    })?;
178
179                // BPF filter for IS-IS packets (LLC DSAP/SSAP 0xFE).
180                // This matches packets with IS-IS LLC headers. The expression is fixed, so a
181                // failure here means it will never compile on this libpcap - and without it the
182                // capture delivers *every* frame on the segment to the LLM. Refuse to start
183                // rather than silently become a firehose.
184                let filter = "ether proto 0xfefe or (ether[14:2] = 0xfefe and ether[16:1] = 0x03)";
185                cap.filter(filter, true).with_context(|| {
186                    format!("failed to apply the IS-IS BPF filter '{}'", filter)
187                })?;
188
189                Ok((cap, local_mac))
190            };
191
192            let (cap, local_mac) = match open_capture() {
193                Ok(handles) => {
194                    let _ = ready_tx.send(Ok(()));
195                    handles
196                }
197                Err(e) => {
198                    console_error!(status_tx, "IS-IS capture startup failed: {:#}", e);
199                    let _ = ready_tx.send(Err(e));
200                    return;
201                }
202            };
203
204            info!(
205                "IS-IS listening on {}, MAC: {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
206                interface_clone,
207                local_mac[0],
208                local_mac[1],
209                local_mac[2],
210                local_mac[3],
211                local_mac[4],
212                local_mac[5]
213            );
214            let _ = status_tx.send(format!("→ IS-IS ready on interface {}", interface_clone));
215
216            let runtime = tokio::runtime::Handle::current();
217
218            // Store capture handle in Arc for packet injection
219            let cap_arc = Arc::new(std::sync::Mutex::new(cap));
220
221            // Capture loop. The pcap read timeout (1000ms, set above) is what bounds how long
222            // a stop takes to be noticed on an idle interface.
223            loop {
224                if stop_in_loop.is_stopped() {
225                    console_info!(status_tx, "IS-IS capture on {} stopping", interface_clone);
226                    break;
227                }
228                let mut cap_guard = cap_arc.lock().unwrap_or_else(|e| e.into_inner());
229                match cap_guard.next_packet() {
230                    Ok(packet) => {
231                        let data = Bytes::copy_from_slice(packet.data);
232                        drop(cap_guard); // Release lock before async processing
233
234                        // Parse Ethernet frame
235                        if data.len() < 14 {
236                            continue; // Too short for Ethernet header
237                        }
238
239                        let _dst_mac = &data[0..6];
240                        let src_mac = &data[6..12];
241                        let _eth_type = u16::from_be_bytes([data[12], data[13]]);
242
243                        // Check for IS-IS: LLC/SNAP headers after Ethernet
244                        // Ethernet (14) + LLC (3) = 17 bytes minimum
245                        if data.len() < 17 {
246                            continue;
247                        }
248
249                        // Skip Ethernet header (14 bytes), check LLC
250                        let llc_offset = 14;
251                        let dsap = data[llc_offset];
252                        let ssap = data[llc_offset + 1];
253                        let ctrl = data[llc_offset + 2];
254
255                        // IS-IS uses LLC DSAP/SSAP = 0xFE, Control = 0x03
256                        if dsap != LLC_DSAP_ISO || ssap != LLC_SSAP_ISO || ctrl != LLC_CTRL {
257                            continue; // Not IS-IS
258                        }
259
260                        // IS-IS PDU starts after LLC header (at offset 17)
261                        if data.len() < 17 + ISIS_HEADER_LEN {
262                            continue; // Too short for IS-IS header
263                        }
264
265                        // Clone the IS-IS PDU and source MAC for async task
266                        let isis_pdu = data.slice(17..);
267                        let src_mac_bytes: [u8; 6] = [
268                            src_mac[0], src_mac[1], src_mac[2], src_mac[3], src_mac[4], src_mac[5],
269                        ];
270
271                        let connection_id = ConnectionId::new(
272                            tokio::runtime::Handle::current()
273                                .block_on(app_state.get_next_unified_id()),
274                        );
275
276                        // DEBUG: Log summary
277                        debug!("IS-IS received {} bytes from {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
278                               data.len(),
279                               src_mac[0], src_mac[1], src_mac[2],
280                               src_mac[3], src_mac[4], src_mac[5]);
281                        Log::new(Some(&status_tx))
282                            .debug(format!("IS-IS received {} bytes", data.len()));
283
284                        // TRACE: Log full payload
285                        let hex_str = hex::encode(&data);
286                        console_trace!(status_tx, "IS-IS frame (hex): {}", hex_str);
287
288                        let llm_clone = llm_client.clone();
289                        let state_clone = app_state.clone();
290                        let status_clone = status_tx.clone();
291                        let protocol_task_clone = protocol_clone.clone();
292                        let system_id_clone = system_id.clone();
293                        let area_id_clone = area_id.clone();
294                        let level_clone = level.clone();
295                        let cap_clone = cap_arc.clone();
296                        let local_mac_clone = local_mac.clone();
297
298                        // Spawn async task to handle PDU with LLM
299                        runtime.spawn(async move {
300                            if let Err(e) = Self::handle_isis_pdu(
301                                &isis_pdu,
302                                &src_mac_bytes,
303                                connection_id,
304                                server_id,
305                                &llm_clone,
306                                &state_clone,
307                                &status_clone,
308                                &protocol_task_clone,
309                                &system_id_clone,
310                                &area_id_clone,
311                                &level_clone,
312                                cap_clone,
313                                local_mac_clone,
314                            )
315                            .await
316                            {
317                                error!("IS-IS PDU handling error: {}", e);
318                                Log::new(Some(&status_clone))
319                                    .error(format!("IS-IS PDU error: {}", e));
320                            }
321                        });
322                    }
323                    Err(pcap::Error::TimeoutExpired) => {
324                        drop(cap_guard);
325                        continue;
326                    }
327                    Err(e) => {
328                        console_error!(status_tx, "IS-IS capture error: {}", e);
329                        break;
330                    }
331                }
332            }
333
334            // The capture handle is shared with in-flight injection tasks, so it closes once
335            // the last of them finishes.
336            drop(cap_arc);
337        });
338
339        // Wait for the blocking task to report whether the capture actually came up.
340        match ready_rx.await {
341            Ok(Ok(())) => {}
342            Ok(Err(e)) => return Err(e),
343            Err(_) => {
344                return Err(anyhow::anyhow!(
345                    "IS-IS capture task on '{}' exited before signalling readiness",
346                    interface
347                ))
348            }
349        }
350
351        // Only now that the capture is genuinely live: `stop_server` aborts this parked task,
352        // which trips `stop` and ends the blocking loop above.
353        app_state_reg
354            .register_server_task(server_id, stop.park_task())
355            .await;
356
357        console_info!(status_tx_ready, "IS-IS capture active on {}", interface);
358
359        Ok(interface)
360    }
361
362    /// Handle incoming IS-IS PDU
363    async fn handle_isis_pdu(
364        isis_pdu: &[u8],
365        src_mac: &[u8],
366        connection_id: ConnectionId,
367        server_id: crate::state::ServerId,
368        llm_client: &OllamaClient,
369        app_state: &Arc<AppState>,
370        status_tx: &mpsc::UnboundedSender<String>,
371        protocol: &Arc<IsisProtocol>,
372        system_id: &str,
373        area_id: &str,
374        level: &str,
375        cap: Arc<std::sync::Mutex<Capture<pcap::Active>>>,
376        local_mac: [u8; 6],
377    ) -> Result<()> {
378        // Parse common header
379        let intradomain_routing_protocol_discriminator = isis_pdu[0];
380        let _length_indicator = isis_pdu[1];
381        let _version_protocol_id_extension = isis_pdu[2];
382        let _id_length = isis_pdu[3];
383        let pdu_type = isis_pdu[4];
384        let version = isis_pdu[5];
385        let _reserved = isis_pdu[6];
386        let _max_area_addresses = isis_pdu[7];
387
388        // Validate protocol discriminator
389        if intradomain_routing_protocol_discriminator
390            != ISIS_INTRADOMAIN_ROUTING_PROTOCOL_DISCRIMINATOR
391        {
392            warn!(
393                "IS-IS invalid protocol discriminator: 0x{:02x}",
394                intradomain_routing_protocol_discriminator
395            );
396            return Ok(());
397        }
398
399        // Validate version (should be 1)
400        if version != 1 {
401            warn!("IS-IS unsupported version: {}", version);
402            return Ok(());
403        }
404
405        // Log PDU type
406        let pdu_type_name = match pdu_type {
407            ISIS_HELLO_LAN_L1 => "LAN Hello L1",
408            ISIS_HELLO_LAN_L2 => "LAN Hello L2",
409            ISIS_HELLO_P2P => "P2P Hello",
410            ISIS_LSP_L1 => "LSP L1",
411            ISIS_LSP_L2 => "LSP L2",
412            ISIS_CSNP_L1 => "CSNP L1",
413            ISIS_CSNP_L2 => "CSNP L2",
414            ISIS_PSNP_L1 => "PSNP L1",
415            ISIS_PSNP_L2 => "PSNP L2",
416            _ => "Unknown",
417        };
418
419        info!(
420            "IS-IS received {} ({}) from {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
421            pdu_type_name,
422            pdu_type,
423            src_mac[0],
424            src_mac[1],
425            src_mac[2],
426            src_mac[3],
427            src_mac[4],
428            src_mac[5]
429        );
430        let _ = status_tx.send(format!(
431            "→ IS-IS {} from {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
432            pdu_type_name, src_mac[0], src_mac[1], src_mac[2], src_mac[3], src_mac[4], src_mac[5]
433        ));
434
435        // Add connection to ServerInstance
436        use crate::state::server::{
437            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
438        };
439        let now = crate::utils::clock::Instant::now();
440        let conn_state = ServerConnectionState {
441            id: connection_id,
442            remote_addr: format!(
443                "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:0",
444                src_mac[0], src_mac[1], src_mac[2], src_mac[3], src_mac[4], src_mac[5]
445            )
446            .parse()
447            .unwrap_or("0.0.0.0:0".parse().unwrap()),
448            local_addr: format!(
449                "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:0",
450                local_mac[0], local_mac[1], local_mac[2], local_mac[3], local_mac[4], local_mac[5]
451            )
452            .parse()
453            .unwrap_or("0.0.0.0:0".parse().unwrap()),
454            bytes_sent: 0,
455            bytes_received: isis_pdu.len() as u64,
456            packets_sent: 0,
457            packets_received: 1,
458            last_activity: now,
459            status: ConnectionStatus::Active,
460            status_changed_at: now,
461            protocol_info: ProtocolConnectionInfo::empty(),
462        };
463        app_state
464            .add_connection_to_server(server_id, conn_state)
465            .await;
466        let _ = status_tx.send("__UPDATE_UI__".to_string());
467
468        // Handle based on PDU type (for now, only Hello)
469        match pdu_type {
470            ISIS_HELLO_LAN_L1 | ISIS_HELLO_LAN_L2 | ISIS_HELLO_P2P => {
471                Self::handle_hello_pdu(
472                    isis_pdu,
473                    src_mac,
474                    pdu_type_name,
475                    connection_id,
476                    server_id,
477                    llm_client,
478                    app_state,
479                    status_tx,
480                    protocol,
481                    system_id,
482                    area_id,
483                    level,
484                    cap,
485                    local_mac,
486                )
487                .await?;
488            }
489            ISIS_LSP_L1 | ISIS_LSP_L2 => {
490                Log::new(Some(status_tx)).info("IS-IS LSP received (not yet handled)");
491            }
492            ISIS_CSNP_L1 | ISIS_CSNP_L2 => {
493                info!("IS-IS CSNP received (not yet handled)");
494            }
495            ISIS_PSNP_L1 | ISIS_PSNP_L2 => {
496                info!("IS-IS PSNP received (not yet handled)");
497            }
498            _ => {
499                warn!("IS-IS unsupported PDU type: {}", pdu_type);
500            }
501        }
502
503        Ok(())
504    }
505
506    /// Handle IS-IS Hello PDU
507    async fn handle_hello_pdu(
508        isis_pdu: &[u8],
509        src_mac: &[u8],
510        pdu_type_name: &str,
511        _connection_id: ConnectionId,
512        server_id: crate::state::ServerId,
513        llm_client: &OllamaClient,
514        app_state: &Arc<AppState>,
515        status_tx: &mpsc::UnboundedSender<String>,
516        protocol: &Arc<IsisProtocol>,
517        _system_id: &str,
518        _area_id: &str,
519        level: &str,
520        cap: Arc<std::sync::Mutex<Capture<pcap::Active>>>,
521        local_mac: [u8; 6],
522    ) -> Result<()> {
523        // Parse TLVs from Hello PDU
524        // Hello header varies by type, but TLVs start after fixed header
525        // For LAN Hello: header is 27 bytes, for P2P: 20 bytes
526        let tlv_start = if isis_pdu[4] == ISIS_HELLO_P2P {
527            20
528        } else {
529            27
530        };
531        let tlvs = if tlv_start < isis_pdu.len() {
532            Self::parse_tlvs(&isis_pdu[tlv_start..])
533        } else {
534            Vec::new()
535        };
536
537        // Extract information from TLVs
538        let mut area_addresses = Vec::new();
539        let mut protocols_supported = Vec::new();
540        let mut ip_addresses = Vec::new();
541        let mut hostname = None;
542
543        for (tlv_type, tlv_value) in &tlvs {
544            match *tlv_type {
545                ISIS_TLV_AREA_ADDRESSES => {
546                    area_addresses.push(hex::encode(tlv_value));
547                }
548                ISIS_TLV_PROTOCOLS_SUPPORTED => {
549                    for proto in tlv_value {
550                        protocols_supported.push(format!("0x{:02x}", proto));
551                    }
552                }
553                ISIS_TLV_IP_INTERFACE_ADDRESSES => {
554                    let mut i = 0;
555                    while i + 3 < tlv_value.len() {
556                        let ip = format!(
557                            "{}.{}.{}.{}",
558                            tlv_value[i],
559                            tlv_value[i + 1],
560                            tlv_value[i + 2],
561                            tlv_value[i + 3]
562                        );
563                        ip_addresses.push(ip);
564                        i += 4;
565                    }
566                }
567                ISIS_TLV_HOSTNAME => {
568                    if let Ok(name) = String::from_utf8(tlv_value.clone()) {
569                        hostname = Some(name);
570                    }
571                }
572                _ => {}
573            }
574        }
575
576        let src_mac_str = format!(
577            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
578            src_mac[0], src_mac[1], src_mac[2], src_mac[3], src_mac[4], src_mac[5]
579        );
580
581        // Create event for LLM
582        let mut event_data = serde_json::json!({
583            "pdu_type": pdu_type_name,
584            "packet_hex": hex::encode(isis_pdu),
585            "src_mac": src_mac_str,
586        });
587
588        if !area_addresses.is_empty() {
589            event_data["area_addresses"] = serde_json::json!(area_addresses);
590        }
591        if !protocols_supported.is_empty() {
592            event_data["protocols_supported"] = serde_json::json!(protocols_supported);
593        }
594        if !ip_addresses.is_empty() {
595            event_data["ip_addresses"] = serde_json::json!(ip_addresses);
596        }
597        if let Some(ref name) = hostname {
598            event_data["hostname"] = serde_json::json!(name);
599        }
600
601        let event = Event::new(&ISIS_HELLO_EVENT, event_data);
602
603        // Whether to engage with an IS-IS speaker — answer a Hello and form an adjacency, act as
604        // a passive listener, or run as a honeypot — is a policy/engagement decision, not
605        // something the received PDU determines. With no operator policy — no server instruction
606        // and no per-event handler — the spec-safe default is to listen passively and not
607        // respond, WITHOUT burning an LLM round-trip per captured PDU. The model is consulted
608        // only when the operator opts in with how the router should behave.
609        if !operator_wants_dynamic(app_state, server_id, &event.event_type.id).await {
610            debug!(
611                "IS-IS {} from {} decision=passive_no_policy (no operator policy configured, \
612                 no response and no LLM call)",
613                pdu_type_name, src_mac_str
614            );
615            let _ = status_tx.send(
616                "IS-IS Hello observed passively: no policy configured (static default, no LLM)"
617                    .to_string(),
618            );
619            return Ok(());
620        }
621
622        Log::new(Some(status_tx)).debug("IS-IS calling LLM for Hello");
623
624        // Call LLM to decide response
625        match call_llm(
626            llm_client,
627            app_state,
628            server_id,
629            None,
630            &event,
631            protocol.as_ref(),
632        )
633        .await
634        {
635            Ok(execution_result) => {
636                // Display messages from LLM
637                for message in &execution_result.messages {
638                    console_info!(status_tx, "{}", message);
639                }
640
641                // ISO 10589 has no error PDU and no per-Hello response obligation: not answering
642                // simply means no adjacency forms. So the model choosing to say nothing
643                // (`ignore_pdu`, or no action at all) is a real, spec-valid answer and is only
644                // distinguished in the log, never on the wire.
645                let mut frames_sent = 0usize;
646
647                // Process protocol results - send frames via pcap
648                for protocol_result in execution_result.protocol_results {
649                    if let Some(output_data) = protocol_result.get_all_output().first() {
650                        // output_data is the IS-IS PDU, we need to wrap in Ethernet + LLC
651                        let frame = Self::build_ethernet_frame(output_data, local_mac, level)?;
652
653                        // Send frame via pcap
654                        let mut cap_guard = cap.lock().unwrap_or_else(|e| e.into_inner());
655                        if let Err(e) = cap_guard.sendpacket(frame.as_ref()) {
656                            console_error!(status_tx, "Failed to send IS-IS frame: {}", e);
657                        } else {
658                            frames_sent += 1;
659                            console_debug!(status_tx, "IS-IS sent {} bytes", frame.len());
660                            trace!("IS-IS sent (hex): {}", hex::encode(&frame));
661                        }
662                    }
663                }
664
665                if frames_sent == 0 {
666                    // The model answered, and its answer was "say nothing". Distinct from a
667                    // backend failure below, and greppable as such.
668                    debug!(
669                        "IS-IS {} from {} decision=model_no_pdu (model answered with no PDU; \
670                         no adjacency formed)",
671                        pdu_type_name, src_mac_str
672                    );
673                } else {
674                    debug!(
675                        "IS-IS {} from {} decision=model_pdu frames={}",
676                        pdu_type_name, src_mac_str, frames_sent
677                    );
678                }
679            }
680            Err(e) => {
681                // IS-IS has no error PDU, and fabricating a Hello here would claim an adjacency
682                // netget cannot honour — on a routing protocol that is worse than silence. So
683                // the peer gets nothing (its own holding timer covers it) and the operator gets
684                // the whole error, plus the Overloaded/Unavailable split so a saturated backend
685                // is distinguishable from a broken one.
686                let category = crate::utils::WireFailure::classify(&e);
687                let decision = if category.is_overloaded() {
688                    "fail_closed_llm_overloaded"
689                } else {
690                    "fail_closed_llm_error"
691                };
692                error!(
693                    "IS-IS {} from {} decision={} (no PDU emitted; IS-IS has no error PDU): {}",
694                    pdu_type_name, src_mac_str, decision, e
695                );
696                let _ = status_tx.send(format!(
697                    "✗ IS-IS LLM error (decision={}, no PDU emitted): {}",
698                    decision, e
699                ));
700            }
701        }
702
703        Ok(())
704    }
705
706    /// Parse IS-IS TLVs from packet data
707    fn parse_tlvs(data: &[u8]) -> Vec<(u8, Vec<u8>)> {
708        let mut tlvs = Vec::new();
709        let mut offset = 0;
710
711        while offset + 1 < data.len() {
712            let tlv_type = data[offset];
713            let tlv_len = data[offset + 1] as usize;
714            offset += 2;
715
716            if offset + tlv_len > data.len() {
717                break;
718            }
719
720            let tlv_value = data[offset..offset + tlv_len].to_vec();
721            tlvs.push((tlv_type, tlv_value));
722            offset += tlv_len;
723        }
724
725        tlvs
726    }
727
728    /// Build Ethernet frame with LLC header and IS-IS PDU
729    fn build_ethernet_frame(isis_pdu: &[u8], src_mac: [u8; 6], level: &str) -> Result<Vec<u8>> {
730        let mut frame = Vec::new();
731
732        // Destination MAC (multicast based on level)
733        let dst_mac = if level.contains("level-1") {
734            ISIS_ALL_L1_IS
735        } else {
736            ISIS_ALL_L2_IS
737        };
738        frame.extend_from_slice(&dst_mac);
739
740        // Source MAC
741        frame.extend_from_slice(&src_mac);
742
743        // Ethernet Type: Length field (for 802.3 with LLC)
744        let length = (3 + isis_pdu.len()) as u16; // LLC (3) + IS-IS PDU
745        frame.extend_from_slice(&length.to_be_bytes());
746
747        // LLC Header (3 bytes)
748        frame.push(LLC_DSAP_ISO); // DSAP = 0xFE (ISO CLNS)
749        frame.push(LLC_SSAP_ISO); // SSAP = 0xFE
750        frame.push(LLC_CTRL); // Control = 0x03 (UI)
751
752        // IS-IS PDU
753        frame.extend_from_slice(isis_pdu);
754
755        Ok(frame)
756    }
757
758    /// Get MAC address of interface (platform-specific)
759    fn get_interface_mac(interface: &str) -> Result<[u8; 6]> {
760        // Only the Linux path reads the interface name; other platforms fall through.
761        #[cfg(not(target_os = "linux"))]
762        let _ = interface;
763        // Try to get MAC from system
764        #[cfg(target_os = "linux")]
765        {
766            use std::fs;
767            let path = format!("/sys/class/net/{}/address", interface);
768            if let Ok(contents) = fs::read_to_string(&path) {
769                let parts: Vec<&str> = contents.trim().split(':').collect();
770                if parts.len() == 6 {
771                    let mut mac = [0u8; 6];
772                    for (i, part) in parts.iter().enumerate() {
773                        if let Ok(byte) = u8::from_str_radix(part, 16) {
774                            mac[i] = byte;
775                        }
776                    }
777                    return Ok(mac);
778                }
779            }
780        }
781
782        // Fallback: use locally administered MAC
783        Err(anyhow::anyhow!("Could not determine interface MAC address"))
784    }
785}
786
787/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for this
788/// server: either a non-empty server instruction was given, or an event handler is configured
789/// for `event_id`. When false the server observes passively and never consults the model —
790/// whether to engage with an IS-IS speaker is a policy decision with no configured policy to
791/// apply.
792async fn operator_wants_dynamic(
793    state: &AppState,
794    server_id: crate::state::ServerId,
795    event_id: &str,
796) -> bool {
797    state
798        .with_server_mut(server_id, |server| {
799            let has_instruction = !server.instruction.trim().is_empty();
800            let has_handler = server
801                .event_handler_config
802                .as_ref()
803                .map(|c| c.find_handler(event_id).is_some())
804                .unwrap_or(false);
805            has_instruction || has_handler
806        })
807        .await
808        .unwrap_or(false)
809}