Skip to main content

netget/client/isis/
mod.rs

1//! IS-IS (Intermediate System to Intermediate System) client implementation
2//!
3//! This module provides functionality to capture and analyze IS-IS PDUs at Layer 2.
4//! It uses libpcap to capture raw Ethernet frames containing IS-IS traffic.
5
6pub mod actions;
7
8pub use actions::IsisClientProtocol;
9
10use anyhow::{Context, Result};
11use pcap::{Capture, Device};
12use std::net::SocketAddr;
13use std::sync::Arc;
14use tokio::sync::mpsc;
15use tracing::{debug, error, info, trace};
16
17use crate::client::isis::actions::ISIS_PDU_RECEIVED_EVENT;
18use crate::client::llm_budget::call_llm_for_client;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26/// IS-IS PDU types
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[repr(u8)]
29pub enum IsisPduType {
30    L1LanHello = 15,
31    L2LanHello = 16,
32    P2PHello = 17,
33    L1Lsp = 18,
34    L2Lsp = 20,
35    L1Csnp = 24,
36    L2Csnp = 25,
37    L1Psnp = 26,
38    L2Psnp = 27,
39    Unknown = 0,
40}
41
42impl From<u8> for IsisPduType {
43    fn from(value: u8) -> Self {
44        match value {
45            15 => IsisPduType::L1LanHello,
46            16 => IsisPduType::L2LanHello,
47            17 => IsisPduType::P2PHello,
48            18 => IsisPduType::L1Lsp,
49            20 => IsisPduType::L2Lsp,
50            24 => IsisPduType::L1Csnp,
51            25 => IsisPduType::L2Csnp,
52            26 => IsisPduType::L1Psnp,
53            27 => IsisPduType::L2Psnp,
54            _ => IsisPduType::Unknown,
55        }
56    }
57}
58
59impl IsisPduType {
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            IsisPduType::L1LanHello => "L1 LAN Hello",
63            IsisPduType::L2LanHello => "L2 LAN Hello",
64            IsisPduType::P2PHello => "P2P Hello",
65            IsisPduType::L1Lsp => "L1 LSP",
66            IsisPduType::L2Lsp => "L2 LSP",
67            IsisPduType::L1Csnp => "L1 CSNP",
68            IsisPduType::L2Csnp => "L2 CSNP",
69            IsisPduType::L1Psnp => "L1 PSNP",
70            IsisPduType::L2Psnp => "L2 PSNP",
71            IsisPduType::Unknown => "Unknown",
72        }
73    }
74}
75
76/// Basic IS-IS PDU header structure
77#[derive(Debug)]
78pub struct IsisPduHeader {
79    pub pdu_type: IsisPduType,
80    pub version: u8,
81    pub id_length: u8,
82    pub pdu_length: u16,
83}
84
85/// Parse basic IS-IS PDU header from raw bytes
86fn parse_isis_header(data: &[u8]) -> Option<IsisPduHeader> {
87    if data.len() < 8 {
88        return None;
89    }
90
91    // IS-IS header:
92    // - Byte 0: Intradomain Routing Protocol Discriminator (0x83)
93    // - Byte 1: Length Indicator
94    // - Byte 2: Version/Protocol ID Extension
95    // - Byte 3: ID Length
96    // - Byte 4: PDU Type
97    // - Byte 5: Version
98    // - Byte 6: Reserved
99    // - Byte 7: Maximum Area Addresses
100
101    if data[0] != 0x83 {
102        return None; // Not IS-IS
103    }
104
105    let pdu_type = IsisPduType::from(data[4]);
106    let version = data[5];
107    let id_length = data[3];
108
109    // Calculate PDU length from length indicator
110    let length_indicator = data[1] as u16;
111    let pdu_length = length_indicator + 1; // Length includes the header
112
113    Some(IsisPduHeader {
114        pdu_type,
115        version,
116        id_length,
117        pdu_length,
118    })
119}
120
121/// IS-IS client that captures IS-IS PDUs from network interface
122pub struct IsisClient;
123
124impl IsisClient {
125    /// List available network interfaces
126    pub fn list_devices() -> Result<Vec<Device>> {
127        Device::list().context("Failed to list network devices")
128    }
129
130    /// Find a device by name
131    pub fn find_device(name: &str) -> Result<Device> {
132        let devices = Self::list_devices()?;
133        devices
134            .into_iter()
135            .find(|d| d.name == name)
136            .ok_or_else(|| anyhow::anyhow!("Device '{}' not found", name))
137    }
138
139    /// Connect to network interface and capture IS-IS PDUs with LLM integration
140    pub async fn connect_with_llm_actions(
141        interface_name: String,
142        llm_client: OllamaClient,
143        app_state: Arc<AppState>,
144        status_tx: mpsc::UnboundedSender<String>,
145        client_id: ClientId,
146    ) -> Result<SocketAddr> {
147        info!(
148            "ISIS client {} starting capture on interface: {}",
149            client_id, interface_name
150        );
151
152        // Update client state
153        app_state
154            .update_client_status(client_id, ClientStatus::Connected)
155            .await;
156        let _ = status_tx.send(format!(
157            "[CLIENT] ISIS client {} capturing on {}",
158            client_id, interface_name
159        ));
160        let _ = status_tx.send("__UPDATE_UI__".to_string());
161
162        let protocol = Arc::new(crate::client::isis::actions::IsisClientProtocol::new());
163
164        // Command channel for injected actions (the dashboard's [ send ]).
165        // This client makes no connected-event LLM call, so there is nothing to race here -
166        // but it is still registered before the pcap task starts, so [ send ] is live from
167        // the moment the client exists.
168        let command_rx =
169            crate::client::command_support::register_command_channel(&app_state, client_id).await;
170        let cmd_task = tokio::spawn(Self::command_loop(
171            command_rx,
172            protocol.clone(),
173            client_id,
174            app_state.clone(),
175            status_tx.clone(),
176        ));
177        app_state.register_client_task(client_id, cmd_task).await;
178
179        // pcap is blocking, so we run it in a blocking task.
180        //
181        // `JoinHandle::abort()` cannot interrupt a thread parked in `next_packet()`, so the
182        // capture loop is stopped cooperatively: it polls this flag every iteration, and the
183        // task registered with `register_client_task` below trips it when `remove_client`
184        // aborts it. The capture opens pcap with `.timeout(1000)`, so a stop is observed
185        // within ~1s. See `crate::utils::shutdown`, and the identical arrangement in
186        // `crate::server::isis`.
187        let stop = crate::utils::StopSignal::new();
188        let stop_in_loop = stop.clone();
189        // Retained by this function; the capture task takes ownership of `app_state`.
190        let app_state_reg = app_state.clone();
191
192        let interface_clone = interface_name.clone();
193        let handle_state = app_state.clone();
194        let handle_status_tx = status_tx.clone();
195        tokio::task::spawn_blocking(move || {
196            // Find device
197            let device = match Self::find_device(&interface_clone) {
198                Ok(d) => d,
199                Err(e) => {
200                    error!("ISIS client {} failed to find device: {}", client_id, e);
201                    let runtime = tokio::runtime::Handle::current();
202                    let _ = runtime.block_on(
203                        app_state
204                            .update_client_status(client_id, ClientStatus::Error(e.to_string())),
205                    );
206                    let _ = status_tx.send("__UPDATE_UI__".to_string());
207                    return;
208                }
209            };
210
211            // Open capture in promiscuous mode
212            let mut cap = match Capture::from_device(device)
213                .map(|c| c.promisc(true).snaplen(65535).timeout(1000))
214                .and_then(|c| c.open())
215            {
216                Ok(c) => c,
217                Err(e) => {
218                    error!("ISIS client {} failed to open capture: {}", client_id, e);
219                    let runtime = tokio::runtime::Handle::current();
220                    let _ = runtime.block_on(
221                        app_state
222                            .update_client_status(client_id, ClientStatus::Error(e.to_string())),
223                    );
224                    let _ = status_tx.send("__UPDATE_UI__".to_string());
225                    return;
226                }
227            };
228
229            // Apply filter for IS-IS traffic
230            // IS-IS uses LLC/SNAP with DSAP=0xFE, SSAP=0xFE
231            if let Err(e) = cap.filter("ether proto 0xfefe or ether[14:2] = 0xfefe", true) {
232                error!("ISIS client {} failed to apply filter: {}", client_id, e);
233            }
234
235            info!("ISIS client {} successfully opened capture", client_id);
236
237            let mut memory = String::new();
238
239            // Capture loop
240            loop {
241                // Checked before the blocking read as well as after it, so a stop arriving
242                // while the loop is between packets is not missed.
243                if stop_in_loop.is_stopped() {
244                    info!(
245                        "ISIS client {} stopping capture (client removed)",
246                        client_id
247                    );
248                    break;
249                }
250
251                match cap.next_packet() {
252                    Ok(packet) => {
253                        let data = packet.data;
254                        trace!("ISIS client {} captured {} bytes", client_id, data.len());
255
256                        // Skip Ethernet header (14 bytes) and LLC/SNAP header (8 bytes)
257                        // Ethernet: dest MAC (6) + src MAC (6) + ethertype (2) = 14 bytes
258                        // LLC/SNAP: DSAP (1) + SSAP (1) + Control (1) + OUI (3) + PID (2) = 8 bytes
259                        let isis_offset = 14 + 8;
260
261                        if data.len() > isis_offset {
262                            let isis_data = &data[isis_offset..];
263
264                            // Parse IS-IS header
265                            if let Some(header) = parse_isis_header(isis_data) {
266                                debug!(
267                                    "ISIS client {} captured PDU: type={}, version={}, length={}",
268                                    client_id,
269                                    header.pdu_type.as_str(),
270                                    header.version,
271                                    header.pdu_length
272                                );
273
274                                // Get instruction
275                                let runtime = tokio::runtime::Handle::current();
276                                let instruction = match runtime
277                                    .block_on(app_state.get_instruction_for_client(client_id))
278                                {
279                                    Some(i) => i,
280                                    None => continue,
281                                };
282
283                                // Call LLM with captured PDU
284                                let event = Event::new(
285                                    &ISIS_PDU_RECEIVED_EVENT,
286                                    serde_json::json!({
287                                        "pdu_type": header.pdu_type.as_str(),
288                                        "pdu_type_code": header.pdu_type as u8,
289                                        "version": header.version,
290                                        "pdu_length": header.pdu_length,
291                                        "raw_pdu_hex": hex::encode(isis_data),
292                                        "raw_frame_hex": hex::encode(data),
293                                    }),
294                                );
295
296                                // Call LLM (blocking version)
297                                let runtime = tokio::runtime::Handle::current();
298                                match runtime.block_on(call_llm_for_client(
299                                    &llm_client,
300                                    &app_state,
301                                    client_id.to_string(),
302                                    &instruction,
303                                    &memory,
304                                    Some(&event),
305                                    protocol.as_ref(),
306                                    &status_tx,
307                                )) {
308                                    Ok(ClientLlmResult {
309                                        actions,
310                                        memory_updates,
311                                    }) => {
312                                        // Update memory
313                                        if let Some(mem) = memory_updates {
314                                            memory = mem;
315                                        }
316
317                                        // IS-IS is a receive-only sniffer, so most of its
318                                        // vocabulary really is interpretation: analyze_topology
319                                        // and wait_for_more put nothing on the wire, and there
320                                        // is no transmit handle to put it on.
321                                        //
322                                        // `stop_capture` is the exception and was being thrown
323                                        // away with the rest, so a model that decided it had
324                                        // seen enough could not stop the capture -- the client
325                                        // ran until someone removed it. The capture loop polls
326                                        // client status, so marking it Disconnected ends it.
327                                        use crate::llm::actions::client_trait::{
328                                            Client, ClientActionResult,
329                                        };
330                                        for action in &actions {
331                                            let stop =
332                                                matches!(
333                                                    protocol.execute_action(action.clone()),
334                                                    Ok(ClientActionResult::Disconnect)
335                                                ) || action.get("type").and_then(|v| v.as_str())
336                                                    == Some("stop_capture");
337                                            if stop {
338                                                info!(
339                                                    "IS-IS client {} stopping capture on the \
340                                                     model's request",
341                                                    client_id
342                                                );
343                                                // This whole closure runs on the blocking
344                                                // pool (pcap is a blocking API), so the
345                                                // status update goes through the same
346                                                // `block_on` bridge the LLM call above uses.
347                                                runtime.block_on(app_state.update_client_status(
348                                                    client_id,
349                                                    ClientStatus::Disconnected,
350                                                ));
351                                                let _ = status_tx.send("__UPDATE_UI__".to_string());
352                                                break;
353                                            }
354                                        }
355                                    }
356                                    Err(e) => {
357                                        error!("ISIS client {} LLM error: {}", client_id, e);
358                                    }
359                                }
360                            } else {
361                                trace!("ISIS client {} skipped non-ISIS packet", client_id);
362                            }
363                        }
364                    }
365                    Err(pcap::Error::TimeoutExpired) => {
366                        // Timeout is expected, continue
367                        continue;
368                    }
369                    Err(e) => {
370                        error!("ISIS client {} capture error: {}", client_id, e);
371                        let runtime = tokio::runtime::Handle::current();
372                        let _ =
373                            runtime.block_on(app_state.update_client_status(
374                                client_id,
375                                ClientStatus::Error(e.to_string()),
376                            ));
377                        let _ = status_tx.send("__UPDATE_UI__".to_string());
378                        break;
379                    }
380                }
381
382                // Check if client is still active.
383                //
384                // The `None` arm is the one that matters: `remove_client` *deletes* the
385                // entry rather than marking it `Disconnected`, so a version of this that
386                // only inspected `Some(client)` kept capturing — and kept calling the LLM —
387                // forever after the client was removed. The `StopSignal` above covers the
388                // same case; this is the cheap second check on the path that already reads
389                // the client.
390                let runtime = tokio::runtime::Handle::current();
391                match runtime.block_on(app_state.get_client(client_id)) {
392                    Some(client)
393                        if matches!(
394                            client.status,
395                            ClientStatus::Disconnected | ClientStatus::Error(_)
396                        ) =>
397                    {
398                        info!("ISIS client {} stopping capture", client_id);
399                        break;
400                    }
401                    Some(_) => {}
402                    None => {
403                        info!("ISIS client {} stopping capture (client gone)", client_id);
404                        break;
405                    }
406                }
407            }
408
409            // The capture is gone: drop the command handle so the dashboard stops offering
410            // [ send ] on a dead client. This also closes the command channel, which ends
411            // `command_loop`.
412            let runtime = tokio::runtime::Handle::current();
413            runtime.block_on(handle_state.remove_client_handle(client_id));
414            let _ = handle_status_tx.send("__UPDATE_UI__".to_string());
415        });
416
417        // The blocking capture task's own `JoinHandle` is deliberately not registered:
418        // aborting it would do nothing, because Tokio cannot unwind a thread parked in
419        // `next_packet()`. Registering the parked task instead makes `remove_client` trip
420        // the flag the loop polls, which is what actually stops it.
421        app_state_reg
422            .register_client_task(client_id, stop.park_task())
423            .await;
424
425        // Return a dummy SocketAddr (pcap doesn't have socket addresses)
426        Ok("0.0.0.0:0".parse().unwrap())
427    }
428
429    /// Drain injected commands until the channel closes (client removed, or the capture
430    /// exited and dropped the handle) or an injected `stop_capture` ends the session.
431    ///
432    /// **This client can never report `Sent`, and that is not a gap in the wiring.** The
433    /// IS-IS client is a receive-only libpcap sniffer: it has no transmit handle, and its
434    /// whole vocabulary is `analyze_topology` / `wait_for_more` (both `WaitForMore` - the
435    /// analysis happens in the LLM's memory) and `stop_capture` (`Disconnect`). An injected
436    /// action is executed through the protocol's own `execute_action`, exactly as an
437    /// LLM-produced one is, and the honest outcome for everything except `stop_capture` is
438    /// `Executed` with the reason. `stop_capture` is real work: the capture loop polls the
439    /// client's status every iteration, so marking the client disconnected here stops it
440    /// within one pcap timeout (1s).
441    async fn command_loop(
442        mut command_rx: mpsc::Receiver<ClientCommand>,
443        protocol: Arc<crate::client::isis::actions::IsisClientProtocol>,
444        client_id: ClientId,
445        app_state: Arc<AppState>,
446        status_tx: mpsc::UnboundedSender<String>,
447    ) {
448        use crate::llm::actions::client_trait::{Client, ClientActionResult};
449        use crate::llm::actions::protocol_trait::Protocol;
450
451        while let Some(command) = command_rx.recv().await {
452            let action = command.action.clone();
453            let outcome = match protocol.as_ref().execute_action(action.clone()) {
454                Err(e) => ClientSendOutcome::Rejected {
455                    error: e.to_string(),
456                },
457                Ok(ClientActionResult::Disconnect) => ClientSendOutcome::Disconnected,
458                Ok(ClientActionResult::WaitForMore) => ClientSendOutcome::Executed {
459                    detail: "executed: the IS-IS client is a receive-only capture, so no PDU \
460                             is transmitted; the analysis lives in the LLM's memory"
461                        .to_string(),
462                },
463                Ok(other) => ClientSendOutcome::Executed {
464                    detail: format!(
465                        "{other:?}: the IS-IS client is a receive-only capture and transmits \
466                         no PDUs"
467                    ),
468                },
469            };
470
471            app_state
472                .record_access_log(
473                    AccessLogOwner::Client(client_id.as_u32()),
474                    protocol.protocol_name(),
475                    None,
476                    "injected_action",
477                    action,
478                    vec![serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null)],
479                )
480                .await;
481
482            let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
483            let _ = status_tx.send("__UPDATE_UI__".to_string());
484            crate::client::command_support::reply(command, Ok(outcome));
485
486            if disconnect {
487                info!(
488                    "ISIS client {} stopping capture on injected action",
489                    client_id
490                );
491                app_state.remove_client_handle(client_id).await;
492                // The capture loop checks this on every iteration and breaks out.
493                app_state
494                    .update_client_status(client_id, ClientStatus::Disconnected)
495                    .await;
496                let _ = status_tx.send("__UPDATE_UI__".to_string());
497                break;
498            }
499        }
500    }
501}