use crate::llm::actions::{
protocol_trait::{ActionResult, Protocol, Server},
ActionDefinition, Parameter,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{Context, Result};
use serde_json::json;
use std::sync::LazyLock;
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
#[cfg(not(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
pub struct IsisProtocol;
impl IsisProtocol {
pub fn new() -> Self {
Self
}
}
impl Protocol for IsisProtocol {
fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
Some(crate::protocol::BindingDefaults::interface_based(
DEFAULT_LOOPBACK_INTERFACE,
))
}
fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
vec![
crate::llm::actions::ParameterDefinition {
name: "system_id".to_string(),
type_hint: "string".to_string(),
description: "IS-IS System ID in format: 0000.0000.0001 (6 bytes in dotted hex notation)".to_string(),
required: false,
example: json!("0000.0000.0001"),
default: None,
},
crate::llm::actions::ParameterDefinition {
name: "area_id".to_string(),
type_hint: "string".to_string(),
description: "IS-IS Area ID in format: 49.0001 (NSAP format, 49 for private networks)".to_string(),
required: false,
example: json!("49.0001"),
default: None,
},
crate::llm::actions::ParameterDefinition {
name: "level".to_string(),
type_hint: "string".to_string(),
description: "IS-IS level: 'level-1' (intra-area), 'level-2' (inter-area backbone), or 'level-1+2' (both)".to_string(),
required: false,
example: json!("level-2"),
default: None,
},
]
}
fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
Vec::new()
}
fn get_sync_actions(&self) -> Vec<ActionDefinition> {
vec![
send_isis_hello_action(),
send_isis_lsp_action(),
send_isis_pdu_action(),
ignore_pdu_action(),
]
}
fn protocol_name(&self) -> &'static str {
"ISIS"
}
fn get_event_types(&self) -> Vec<EventType> {
get_isis_event_types()
}
fn stack_name(&self) -> &'static str {
"ETH>LLC>ISIS"
}
fn keywords(&self) -> Vec<&'static str> {
vec!["isis", "is-is"]
}
fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
use crate::protocol::metadata::{
DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
};
ProtocolMetadataV2::builder()
.connectionless()
.state(DevelopmentState::Experimental)
.deliberately_silent()
.privilege_requirement(PrivilegeRequirement::PacketCapture)
.implementation("Layer 2 IS-IS with pcap (ISO/IEC 10589, RFC 1195)")
.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")
.e2e_testing(
"tests/capture_startup_reports_failure_test.rs asserts spawn() returns Err for an \
unknown device and for missing capture privilege. PDU handling itself is \
untested: tests/server/isis/e2e_test.rs is #[ignore]d for root.",
)
.notes(
"Layer 2 over Ethernet+LLC (DSAP/SSAP 0xFE), not UDP. Startup failure is \
reported: spawn() awaits the pcap handle and the BPF filter and returns Err, \
so a privilege failure lands in ServerStatus::Error rather than Running. \
NEVER validated against a real router (FRR/Cisco) or any IS-IS speaker - the \
earlier 'interoperable with real routers' claim was unfounded. Whether to \
respond is policy, so with no operator policy the server is a passive listener \
(no response, no LLM call); this passive default is compile-verified since the \
pcap path needs root and the E2E suite is #[ignore]d. LSP/CSNP/PSNP are parsed \
and logged but not handled; there is no LSP database or SPF.",
)
.build()
}
fn description(&self) -> &'static str {
"IS-IS (Intermediate System to Intermediate System) Layer 2 routing protocol server"
}
fn example_prompt(&self) -> &'static str {
"start an is-is router on interface eth0 with system-id 0000.0000.0001 in area 49.0001 at level-2"
}
fn group_name(&self) -> &'static str {
"Experimental"
}
fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
use crate::llm::actions::StartupExamples;
let script = r#"import json, sys
data = json.load(sys.stdin)
event = data["event"]
if data["event_type_id"] == "isis_hello":
actions = [{"type": "send_isis_hello",
"system_id": "0000.0000.0001", "area_id": "49.0001"}]
else:
actions = []
print(json.dumps({"actions": actions}))"#;
StartupExamples::new(
json!({
"type": "open_server",
"port": 0,
"base_stack": "isis",
"instruction": "IS-IS router with system-id 0000.0000.0001 in area 49.0001. Respond to Hello PDUs and form adjacencies with neighbors."
}),
json!({
"type": "open_server",
"port": 0,
"base_stack": "isis",
"event_handlers": [{
"event_pattern": "isis_hello",
"handler": {
"type": "script",
"language": "python",
"code": script
}
}]
}),
json!({
"type": "open_server",
"port": 0,
"base_stack": "isis",
"event_handlers": [{
"event_pattern": "isis_hello",
"handler": {
"type": "static",
"actions": [{
"type": "send_isis_hello",
"pdu_type": "lan_hello_l2",
"system_id": "0000.0000.0001",
"area_id": "49.0001",
"holding_time": 30
}]
}
}]
}),
)
}
}
impl Server for IsisProtocol {
fn spawn(
&self,
ctx: crate::protocol::SpawnContext,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
> {
Box::pin(async move {
use crate::server::isis::IsisServer;
let interface = ctx
.interface()
.context("IS-IS requires network interface")?
.to_string();
let listen_addr = ctx.legacy_listen_addr();
let _interface_name = IsisServer::spawn_with_llm_actions(
interface,
ctx.llm_client,
ctx.state,
ctx.status_tx,
ctx.server_id,
ctx.startup_params,
)
.await?;
Ok(listen_addr)
})
}
fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
let action_type = action
.get("type")
.and_then(|v| v.as_str())
.context("Missing 'type' field in action")?;
match action_type {
"send_isis_hello" => self.execute_send_isis_hello(action),
"send_isis_lsp" => self.execute_send_isis_lsp(action),
"send_isis_pdu" => self.execute_send_isis_pdu(action),
"ignore_pdu" => Ok(ActionResult::NoAction),
_ => Err(anyhow::anyhow!("Unknown IS-IS action: {}", action_type)),
}
}
}
impl IsisProtocol {
fn execute_send_isis_hello(&self, action: serde_json::Value) -> Result<ActionResult> {
let pdu_type_str = action
.get("pdu_type")
.and_then(|v| v.as_str())
.unwrap_or("lan_hello_l2");
let pdu_type = match pdu_type_str {
"lan_hello_l1" => 15,
"lan_hello_l2" => 16,
"p2p_hello" => 17,
_ => 16, };
let system_id = action
.get("system_id")
.and_then(|v| v.as_str())
.unwrap_or("0000.0000.0001");
let area_id = action
.get("area_id")
.and_then(|v| v.as_str())
.unwrap_or("49.0001");
let holding_time = action
.get("holding_time")
.and_then(|v| v.as_u64())
.unwrap_or(30) as u16;
let packet = Self::build_isis_hello(pdu_type, system_id, area_id, holding_time)?;
Ok(ActionResult::Output(packet))
}
fn execute_send_isis_lsp(&self, action: serde_json::Value) -> Result<ActionResult> {
let level = action
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("level-2");
let pdu_type = match level {
"level-1" => 18,
"level-2" => 20,
_ => 20,
};
let system_id = action
.get("system_id")
.and_then(|v| v.as_str())
.unwrap_or("0000.0000.0001");
let packet = Self::build_isis_lsp(pdu_type, system_id)?;
Ok(ActionResult::Output(packet))
}
fn execute_send_isis_pdu(&self, action: serde_json::Value) -> Result<ActionResult> {
let data = action
.get("data")
.and_then(|v| v.as_str())
.context("Missing 'data' parameter")?;
let bytes = hex::decode(data).context("Invalid hex data")?;
Ok(ActionResult::Output(bytes))
}
pub fn build_isis_hello(
pdu_type: u8,
system_id: &str,
area_id: &str,
holding_time: u16,
) -> Result<Vec<u8>> {
let mut packet = Vec::new();
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 {
15 => 1, 16 => 2, 17 => 3, _ => 2,
};
packet.push(circuit_type);
let sys_id_bytes = Self::parse_system_id(system_id)?;
packet.extend_from_slice(&sys_id_bytes);
packet.extend_from_slice(&holding_time.to_be_bytes());
packet.extend_from_slice(&[0, 0]);
if pdu_type == 15 || pdu_type == 16 {
packet.push(64); packet.extend_from_slice(&sys_id_bytes); packet.push(0); }
let tlvs = Self::build_hello_tlvs(area_id, system_id)?;
packet.extend_from_slice(&tlvs);
let header_len = if pdu_type == 15 || pdu_type == 16 {
27 } else {
20 };
packet[1] = header_len;
const PDU_LEN_OFFSET: usize = 17;
let pdu_len = packet.len() as u16;
packet[PDU_LEN_OFFSET..PDU_LEN_OFFSET + 2].copy_from_slice(&pdu_len.to_be_bytes());
Ok(packet)
}
fn build_isis_lsp(pdu_type: u8, system_id: &str) -> Result<Vec<u8>> {
let mut packet = Vec::new();
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)?;
packet.extend_from_slice(&sys_id_bytes);
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;
packet[8..10].copy_from_slice(&pdu_len.to_be_bytes());
Ok(packet)
}
fn build_hello_tlvs(area_id: &str, _system_id: &str) -> Result<Vec<u8>> {
let mut tlvs = Vec::new();
tlvs.push(1); let area_bytes = Self::parse_area_id(area_id)?;
tlvs.push(area_bytes.len() as u8 + 1); tlvs.push(area_bytes.len() as u8); tlvs.extend_from_slice(&area_bytes);
tlvs.push(129); tlvs.push(1); tlvs.push(0xCC);
Ok(tlvs)
}
fn parse_system_id(system_id: &str) -> Result<[u8; 6]> {
let parts: Vec<&str> = system_id.split('.').collect();
if parts.len() != 3 {
return Err(anyhow::anyhow!("Invalid system ID format: {}", system_id));
}
let mut bytes = [0u8; 6];
for (i, part) in parts.iter().enumerate() {
let val = u16::from_str_radix(part, 16)
.context(format!("Invalid hex in system ID: {}", part))?;
bytes[i * 2] = (val >> 8) as u8;
bytes[i * 2 + 1] = val as u8;
}
Ok(bytes)
}
fn parse_area_id(area_id: &str) -> Result<Vec<u8>> {
let parts: Vec<&str> = area_id.split('.').collect();
let mut bytes = Vec::new();
for part in parts {
if part.len() == 2 {
let val = u8::from_str_radix(part, 16)
.context(format!("Invalid hex in area ID: {}", part))?;
bytes.push(val);
} else if part.len() == 4 {
let val = u16::from_str_radix(part, 16)
.context(format!("Invalid hex in area ID: {}", part))?;
bytes.push((val >> 8) as u8);
bytes.push(val as u8);
} else {
return Err(anyhow::anyhow!("Invalid area ID part length: {}", part));
}
}
Ok(bytes)
}
}
pub static ISIS_HELLO_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"isis_hello",
"An IS-IS Hello PDU arrived from a neighbour. Answer with send_isis_hello to adjacency \
with it, send_isis_lsp to advertise link state, send_isis_pdu for any other PDU type, \
or ignore_pdu to stay silent.",
json!({
"type": "send_isis_hello",
"pdu_type": "lan_hello_l2",
"system_id": "0000.0000.0001",
"area_id": "49.0001"
}),
)
.with_actions(vec![
send_isis_hello_action(),
send_isis_lsp_action(),
send_isis_pdu_action(),
ignore_pdu_action(),
])
.with_log_template(
LogTemplate::new()
.with_info("IS-IS {pdu_type} from {peer_addr}")
.with_debug("IS-IS Hello: type={pdu_type} peer={peer_addr} areas={area_addresses}")
.with_trace("IS-IS Hello: {json_pretty(.)}"),
)
});
fn get_isis_event_types() -> Vec<EventType> {
vec![ISIS_HELLO_EVENT.clone()]
}
fn send_isis_hello_action() -> ActionDefinition {
ActionDefinition {
name: "send_isis_hello".to_string(),
description: "Send IS-IS Hello PDU for neighbor discovery".to_string(),
parameters: vec![
Parameter {
name: "pdu_type".to_string(),
type_hint: "string".to_string(),
description: "Hello PDU type: lan_hello_l1, lan_hello_l2, or p2p_hello".to_string(),
required: false,
},
Parameter {
name: "system_id".to_string(),
type_hint: "string".to_string(),
description: "Local system ID in format: 0000.0000.0001".to_string(),
required: false,
},
Parameter {
name: "area_id".to_string(),
type_hint: "string".to_string(),
description: "Area ID in format: 49.0001".to_string(),
required: false,
},
Parameter {
name: "holding_time".to_string(),
type_hint: "number".to_string(),
description: "Holding time in seconds (default: 30)".to_string(),
required: false,
},
],
example: json!({
"type": "send_isis_hello",
"pdu_type": "lan_hello_l2",
"system_id": "0000.0000.0001",
"area_id": "49.0001"
}),
log_template: Some(
LogTemplate::new()
.with_info("-> IS-IS {pdu_type} Hello sys={system_id}")
.with_debug(
"IS-IS send_isis_hello: type={pdu_type} sys_id={system_id} area={area_id}",
),
),
}
}
fn send_isis_lsp_action() -> ActionDefinition {
ActionDefinition {
name: "send_isis_lsp".to_string(),
description: "Send IS-IS Link State PDU".to_string(),
parameters: vec![
Parameter {
name: "level".to_string(),
type_hint: "string".to_string(),
description: "IS-IS level: level-1 or level-2".to_string(),
required: false,
},
Parameter {
name: "system_id".to_string(),
type_hint: "string".to_string(),
description: "Local system ID".to_string(),
required: false,
},
],
example: json!({
"type": "send_isis_lsp",
"level": "level-2",
"system_id": "0000.0000.0001"
}),
log_template: Some(
LogTemplate::new()
.with_info("-> IS-IS LSP {level} sys={system_id}")
.with_debug("IS-IS send_isis_lsp: level={level} sys_id={system_id}"),
),
}
}
fn send_isis_pdu_action() -> ActionDefinition {
ActionDefinition {
name: "send_isis_pdu".to_string(),
description: "Send raw IS-IS PDU from hex data".to_string(),
parameters: vec![Parameter {
name: "data".to_string(),
type_hint: "string".to_string(),
description: "Hex-encoded IS-IS PDU".to_string(),
required: true,
}],
example: json!({
"type": "send_isis_pdu",
"data": "831b01001001000002000000000001001e00244000000000000100\
0104034900018101cc"
}),
log_template: Some(
LogTemplate::new()
.with_info("-> IS-IS raw PDU ({data_len}B)")
.with_debug("IS-IS send_isis_pdu: len={data_len}"),
),
}
}
fn ignore_pdu_action() -> ActionDefinition {
ActionDefinition {
name: "ignore_pdu".to_string(),
description: "Ignore the IS-IS PDU (no response)".to_string(),
parameters: vec![],
example: json!({
"type": "ignore_pdu"
}),
log_template: Some(
LogTemplate::new()
.with_info("-> IS-IS ignore PDU")
.with_debug("IS-IS ignore_pdu"),
),
}
}