Skip to main content

netget/server/bgp/
actions.rs

1//! BGP protocol actions.
2//!
3//! Every `send_bgp_*` action validates and normalises its parameters here and returns
4//! [`ActionResult::Custom`] carrying a structured *intent*, not wire bytes.
5//!
6//! The split exists because [`Protocol::execute_action`] is a pure function of the action JSON
7//! with no access to the session, while the correct encoding of an UPDATE depends on whether
8//! four-octet AS was negotiated with *this* peer (RFC 6793: a two-octet peer must be sent a
9//! two-octet AS_PATH). So the session, which knows, does the encoding — see
10//! [`super::wire::encode_intent`].
11//!
12//! Validating here rather than at encode time also means a malformed action is reported as a
13//! failed action, with a message naming the field, instead of silently producing nothing.
14
15use crate::llm::actions::{
16    protocol_trait::{ActionResult, Protocol, Server},
17    ActionDefinition, Parameter,
18};
19use crate::protocol::log_template::LogTemplate;
20use crate::protocol::EventType;
21use crate::state::app_state::AppState;
22use anyhow::{bail, Context, Result};
23use serde_json::json;
24use std::net::Ipv4Addr;
25use std::sync::LazyLock;
26
27/// `ActionResult::Custom` name under which BGP hands a validated message intent to the session.
28pub const BGP_MESSAGE_INTENT: &str = "bgp_message";
29
30/// BGP protocol action handler
31pub struct BgpProtocol;
32
33impl Default for BgpProtocol {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl BgpProtocol {
40    pub fn new() -> Self {
41        Self
42    }
43
44    fn intent(data: serde_json::Value) -> ActionResult {
45        ActionResult::Custom {
46            name: BGP_MESSAGE_INTENT.to_string(),
47            data,
48        }
49    }
50
51    fn execute_send_bgp_open(&self, action: serde_json::Value) -> Result<ActionResult> {
52        let my_as = action
53            .get("my_as")
54            .and_then(|v| v.as_u64())
55            .context("send_bgp_open requires my_as")?;
56        let my_as = u32::try_from(my_as)
57            .with_context(|| format!("my_as {my_as} is outside the 1-4294967295 AS range"))?;
58        if my_as == 0 {
59            bail!("my_as must be between 1 and 4294967295");
60        }
61
62        let hold_time = action
63            .get("hold_time")
64            .and_then(|v| v.as_u64())
65            .unwrap_or(180);
66        let hold_time = u16::try_from(hold_time)
67            .with_context(|| format!("hold_time {hold_time} is outside 0-65535"))?;
68        if hold_time != 0 && hold_time < 3 {
69            bail!("hold_time must be 0 (timers disabled) or at least 3 seconds (RFC 4271 6.2)");
70        }
71
72        let router_id = action
73            .get("router_id")
74            .and_then(|v| v.as_str())
75            .context("send_bgp_open requires router_id")?;
76        let router_id: Ipv4Addr = router_id
77            .parse()
78            .with_context(|| format!("router_id {router_id:?} is not an IPv4 address"))?;
79        if router_id.is_unspecified() {
80            bail!("router_id must not be 0.0.0.0 (RFC 4271 requires a valid BGP Identifier)");
81        }
82
83        Ok(Self::intent(json!({
84            "kind": "open",
85            "my_as": my_as,
86            "hold_time": hold_time,
87            "router_id": router_id.to_string(),
88        })))
89    }
90
91    fn execute_send_bgp_keepalive(&self, _action: serde_json::Value) -> Result<ActionResult> {
92        Ok(Self::intent(json!({ "kind": "keepalive" })))
93    }
94
95    fn execute_send_bgp_update(&self, action: serde_json::Value) -> Result<ActionResult> {
96        let prefixes = |key: &str| -> Result<Vec<String>> {
97            let Some(value) = action.get(key) else {
98                return Ok(Vec::new());
99            };
100            if value.is_null() {
101                return Ok(Vec::new());
102            }
103            let array = value
104                .as_array()
105                .with_context(|| format!("{key} must be an array of CIDR prefix strings"))?;
106            array
107                .iter()
108                .map(|v| {
109                    let s = v
110                        .as_str()
111                        .with_context(|| format!("{key} entries must be strings like 10.0.0.0/24"))?
112                        .to_string();
113                    // Parse now so a bad prefix is an action failure rather than a silent drop.
114                    super::wire::ipv4_unicast(&s)?;
115                    Ok(s)
116                })
117                .collect()
118        };
119
120        let withdrawn = prefixes("withdrawn_routes")?;
121        let nlri = prefixes("nlri")?;
122        if withdrawn.is_empty() && nlri.is_empty() {
123            bail!("send_bgp_update needs at least one prefix in nlri or withdrawn_routes");
124        }
125
126        let next_hop = match action.get("next_hop").and_then(|v| v.as_str()) {
127            Some(s) => Some(
128                s.parse::<Ipv4Addr>()
129                    .with_context(|| format!("next_hop {s:?} is not an IPv4 address"))?,
130            ),
131            None => None,
132        };
133        if !nlri.is_empty() && next_hop.is_none() {
134            bail!(
135                "send_bgp_update with nlri requires next_hop \
136                 (RFC 4271 9.1 makes NEXT_HOP mandatory for an announcement)"
137            );
138        }
139
140        let as_path: Vec<u32> = match action.get("as_path") {
141            Some(v) if !v.is_null() => {
142                let array = v
143                    .as_array()
144                    .context("as_path must be an array of AS numbers")?;
145                array
146                    .iter()
147                    .map(|v| {
148                        let n = v.as_u64().context("as_path entries must be AS numbers")?;
149                        u32::try_from(n).with_context(|| format!("AS number {n} is out of range"))
150                    })
151                    .collect::<Result<_>>()?
152            }
153            _ => Vec::new(),
154        };
155
156        let origin = match action.get("origin").and_then(|v| v.as_str()) {
157            None => "IGP",
158            Some(o) => match o.to_ascii_uppercase().as_str() {
159                "IGP" => "IGP",
160                "EGP" => "EGP",
161                "INCOMPLETE" => "INCOMPLETE",
162                other => bail!("origin must be IGP, EGP or INCOMPLETE, got {other:?}"),
163            },
164        };
165
166        let u32_field = |key: &str| -> Result<Option<u32>> {
167            match action.get(key) {
168                Some(v) if !v.is_null() => {
169                    let n = v
170                        .as_u64()
171                        .with_context(|| format!("{key} must be a number"))?;
172                    Ok(Some(u32::try_from(n).with_context(|| {
173                        format!("{key} value {n} exceeds 4294967295")
174                    })?))
175                }
176                _ => Ok(None),
177            }
178        };
179
180        Ok(Self::intent(json!({
181            "kind": "update",
182            "withdrawn_routes": withdrawn,
183            "nlri": nlri,
184            "next_hop": next_hop.map(|h| h.to_string()),
185            "as_path": as_path,
186            "origin": origin,
187            "med": u32_field("med")?,
188            "local_pref": u32_field("local_pref")?,
189        })))
190    }
191
192    fn execute_send_bgp_notification(&self, action: serde_json::Value) -> Result<ActionResult> {
193        let error_code = action
194            .get("error_code")
195            .and_then(|v| v.as_u64())
196            .context("send_bgp_notification requires error_code")?;
197        let error_code = u8::try_from(error_code)
198            .with_context(|| format!("error_code {error_code} must be 1-6"))?;
199        if !(1..=6).contains(&error_code) {
200            bail!("error_code must be 1-6 (see RFC 4271 section 6), got {error_code}");
201        }
202
203        let error_subcode = action
204            .get("error_subcode")
205            .and_then(|v| v.as_u64())
206            .unwrap_or(0);
207        let error_subcode = u8::try_from(error_subcode)
208            .with_context(|| format!("error_subcode {error_subcode} must be 0-255"))?;
209
210        // Documented as hex, and actually decoded as hex — the executor and the documentation
211        // have to agree, or a model following the docs puts literal ASCII on the wire.
212        let data = match action.get("data") {
213            Some(v) if !v.is_null() => {
214                let s = v
215                    .as_str()
216                    .context("data must be a hex-encoded string, e.g. \"00b4\"")?;
217                if s.is_empty() {
218                    String::new()
219                } else {
220                    hex::decode(s).with_context(|| format!("data {s:?} is not valid hex"))?;
221                    s.to_string()
222                }
223            }
224            _ => String::new(),
225        };
226
227        Ok(Self::intent(json!({
228            "kind": "notification",
229            "error_code": error_code,
230            "error_subcode": error_subcode,
231            "data": data,
232        })))
233    }
234}
235
236// ============================================================================
237// Action Definitions (shared between get_sync_actions() and the event types).
238// ============================================================================
239
240fn send_bgp_open_action() -> ActionDefinition {
241    ActionDefinition {
242        name: "send_bgp_open".to_string(),
243        description: "Accept the peering request by sending our BGP OPEN. NetGet adds the \
244                      four-octet AS capability and sends the follow-up KEEPALIVE itself."
245            .to_string(),
246        parameters: vec![
247            Parameter {
248                name: "my_as".to_string(),
249                type_hint: "number".to_string(),
250                description: "Our AS number, 1-4294967295. Values above 65535 are carried in \
251                              the four-octet AS capability with AS_TRANS in the legacy field."
252                    .to_string(),
253                required: true,
254            },
255            Parameter {
256                name: "hold_time".to_string(),
257                type_hint: "number".to_string(),
258                description: "Proposed hold time in seconds (default 180). Must be 0 or >= 3. \
259                              The session uses the smaller of this and the peer's."
260                    .to_string(),
261                required: false,
262            },
263            Parameter {
264                name: "router_id".to_string(),
265                type_hint: "string".to_string(),
266                description: "BGP router identifier, IPv4 dotted quad, not 0.0.0.0".to_string(),
267                required: true,
268            },
269        ],
270        example: json!({
271            "type": "send_bgp_open",
272            "my_as": 65001,
273            "hold_time": 180,
274            "router_id": "192.168.1.1"
275        }),
276        log_template: Some(
277            LogTemplate::new()
278                .with_info("-> BGP OPEN AS{my_as} hold={hold_time}s")
279                .with_debug(
280                    "BGP send_bgp_open: AS={my_as}, hold_time={hold_time}, router_id={router_id}",
281                ),
282        ),
283    }
284}
285
286fn send_bgp_keepalive_action() -> ActionDefinition {
287    ActionDefinition {
288        name: "send_bgp_keepalive".to_string(),
289        description: "Send a BGP KEEPALIVE. Rarely needed: NetGet sends keepalives on its own \
290                      at a third of the negotiated hold time for the life of the session."
291            .to_string(),
292        parameters: vec![],
293        example: json!({ "type": "send_bgp_keepalive" }),
294        log_template: Some(
295            LogTemplate::new()
296                .with_info("-> BGP KEEPALIVE")
297                .with_debug("BGP send_bgp_keepalive"),
298        ),
299    }
300}
301
302fn send_bgp_update_action() -> ActionDefinition {
303    ActionDefinition {
304        name: "send_bgp_update".to_string(),
305        description: "Announce and/or withdraw routes with a BGP UPDATE. This is how routes \
306                      reach the peer; NetGet keeps no routing table, so every announcement is \
307                      exactly what this action says."
308            .to_string(),
309        parameters: vec![
310            Parameter {
311                name: "nlri".to_string(),
312                type_hint: "array".to_string(),
313                description: "CIDR prefixes to announce, e.g. [\"192.168.0.0/16\"]".to_string(),
314                required: false,
315            },
316            Parameter {
317                name: "withdrawn_routes".to_string(),
318                type_hint: "array".to_string(),
319                description: "CIDR prefixes to withdraw, e.g. [\"10.0.0.0/24\"]".to_string(),
320                required: false,
321            },
322            Parameter {
323                name: "next_hop".to_string(),
324                type_hint: "string".to_string(),
325                description: "Next-hop IPv4 address. Required whenever nlri is non-empty."
326                    .to_string(),
327                required: false,
328            },
329            Parameter {
330                name: "as_path".to_string(),
331                type_hint: "array".to_string(),
332                description: "AS numbers forming the AS_SEQUENCE, e.g. [65001]. An empty list \
333                              is a locally originated route."
334                    .to_string(),
335                required: false,
336            },
337            Parameter {
338                name: "origin".to_string(),
339                type_hint: "string".to_string(),
340                description: "ORIGIN attribute: IGP (default), EGP or INCOMPLETE".to_string(),
341                required: false,
342            },
343            Parameter {
344                name: "med".to_string(),
345                type_hint: "number".to_string(),
346                description: "Optional MULTI_EXIT_DISC metric".to_string(),
347                required: false,
348            },
349            Parameter {
350                name: "local_pref".to_string(),
351                type_hint: "number".to_string(),
352                description: "Optional LOCAL_PREF, meaningful to iBGP peers".to_string(),
353                required: false,
354            },
355        ],
356        example: json!({
357            "type": "send_bgp_update",
358            "nlri": ["10.0.0.0/24"],
359            "next_hop": "192.168.1.1",
360            "as_path": [65001],
361            "origin": "IGP"
362        }),
363        log_template: Some(
364            LogTemplate::new()
365                .with_info("-> BGP UPDATE")
366                .with_debug("BGP send_bgp_update: withdrawn={withdrawn_routes} nlri={nlri}"),
367        ),
368    }
369}
370
371fn send_bgp_notification_action() -> ActionDefinition {
372    ActionDefinition {
373        name: "send_bgp_notification".to_string(),
374        description: "Refuse or tear down the session with a BGP NOTIFICATION. On a bgp_open \
375                      event this is the explicit refusal to peer, and it is the only thing that \
376                      counts as one."
377            .to_string(),
378        parameters: vec![
379            Parameter {
380                name: "error_code".to_string(),
381                type_hint: "number".to_string(),
382                description: "RFC 4271 error code 1-6: 1 header, 2 OPEN, 3 UPDATE, \
383                              4 hold timer expired, 5 FSM, 6 cease"
384                    .to_string(),
385                required: true,
386            },
387            Parameter {
388                name: "error_subcode".to_string(),
389                type_hint: "number".to_string(),
390                description: "Error subcode, 0 if unspecified. With code 2: 2 bad peer AS, \
391                              3 bad BGP identifier. With code 6: 2 administrative shutdown, \
392                              5 connection rejected."
393                    .to_string(),
394                required: false,
395            },
396            Parameter {
397                name: "data".to_string(),
398                type_hint: "string".to_string(),
399                description: "Optional diagnostic bytes as a hex string, e.g. \"00b4\". \
400                              Decoded as hex; leave it out unless the subcode defines a payload."
401                    .to_string(),
402                required: false,
403            },
404        ],
405        example: json!({
406            "type": "send_bgp_notification",
407            "error_code": 6,
408            "error_subcode": 5
409        }),
410        log_template: Some(
411            LogTemplate::new()
412                .with_info("-> BGP NOTIFICATION code={error_code}")
413                .with_debug(
414                    "BGP send_bgp_notification: code={error_code}, subcode={error_subcode}",
415                ),
416        ),
417    }
418}
419
420fn wait_for_more_action() -> ActionDefinition {
421    ActionDefinition {
422        name: "wait_for_more".to_string(),
423        description: "Take no action and wait for the next BGP message.".to_string(),
424        parameters: vec![],
425        example: json!({ "type": "wait_for_more" }),
426        log_template: Some(
427            LogTemplate::new()
428                .with_info("-> BGP wait for more")
429                .with_debug("BGP wait_for_more: awaiting additional messages"),
430        ),
431    }
432}
433
434// ============================================================================
435// Event Types
436//
437// `call_llm` builds the model's tool list from `EventType::actions`, not from
438// get_sync_actions(), so every event carries its own action list.
439// ============================================================================
440
441/// Actions any BGP session event can respond with.
442fn bgp_response_actions() -> Vec<ActionDefinition> {
443    vec![
444        send_bgp_open_action(),
445        send_bgp_keepalive_action(),
446        send_bgp_update_action(),
447        send_bgp_notification_action(),
448        wait_for_more_action(),
449    ]
450}
451
452pub static BGP_OPEN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
453    EventType::new(
454        "bgp_open",
455        "BGP OPEN received. Decide whether to peer with this neighbour. Reply with \
456         send_bgp_open to accept, or send_bgp_notification to refuse. If neither is returned, \
457         NetGet sends the OPEN configured at startup.",
458        json!({
459            "type": "send_bgp_open",
460            "my_as": 65001,
461            "hold_time": 180,
462            "router_id": "192.168.1.1"
463        }),
464    )
465    .with_alternative_example(json!({
466        "type": "send_bgp_notification",
467        "error_code": 2,
468        "error_subcode": 2
469    }))
470    .with_actions(bgp_response_actions())
471    .with_log_template(
472        LogTemplate::new()
473            .with_info("BGP OPEN from AS{peer_as} router_id={peer_router_id}")
474            .with_debug(
475                "BGP OPEN: AS={peer_as} hold={peer_hold_time} asn4={peer_supports_four_octet_as}",
476            )
477            .with_trace("BGP OPEN: {json_pretty(.)}"),
478    )
479});
480
481/// Raised once, when the session reaches Established.
482///
483/// This replaces a `bgp_keepalive` event that was declared and never emitted. Emitting one per
484/// KEEPALIVE would have meant a model call every hold/3 seconds per peer, forever, to decide
485/// nothing; this fires exactly when there is a decision to make, namely which routes to
486/// advertise to a peer that is now willing to accept them.
487pub static BGP_ESTABLISHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
488    EventType::new(
489        "bgp_established",
490        "BGP session reached Established. The peer will now accept UPDATEs. Reply with \
491         send_bgp_update to advertise routes, or wait_for_more to advertise nothing.",
492        json!({
493            "type": "send_bgp_update",
494            "nlri": ["10.0.0.0/24"],
495            "next_hop": "192.168.1.1",
496            "as_path": [65001],
497            "origin": "IGP"
498        }),
499    )
500    .with_alternative_example(json!({ "type": "wait_for_more" }))
501    .with_actions(bgp_response_actions())
502    .with_log_template(
503        LogTemplate::new()
504            .with_info("BGP established with AS{peer_as}")
505            .with_debug("BGP established with AS{peer_as} on {connection_id}")
506            .with_trace("BGP established: {json_pretty(.)}"),
507    )
508});
509
510pub static BGP_UPDATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
511    EventType::new(
512        "bgp_update",
513        "BGP UPDATE received, decoded into withdrawn_routes, nlri, origin, next_hop, as_path \
514         and the full path_attributes list. Reply with send_bgp_update to advertise routes back, \
515         or wait_for_more.",
516        json!({ "type": "wait_for_more" }),
517    )
518    .with_actions(bgp_response_actions())
519    .with_log_template(
520        LogTemplate::new()
521            .with_info("BGP UPDATE from AS{peer_as}")
522            .with_debug("BGP UPDATE from AS{peer_as} on {connection_id}")
523            .with_trace("BGP UPDATE: {json_pretty(.)}"),
524    )
525});
526
527pub static BGP_NOTIFICATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
528    EventType::new(
529        "bgp_notification",
530        "BGP NOTIFICATION received. RFC 4271 forbids replying to one, so the session closes \
531         regardless of what is returned and nothing is written to the socket. Record what \
532         matters with set_memory or append_to_log.",
533        json!({ "type": "wait_for_more" }),
534    )
535    // No wire verbs, deliberately. `on_notification` discards whatever the handler returns —
536    // it has to, because RFC 4271 section 4.5 forbids answering a NOTIFICATION — so the four
537    // `send_bgp_*` verbs this used to advertise could never reach the socket, which is the
538    // "advertised but structurally inert" shape the root CLAUDE.md warns about.
539    //
540    // `wait_for_more` stays rather than `.with_no_actions()`, because a *server* narrows: its
541    // tool list is built from this list alone, so dropping it would leave the declared response
542    // example (`wait_for_more`) teaching an action the model was never offered. The common
543    // actions (set_memory, append_to_log) are added by `call_llm` regardless, so a handler can
544    // still record what it saw.
545    .with_actions(vec![wait_for_more_action()])
546    .with_log_template(
547        LogTemplate::new()
548            .with_info("BGP NOTIFICATION code={error_code} subcode={error_subcode}")
549            .with_debug("BGP NOTIFICATION: {error_name} / {error_subcode_name} from AS{peer_as}")
550            .with_trace("BGP NOTIFICATION: {json_pretty(.)}"),
551    )
552});
553
554// Implement Protocol trait (common functionality)
555impl Protocol for BgpProtocol {
556    /// No user-triggered actions.
557    ///
558    /// There were three (`announce_route`, `withdraw_route`, `reset_peer`). All were dead: the
559    /// first two logged their argument and returned `NoAction`, and none of the three could
560    /// reach a peer, because an async action carries no connection and this server never
561    /// consumed async action results. Routes are advertised from a `bgp_established` or
562    /// `bgp_update` handler with `send_bgp_update`, which does reach the socket.
563    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
564        vec![]
565    }
566
567    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
568        bgp_response_actions()
569    }
570
571    fn protocol_name(&self) -> &'static str {
572        "BGP"
573    }
574
575    fn get_event_types(&self) -> Vec<EventType> {
576        vec![
577            BGP_OPEN_EVENT.clone(),
578            BGP_ESTABLISHED_EVENT.clone(),
579            BGP_UPDATE_EVENT.clone(),
580            BGP_NOTIFICATION_EVENT.clone(),
581        ]
582    }
583
584    fn stack_name(&self) -> &'static str {
585        "ETH>IP>TCP>BGP"
586    }
587
588    fn keywords(&self) -> Vec<&'static str> {
589        vec!["bgp", "border gateway"]
590    }
591
592    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
593        use crate::protocol::metadata::{
594            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
595        };
596
597        ProtocolMetadataV2::builder()
598            .state(DevelopmentState::Experimental)
599            // BGP's assigned port is TCP 179, below 1024. Any other port needs no privilege.
600            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(179))
601            .well_known_port(179)
602            .implementation(
603                "RFC 4271 session over netgauze-bgp-pkt codec; four-octet AS (RFC 6793)",
604            )
605            .llm_control("Optional: the OPEN handshake is mechanical (configured OPEN, no LLM); whether to advertise routes is policy (LLM). With no policy configured the session handshakes on the configured OPEN and advertises nothing, all with no LLM call")
606            .e2e_testing("Two-way wire conformance against netgauze, plus mocked session E2E")
607            .notes(
608                "Session is complete: OPEN validation, capability and hold-time negotiation, \
609                 KEEPALIVE cadence, hold-timer expiry, NOTIFICATION on error. The OPEN handshake \
610                 is mechanical (fully determined by the configured ASN/router-id/hold-time and a \
611                 validated peer), so with no operator policy (no instruction, no handler) it \
612                 completes on the configured OPEN with NO LLM round-trip; established/update then \
613                 advertise nothing, which is correct with no routing policy. KEEPALIVE cadence \
614                 never consulted the LLM. The model is consulted only when the operator opts in. \
615                 No RIB by design - routes are whatever a handler advertises with send_bgp_update, \
616                 nothing is stored or re-advertised, and there is no best-path selection or \
617                 propagation between peers. IPv4 unicast only on the send path. Never peered \
618                 against a live BGP daemon.",
619            )
620            .max_inbound_bytes(crate::server::bgp::wire::BGP_MAX_MESSAGE_LEN)
621            .build()
622    }
623
624    fn description(&self) -> &'static str {
625        "BGP routing server"
626    }
627
628    fn example_prompt(&self) -> &'static str {
629        "Start a BGP routing server on port 8179"
630    }
631
632    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
633        use crate::llm::actions::ParameterDefinition;
634        vec![
635            ParameterDefinition {
636                name: "as_number".to_string(),
637                type_hint: "integer".to_string(),
638                description: "Our Autonomous System Number (1-4294967295). Use private ASNs \
639                              (64512-65534) for testing. Values above 65535 are advertised via \
640                              the four-octet AS capability."
641                    .to_string(),
642                required: false,
643                example: json!(65001),
644                default: None,
645            },
646            ParameterDefinition {
647                name: "router_id".to_string(),
648                type_hint: "string".to_string(),
649                description: "BGP router ID in IPv4 dotted-quad form, not 0.0.0.0".to_string(),
650                required: false,
651                example: json!("192.168.1.1"),
652                default: None,
653            },
654            ParameterDefinition {
655                name: "hold_time".to_string(),
656                type_hint: "integer".to_string(),
657                description: "Hold time we propose, in seconds (default 180). Must be 0 \
658                              (timers disabled) or at least 3. Keepalives are sent at a third \
659                              of the negotiated value."
660                    .to_string(),
661                required: false,
662                example: json!(180),
663                default: None,
664            },
665        ]
666    }
667
668    fn group_name(&self) -> &'static str {
669        "VPN & Routing"
670    }
671
672    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
673        use crate::llm::actions::StartupExamples;
674
675        // Deterministic: acknowledge every UPDATE with a KEEPALIVE, no LLM call.
676        let script = r#"import json, sys
677data = json.load(sys.stdin)
678event = data["event"]
679if data["event_type_id"] == "bgp_update":
680    actions = [{"type": "send_bgp_keepalive"}]
681else:
682    actions = []
683print(json.dumps({"actions": actions}))"#;
684
685        StartupExamples::new(
686            // LLM mode: the model decides who to peer with and what to advertise.
687            json!({
688                "type": "open_server",
689                "port": 179,
690                "base_stack": "bgp",
691                "instruction": "Peer with anyone in AS 65000-65535 and advertise 10.0.0.0/24",
692                "startup_params": {
693                    "as_number": 65001,
694                    "router_id": "192.168.1.1"
695                }
696            }),
697            // Script mode.
698            json!({
699                "type": "open_server",
700                "port": 179,
701                "base_stack": "bgp",
702                "startup_params": {
703                    "as_number": 65001,
704                    "router_id": "192.168.1.1"
705                },
706                "event_handlers": [{
707                    "event_pattern": "bgp_update",
708                    "handler": {
709                        "type": "script",
710                        "language": "python",
711                        "code": script
712                    }
713                }]
714            }),
715            // Static mode: accept every peer and advertise one prefix once established.
716            json!({
717                "type": "open_server",
718                "port": 179,
719                "base_stack": "bgp",
720                "startup_params": {
721                    "as_number": 65001,
722                    "router_id": "192.168.1.1"
723                },
724                "event_handlers": [
725                    {
726                        "event_pattern": "bgp_open",
727                        "handler": {
728                            "type": "static",
729                            "actions": [{
730                                "type": "send_bgp_open",
731                                "my_as": 65001,
732                                "hold_time": 180,
733                                "router_id": "192.168.1.1"
734                            }]
735                        }
736                    },
737                    {
738                        "event_pattern": "bgp_established",
739                        "handler": {
740                            "type": "static",
741                            "actions": [{
742                                "type": "send_bgp_update",
743                                "nlri": ["10.0.0.0/24"],
744                                "next_hop": "192.168.1.1",
745                                "as_path": [65001],
746                                "origin": "IGP"
747                            }]
748                        }
749                    }
750                ]
751            }),
752        )
753    }
754}
755
756// Implement Server trait (server-specific functionality)
757impl Server for BgpProtocol {
758    fn spawn(
759        &self,
760        ctx: crate::protocol::SpawnContext,
761    ) -> std::pin::Pin<
762        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
763    > {
764        Box::pin(async move {
765            use crate::server::bgp::BgpServer;
766            BgpServer::spawn_with_llm_actions(
767                ctx.legacy_listen_addr(),
768                ctx.llm_client,
769                ctx.state,
770                ctx.status_tx,
771                ctx.server_id,
772                ctx.startup_params,
773            )
774            .await
775        })
776    }
777
778    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
779        let action_type = action
780            .get("type")
781            .and_then(|v| v.as_str())
782            .context("Missing action type")?;
783
784        match action_type {
785            "send_bgp_open" => self.execute_send_bgp_open(action),
786            "send_bgp_keepalive" => self.execute_send_bgp_keepalive(action),
787            "send_bgp_update" => self.execute_send_bgp_update(action),
788            "send_bgp_notification" => self.execute_send_bgp_notification(action),
789            "wait_for_more" => Ok(ActionResult::WaitForMore),
790            // Not offered to the model (refusing or ending a session is `send_bgp_notification`),
791            // but the dashboard's "disconnect this peer" injects it through the session's peer
792            // command task, which turns it into NOTIFICATION 6/2 followed by the close.
793            "close_connection" => Ok(ActionResult::CloseConnection),
794            _ => Err(anyhow::anyhow!("Unknown BGP action type: {}", action_type)),
795        }
796    }
797}