Skip to main content

netget/server/bfd/
actions.rs

1//! What the model decides for a BFD speaker: which peers get a session and with what timers,
2//! and what to do when a session changes state. The packets themselves — every few hundred
3//! milliseconds, with jitter, Poll and Final — are Rust's job (`runner.rs`); a model cannot
4//! keep a 300 ms clock.
5use super::packet;
6use super::session::{self, Timers};
7use crate::llm::actions::{
8    protocol_trait::{ActionResult, Protocol, Server},
9    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
10};
11use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
12use crate::state::app_state::AppState;
13use anyhow::{bail, Result};
14use serde_json::{json, Value};
15use std::sync::LazyLock;
16
17pub const ACCEPT: &str = "bfd_accept_session";
18pub const DEFAULT_AUTH_KEY_ID: u8 = 1;
19/// Sessions one server holds at once; a new peer past this is not asked about.
20pub const MAX_SESSIONS: usize = 64;
21
22#[derive(Default, Clone)]
23pub struct BfdProtocol;
24
25impl BfdProtocol {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
32    Parameter {
33        name: name.into(),
34        type_hint: type_hint.into(),
35        description: description.into(),
36        required,
37    }
38}
39
40pub fn action(
41    name: &str,
42    description: &str,
43    parameters: Vec<Parameter>,
44    example: Value,
45) -> ActionDefinition {
46    let info = match name {
47        "bfd_accept_session" => "-> BFD session accepted".to_string(),
48        "bfd_set_timers" => {
49            "-> BFD timers tx {desired_min_tx_ms} ms rx {required_min_rx_ms} ms".to_string()
50        }
51        "bfd_admin_down" => "-> BFD session AdminDown".to_string(),
52        "bfd_admin_up" => "-> BFD session back from AdminDown".to_string(),
53        other => format!("-> BFD {}", other.trim_start_matches("bfd_")),
54    };
55    ActionDefinition {
56        name: name.into(),
57        description: description.into(),
58        parameters,
59        example,
60        log_template: Some(LogTemplate::new().with_info(info)),
61    }
62}
63
64pub fn timer_params() -> Vec<Parameter> {
65    vec![
66        p("desired_min_tx_ms", "number", "How often this side wants to send, in ms (10-60000; at least 1000 is sent while the session is not Up)", false),
67        p("required_min_rx_ms", "number", "The fastest this side accepts packets, in ms (10-60000)", false),
68        p("detect_mult", "number", "Packets the peer may miss before the session is declared down (1-255)", false),
69    ]
70}
71
72pub fn accept_action() -> ActionDefinition {
73    action(
74        ACCEPT,
75        "Bring up a BFD session with this peer, optionally with its own timers (otherwise the server's). To refuse, answer nothing: BFD has no refusal message.",
76        timer_params(),
77        json!({"type":ACCEPT,"desired_min_tx_ms":300,"required_min_rx_ms":300,"detect_mult":3}),
78    )
79}
80
81pub fn set_timers_action() -> ActionDefinition {
82    action(
83        "bfd_set_timers",
84        "Change this session's timers; while Up the change is negotiated with a Poll Sequence.",
85        timer_params(),
86        json!({"type":"bfd_set_timers","desired_min_tx_ms":500,"required_min_rx_ms":500,"detect_mult":3}),
87    )
88}
89
90pub fn admin_down_action() -> ActionDefinition {
91    action(
92        "bfd_admin_down",
93        "Take this session administratively down; the peer sees AdminDown and goes Down.",
94        vec![p(
95            "diag",
96            "string",
97            "Diagnostic to send, e.g. administratively_down (default) or path_down",
98            false,
99        )],
100        json!({"type":"bfd_admin_down","diag":"administratively_down"}),
101    )
102}
103
104pub fn admin_up_action() -> ActionDefinition {
105    action(
106        "bfd_admin_up",
107        "Return an AdminDown session to Down, so it comes back up with the peer.",
108        vec![],
109        json!({"type":"bfd_admin_up"}),
110    )
111}
112
113pub fn session_actions() -> Vec<ActionDefinition> {
114    vec![set_timers_action(), admin_down_action(), admin_up_action()]
115}
116
117pub static SESSION_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
118    EventType::new(
119        "bfd_session_request",
120        "A BFD peer with no session here is sending Control packets. Answer bfd_accept_session to bring a session up, or nothing to ignore it.",
121        accept_action().example.clone(),
122    )
123    .with_parameters(vec![
124        p("peer", "string", "The peer's IP address", true),
125        p("peer_discriminator", "number", "The peer's My Discriminator for the session", true),
126        p("peer_state", "string", "The state the peer reports: Down, Init, Up or AdminDown", true),
127        p("desired_min_tx_ms", "number", "How often the peer wants to send, in ms", true),
128        p("required_min_rx_ms", "number", "The fastest the peer accepts packets, in ms", true),
129        p("detect_mult", "number", "The peer's detection multiplier", true),
130        p("authentication", "string", "The authentication type the peer used (simple, keyed_md5, keyed_sha1, ...), or null", false),
131        p("multihop", "boolean", "Whether this is a multihop session (RFC 5883) rather than single-hop (RFC 5881)", true),
132    ])
133    .with_actions(vec![accept_action()])
134});
135
136pub fn state_params() -> Vec<Parameter> {
137    vec![
138        p("peer", "string", "The peer's IP address", true),
139        p("state", "string", "The session's new state: AdminDown, Down, Init or Up", true),
140        p("previous_state", "string", "The state before", true),
141        p("diag", "string", "Why, as a diagnostic name, e.g. control_detection_time_expired, neighbor_signaled_session_down", true),
142        p("remote_state", "string", "The state the peer last reported", true),
143        p("local_discriminator", "number", "This side's discriminator", true),
144        p("remote_discriminator", "number", "The peer's discriminator (0 once the peer has gone silent)", true),
145        p("tx_interval_ms", "number", "The interval this side is sending at", true),
146        p("detection_time_ms", "number", "How long silence takes to bring the session down", true),
147        p("remote_desired_min_tx_ms", "number", "How often the peer wants to send", true),
148        p("remote_required_min_rx_ms", "number", "The fastest the peer accepts packets", true),
149    ]
150}
151
152pub static STATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
153    EventType::new(
154        "bfd_session_state",
155        "A BFD session changed state. Optionally answer bfd_set_timers, bfd_admin_down or bfd_admin_up.",
156        set_timers_action().example.clone(),
157    )
158    .with_parameters(state_params())
159    .with_actions(session_actions())
160});
161
162/// The authentication startup parameters, shared with the client.
163pub fn auth_params() -> Vec<ParameterDefinition> {
164    vec![
165        ParameterDefinition {
166            name: "auth_type".into(),
167            type_hint: "string".into(),
168            description: "Authenticate every packet: simple, keyed_md5, meticulous_keyed_md5, keyed_sha1 or meticulous_keyed_sha1 (default: none)".into(),
169            required: false,
170            example: json!("keyed_sha1"),
171            default: None,
172        },
173        ParameterDefinition {
174            name: "auth_key_id".into(),
175            type_hint: "number".into(),
176            description: "The Auth Key ID both sides use, 0-255".into(),
177            required: false,
178            example: json!(1),
179            default: Some(json!(DEFAULT_AUTH_KEY_ID)),
180        },
181        ParameterDefinition {
182            name: "auth_password".into(),
183            type_hint: "string".into(),
184            description: "The shared key: up to 16 bytes (simple, MD5) or 20 (SHA1)".into(),
185            required: false,
186            example: json!("netget-bfd-key"),
187            default: None,
188        },
189    ]
190}
191
192/// The timer startup parameters, shared with the client.
193pub fn timer_startup_params() -> Vec<ParameterDefinition> {
194    vec![
195        ParameterDefinition {
196            name: "desired_min_tx_ms".into(),
197            type_hint: "number".into(),
198            description: "How often to send once Up, in ms (10-60000)".into(),
199            required: false,
200            example: json!(100),
201            default: Some(json!(session::DEFAULT_DESIRED_MIN_TX_MS)),
202        },
203        ParameterDefinition {
204            name: "required_min_rx_ms".into(),
205            type_hint: "number".into(),
206            description: "The fastest the peer may send, in ms (10-60000)".into(),
207            required: false,
208            example: json!(100),
209            default: Some(json!(session::DEFAULT_REQUIRED_MIN_RX_MS)),
210        },
211        ParameterDefinition {
212            name: "detect_mult".into(),
213            type_hint: "number".into(),
214            description: "Missed packets before the session goes down (1-255)".into(),
215            required: false,
216            example: json!(5),
217            default: Some(json!(session::DEFAULT_DETECT_MULT)),
218        },
219    ]
220}
221
222/// The authentication configured in startup parameters, if any.
223pub fn auth_from(
224    params: Option<&crate::protocol::StartupParams>,
225) -> Result<Option<packet::AuthConfig>> {
226    let Some(params) = params else {
227        return Ok(None);
228    };
229    let kind = params.get_optional_string("auth_type")?;
230    let password = params.get_optional_string("auth_password")?;
231    let key_id = params
232        .get_optional_u64("auth_key_id")?
233        .unwrap_or(DEFAULT_AUTH_KEY_ID as u64);
234    match (kind, password) {
235        (None, None) => Ok(None),
236        (Some(kind), Some(password)) => {
237            anyhow::ensure!(key_id <= 255, "auth_key_id must be 0-255");
238            Ok(Some(packet::AuthConfig::new(
239                packet::AuthType::parse(&kind)?,
240                key_id as u8,
241                &password,
242            )?))
243        }
244        _ => bail!("auth_type and auth_password are given together"),
245    }
246}
247
248/// The session timers configured in startup parameters.
249pub fn timers_from(params: Option<&crate::protocol::StartupParams>) -> Result<Timers> {
250    let mut v = json!({});
251    if let Some(params) = params {
252        for key in ["desired_min_tx_ms", "required_min_rx_ms", "detect_mult"] {
253            if let Some(n) = params.get_optional_u64(key)? {
254                v[key] = json!(n);
255            }
256        }
257    }
258    Timers::from_json(&v, Timers::default())
259}
260
261impl Protocol for BfdProtocol {
262    fn protocol_name(&self) -> &'static str {
263        "BFD"
264    }
265    fn stack_name(&self) -> &'static str {
266        "ETH>IP>UDP>BFD"
267    }
268    fn keywords(&self) -> Vec<&'static str> {
269        vec![
270            "bfd",
271            "bidirectional forwarding detection",
272            "rfc 5880",
273            "liveness",
274            "3784",
275            "4784",
276        ]
277    }
278    fn description(&self) -> &'static str {
279        "BFD speaker (RFC 5880/5881/5883): accepts sessions from routers such as BIRD and FRR, keeps them Up with periodic Control packets, and reports every state change"
280    }
281    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
282        // Sessions are driven through their own events, or per session from the dashboard
283        // (each live session takes bfd_set_timers / bfd_admin_down / bfd_admin_up).
284        Vec::new()
285    }
286    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
287        let mut all = vec![accept_action()];
288        all.extend(session_actions());
289        all
290    }
291    fn get_event_types(&self) -> Vec<EventType> {
292        vec![SESSION_REQUEST_EVENT.clone(), STATE_EVENT.clone()]
293    }
294    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
295        Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
296    }
297    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
298        let mut params = vec![
299            ParameterDefinition {
300                name: "multihop".into(),
301                type_hint: "boolean".into(),
302                description: "Multihop BFD (RFC 5883, port 4784): no TTL-255 check on what arrives. Defaults to true when the server listens on 4784".into(),
303                required: false,
304                example: json!(true),
305                default: None,
306            },
307            ParameterDefinition {
308                name: "max_sessions".into(),
309                type_hint: "number".into(),
310                description: "Sessions held at once; a new peer past this is ignored".into(),
311                required: false,
312                example: json!(16),
313                default: Some(json!(MAX_SESSIONS)),
314            },
315        ];
316        params.extend(timer_startup_params());
317        params.extend(auth_params());
318        params
319    }
320    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
321        use crate::protocol::metadata::*;
322        ProtocolMetadataV2::builder()
323            .state(DevelopmentState::Experimental)
324            .privilege_requirement(PrivilegeRequirement::None)
325            .well_known_udp_port(3784)
326            .deliberately_silent()
327            .implementation("Hand-rolled RFC 5880 Asynchronous mode (src/server/bfd/): the Control packet and all five authentication types, the state machine, jittered periodic transmission, Poll/Final, the Detection Time, and the RFC 5881 TTL-255 check read with IP_RECVTTL; one task per session, sending from a source port in 49152-65535 with TTL 255; passive role (silent until a peer speaks)")
328            .llm_control("Which peers get a session and with what timers, and what to do when a session goes up or down (change timers, take it administratively down or back up)")
329            .e2e_testing("tests/server/bfd: BIRD 2 (multihop sessions brought Up and read back with birdc, timers renegotiated by Poll Sequence, AdminDown, keyed SHA1 with a right and a wrong key); a raw-socket peer for the TTL check and the bounds; the pcap oracle (tshark's bfd dissector) over the packets NetGet sent")
330            .notes("DELIBERATELY SILENT: BFD has no refusal; a peer the model does not accept (or a backend failure) is never answered, so its session stays Down (logged decision=model_silent / fail_closed_*). The model is consulted per session and per state change, never per packet. No Demand mode, no Echo function, no IPv6 TTL (hop limit) check.")
331            .max_inbound_bytes(packet::MAX_PACKET)
332            .build()
333    }
334    fn example_prompt(&self) -> &'static str {
335        "Run BFD on port 4784 and accept sessions from 192.0.2.1 with 300 ms timers"
336    }
337    fn get_startup_examples(&self) -> StartupExamples {
338        let base = json!({"type":"open_server","base_stack":"bfd","port":0,"startup_params":{"multihop":true},
339            "instruction":"Accept BFD sessions from 127.0.0.1 only, with 300 ms timers"});
340        let mut static_example = base.clone();
341        static_example["event_handlers"] = json!([
342            {"event_pattern":"bfd_session_request","handler":{"type":"static","actions":[accept_action().example]}},
343            {"event_pattern":"bfd_session_state","handler":{"type":"static","actions":[]}}
344        ]);
345        let mut scripted = base.clone();
346        scripted["event_handlers"] = json!([{"event_pattern":"bfd_session_request","handler":{"type":"script","language":"python",
347            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'bfd_accept_session'}] if e['peer']=='127.0.0.1' else []}))"}}]);
348        StartupExamples::new(base, scripted, static_example)
349    }
350    fn group_name(&self) -> &'static str {
351        "Routing"
352    }
353}
354
355impl Server for BfdProtocol {
356    fn spawn(
357        &self,
358        ctx: SpawnContext,
359    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
360    {
361        Box::pin(super::spawn(ctx))
362    }
363    fn execute_action(&self, action: Value) -> Result<ActionResult> {
364        let kind = action["type"].as_str().unwrap_or_default();
365        match kind {
366            ACCEPT => {
367                Timers::from_json(&action, Timers::default())?;
368            }
369            "bfd_set_timers" | "bfd_admin_down" | "bfd_admin_up" => {
370                super::runner::check_action(&action)?;
371            }
372            other => bail!("Unknown BFD action {other:?}"),
373        }
374        // The server applies it to the session the event was about.
375        Ok(ActionResult::Custom {
376            name: kind.into(),
377            data: action,
378        })
379    }
380}