Skip to main content

netget/server/stp/
actions.rs

1//! STP / RSTP protocol actions, events, and the bridge configuration read from startup
2//! parameters.
3//!
4//! Everything the model sees is structured: bridge priority and system ID extension are two
5//! numbers, the flags octet is seven named booleans and an enum, and the timers are seconds.
6//! No hex string, no byte blob, and nothing in an action or an event that a model would have
7//! to encode by hand. [`super::codec`] turns those values into wire octets.
8
9use 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/// Name of the loopback interface on this platform.
27///
28/// Only relevant to the raw transport's default binding. Loopback is a deliberately useless
29/// default for STP — there is no bridged segment there — but it is the one interface that
30/// exists everywhere, and `spawn()` refuses with a clear message rather than pretending.
31#[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/// Which wire this server speaks on.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum StpTransport {
53    /// Real 802.3 frames through libpcap. Needs `CAP_NET_RAW` / `/dev/bpf*`.
54    Raw,
55    /// Complete 802.3 frames carried one-per-datagram over UDP.
56    ///
57    /// This exists so the event → LLM → action → frame path can be exercised without
58    /// privilege; the frame in the datagram is byte-identical to the frame the raw transport
59    /// would put on the wire, so the codec and the decision path are the real ones. Only the
60    /// link layer is simulated. See `src/server/stp/CLAUDE.md`.
61    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/// This bridge's own identity and timers, from the startup parameters.
82///
83/// Two things read it, which is what makes every declared parameter live:
84///
85/// 1. [`StpBridgeConfig::apply_defaults`] fills in every field the model's action left out,
86///    immediately before the frame is built. An action that names a field keeps its own value,
87///    so a prompt can still deliberately advertise a different bridge from the configured one.
88/// 2. [`StpBridgeConfig::local_summary`] is attached to every event as `local_*`, so the model
89///    can compare what arrived against what this bridge is configured to claim — which is the
90///    whole root-election question.
91#[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    /// 0 (STP) or 2 (RSTP).
100    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            // Locally administered, so it cannot collide with a real vendor OUI.
111            bridge_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
112            // 802.1D-2004 §17.14 default bridge priority.
113            bridge_priority: 32768,
114            system_id_extension: 0,
115            port_priority: 128,
116            port_number: 1,
117            protocol_version: VERSION_RSTP,
118            // 802.1D-2004 Table 17-1 recommended values.
119            hello_time_seconds: 2.0,
120            max_age_seconds: 20.0,
121            forward_delay_seconds: 15.0,
122        }
123    }
124}
125
126impl StpBridgeConfig {
127    /// Read every declared startup parameter. Errors propagate; nothing is unwrapped.
128    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            // Validated here rather than at send time so a misconfigured bridge refuses to
144            // start instead of failing on every BPDU it tries to emit.
145            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    /// This bridge's own identifier, as it would appear on the wire.
187    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    /// The `local_*` block attached to every event.
208    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    /// Fill in whatever the model's action left out, from this bridge's configuration.
224    ///
225    /// Without this the builder would fall back to protocol constants and the operator's
226    /// configured identity would never reach the wire — the defect `ospf` had for four of its
227    /// six parameters.
228    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        // With no better information this bridge claims itself as root, which is what a
244        // freshly started bridge does before it hears anything.
245        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
268// ---------------------------------------------------------------------------
269// Action JSON -> codec types
270// ---------------------------------------------------------------------------
271
272fn 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    /// Build the BPDU an action describes.
340    ///
341    /// Any field the action omits falls back to a protocol default here, so the action's own
342    /// declared `example` is executable on its own — `tests/executable_examples_test.rs`
343    /// calls `execute_action` with nothing else in scope. `mod.rs` layers the operator's
344    /// [`StpBridgeConfig`] defaults on top *before* this runs, so a live server's omissions
345    /// come from its configuration rather than from these constants.
346    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        // Encode once here so an action that cannot become a frame is rejected at execution
422        // time, where the error reaches the model, rather than deep in the transport.
423        bpdu.encode()?;
424        Ok(bpdu)
425    }
426
427    /// The destination MAC an action asks for, defaulting to the Bridge Group Address.
428    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    /// The source MAC an action asks for.
433    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
438// ---------------------------------------------------------------------------
439// Protocol
440// ---------------------------------------------------------------------------
441
442/// STP / RSTP protocol handler.
443pub 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        // Both transports are described here: the raw transport reads `interface`, the UDP
460        // test transport reads `host`/`port`. `BindingDefaults` carries all four, and which
461        // pair is consulted is decided by the `transport` startup parameter.
462        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        // The same three verbs the model gets on an event: STP has no user-initiated
585        // vocabulary distinct from its wire vocabulary, so narrowing here would only hide
586        // things.
587        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        // The UDP test transport carries the same frames over an ordinary socket.
625
626        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            // libpcap capture/injection, never a SOCK_RAW. RawSockets would refuse to start on
640            // a host with /dev/bpf* access but no root - the "don't claim more than you need"
641            // rule that ospf got wrong by declaring Root when it wanted CAP_NET_RAW.
642            .privilege_requirement(PrivilegeRequirement::PacketCapture)
643            .connectionless()
644            // Deliberately silent: STP has no error BPDU. A BPDU asserts a bridge's priority and
645            // its view of the root, and neighbours act on it by re-electing the root or blocking
646            // ports, so a fabricated one reshapes the topology.
647            .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        // Deterministic observer: log every BPDU, answer nothing. This is the safe default for
704        // a protocol whose only reply re-shapes somebody's network.
705        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                // Validate now, build again in the transport once the operator's configured
792                // defaults have been layered in. Validating here is what makes the declared
793                // example executable and what puts a bad priority in front of the model.
794                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
814// ---------------------------------------------------------------------------
815// Action definitions
816// ---------------------------------------------------------------------------
817
818fn 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
1060// ---------------------------------------------------------------------------
1061// Event types
1062// ---------------------------------------------------------------------------
1063
1064fn 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}