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}