1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14#[cfg(any(
27 target_os = "macos",
28 target_os = "ios",
29 target_os = "freebsd",
30 target_os = "openbsd",
31 target_os = "netbsd",
32 target_os = "dragonfly"
33))]
34const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
35#[cfg(not(any(
36 target_os = "macos",
37 target_os = "ios",
38 target_os = "freebsd",
39 target_os = "openbsd",
40 target_os = "netbsd",
41 target_os = "dragonfly"
42)))]
43const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
44
45pub struct IsisProtocol;
46
47impl IsisProtocol {
48 pub fn new() -> Self {
49 Self
50 }
51}
52
53impl Protocol for IsisProtocol {
55 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
56 Some(crate::protocol::BindingDefaults::interface_based(
58 DEFAULT_LOOPBACK_INTERFACE,
59 ))
60 }
61
62 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
63 vec![
65 crate::llm::actions::ParameterDefinition {
66 name: "system_id".to_string(),
67 type_hint: "string".to_string(),
68 description: "IS-IS System ID in format: 0000.0000.0001 (6 bytes in dotted hex notation)".to_string(),
69 required: false,
70 example: json!("0000.0000.0001"),
71 default: None,
72 },
73 crate::llm::actions::ParameterDefinition {
74 name: "area_id".to_string(),
75 type_hint: "string".to_string(),
76 description: "IS-IS Area ID in format: 49.0001 (NSAP format, 49 for private networks)".to_string(),
77 required: false,
78 example: json!("49.0001"),
79 default: None,
80 },
81 crate::llm::actions::ParameterDefinition {
82 name: "level".to_string(),
83 type_hint: "string".to_string(),
84 description: "IS-IS level: 'level-1' (intra-area), 'level-2' (inter-area backbone), or 'level-1+2' (both)".to_string(),
85 required: false,
86 example: json!("level-2"),
87 default: None,
88 },
89 ]
90 }
91 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
92 Vec::new()
93 }
94 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
95 vec![
96 send_isis_hello_action(),
97 send_isis_lsp_action(),
98 send_isis_pdu_action(),
99 ignore_pdu_action(),
100 ]
101 }
102 fn protocol_name(&self) -> &'static str {
103 "ISIS"
104 }
105 fn get_event_types(&self) -> Vec<EventType> {
106 get_isis_event_types()
107 }
108 fn stack_name(&self) -> &'static str {
109 "ETH>LLC>ISIS"
113 }
114 fn keywords(&self) -> Vec<&'static str> {
115 vec!["isis", "is-is"]
116 }
117 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
118 use crate::protocol::metadata::{
119 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
120 };
121
122 ProtocolMetadataV2::builder()
123 .connectionless()
124 .state(DevelopmentState::Experimental)
125 .deliberately_silent()
128 .privilege_requirement(PrivilegeRequirement::PacketCapture)
129 .implementation("Layer 2 IS-IS with pcap (ISO/IEC 10589, RFC 1195)")
130 .llm_control("Optional: whether to engage with an IS-IS speaker (answer a Hello, form an adjacency, act as a honeypot) is a policy decision. With no operator policy (no instruction, no handler) the server observes passively and does NOT respond, with no LLM round-trip per PDU. When the operator opts in, the LLM decides the Hello/LSP responses")
131 .e2e_testing(
132 "tests/capture_startup_reports_failure_test.rs asserts spawn() returns Err for an \
133 unknown device and for missing capture privilege. PDU handling itself is \
134 untested: tests/server/isis/e2e_test.rs is #[ignore]d for root.",
135 )
136 .notes(
137 "Layer 2 over Ethernet+LLC (DSAP/SSAP 0xFE), not UDP. Startup failure is \
138 reported: spawn() awaits the pcap handle and the BPF filter and returns Err, \
139 so a privilege failure lands in ServerStatus::Error rather than Running. \
140 NEVER validated against a real router (FRR/Cisco) or any IS-IS speaker - the \
141 earlier 'interoperable with real routers' claim was unfounded. Whether to \
142 respond is policy, so with no operator policy the server is a passive listener \
143 (no response, no LLM call); this passive default is compile-verified since the \
144 pcap path needs root and the E2E suite is #[ignore]d. LSP/CSNP/PSNP are parsed \
145 and logged but not handled; there is no LSP database or SPF.",
146 )
147 .build()
148 }
149 fn description(&self) -> &'static str {
150 "IS-IS (Intermediate System to Intermediate System) Layer 2 routing protocol server"
151 }
152 fn example_prompt(&self) -> &'static str {
153 "start an is-is router on interface eth0 with system-id 0000.0000.0001 in area 49.0001 at level-2"
154 }
155 fn group_name(&self) -> &'static str {
156 "Experimental"
157 }
158
159 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
160 use crate::llm::actions::StartupExamples;
161
162 let script = r#"import json, sys
165data = json.load(sys.stdin)
166event = data["event"]
167if data["event_type_id"] == "isis_hello":
168 actions = [{"type": "send_isis_hello",
169 "system_id": "0000.0000.0001", "area_id": "49.0001"}]
170else:
171 actions = []
172print(json.dumps({"actions": actions}))"#;
173
174 StartupExamples::new(
175 json!({
177 "type": "open_server",
178 "port": 0,
179 "base_stack": "isis",
180 "instruction": "IS-IS router with system-id 0000.0000.0001 in area 49.0001. Respond to Hello PDUs and form adjacencies with neighbors."
181 }),
182 json!({
184 "type": "open_server",
185 "port": 0,
186 "base_stack": "isis",
187 "event_handlers": [{
188 "event_pattern": "isis_hello",
189 "handler": {
190 "type": "script",
191 "language": "python",
192 "code": script
193 }
194 }]
195 }),
196 json!({
198 "type": "open_server",
199 "port": 0,
200 "base_stack": "isis",
201 "event_handlers": [{
202 "event_pattern": "isis_hello",
203 "handler": {
204 "type": "static",
205 "actions": [{
206 "type": "send_isis_hello",
207 "pdu_type": "lan_hello_l2",
208 "system_id": "0000.0000.0001",
209 "area_id": "49.0001",
210 "holding_time": 30
211 }]
212 }
213 }]
214 }),
215 )
216 }
217}
218
219impl Server for IsisProtocol {
221 fn spawn(
222 &self,
223 ctx: crate::protocol::SpawnContext,
224 ) -> std::pin::Pin<
225 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
226 > {
227 Box::pin(async move {
228 use crate::server::isis::IsisServer;
229
230 let interface = ctx
233 .interface()
234 .context("IS-IS requires network interface")?
235 .to_string();
236
237 let listen_addr = ctx.legacy_listen_addr();
239
240 let _interface_name = IsisServer::spawn_with_llm_actions(
242 interface,
243 ctx.llm_client,
244 ctx.state,
245 ctx.status_tx,
246 ctx.server_id,
247 ctx.startup_params,
248 )
249 .await?;
250
251 Ok(listen_addr)
253 })
254 }
255 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
256 let action_type = action
257 .get("type")
258 .and_then(|v| v.as_str())
259 .context("Missing 'type' field in action")?;
260
261 match action_type {
262 "send_isis_hello" => self.execute_send_isis_hello(action),
263 "send_isis_lsp" => self.execute_send_isis_lsp(action),
264 "send_isis_pdu" => self.execute_send_isis_pdu(action),
265 "ignore_pdu" => Ok(ActionResult::NoAction),
266 _ => Err(anyhow::anyhow!("Unknown IS-IS action: {}", action_type)),
267 }
268 }
269}
270
271impl IsisProtocol {
272 fn execute_send_isis_hello(&self, action: serde_json::Value) -> Result<ActionResult> {
274 let pdu_type_str = action
276 .get("pdu_type")
277 .and_then(|v| v.as_str())
278 .unwrap_or("lan_hello_l2");
279
280 let pdu_type = match pdu_type_str {
281 "lan_hello_l1" => 15,
282 "lan_hello_l2" => 16,
283 "p2p_hello" => 17,
284 _ => 16, };
286
287 let system_id = action
288 .get("system_id")
289 .and_then(|v| v.as_str())
290 .unwrap_or("0000.0000.0001");
291
292 let area_id = action
293 .get("area_id")
294 .and_then(|v| v.as_str())
295 .unwrap_or("49.0001");
296
297 let holding_time = action
298 .get("holding_time")
299 .and_then(|v| v.as_u64())
300 .unwrap_or(30) as u16;
301
302 let packet = Self::build_isis_hello(pdu_type, system_id, area_id, holding_time)?;
304
305 Ok(ActionResult::Output(packet))
306 }
307
308 fn execute_send_isis_lsp(&self, action: serde_json::Value) -> Result<ActionResult> {
310 let level = action
311 .get("level")
312 .and_then(|v| v.as_str())
313 .unwrap_or("level-2");
314
315 let pdu_type = match level {
316 "level-1" => 18,
317 "level-2" => 20,
318 _ => 20,
319 };
320
321 let system_id = action
322 .get("system_id")
323 .and_then(|v| v.as_str())
324 .unwrap_or("0000.0000.0001");
325
326 let packet = Self::build_isis_lsp(pdu_type, system_id)?;
328
329 Ok(ActionResult::Output(packet))
330 }
331
332 fn execute_send_isis_pdu(&self, action: serde_json::Value) -> Result<ActionResult> {
334 let data = action
335 .get("data")
336 .and_then(|v| v.as_str())
337 .context("Missing 'data' parameter")?;
338
339 let bytes = hex::decode(data).context("Invalid hex data")?;
340
341 Ok(ActionResult::Output(bytes))
342 }
343
344 pub fn build_isis_hello(
346 pdu_type: u8,
347 system_id: &str,
348 area_id: &str,
349 holding_time: u16,
350 ) -> Result<Vec<u8>> {
351 let mut packet = Vec::new();
352
353 packet.push(0x83); packet.push(27); packet.push(1); packet.push(0); packet.push(pdu_type); packet.push(1); packet.push(0); packet.push(0); let circuit_type = match pdu_type {
366 15 => 1, 16 => 2, 17 => 3, _ => 2,
370 };
371 packet.push(circuit_type);
372
373 let sys_id_bytes = Self::parse_system_id(system_id)?;
375 packet.extend_from_slice(&sys_id_bytes);
376
377 packet.extend_from_slice(&holding_time.to_be_bytes());
379
380 packet.extend_from_slice(&[0, 0]);
382
383 if pdu_type == 15 || pdu_type == 16 {
385 packet.push(64); packet.extend_from_slice(&sys_id_bytes); packet.push(0); }
389
390 let tlvs = Self::build_hello_tlvs(area_id, system_id)?;
392 packet.extend_from_slice(&tlvs);
393
394 let header_len = if pdu_type == 15 || pdu_type == 16 {
396 27 } else {
398 20 };
400 packet[1] = header_len;
401
402 const PDU_LEN_OFFSET: usize = 17;
421 let pdu_len = packet.len() as u16;
422 packet[PDU_LEN_OFFSET..PDU_LEN_OFFSET + 2].copy_from_slice(&pdu_len.to_be_bytes());
423
424 Ok(packet)
425 }
426
427 fn build_isis_lsp(pdu_type: u8, system_id: &str) -> Result<Vec<u8>> {
429 let mut packet = Vec::new();
430
431 packet.push(0x83); packet.push(27); packet.push(1); packet.push(0); packet.push(pdu_type); packet.push(1); packet.push(0); packet.push(0); packet.extend_from_slice(&[0, 0]); packet.extend_from_slice(&[0, 0]); let sys_id_bytes = Self::parse_system_id(system_id)?;
447 packet.extend_from_slice(&sys_id_bytes);
448 packet.push(0); packet.push(0); packet.extend_from_slice(&[0, 0, 0, 0]); packet.extend_from_slice(&[0, 0]); packet.push(0); let pdu_len = packet.len() as u16;
457 packet[8..10].copy_from_slice(&pdu_len.to_be_bytes());
458
459 Ok(packet)
460 }
461
462 fn build_hello_tlvs(area_id: &str, _system_id: &str) -> Result<Vec<u8>> {
464 let mut tlvs = Vec::new();
465
466 tlvs.push(1); let area_bytes = Self::parse_area_id(area_id)?;
469 tlvs.push(area_bytes.len() as u8 + 1); tlvs.push(area_bytes.len() as u8); tlvs.extend_from_slice(&area_bytes);
472
473 tlvs.push(129); tlvs.push(1); tlvs.push(0xCC); Ok(tlvs)
479 }
480
481 fn parse_system_id(system_id: &str) -> Result<[u8; 6]> {
483 let parts: Vec<&str> = system_id.split('.').collect();
484 if parts.len() != 3 {
485 return Err(anyhow::anyhow!("Invalid system ID format: {}", system_id));
486 }
487
488 let mut bytes = [0u8; 6];
489 for (i, part) in parts.iter().enumerate() {
490 let val = u16::from_str_radix(part, 16)
491 .context(format!("Invalid hex in system ID: {}", part))?;
492 bytes[i * 2] = (val >> 8) as u8;
493 bytes[i * 2 + 1] = val as u8;
494 }
495
496 Ok(bytes)
497 }
498
499 fn parse_area_id(area_id: &str) -> Result<Vec<u8>> {
501 let parts: Vec<&str> = area_id.split('.').collect();
502 let mut bytes = Vec::new();
503
504 for part in parts {
505 if part.len() == 2 {
507 let val = u8::from_str_radix(part, 16)
508 .context(format!("Invalid hex in area ID: {}", part))?;
509 bytes.push(val);
510 } else if part.len() == 4 {
511 let val = u16::from_str_radix(part, 16)
512 .context(format!("Invalid hex in area ID: {}", part))?;
513 bytes.push((val >> 8) as u8);
514 bytes.push(val as u8);
515 } else {
516 return Err(anyhow::anyhow!("Invalid area ID part length: {}", part));
517 }
518 }
519
520 Ok(bytes)
521 }
522}
523
524pub static ISIS_HELLO_EVENT: LazyLock<EventType> = LazyLock::new(|| {
526 EventType::new(
527 "isis_hello",
528 "An IS-IS Hello PDU arrived from a neighbour. Answer with send_isis_hello to adjacency \
529 with it, send_isis_lsp to advertise link state, send_isis_pdu for any other PDU type, \
530 or ignore_pdu to stay silent.",
531 json!({
532 "type": "send_isis_hello",
533 "pdu_type": "lan_hello_l2",
534 "system_id": "0000.0000.0001",
535 "area_id": "49.0001"
536 }),
537 )
538 .with_actions(vec![
542 send_isis_hello_action(),
543 send_isis_lsp_action(),
544 send_isis_pdu_action(),
545 ignore_pdu_action(),
546 ])
547 .with_log_template(
548 LogTemplate::new()
549 .with_info("IS-IS {pdu_type} from {peer_addr}")
550 .with_debug("IS-IS Hello: type={pdu_type} peer={peer_addr} areas={area_addresses}")
551 .with_trace("IS-IS Hello: {json_pretty(.)}"),
552 )
553});
554
555fn get_isis_event_types() -> Vec<EventType> {
556 vec![ISIS_HELLO_EVENT.clone()]
557}
558
559fn send_isis_hello_action() -> ActionDefinition {
561 ActionDefinition {
562 name: "send_isis_hello".to_string(),
563 description: "Send IS-IS Hello PDU for neighbor discovery".to_string(),
564 parameters: vec![
565 Parameter {
566 name: "pdu_type".to_string(),
567 type_hint: "string".to_string(),
568 description: "Hello PDU type: lan_hello_l1, lan_hello_l2, or p2p_hello".to_string(),
569 required: false,
570 },
571 Parameter {
572 name: "system_id".to_string(),
573 type_hint: "string".to_string(),
574 description: "Local system ID in format: 0000.0000.0001".to_string(),
575 required: false,
576 },
577 Parameter {
578 name: "area_id".to_string(),
579 type_hint: "string".to_string(),
580 description: "Area ID in format: 49.0001".to_string(),
581 required: false,
582 },
583 Parameter {
584 name: "holding_time".to_string(),
585 type_hint: "number".to_string(),
586 description: "Holding time in seconds (default: 30)".to_string(),
587 required: false,
588 },
589 ],
590 example: json!({
591 "type": "send_isis_hello",
592 "pdu_type": "lan_hello_l2",
593 "system_id": "0000.0000.0001",
594 "area_id": "49.0001"
595 }),
596 log_template: Some(
597 LogTemplate::new()
598 .with_info("-> IS-IS {pdu_type} Hello sys={system_id}")
599 .with_debug(
600 "IS-IS send_isis_hello: type={pdu_type} sys_id={system_id} area={area_id}",
601 ),
602 ),
603 }
604}
605
606fn send_isis_lsp_action() -> ActionDefinition {
607 ActionDefinition {
608 name: "send_isis_lsp".to_string(),
609 description: "Send IS-IS Link State PDU".to_string(),
610 parameters: vec![
611 Parameter {
612 name: "level".to_string(),
613 type_hint: "string".to_string(),
614 description: "IS-IS level: level-1 or level-2".to_string(),
615 required: false,
616 },
617 Parameter {
618 name: "system_id".to_string(),
619 type_hint: "string".to_string(),
620 description: "Local system ID".to_string(),
621 required: false,
622 },
623 ],
624 example: json!({
625 "type": "send_isis_lsp",
626 "level": "level-2",
627 "system_id": "0000.0000.0001"
628 }),
629 log_template: Some(
630 LogTemplate::new()
631 .with_info("-> IS-IS LSP {level} sys={system_id}")
632 .with_debug("IS-IS send_isis_lsp: level={level} sys_id={system_id}"),
633 ),
634 }
635}
636
637fn send_isis_pdu_action() -> ActionDefinition {
638 ActionDefinition {
639 name: "send_isis_pdu".to_string(),
640 description: "Send raw IS-IS PDU from hex data".to_string(),
641 parameters: vec![Parameter {
642 name: "data".to_string(),
643 type_hint: "string".to_string(),
644 description: "Hex-encoded IS-IS PDU".to_string(),
645 required: true,
646 }],
647 example: json!({
654 "type": "send_isis_pdu",
655 "data": "831b01001001000002000000000001001e00244000000000000100\
656 0104034900018101cc"
657 }),
658 log_template: Some(
659 LogTemplate::new()
660 .with_info("-> IS-IS raw PDU ({data_len}B)")
661 .with_debug("IS-IS send_isis_pdu: len={data_len}"),
662 ),
663 }
664}
665
666fn ignore_pdu_action() -> ActionDefinition {
667 ActionDefinition {
668 name: "ignore_pdu".to_string(),
669 description: "Ignore the IS-IS PDU (no response)".to_string(),
670 parameters: vec![],
671 example: json!({
672 "type": "ignore_pdu"
673 }),
674 log_template: Some(
675 LogTemplate::new()
676 .with_info("-> IS-IS ignore PDU")
677 .with_debug("IS-IS ignore_pdu"),
678 ),
679 }
680}