1use crate::llm::actions::{
10 protocol_trait::{ActionResult, Protocol, Server},
11 ActionDefinition, Parameter, ParameterDefinition,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::protocol::StartupParams;
16use crate::state::app_state::AppState;
17use anyhow::{bail, Context, Result};
18use serde_json::json;
19use std::sync::LazyLock;
20
21use super::codec::{
22 self, BpduFlags, BridgeId, ConfigBpdu, PortId, PortRole, BPDU_TYPE_CONFIG, BPDU_TYPE_RST,
23 VERSION_RSTP, VERSION_STP,
24};
25
26#[cfg(any(
32 target_os = "macos",
33 target_os = "ios",
34 target_os = "freebsd",
35 target_os = "openbsd",
36 target_os = "netbsd",
37 target_os = "dragonfly"
38))]
39const DEFAULT_LOOPBACK_INTERFACE: &str = "lo0";
40#[cfg(not(any(
41 target_os = "macos",
42 target_os = "ios",
43 target_os = "freebsd",
44 target_os = "openbsd",
45 target_os = "netbsd",
46 target_os = "dragonfly"
47)))]
48const DEFAULT_LOOPBACK_INTERFACE: &str = "lo";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum StpTransport {
53 Raw,
55 Udp,
62}
63
64impl StpTransport {
65 fn from_name(name: &str) -> Result<Self> {
66 match name.trim().to_ascii_lowercase().as_str() {
67 "raw" | "raw802.3" | "ethernet" | "pcap" => Ok(StpTransport::Raw),
68 "udp" | "test" => Ok(StpTransport::Udp),
69 other => bail!("unknown transport '{other}' (expected 'raw' or 'udp')"),
70 }
71 }
72
73 pub fn as_str(self) -> &'static str {
74 match self {
75 StpTransport::Raw => "raw",
76 StpTransport::Udp => "udp",
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
92pub struct StpBridgeConfig {
93 pub transport: StpTransport,
94 pub bridge_mac: [u8; 6],
95 pub bridge_priority: u16,
96 pub system_id_extension: u16,
97 pub port_priority: u8,
98 pub port_number: u16,
99 pub protocol_version: u8,
101 pub hello_time_seconds: f64,
102 pub max_age_seconds: f64,
103 pub forward_delay_seconds: f64,
104}
105
106impl Default for StpBridgeConfig {
107 fn default() -> Self {
108 Self {
109 transport: StpTransport::Raw,
110 bridge_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
112 bridge_priority: 32768,
114 system_id_extension: 0,
115 port_priority: 128,
116 port_number: 1,
117 protocol_version: VERSION_RSTP,
118 hello_time_seconds: 2.0,
120 max_age_seconds: 20.0,
121 forward_delay_seconds: 15.0,
122 }
123 }
124}
125
126impl StpBridgeConfig {
127 pub fn from_startup_params(params: Option<&StartupParams>) -> Result<Self> {
129 let mut config = Self::default();
130 let Some(params) = params else {
131 return Ok(config);
132 };
133
134 if let Some(v) = params.get_optional_string("transport")? {
135 config.transport = StpTransport::from_name(&v)?;
136 }
137 if let Some(v) = params.get_optional_string("bridge_mac")? {
138 config.bridge_mac = codec::parse_mac(&v).context("invalid bridge_mac")?;
139 }
140 if let Some(v) = params.get_optional_i64("bridge_priority")? {
141 let priority = u16::try_from(v)
142 .map_err(|_| anyhow::anyhow!("bridge_priority must be 0..=61440, got {v}"))?;
143 BridgeId::new(priority, 0, config.bridge_mac)?;
146 config.bridge_priority = priority;
147 }
148 if let Some(v) = params.get_optional_i64("system_id_extension")? {
149 let ext = u16::try_from(v)
150 .map_err(|_| anyhow::anyhow!("system_id_extension must be 0..=4095, got {v}"))?;
151 BridgeId::new(config.bridge_priority, ext, config.bridge_mac)?;
152 config.system_id_extension = ext;
153 }
154 if let Some(v) = params.get_optional_i64("port_priority")? {
155 let priority = u8::try_from(v)
156 .map_err(|_| anyhow::anyhow!("port_priority must be 0..=240, got {v}"))?;
157 PortId::new(priority, config.port_number)?;
158 config.port_priority = priority;
159 }
160 if let Some(v) = params.get_optional_i64("port_number")? {
161 let number = u16::try_from(v)
162 .map_err(|_| anyhow::anyhow!("port_number must be 0..=4095, got {v}"))?;
163 PortId::new(config.port_priority, number)?;
164 config.port_number = number;
165 }
166 if let Some(v) = params.get_optional_string("protocol_version")? {
167 config.protocol_version = match v.trim().to_ascii_lowercase().as_str() {
168 "stp" | "802.1d" | "0" => VERSION_STP,
169 "rstp" | "802.1w" | "2" => VERSION_RSTP,
170 other => bail!("unknown protocol_version '{other}' (expected 'stp' or 'rstp')"),
171 };
172 }
173 if let Some(v) = params.get_optional_i64("hello_time")? {
174 config.hello_time_seconds = validated_timer("hello_time", v)?;
175 }
176 if let Some(v) = params.get_optional_i64("max_age")? {
177 config.max_age_seconds = validated_timer("max_age", v)?;
178 }
179 if let Some(v) = params.get_optional_i64("forward_delay")? {
180 config.forward_delay_seconds = validated_timer("forward_delay", v)?;
181 }
182
183 Ok(config)
184 }
185
186 pub fn bridge_id(&self) -> Result<BridgeId> {
188 BridgeId::new(
189 self.bridge_priority,
190 self.system_id_extension,
191 self.bridge_mac,
192 )
193 }
194
195 pub fn port_id(&self) -> Result<PortId> {
196 PortId::new(self.port_priority, self.port_number)
197 }
198
199 fn version_name(&self) -> &'static str {
200 if self.protocol_version >= VERSION_RSTP {
201 "rstp"
202 } else {
203 "stp"
204 }
205 }
206
207 pub fn local_summary(&self) -> serde_json::Value {
209 json!({
210 "local_transport": self.transport.as_str(),
211 "local_bridge_mac": codec::format_mac(&self.bridge_mac),
212 "local_bridge_priority": self.bridge_priority,
213 "local_system_id_extension": self.system_id_extension,
214 "local_port_priority": self.port_priority,
215 "local_port_number": self.port_number,
216 "local_protocol_version": self.version_name(),
217 "local_hello_time": self.hello_time_seconds,
218 "local_max_age": self.max_age_seconds,
219 "local_forward_delay": self.forward_delay_seconds,
220 })
221 }
222
223 pub fn apply_defaults(&self, action: &mut serde_json::Value) {
229 let Some(object) = action.as_object_mut() else {
230 return;
231 };
232 let mut set = |key: &str, value: serde_json::Value| {
233 object.entry(key.to_string()).or_insert(value);
234 };
235 set("protocol_version", json!(self.version_name()));
236 set("source_mac", json!(codec::format_mac(&self.bridge_mac)));
237 set("bridge_mac", json!(codec::format_mac(&self.bridge_mac)));
238 set("bridge_priority", json!(self.bridge_priority));
239 set(
240 "bridge_system_id_extension",
241 json!(self.system_id_extension),
242 );
243 set(
246 "root_bridge_mac",
247 json!(codec::format_mac(&self.bridge_mac)),
248 );
249 set("root_priority", json!(self.bridge_priority));
250 set("root_system_id_extension", json!(self.system_id_extension));
251 set("root_path_cost", json!(0));
252 set("port_priority", json!(self.port_priority));
253 set("port_number", json!(self.port_number));
254 set("message_age", json!(0));
255 set("max_age", json!(self.max_age_seconds));
256 set("hello_time", json!(self.hello_time_seconds));
257 set("forward_delay", json!(self.forward_delay_seconds));
258 }
259}
260
261fn validated_timer(name: &str, value: i64) -> Result<f64> {
262 if !(0..=255).contains(&value) {
263 bail!("{name} must be 0..=255 seconds (the wire field is 16 bits of 1/256s), got {value}");
264 }
265 Ok(value as f64)
266}
267
268fn field_u16(action: &serde_json::Value, key: &str, default: u16) -> Result<u16> {
273 match action.get(key) {
274 None | Some(serde_json::Value::Null) => Ok(default),
275 Some(v) => {
276 let n = v
277 .as_i64()
278 .with_context(|| format!("'{key}' must be an integer, got {v}"))?;
279 u16::try_from(n).map_err(|_| anyhow::anyhow!("'{key}' out of range: {n}"))
280 }
281 }
282}
283
284fn field_u32(action: &serde_json::Value, key: &str, default: u32) -> Result<u32> {
285 match action.get(key) {
286 None | Some(serde_json::Value::Null) => Ok(default),
287 Some(v) => {
288 let n = v
289 .as_i64()
290 .with_context(|| format!("'{key}' must be an integer, got {v}"))?;
291 u32::try_from(n).map_err(|_| anyhow::anyhow!("'{key}' out of range: {n}"))
292 }
293 }
294}
295
296fn field_u8(action: &serde_json::Value, key: &str, default: u8) -> Result<u8> {
297 match action.get(key) {
298 None | Some(serde_json::Value::Null) => Ok(default),
299 Some(v) => {
300 let n = v
301 .as_i64()
302 .with_context(|| format!("'{key}' must be an integer, got {v}"))?;
303 u8::try_from(n).map_err(|_| anyhow::anyhow!("'{key}' out of range: {n}"))
304 }
305 }
306}
307
308fn field_f64(action: &serde_json::Value, key: &str, default: f64) -> Result<f64> {
309 match action.get(key) {
310 None | Some(serde_json::Value::Null) => Ok(default),
311 Some(v) => v
312 .as_f64()
313 .with_context(|| format!("'{key}' must be a number of seconds, got {v}")),
314 }
315}
316
317fn field_bool(action: &serde_json::Value, key: &str) -> Result<bool> {
318 match action.get(key) {
319 None | Some(serde_json::Value::Null) => Ok(false),
320 Some(v) => v
321 .as_bool()
322 .with_context(|| format!("'{key}' must be true or false, got {v}")),
323 }
324}
325
326fn field_mac(action: &serde_json::Value, key: &str, default: [u8; 6]) -> Result<[u8; 6]> {
327 match action.get(key) {
328 None | Some(serde_json::Value::Null) => Ok(default),
329 Some(v) => {
330 let s = v
331 .as_str()
332 .with_context(|| format!("'{key}' must be a MAC address string, got {v}"))?;
333 codec::parse_mac(s).with_context(|| format!("invalid {key}"))
334 }
335 }
336}
337
338impl StpProtocol {
339 pub fn config_bpdu_from_action(action: &serde_json::Value) -> Result<ConfigBpdu> {
347 let defaults = StpBridgeConfig::default();
348
349 let version = match action.get("protocol_version").and_then(|v| v.as_str()) {
350 None => defaults.protocol_version,
351 Some(name) => match name.trim().to_ascii_lowercase().as_str() {
352 "stp" | "802.1d" => VERSION_STP,
353 "rstp" | "802.1w" => VERSION_RSTP,
354 other => bail!("unknown protocol_version '{other}' (expected 'stp' or 'rstp')"),
355 },
356 };
357 let bpdu_type = if version >= VERSION_RSTP {
358 BPDU_TYPE_RST
359 } else {
360 BPDU_TYPE_CONFIG
361 };
362
363 let port_role = match action.get("port_role").and_then(|v| v.as_str()) {
364 Some(name) => PortRole::from_name(name)?,
365 None if version >= VERSION_RSTP => PortRole::Designated,
366 None => PortRole::Unknown,
367 };
368
369 let flags = BpduFlags {
370 topology_change: field_bool(action, "topology_change")?,
371 proposal: field_bool(action, "proposal")?,
372 port_role,
373 learning: field_bool(action, "learning")?,
374 forwarding: field_bool(action, "forwarding")?,
375 agreement: field_bool(action, "agreement")?,
376 topology_change_ack: field_bool(action, "topology_change_ack")?,
377 };
378
379 let bridge_mac = field_mac(action, "bridge_mac", defaults.bridge_mac)?;
380 let bridge = BridgeId::new(
381 field_u16(action, "bridge_priority", defaults.bridge_priority)?,
382 field_u16(
383 action,
384 "bridge_system_id_extension",
385 defaults.system_id_extension,
386 )?,
387 bridge_mac,
388 )?;
389 let root = BridgeId::new(
390 field_u16(action, "root_priority", bridge.priority)?,
391 field_u16(
392 action,
393 "root_system_id_extension",
394 bridge.system_id_extension,
395 )?,
396 field_mac(action, "root_bridge_mac", bridge_mac)?,
397 )?;
398 let port = PortId::new(
399 field_u8(action, "port_priority", defaults.port_priority)?,
400 field_u16(action, "port_number", defaults.port_number)?,
401 )?;
402
403 let bpdu = ConfigBpdu {
404 version,
405 bpdu_type,
406 flags,
407 root,
408 root_path_cost: field_u32(action, "root_path_cost", 0)?,
409 bridge,
410 port,
411 message_age_seconds: field_f64(action, "message_age", 0.0)?,
412 max_age_seconds: field_f64(action, "max_age", defaults.max_age_seconds)?,
413 hello_time_seconds: field_f64(action, "hello_time", defaults.hello_time_seconds)?,
414 forward_delay_seconds: field_f64(
415 action,
416 "forward_delay",
417 defaults.forward_delay_seconds,
418 )?,
419 };
420
421 bpdu.encode()?;
424 Ok(bpdu)
425 }
426
427 pub fn destination_from_action(action: &serde_json::Value) -> Result<[u8; 6]> {
429 field_mac(action, "destination_mac", codec::STP_MULTICAST_MAC)
430 }
431
432 pub fn source_from_action(action: &serde_json::Value, fallback: [u8; 6]) -> Result<[u8; 6]> {
434 field_mac(action, "source_mac", fallback)
435 }
436}
437
438pub struct StpProtocol;
444
445impl StpProtocol {
446 pub fn new() -> Self {
447 Self
448 }
449}
450
451impl Default for StpProtocol {
452 fn default() -> Self {
453 Self::new()
454 }
455}
456
457impl Protocol for StpProtocol {
458 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
459 Some(crate::protocol::BindingDefaults {
463 mac_address: None,
464 interface: Some(DEFAULT_LOOPBACK_INTERFACE.to_string()),
465 host: Some("127.0.0.1".to_string()),
466 port: Some(0),
467 })
468 }
469
470 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
471 vec![
472 ParameterDefinition {
473 name: "transport".to_string(),
474 type_hint: "string".to_string(),
475 description: "'raw' (default) sends and receives real 802.3 BPDU frames through \
476 libpcap and needs CAP_NET_RAW or /dev/bpf* access. 'udp' carries \
477 one complete 802.3 frame per UDP datagram on the configured \
478 host/port — the same frames, no privilege, no link layer — for \
479 testing and for driving the protocol from a script."
480 .to_string(),
481 required: false,
482 example: json!("raw"),
483 default: None,
484 },
485 ParameterDefinition {
486 name: "bridge_mac".to_string(),
487 type_hint: "string".to_string(),
488 description: "MAC address of this bridge, used as the source address of every \
489 frame and as the MAC half of the bridge identifier \
490 (aa:bb:cc:dd:ee:ff)."
491 .to_string(),
492 required: false,
493 example: json!("02:00:00:00:00:01"),
494 default: None,
495 },
496 ParameterDefinition {
497 name: "bridge_priority".to_string(),
498 type_hint: "number".to_string(),
499 description: "Bridge priority, 0..=61440 in steps of 4096 (default 32768). \
500 LOWER WINS THE ROOT ELECTION: a bridge that advertises 0 claims \
501 to be the root of the whole spanning tree and every switch that \
502 believes it will re-converge around it."
503 .to_string(),
504 required: false,
505 example: json!(32768),
506 default: None,
507 },
508 ParameterDefinition {
509 name: "system_id_extension".to_string(),
510 type_hint: "number".to_string(),
511 description: "System ID extension, 0..=4095 — the VLAN id in per-VLAN spanning \
512 tree. Occupies the low 12 bits of the same 16-bit field as the \
513 priority."
514 .to_string(),
515 required: false,
516 example: json!(0),
517 default: None,
518 },
519 ParameterDefinition {
520 name: "port_priority".to_string(),
521 type_hint: "number".to_string(),
522 description: "Port priority, 0..=240 in steps of 16 (default 128). High 4 bits \
523 of the port identifier."
524 .to_string(),
525 required: false,
526 example: json!(128),
527 default: None,
528 },
529 ParameterDefinition {
530 name: "port_number".to_string(),
531 type_hint: "number".to_string(),
532 description: "Port number, 0..=4095 (default 1). Low 12 bits of the port \
533 identifier."
534 .to_string(),
535 required: false,
536 example: json!(1),
537 default: None,
538 },
539 ParameterDefinition {
540 name: "protocol_version".to_string(),
541 type_hint: "string".to_string(),
542 description: "'rstp' (default, 802.1w, version 2, RST BPDUs) or 'stp' (802.1D, \
543 version 0, configuration BPDUs). Decides what this bridge emits; \
544 received BPDUs of either kind are always understood."
545 .to_string(),
546 required: false,
547 example: json!("rstp"),
548 default: None,
549 },
550 ParameterDefinition {
551 name: "hello_time".to_string(),
552 type_hint: "number".to_string(),
553 description: "Hello time in whole seconds, 0..=255 (default 2). Encoded on the \
554 wire in units of 1/256 second."
555 .to_string(),
556 required: false,
557 example: json!(2),
558 default: None,
559 },
560 ParameterDefinition {
561 name: "max_age".to_string(),
562 type_hint: "number".to_string(),
563 description: "Max age in whole seconds, 0..=255 (default 20). Encoded on the \
564 wire in units of 1/256 second."
565 .to_string(),
566 required: false,
567 example: json!(20),
568 default: None,
569 },
570 ParameterDefinition {
571 name: "forward_delay".to_string(),
572 type_hint: "number".to_string(),
573 description: "Forward delay in whole seconds, 0..=255 (default 15). Encoded on \
574 the wire in units of 1/256 second."
575 .to_string(),
576 required: false,
577 example: json!(15),
578 default: None,
579 },
580 ]
581 }
582
583 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
584 stp_actions()
588 }
589
590 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
591 stp_actions()
592 }
593
594 fn protocol_name(&self) -> &'static str {
595 "STP"
596 }
597
598 fn get_event_types(&self) -> Vec<EventType> {
599 get_stp_event_types()
600 }
601
602 fn stack_name(&self) -> &'static str {
603 "ETH>LLC>STP"
604 }
605
606 fn keywords(&self) -> Vec<&'static str> {
607 vec![
608 "stp",
609 "rstp",
610 "spanning tree",
611 "bpdu",
612 "802.1d",
613 "802.1w",
614 "bridge",
615 "root bridge",
616 ]
617 }
618
619 fn startup_privilege_requirement(
620 &self,
621
622 startup_params: Option<&serde_json::Value>,
623 ) -> crate::protocol::metadata::PrivilegeRequirement {
624 crate::llm::actions::protocol_trait::unless_udp_transport(
627 self.metadata().privilege_requirement,
628 startup_params,
629 )
630 }
631
632 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
633 use crate::protocol::metadata::{
634 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
635 };
636
637 ProtocolMetadataV2::builder()
638 .state(DevelopmentState::Experimental)
639 .privilege_requirement(PrivilegeRequirement::PacketCapture)
643 .connectionless()
644 .deliberately_silent()
648 .implementation(
649 "Hand-written IEEE 802.1D-2004 / 802.1w BPDU codec (src/server/stp/codec.rs), \
650 pure and I/O-free, under two transports: real 802.3 LLC frames via libpcap, \
651 and a UDP transport carrying one complete 802.3 frame \
652 per datagram for unprivileged use.",
653 )
654 .llm_control(
655 "Every bridge parameter: bridge and root priority, system ID extension (VLAN), \
656 MAC, path cost, port identifier, all four timers, and each RSTP flag \
657 (proposal, agreement, learning, forwarding, port role). The model decides \
658 whether to answer at all — no spanning-tree state machine runs, and nothing is \
659 emitted unprompted.",
660 )
661 .e2e_testing(
662 "tests/server/stp/codec_test.rs asserts the encoder byte-for-byte against \
663 literal specification bytes (including the 1/256-second timer encoding and the \
664 4/12-bit priority packing) and decodes literal config, RST and TCN BPDUs. \
665 tests/server/stp/e2e_test.rs runs the full frame -> event -> mocked LLM -> \
666 action -> frame path over the UDP transport, unprivileged, and asserts that an \
667 LLM failure produces no frame at all.",
668 )
669 .notes(
670 "PROVEN: the BPDU codec, against literal spec bytes in both directions, and the \
671 whole decision path (decode, event, handler/LLM dispatch, action, re-encode, \
672 transmit) over the UDP transport. NOT PROVEN: the raw 802.3 transport has \
673 never been executed — it needs CAP_NET_RAW or /dev/bpf* and no test in this \
674 tree runs privileged — and no third-party STP peer (mstpd, a real switch) has \
675 ever spoken to this server. Also note the privilege gate is protocol-wide: \
676 because this protocol declares RawSockets, server_startup refuses to start it \
677 unprivileged even with transport='udp', so the UDP transport is reachable by \
678 calling spawn() directly, which is what the e2e suite does. ON THE WIRE, \
679 SILENCE IS THE FAILURE MODE: every BPDU is a positive assertion about \
680 topology, and a fabricated one can trigger a real re-convergence, so an LLM \
681 error emits nothing and records decision=fail_closed_* in the log instead.",
682 )
683 .max_inbound_bytes(crate::server::stp::codec::MAX_8023_LENGTH)
684 .build()
685 }
686
687 fn description(&self) -> &'static str {
688 "STP / RSTP (IEEE 802.1D / 802.1w) spanning tree bridge — receives BPDUs and emits \
689 LLM-decided ones"
690 }
691
692 fn example_prompt(&self) -> &'static str {
693 "Listen for spanning tree BPDUs on eth0 and report which bridge is claiming to be root"
694 }
695
696 fn group_name(&self) -> &'static str {
697 "Network"
698 }
699
700 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
701 use crate::llm::actions::StartupExamples;
702
703 let script = r#"import json, sys
706data = json.load(sys.stdin)
707event = data["event"]
708print(json.dumps({
709 "actions": [{"type": "no_bpdu"}],
710 "message": "root %s/%s cost %s from %s" % (
711 event.get("root_priority"), event.get("root_bridge_mac"),
712 event.get("root_path_cost"), event.get("source_mac"))
713}))"#;
714
715 StartupExamples::new(
716 json!({
717 "type": "open_server",
718 "interface": "eth0",
719 "base_stack": "stp",
720 "instruction": "Observe spanning tree BPDUs. Report which bridge is root and \
721 what it claims. Do not send anything.",
722 "startup_params": {
723 "bridge_mac": "02:00:00:00:00:01",
724 "bridge_priority": 32768
725 }
726 }),
727 json!({
728 "type": "open_server",
729 "interface": "eth0",
730 "base_stack": "stp",
731 "event_handlers": [{
732 "event_pattern": "stp_bpdu_received",
733 "handler": {
734 "type": "script",
735 "language": "python",
736 "code": script
737 }
738 }]
739 }),
740 json!({
741 "type": "open_server",
742 "interface": "eth0",
743 "base_stack": "stp",
744 "event_handlers": [{
745 "event_pattern": "*",
746 "handler": {
747 "type": "static",
748 "actions": [{"type": "no_bpdu"}]
749 }
750 }]
751 }),
752 )
753 }
754}
755
756impl Server for StpProtocol {
757 fn spawn(
758 &self,
759 ctx: crate::protocol::SpawnContext,
760 ) -> std::pin::Pin<
761 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
762 > {
763 Box::pin(async move {
764 use super::StpServer;
765
766 let config = StpBridgeConfig::from_startup_params(ctx.startup_params.as_ref())?;
767 let interface = ctx.interface().map(|s| s.to_string());
768 let listen_addr = ctx.legacy_listen_addr();
769
770 StpServer::spawn_with_llm_actions(
771 interface,
772 listen_addr,
773 config,
774 ctx.llm_client,
775 ctx.state,
776 ctx.status_tx,
777 ctx.server_id,
778 )
779 .await
780 })
781 }
782
783 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
784 let action_type = action
785 .get("type")
786 .and_then(|v| v.as_str())
787 .context("Missing 'type' field in action")?;
788
789 match action_type {
790 "send_stp_bpdu" => {
791 Self::config_bpdu_from_action(&action)?;
795 Self::destination_from_action(&action)?;
796 Ok(ActionResult::Custom {
797 name: "stp_action".to_string(),
798 data: action,
799 })
800 }
801 "send_stp_tcn" => {
802 Self::destination_from_action(&action)?;
803 Ok(ActionResult::Custom {
804 name: "stp_action".to_string(),
805 data: action,
806 })
807 }
808 "no_bpdu" => Ok(ActionResult::NoAction),
809 other => Err(anyhow::anyhow!("Unknown STP action: {}", other)),
810 }
811 }
812}
813
814fn stp_actions() -> Vec<ActionDefinition> {
819 vec![
820 send_stp_bpdu_action(),
821 send_stp_tcn_action(),
822 no_bpdu_action(),
823 ]
824}
825
826fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
827 Parameter {
828 name: name.to_string(),
829 type_hint: type_hint.to_string(),
830 description: description.to_string(),
831 required,
832 }
833}
834
835fn send_stp_bpdu_action() -> ActionDefinition {
836 ActionDefinition {
837 name: "send_stp_bpdu".to_string(),
838 description:
839 "Transmit a spanning tree configuration BPDU (802.1D) or RST BPDU (802.1w). \
840 DANGEROUS ON A REAL NETWORK: a BPDU is an assertion about topology, and a \
841 root_priority lower than the current root's makes every listening switch \
842 re-converge the spanning tree around this bridge, briefly blackholing traffic. \
843 Every field is optional; anything omitted comes from the server's startup \
844 parameters."
845 .to_string(),
846 parameters: vec![
847 param(
848 "protocol_version",
849 "string",
850 "'rstp' (802.1w, version 2, RST BPDU) or 'stp' (802.1D, version 0, \
851 configuration BPDU). Defaults to the server's configured version.",
852 false,
853 ),
854 param(
855 "root_bridge_mac",
856 "string",
857 "MAC half of the root bridge identifier this BPDU advertises \
858 (aa:bb:cc:dd:ee:ff).",
859 false,
860 ),
861 param(
862 "root_priority",
863 "number",
864 "Priority half of the root bridge identifier, 0..=61440 in steps of 4096. Lower \
865 wins the election — 0 claims the root of the entire spanning tree.",
866 false,
867 ),
868 param(
869 "root_system_id_extension",
870 "number",
871 "System ID extension (VLAN id) of the root identifier, 0..=4095.",
872 false,
873 ),
874 param(
875 "root_path_cost",
876 "number",
877 "Cost of the path from this bridge to the advertised root. 0 means this bridge \
878 is the root.",
879 false,
880 ),
881 param(
882 "bridge_mac",
883 "string",
884 "MAC half of this bridge's own identifier.",
885 false,
886 ),
887 param(
888 "bridge_priority",
889 "number",
890 "Priority half of this bridge's own identifier, 0..=61440 in steps of 4096.",
891 false,
892 ),
893 param(
894 "bridge_system_id_extension",
895 "number",
896 "System ID extension (VLAN id) of this bridge's identifier, 0..=4095.",
897 false,
898 ),
899 param(
900 "port_priority",
901 "number",
902 "Port priority, 0..=240 in steps of 16.",
903 false,
904 ),
905 param("port_number", "number", "Port number, 0..=4095.", false),
906 param(
907 "message_age",
908 "number",
909 "Message age in seconds. Wire encoding is 1/256s, handled for you.",
910 false,
911 ),
912 param("max_age", "number", "Max age in seconds (typically 20).", false),
913 param(
914 "hello_time",
915 "number",
916 "Hello time in seconds (typically 2).",
917 false,
918 ),
919 param(
920 "forward_delay",
921 "number",
922 "Forward delay in seconds (typically 15).",
923 false,
924 ),
925 param(
926 "topology_change",
927 "boolean",
928 "Topology change flag (bit 0). Tells receivers to age out their forwarding \
929 tables quickly.",
930 false,
931 ),
932 param(
933 "topology_change_ack",
934 "boolean",
935 "Topology change acknowledgement flag (bit 7).",
936 false,
937 ),
938 param(
939 "proposal",
940 "boolean",
941 "RSTP proposal flag (bit 1) — offers the rapid transition handshake.",
942 false,
943 ),
944 param(
945 "agreement",
946 "boolean",
947 "RSTP agreement flag (bit 6) — the answer to a proposal.",
948 false,
949 ),
950 param("learning", "boolean", "RSTP learning flag (bit 4).", false),
951 param(
952 "forwarding",
953 "boolean",
954 "RSTP forwarding flag (bit 5).",
955 false,
956 ),
957 param(
958 "port_role",
959 "string",
960 "RSTP port role: 'designated', 'root', 'alternate' (or 'backup'), or 'unknown'.",
961 false,
962 ),
963 param(
964 "destination_mac",
965 "string",
966 "Destination MAC. Defaults to the Bridge Group Address 01:80:c2:00:00:00, which \
967 is where BPDUs belong; override only to target one bridge.",
968 false,
969 ),
970 param(
971 "source_mac",
972 "string",
973 "Source MAC of the frame. Defaults to the server's configured bridge_mac.",
974 false,
975 ),
976 ],
977 example: json!({
978 "type": "send_stp_bpdu",
979 "protocol_version": "rstp",
980 "root_bridge_mac": "02:00:00:00:00:01",
981 "root_priority": 32768,
982 "root_system_id_extension": 0,
983 "root_path_cost": 0,
984 "bridge_mac": "02:00:00:00:00:01",
985 "bridge_priority": 32768,
986 "bridge_system_id_extension": 0,
987 "port_priority": 128,
988 "port_number": 1,
989 "message_age": 0,
990 "max_age": 20,
991 "hello_time": 2,
992 "forward_delay": 15,
993 "port_role": "designated",
994 "learning": true,
995 "forwarding": true
996 }),
997 log_template: Some(
998 LogTemplate::new()
999 .with_info("-> STP BPDU root {root_priority}/{root_bridge_mac} cost {root_path_cost}")
1000 .with_debug(
1001 "STP send_stp_bpdu: version={protocol_version} root={root_priority}/{root_bridge_mac} \
1002 bridge={bridge_priority}/{bridge_mac} port={port_priority}.{port_number} role={port_role}",
1003 )
1004 .with_trace("STP BPDU action: {json_pretty(.)}"),
1005 ),
1006 }
1007}
1008
1009fn send_stp_tcn_action() -> ActionDefinition {
1010 ActionDefinition {
1011 name: "send_stp_tcn".to_string(),
1012 description:
1013 "Transmit a Topology Change Notification BPDU. It carries no fields — its whole \
1014 content is 'something changed' — and a real bridge answers by flooding the change \
1015 towards the root, which shortens every switch's MAC ageing timer on the segment. \
1016 Send it only when you mean to."
1017 .to_string(),
1018 parameters: vec![
1019 param(
1020 "destination_mac",
1021 "string",
1022 "Destination MAC. Defaults to the Bridge Group Address 01:80:c2:00:00:00.",
1023 false,
1024 ),
1025 param(
1026 "source_mac",
1027 "string",
1028 "Source MAC of the frame. Defaults to the server's configured bridge_mac.",
1029 false,
1030 ),
1031 ],
1032 example: json!({ "type": "send_stp_tcn" }),
1033 log_template: Some(
1034 LogTemplate::new()
1035 .with_info("-> STP topology change notification")
1036 .with_debug("STP send_stp_tcn: destination={destination_mac}"),
1037 ),
1038 }
1039}
1040
1041fn no_bpdu_action() -> ActionDefinition {
1042 ActionDefinition {
1043 name: "no_bpdu".to_string(),
1044 description:
1045 "Answer with nothing. This is a real decision, not a failure: a spanning tree \
1046 listener that observes without transmitting cannot perturb the topology, and it is \
1047 the correct answer whenever you are unsure. Prefer it to guessing a bridge \
1048 priority."
1049 .to_string(),
1050 parameters: vec![],
1051 example: json!({ "type": "no_bpdu" }),
1052 log_template: Some(
1053 LogTemplate::new()
1054 .with_info("-> STP silent (no BPDU)")
1055 .with_debug("STP no_bpdu: observed, nothing transmitted"),
1056 ),
1057 }
1058}
1059
1060fn bpdu_event_parameters() -> Vec<Parameter> {
1065 vec![
1066 param(
1067 "bpdu_type",
1068 "string",
1069 "'configuration', 'rst' or 'topology_change_notification'.",
1070 true,
1071 ),
1072 param(
1073 "protocol_version",
1074 "string",
1075 "'stp' (version 0) or 'rstp' (version 2); higher versions are reported as their \
1076 number.",
1077 true,
1078 ),
1079 param(
1080 "is_rstp",
1081 "boolean",
1082 "True for an RST BPDU (type 0x02).",
1083 true,
1084 ),
1085 param(
1086 "is_tcn",
1087 "boolean",
1088 "True for a Topology Change Notification BPDU (type 0x80), which carries no other \
1089 fields.",
1090 true,
1091 ),
1092 param(
1093 "source_mac",
1094 "string",
1095 "Source MAC of the 802.3 frame — the neighbouring bridge port that sent it.",
1096 true,
1097 ),
1098 param(
1099 "destination_mac",
1100 "string",
1101 "Destination MAC, normally the Bridge Group Address 01:80:c2:00:00:00.",
1102 true,
1103 ),
1104 param(
1105 "root_bridge_mac",
1106 "string",
1107 "MAC half of the root bridge identifier the sender believes in.",
1108 false,
1109 ),
1110 param(
1111 "root_priority",
1112 "number",
1113 "Priority half of the root identifier, a multiple of 4096. Lower wins.",
1114 false,
1115 ),
1116 param(
1117 "root_system_id_extension",
1118 "number",
1119 "System ID extension (VLAN) of the root identifier.",
1120 false,
1121 ),
1122 param(
1123 "root_path_cost",
1124 "number",
1125 "The sender's cost to reach that root. 0 means the sender is the root.",
1126 false,
1127 ),
1128 param(
1129 "bridge_mac",
1130 "string",
1131 "MAC half of the sending bridge's own identifier.",
1132 false,
1133 ),
1134 param(
1135 "bridge_priority",
1136 "number",
1137 "Priority half of the sending bridge's identifier.",
1138 false,
1139 ),
1140 param(
1141 "bridge_system_id_extension",
1142 "number",
1143 "System ID extension (VLAN) of the sending bridge's identifier.",
1144 false,
1145 ),
1146 param(
1147 "port_priority",
1148 "number",
1149 "Priority half of the sender's port identifier.",
1150 false,
1151 ),
1152 param(
1153 "port_number",
1154 "number",
1155 "Port number half of the sender's port identifier.",
1156 false,
1157 ),
1158 param(
1159 "flags",
1160 "object",
1161 "The flags octet decoded: topology_change, topology_change_ack, proposal, \
1162 agreement, learning, forwarding, port_role.",
1163 false,
1164 ),
1165 param(
1166 "message_age",
1167 "number",
1168 "Message age in seconds (decoded from 1/256s units).",
1169 false,
1170 ),
1171 param("max_age", "number", "Max age in seconds.", false),
1172 param("hello_time", "number", "Hello time in seconds.", false),
1173 param(
1174 "forward_delay",
1175 "number",
1176 "Forward delay in seconds.",
1177 false,
1178 ),
1179 param(
1180 "local_bridge_priority",
1181 "number",
1182 "This server's own configured bridge priority, for comparison with root_priority.",
1183 false,
1184 ),
1185 param(
1186 "local_bridge_mac",
1187 "string",
1188 "This server's own configured bridge MAC.",
1189 false,
1190 ),
1191 ]
1192}
1193
1194pub static STP_BPDU_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1195 EventType::new(
1196 "stp_bpdu_received",
1197 "A spanning tree BPDU arrived that does not signal a topology change: a configuration \
1198 BPDU (802.1D) or an RST BPDU (802.1w) with the topology-change flag clear. Answer with \
1199 no_bpdu unless you intend to take part in the spanning tree.",
1200 json!({ "type": "no_bpdu" }),
1201 )
1202 .with_parameters(bpdu_event_parameters())
1203 .with_actions(stp_actions())
1204 .with_alternative_example(json!({
1205 "type": "send_stp_bpdu",
1206 "protocol_version": "rstp",
1207 "root_bridge_mac": "02:00:00:00:00:01",
1208 "root_priority": 0,
1209 "root_path_cost": 0,
1210 "bridge_mac": "02:00:00:00:00:01",
1211 "bridge_priority": 0,
1212 "port_role": "designated",
1213 "learning": true,
1214 "forwarding": true
1215 }))
1216 .with_log_template(
1217 LogTemplate::new()
1218 .with_info(
1219 "STP {bpdu_type} from {source_mac}: root {root_priority}/{root_bridge_mac} cost {root_path_cost}",
1220 )
1221 .with_debug(
1222 "STP {bpdu_type} from {source_mac}: root={root_priority}/{root_bridge_mac} \
1223 bridge={bridge_priority}/{bridge_mac} port={port_priority}.{port_number} \
1224 age={message_age} max_age={max_age} hello={hello_time} fwd={forward_delay}",
1225 )
1226 .with_trace("STP BPDU: {json_pretty(.)}"),
1227 )
1228});
1229
1230pub static STP_TOPOLOGY_CHANGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1231 EventType::new(
1232 "stp_topology_change",
1233 "The segment signalled a topology change: either a Topology Change Notification BPDU \
1234 (type 0x80), or a configuration/RST BPDU with the topology-change flag set. Downstream \
1235 switches will shorten their MAC ageing timers. Answer with no_bpdu unless you intend to \
1236 propagate or acknowledge it.",
1237 json!({ "type": "no_bpdu" }),
1238 )
1239 .with_parameters({
1240 let mut params = bpdu_event_parameters();
1241 params.push(param(
1242 "change_reason",
1243 "string",
1244 "'tcn_bpdu' when a Topology Change Notification arrived, 'topology_change_flag' when \
1245 a configuration or RST BPDU carried the flag.",
1246 true,
1247 ));
1248 params
1249 })
1250 .with_actions(stp_actions())
1251 .with_alternative_example(json!({ "type": "send_stp_tcn" }))
1252 .with_log_template(
1253 LogTemplate::new()
1254 .with_info("STP topology change from {source_mac} ({change_reason})")
1255 .with_debug(
1256 "STP topology change from {source_mac}: reason={change_reason} \
1257 root={root_priority}/{root_bridge_mac}",
1258 )
1259 .with_trace("STP topology change: {json_pretty(.)}"),
1260 )
1261});
1262
1263pub fn get_stp_event_types() -> Vec<EventType> {
1264 vec![
1265 STP_BPDU_RECEIVED_EVENT.clone(),
1266 STP_TOPOLOGY_CHANGE_EVENT.clone(),
1267 ]
1268}