Skip to main content

netget/client/bgp/
actions.rs

1//! BGP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::{ConnectContext, EventType};
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// BGP client connected event
15///
16/// Fires when the session reaches Established, i.e. after the peer's OPEN *and* its KEEPALIVE
17/// have both been seen. It used to fire on the OPEN alone, which announced a session the peer
18/// had not yet confirmed.
19pub static BGP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
20    EventType::new(
21        "bgp_connected",
22        "BGP session with the peer reached Established (OPEN and KEEPALIVE both exchanged). \
23         This client announces no routes; reply with wait_for_more to keep monitoring, or \
24         send_notification to tear the session down.",
25        json!({"type": "wait_for_more"}),
26    )
27    .with_parameters(vec![
28        Parameter {
29            name: "remote_addr".to_string(),
30            type_hint: "string".to_string(),
31            description: "Remote BGP peer address".to_string(),
32            required: true,
33        },
34        Parameter {
35            name: "peer_as".to_string(),
36            type_hint: "number".to_string(),
37            description: "Peer AS number. Taken from the peer's four-octet-AS capability when \
38                          it advertised one, so values above 65535 are reported truthfully."
39                .to_string(),
40            required: true,
41        },
42        Parameter {
43            name: "peer_router_id".to_string(),
44            type_hint: "string".to_string(),
45            description: "Peer BGP router ID".to_string(),
46            required: true,
47        },
48        Parameter {
49            name: "hold_time".to_string(),
50            type_hint: "number".to_string(),
51            description: "Negotiated hold time in seconds (the smaller of the two proposals; \
52                          0 means both timers are disabled)"
53                .to_string(),
54            required: true,
55        },
56        Parameter {
57            name: "peer_supports_four_octet_as".to_string(),
58            type_hint: "boolean".to_string(),
59            description: "Whether the peer advertised RFC 6793 four-octet AS support".to_string(),
60            required: true,
61        },
62    ])
63    .with_actions(bgp_client_response_actions())
64});
65
66/// BGP UPDATE message received event
67///
68/// The body is decoded field by field. It used to be delivered as `update_data_hex`, a raw hex
69/// blob no model can act on and which the root CLAUDE.md forbids in event data.
70pub static BGP_CLIENT_UPDATE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
71    EventType::new(
72        "bgp_update_received",
73        "BGP UPDATE received from the peer, decoded into withdrawn_routes, nlri, origin, \
74         next_hop, as_path and the full path_attributes list. This client announces no routes, \
75         so there is no way to answer an UPDATE with one — but the session verbs do reach the \
76         wire: reply with wait_for_more to keep monitoring, or send_notification / disconnect \
77         to tear the peering down over what the peer just advertised.",
78        json!({"type": "wait_for_more"}),
79    )
80    .with_parameters(vec![
81        Parameter {
82            name: "nlri".to_string(),
83            type_hint: "array".to_string(),
84            description: "Announced prefixes in CIDR form, e.g. [\"10.0.0.0/24\"]".to_string(),
85            required: true,
86        },
87        Parameter {
88            name: "withdrawn_routes".to_string(),
89            type_hint: "array".to_string(),
90            description: "Withdrawn prefixes in CIDR form".to_string(),
91            required: true,
92        },
93        Parameter {
94            name: "next_hop".to_string(),
95            type_hint: "string".to_string(),
96            description: "NEXT_HOP attribute, or null when the UPDATE only withdraws".to_string(),
97            required: false,
98        },
99        Parameter {
100            name: "as_path".to_string(),
101            type_hint: "array".to_string(),
102            description: "AS_PATH as a list of AS numbers".to_string(),
103            required: false,
104        },
105        Parameter {
106            name: "origin".to_string(),
107            type_hint: "string".to_string(),
108            description: "ORIGIN attribute: IGP, EGP or Incomplete".to_string(),
109            required: false,
110        },
111        Parameter {
112            name: "path_attributes".to_string(),
113            type_hint: "array".to_string(),
114            description: "Every path attribute, each with its type_name and flags".to_string(),
115            required: true,
116        },
117        Parameter {
118            name: "end_of_rib".to_string(),
119            type_hint: "boolean".to_string(),
120            description: "True when this is an End-of-RIB marker (RFC 4724)".to_string(),
121            required: true,
122        },
123        Parameter {
124            name: "peer_as".to_string(),
125            type_hint: "number".to_string(),
126            description: "AS number of the peer that sent this UPDATE".to_string(),
127            required: false,
128        },
129    ])
130    .with_actions(bgp_client_response_actions())
131});
132
133/// BGP NOTIFICATION message received event
134pub static BGP_CLIENT_NOTIFICATION_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
135    EventType::new(
136        "bgp_notification_received",
137        "BGP NOTIFICATION received from the peer. RFC 4271 forbids answering one, so the \
138         session closes regardless of what is returned and nothing is written to the socket.",
139        json!({"type": "wait_for_more"}),
140    )
141    .with_parameters(vec![
142        Parameter {
143            name: "error_code".to_string(),
144            type_hint: "number".to_string(),
145            description: "BGP error code (RFC 4271 section 6)".to_string(),
146            required: true,
147        },
148        Parameter {
149            name: "error_name".to_string(),
150            type_hint: "string".to_string(),
151            description: "Human-readable error name, e.g. \"OPEN Message Error\"".to_string(),
152            required: true,
153        },
154        Parameter {
155            name: "error_subcode".to_string(),
156            type_hint: "number".to_string(),
157            description: "BGP error subcode".to_string(),
158            required: true,
159        },
160        Parameter {
161            name: "error_subcode_name".to_string(),
162            type_hint: "string".to_string(),
163            description: "Human-readable subcode name, e.g. \"Bad Peer AS\"".to_string(),
164            required: true,
165        },
166        Parameter {
167            name: "peer_as".to_string(),
168            type_hint: "number".to_string(),
169            description: "AS number of the peer, if its OPEN was seen".to_string(),
170            required: false,
171        },
172    ])
173    .with_no_actions()
174});
175
176/// Everything a BGP client handler can answer a session event with.
177///
178/// One list, used for both the async and the sync set. A client cannot narrow its vocabulary
179/// per event even if it wanted to: `client_llm_action_set`
180/// (`src/llm/actions/client_trait.rs`) offers the model `get_async_actions` ∪ `get_sync_actions`
181/// ∪ the firing event's own actions, because `call_llm_for_client` serves both the initial
182/// instruction and every network event through one entry point. Declaring the same list twice
183/// is therefore redundant rather than load-bearing — but it is also what keeps the declaration
184/// honest about what the model will actually be offered.
185///
186/// The corollary matters for [`BGP_CLIENT_NOTIFICATION_RECEIVED_EVENT`]: its `with_no_actions()`
187/// does *not* stop these verbs being offered, so the read loop still has to log anything the
188/// model returns for it as discarded rather than assume it cannot happen.
189fn bgp_client_response_actions() -> Vec<ActionDefinition> {
190    vec![
191        ActionDefinition {
192            name: "send_keepalive".to_string(),
193            description: "Send a BGP KEEPALIVE to the peer. Routine keepalives are already \
194                          sent automatically at a third of the negotiated hold time; use this \
195                          only to send an extra one."
196                .to_string(),
197            parameters: vec![],
198            example: json!({
199                "type": "send_keepalive"
200            }),
201            log_template: None,
202        },
203        ActionDefinition {
204            name: "send_notification".to_string(),
205            description: "Send a BGP NOTIFICATION. RFC 4271 requires the peer to close the \
206                          session on receipt, so this ends the peering."
207                .to_string(),
208            parameters: vec![
209                Parameter {
210                    name: "error_code".to_string(),
211                    type_hint: "number".to_string(),
212                    description: "BGP error code, 1-6 (1 Message Header, 2 OPEN, 3 UPDATE, \
213                                  4 Hold Timer Expired, 5 FSM, 6 Cease)"
214                        .to_string(),
215                    required: true,
216                },
217                Parameter {
218                    name: "error_subcode".to_string(),
219                    type_hint: "number".to_string(),
220                    description: "BGP error subcode (default 0 = unspecified)".to_string(),
221                    required: false,
222                },
223            ],
224            example: json!({
225                "type": "send_notification",
226                "error_code": 6,
227                "error_subcode": 2
228            }),
229            log_template: None,
230        },
231        ActionDefinition {
232            name: "disconnect".to_string(),
233            description: "Close the session gracefully: send NOTIFICATION 6/2 (Cease / \
234                          Administrative Shutdown), then close the TCP connection."
235                .to_string(),
236            parameters: vec![],
237            example: json!({
238                "type": "disconnect"
239            }),
240            log_template: None,
241        },
242        ActionDefinition {
243            name: "wait_for_more".to_string(),
244            description: "Do nothing and keep monitoring the session".to_string(),
245            parameters: vec![],
246            example: json!({
247                "type": "wait_for_more"
248            }),
249            log_template: None,
250        },
251    ]
252}
253
254/// BGP client protocol action handler
255pub struct BgpClientProtocol;
256
257impl BgpClientProtocol {
258    pub fn new() -> Self {
259        Self
260    }
261}
262
263impl Default for BgpClientProtocol {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269// Implement Protocol trait (common functionality)
270impl Protocol for BgpClientProtocol {
271    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
272        bgp_client_response_actions()
273    }
274    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
275        bgp_client_response_actions()
276    }
277    fn get_event_types(&self) -> Vec<EventType> {
278        vec![
279            BGP_CLIENT_CONNECTED_EVENT.clone(),
280            BGP_CLIENT_UPDATE_RECEIVED_EVENT.clone(),
281            BGP_CLIENT_NOTIFICATION_RECEIVED_EVENT.clone(),
282        ]
283    }
284    fn protocol_name(&self) -> &'static str {
285        "BGP"
286    }
287    fn stack_name(&self) -> &'static str {
288        "ETH>IP>TCP>BGP"
289    }
290    fn keywords(&self) -> Vec<&'static str> {
291        vec!["bgp", "border gateway"]
292    }
293    fn description(&self) -> &'static str {
294        "BGP routing client (query mode)"
295    }
296    fn example_prompt(&self) -> &'static str {
297        "Connect to BGP peer at 192.168.1.1:179 and query routing table"
298    }
299    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
300        vec![
301            ParameterDefinition {
302                name: "local_as".to_string(),
303                type_hint: "integer".to_string(),
304                description: "Local BGP AS number (can be fake for monitoring)".to_string(),
305                required: false,
306                example: json!(65000),
307                default: None,
308            },
309            ParameterDefinition {
310                name: "router_id".to_string(),
311                type_hint: "string".to_string(),
312                description: "BGP router ID in IPv4 format".to_string(),
313                required: false,
314                example: json!("192.168.1.100"),
315                default: None,
316            },
317            ParameterDefinition {
318                name: "hold_time".to_string(),
319                type_hint: "integer".to_string(),
320                description: "BGP hold time in seconds (default 180)".to_string(),
321                required: false,
322                example: json!(180),
323                default: None,
324            },
325        ]
326    }
327    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
328        use crate::protocol::metadata::{
329            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
330        };
331
332        ProtocolMetadataV2::builder()
333            .state(DevelopmentState::Experimental)
334            .privilege_requirement(PrivilegeRequirement::None)
335            .implementation(
336                "BGP-4 query client (RFC 4271, RFC 6793) over netgauze-bgp-pkt, \
337                             sharing src/server/bgp/wire.rs with the BGP server",
338            )
339            .llm_control("Session establishment, route monitoring")
340            .e2e_testing("NetGet BGP server")
341            .notes(
342                "Query mode only, no active route announcement, no RIB. Four-octet ASNs \
343                    are advertised and parsed. Keepalives go at a third of the negotiated hold \
344                    time and the hold timer is enforced: a peer that sends nothing for the \
345                    negotiated hold time earns NOTIFICATION 4/0 and the session closes, proven \
346                    end to end in tests/client/bgp/hold_timer_test.rs. Never peered against a \
347                    real BGP daemon.",
348            )
349            .build()
350    }
351    fn group_name(&self) -> &'static str {
352        "VPN & Routing"
353    }
354    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
355        use crate::llm::actions::StartupExamples;
356        use serde_json::json;
357
358        StartupExamples::new(
359            // LLM mode: LLM controls BGP session and route monitoring
360            json!({
361                "type": "open_client",
362                "remote_addr": "192.168.1.1:179",
363                "base_stack": "bgp",
364                "instruction": "Establish BGP session and monitor routing updates",
365                "startup_params": {
366                    "local_as": 65000,
367                    "router_id": "192.168.1.100"
368                }
369            }),
370            // Script mode: Code-based BGP update handling
371            json!({
372                "type": "open_client",
373                "remote_addr": "192.168.1.1:179",
374                "base_stack": "bgp",
375                "startup_params": {
376                    "local_as": 65000,
377                    "router_id": "192.168.1.100"
378                },
379                "event_handlers": [{
380                    "event_pattern": "bgp_update_received",
381                    "handler": {
382                        "type": "script",
383                        "language": "python",
384                        "code": "<bgp_client_handler>"
385                    }
386                }]
387            }),
388            // Static mode: Fixed BGP keepalive response
389            json!({
390                "type": "open_client",
391                "remote_addr": "192.168.1.1:179",
392                "base_stack": "bgp",
393                "startup_params": {
394                    "local_as": 65000,
395                    "router_id": "192.168.1.100",
396                    "hold_time": 180
397                },
398                "event_handlers": [
399                    {
400                        "event_pattern": "bgp_connected",
401                        "handler": {
402                            "type": "static",
403                            "actions": [{
404                                "type": "send_keepalive"
405                            }]
406                        }
407                    },
408                    {
409                        "event_pattern": "bgp_update_received",
410                        "handler": {
411                            "type": "static",
412                            "actions": [{
413                                "type": "send_keepalive"
414                            }]
415                        }
416                    }
417                ]
418            }),
419        )
420    }
421}
422
423// Implement Client trait (client-specific functionality)
424impl Client for BgpClientProtocol {
425    fn connect(
426        &self,
427        ctx: ConnectContext,
428    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
429    {
430        Box::pin(async move {
431            use crate::client::bgp::BgpClient;
432            BgpClient::connect_with_llm_actions(
433                ctx.remote_addr,
434                ctx.llm_client,
435                ctx.state,
436                ctx.status_tx,
437                ctx.client_id,
438                ctx.startup_params,
439            )
440            .await
441        })
442    }
443    /// Turn an action into bytes.
444    ///
445    /// Every message is built by [`crate::server::bgp::wire`], the same codec the BGP server
446    /// uses, rather than by assembling a marker and a length by hand here.
447    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
448        use crate::server::bgp::wire;
449
450        let action_type = action
451            .get("type")
452            .and_then(|v| v.as_str())
453            .context("Missing 'type' field in action")?;
454
455        match action_type {
456            "send_keepalive" => Ok(ClientActionResult::SendData(wire::encode_keepalive())),
457            "send_notification" => {
458                // No default. A NOTIFICATION ends the session, and inventing a Cease for an
459                // action that forgot to say why would make an unusable answer look deliberate.
460                let error_code_raw = action
461                    .get("error_code")
462                    .and_then(|v| v.as_u64())
463                    .context("send_notification requires error_code (1-6, see RFC 4271 §6)")?;
464                let error_code = u8::try_from(error_code_raw)
465                    .ok()
466                    .filter(|c| (1..=6).contains(c))
467                    .with_context(|| {
468                        format!("send_notification error_code must be 1-6, got {error_code_raw}")
469                    })?;
470
471                let error_subcode_raw = action
472                    .get("error_subcode")
473                    .and_then(|v| v.as_u64())
474                    .unwrap_or(0);
475                let error_subcode = u8::try_from(error_subcode_raw).with_context(|| {
476                    format!(
477                        "send_notification error_subcode must be 0-255, got {error_subcode_raw}"
478                    )
479                })?;
480
481                Ok(ClientActionResult::SendData(wire::encode_notification(
482                    error_code,
483                    error_subcode,
484                    &[],
485                )?))
486            }
487            // RFC 4271 §6.7: a peer that is going away says so. Closing the socket without a
488            // NOTIFICATION leaves the peer to discover it from the TCP FIN. The description
489            // has always promised the Cease; only now is it actually sent.
490            "disconnect" => Ok(ClientActionResult::Multiple(vec![
491                ClientActionResult::SendData(wire::encode_notification(
492                    wire::ERR_CEASE,
493                    2, // Administrative Shutdown
494                    &[],
495                )?),
496                ClientActionResult::Disconnect,
497            ])),
498            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
499            _ => Err(anyhow::anyhow!(
500                "Unknown BGP client action: {}",
501                action_type
502            )),
503        }
504    }
505}