Skip to main content

netget/client/ospf/
actions.rs

1//! OSPF client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{anyhow, Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// OSPF client connected event
15pub static OSPF_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "ospf_client_connected",
18        "OSPF client successfully joined multicast group and ready to query routers",
19        json!({
20            "type": "wait_for_more"
21        }),
22    )
23    .with_parameters(vec![
24        Parameter {
25            name: "interface_ip".to_string(),
26            type_hint: "string".to_string(),
27            description: "Interface IP address".to_string(),
28            required: true,
29        },
30        Parameter {
31            name: "router_id".to_string(),
32            type_hint: "string".to_string(),
33            description: "Client's OSPF router ID".to_string(),
34            required: true,
35        },
36    ])
37});
38
39/// OSPF Hello packet received event
40pub static OSPF_CLIENT_HELLO_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
41    EventType::new(
42        "ospf_hello_received",
43        "OSPF Hello packet received from router",
44        json!({
45            "type": "wait_for_more"
46        }),
47    )
48    .with_parameters(vec![
49        Parameter {
50            name: "neighbor_id".to_string(),
51            type_hint: "string".to_string(),
52            description: "Neighbor router ID".to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "neighbor_ip".to_string(),
57            type_hint: "string".to_string(),
58            description: "Neighbor IP address".to_string(),
59            required: true,
60        },
61        Parameter {
62            name: "area_id".to_string(),
63            type_hint: "string".to_string(),
64            description: "OSPF area ID".to_string(),
65            required: true,
66        },
67        Parameter {
68            name: "network_mask".to_string(),
69            type_hint: "string".to_string(),
70            description: "Network mask".to_string(),
71            required: true,
72        },
73        Parameter {
74            name: "hello_interval".to_string(),
75            type_hint: "number".to_string(),
76            description: "Hello interval in seconds".to_string(),
77            required: true,
78        },
79        Parameter {
80            name: "router_dead_interval".to_string(),
81            type_hint: "number".to_string(),
82            description: "Router dead interval in seconds".to_string(),
83            required: true,
84        },
85        Parameter {
86            name: "router_priority".to_string(),
87            type_hint: "number".to_string(),
88            description: "Router priority for DR election".to_string(),
89            required: true,
90        },
91        Parameter {
92            name: "dr".to_string(),
93            type_hint: "string".to_string(),
94            description: "Designated router IP".to_string(),
95            required: true,
96        },
97        Parameter {
98            name: "bdr".to_string(),
99            type_hint: "string".to_string(),
100            description: "Backup designated router IP".to_string(),
101            required: true,
102        },
103        Parameter {
104            name: "neighbors".to_string(),
105            type_hint: "array".to_string(),
106            description: "List of neighbor router IDs".to_string(),
107            required: true,
108        },
109    ])
110});
111
112/// OSPF Database Description packet received event
113pub static OSPF_CLIENT_DD_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
114    EventType::new(
115        "ospf_database_description_received",
116        "OSPF Database Description packet received",
117        json!({
118            "type": "wait_for_more"
119        }),
120    )
121    .with_parameters(vec![
122        Parameter {
123            name: "neighbor_id".to_string(),
124            type_hint: "string".to_string(),
125            description: "Neighbor router ID".to_string(),
126            required: true,
127        },
128        Parameter {
129            name: "sequence".to_string(),
130            type_hint: "number".to_string(),
131            description: "DD sequence number".to_string(),
132            required: true,
133        },
134        Parameter {
135            name: "init".to_string(),
136            type_hint: "boolean".to_string(),
137            description: "Init flag".to_string(),
138            required: true,
139        },
140        Parameter {
141            name: "more".to_string(),
142            type_hint: "boolean".to_string(),
143            description: "More flag".to_string(),
144            required: true,
145        },
146        Parameter {
147            name: "master".to_string(),
148            type_hint: "boolean".to_string(),
149            description: "Master/Slave flag".to_string(),
150            required: true,
151        },
152    ])
153});
154
155/// OSPF Link State Update received event
156pub static OSPF_CLIENT_LSU_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
157    EventType::new(
158        "ospf_link_state_update_received",
159        "OSPF Link State Update received: a router is flooding LSAs, which is where the \
160         topology actually lives. Acknowledge them with send_link_state_ack, passing this \
161         event's 'lsa_headers' array straight back - unacknowledged LSAs are retransmitted \
162         every RxmtInterval until the router gives up on the adjacency (RFC 2328 §13.5).",
163        json!({
164            "type": "send_link_state_ack",
165            "router_id": "1.1.1.1",
166            "area_id": "0.0.0.0",
167            "lsa_headers": [{
168                "age": 1,
169                "options": 2,
170                "lsa_type": 1,
171                "link_state_id": "2.2.2.2",
172                "advertising_router": "2.2.2.2",
173                "sequence": 2147483649_u32,
174                "checksum": 65262,
175                "length": 48
176            }],
177            "destination": "192.168.1.2"
178        }),
179    )
180    .with_parameters(vec![
181        Parameter {
182            name: "neighbor_id".to_string(),
183            type_hint: "string".to_string(),
184            description: "Neighbor router ID".to_string(),
185            required: true,
186        },
187        Parameter {
188            name: "advertised_lsa_count".to_string(),
189            type_hint: "number".to_string(),
190            description: "LSA count the packet's own header claims. May exceed lsa_count if \
191                          the packet was truncated or lied."
192                .to_string(),
193            required: true,
194        },
195        Parameter {
196            name: "lsa_count".to_string(),
197            type_hint: "number".to_string(),
198            description: "Number of LSA headers actually parsed out of the packet".to_string(),
199            required: true,
200        },
201        Parameter {
202            name: "lsa_headers".to_string(),
203            type_hint: "array".to_string(),
204            description: "One object per LSA: lsa_type, lsa_type_name, link_state_id, \
205                          advertising_router, sequence, age, options, checksum, length. \
206                          This is the topology data, and also exactly what \
207                          send_link_state_ack needs handed back."
208                .to_string(),
209            required: true,
210        },
211    ])
212});
213
214/// OSPF client protocol action handler
215#[derive(Default)]
216pub struct OspfClientProtocol;
217
218impl OspfClientProtocol {
219    pub fn new() -> Self {
220        Self
221    }
222}
223
224// Implement Protocol trait (common functionality)
225impl Protocol for OspfClientProtocol {
226    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
227        vec![
228            ActionDefinition {
229                name: "send_hello".to_string(),
230                description: "Send OSPF Hello packet to discover neighbors".to_string(),
231                parameters: vec![
232                    Parameter {
233                        name: "router_id".to_string(),
234                        type_hint: "string".to_string(),
235                        description: "Our router ID (e.g., '1.1.1.1')".to_string(),
236                        required: true,
237                    },
238                    Parameter {
239                        name: "area_id".to_string(),
240                        type_hint: "string".to_string(),
241                        description: "OSPF area ID (e.g., '0.0.0.0' for backbone)".to_string(),
242                        required: true,
243                    },
244                    Parameter {
245                        name: "network_mask".to_string(),
246                        type_hint: "string".to_string(),
247                        description: "Network mask (e.g., '255.255.255.0')".to_string(),
248                        required: true,
249                    },
250                    Parameter {
251                        name: "priority".to_string(),
252                        type_hint: "number".to_string(),
253                        description: "Router priority (0-255, 0 means never DR)".to_string(),
254                        required: false,
255                    },
256                    Parameter {
257                        name: "neighbors".to_string(),
258                        type_hint: "array".to_string(),
259                        description: "List of neighbor router IDs we've seen".to_string(),
260                        required: false,
261                    },
262                    Parameter {
263                        name: "destination".to_string(),
264                        type_hint: "string".to_string(),
265                        description: "Destination: 'multicast', 'dr_multicast', or IP address"
266                            .to_string(),
267                        required: false,
268                    },
269                ],
270                example: json!({
271                    "type": "send_hello",
272                    "router_id": "1.1.1.1",
273                    "area_id": "0.0.0.0",
274                    "network_mask": "255.255.255.0",
275                    "priority": 1,
276                    "neighbors": [],
277                    "destination": "multicast"
278                }),
279                log_template: None,
280            },
281            ActionDefinition {
282                name: "send_database_description".to_string(),
283                description: "Send Database Description packet to exchange LSDB info".to_string(),
284                parameters: vec![
285                    Parameter {
286                        name: "router_id".to_string(),
287                        type_hint: "string".to_string(),
288                        description: "Our router ID".to_string(),
289                        required: true,
290                    },
291                    Parameter {
292                        name: "area_id".to_string(),
293                        type_hint: "string".to_string(),
294                        description: "OSPF area ID".to_string(),
295                        required: true,
296                    },
297                    Parameter {
298                        name: "sequence".to_string(),
299                        type_hint: "number".to_string(),
300                        description: "DD sequence number".to_string(),
301                        required: true,
302                    },
303                    Parameter {
304                        name: "init".to_string(),
305                        type_hint: "boolean".to_string(),
306                        description: "Init flag (first DD packet)".to_string(),
307                        required: false,
308                    },
309                    Parameter {
310                        name: "more".to_string(),
311                        type_hint: "boolean".to_string(),
312                        description: "More flag (more DD packets to follow)".to_string(),
313                        required: false,
314                    },
315                    Parameter {
316                        name: "master".to_string(),
317                        type_hint: "boolean".to_string(),
318                        description: "Master/Slave flag".to_string(),
319                        required: false,
320                    },
321                    Parameter {
322                        name: "destination".to_string(),
323                        type_hint: "string".to_string(),
324                        description: "Destination IP address".to_string(),
325                        required: true,
326                    },
327                ],
328                example: json!({
329                    "type": "send_database_description",
330                    "router_id": "1.1.1.1",
331                    "area_id": "0.0.0.0",
332                    "sequence": 12345,
333                    "init": false,
334                    "more": true,
335                    "master": true,
336                    "destination": "192.168.1.2"
337                }),
338                log_template: None,
339            },
340            ActionDefinition {
341                name: "send_link_state_request".to_string(),
342                description: "Send Link State Request to query specific LSAs".to_string(),
343                parameters: vec![
344                    Parameter {
345                        name: "router_id".to_string(),
346                        type_hint: "string".to_string(),
347                        description: "Our router ID".to_string(),
348                        required: true,
349                    },
350                    Parameter {
351                        name: "area_id".to_string(),
352                        type_hint: "string".to_string(),
353                        description: "OSPF area ID".to_string(),
354                        required: true,
355                    },
356                    Parameter {
357                        name: "destination".to_string(),
358                        type_hint: "string".to_string(),
359                        description: "Destination IP address".to_string(),
360                        required: true,
361                    },
362                ],
363                example: json!({
364                    "type": "send_link_state_request",
365                    "router_id": "1.1.1.1",
366                    "area_id": "0.0.0.0",
367                    "requests": [{
368                        "lsa_type": 1,
369                        "link_state_id": "2.2.2.2",
370                        "advertising_router": "2.2.2.2"
371                    }],
372                    "destination": "192.168.1.2"
373                }),
374                log_template: None,
375            },
376            ActionDefinition {
377                name: "send_link_state_ack".to_string(),
378                description: "Acknowledge the LSAs a router flooded to us. Without this the \
379                              router retransmits every LSA each RxmtInterval until it gives \
380                              up on the adjacency."
381                    .to_string(),
382                parameters: vec![
383                    Parameter {
384                        name: "router_id".to_string(),
385                        type_hint: "string".to_string(),
386                        description: "Our router ID".to_string(),
387                        required: true,
388                    },
389                    Parameter {
390                        name: "area_id".to_string(),
391                        type_hint: "string".to_string(),
392                        description: "OSPF area ID".to_string(),
393                        required: true,
394                    },
395                    Parameter {
396                        name: "lsa_headers".to_string(),
397                        type_hint: "array".to_string(),
398                        description: "The LSA headers being acknowledged - pass back the \
399                                      'lsa_headers' array exactly as the \
400                                      ospf_link_state_update_received event delivered it. \
401                                      An acknowledgement is matched header by header, so an \
402                                      empty list acknowledges nothing."
403                            .to_string(),
404                        required: true,
405                    },
406                    Parameter {
407                        name: "destination".to_string(),
408                        type_hint: "string".to_string(),
409                        description: "Destination IP address".to_string(),
410                        required: true,
411                    },
412                ],
413                example: json!({
414                    "type": "send_link_state_ack",
415                    "router_id": "1.1.1.1",
416                    "area_id": "0.0.0.0",
417                    "lsa_headers": [{
418                        "age": 1,
419                        "options": 2,
420                        "lsa_type": 1,
421                        "link_state_id": "2.2.2.2",
422                        "advertising_router": "2.2.2.2",
423                        "sequence": 2147483649_u32,
424                        "checksum": 65262,
425                        "length": 48
426                    }],
427                    "destination": "192.168.1.2"
428                }),
429                log_template: None,
430            },
431            ActionDefinition {
432                name: "disconnect".to_string(),
433                description: "Stop OSPF client and leave multicast group".to_string(),
434                parameters: vec![],
435                example: json!({
436                    "type": "disconnect"
437                }),
438                log_template: None,
439            },
440        ]
441    }
442    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
443        vec![ActionDefinition {
444            name: "wait_for_more".to_string(),
445            description: "Wait for more OSPF packets without sending a response".to_string(),
446            parameters: vec![],
447            example: json!({
448                "type": "wait_for_more"
449            }),
450            log_template: None,
451        }]
452    }
453    fn get_event_types(&self) -> Vec<EventType> {
454        vec![
455            OSPF_CLIENT_CONNECTED_EVENT.clone(),
456            OSPF_CLIENT_HELLO_RECEIVED_EVENT.clone(),
457            OSPF_CLIENT_DD_RECEIVED_EVENT.clone(),
458            OSPF_CLIENT_LSU_RECEIVED_EVENT.clone(),
459        ]
460    }
461    fn protocol_name(&self) -> &'static str {
462        "ospf"
463    }
464    fn stack_name(&self) -> &'static str {
465        "network"
466    }
467    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
468        vec![
469            crate::llm::actions::ParameterDefinition {
470                name: "router_id".to_string(),
471                type_hint: "string".to_string(),
472                description: "OSPF router ID (defaults to interface IP)".to_string(),
473                required: false,
474                example: json!("1.1.1.1"),
475                default: None,
476            },
477            crate::llm::actions::ParameterDefinition {
478                name: "area_id".to_string(),
479                type_hint: "string".to_string(),
480                description: "OSPF area ID (default: 0.0.0.0)".to_string(),
481                required: false,
482                example: json!("0.0.0.0"),
483                default: None,
484            },
485        ]
486    }
487    fn keywords(&self) -> Vec<&'static str> {
488        vec!["ospf", "ospf client", "open shortest path first"]
489    }
490    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
491        use crate::protocol::metadata::*;
492        ProtocolMetadataV2 {
493            state: DevelopmentState::Experimental,
494            privilege_requirement: PrivilegeRequirement::RawSockets,
495            implementation: "Raw IP socket (protocol 89) client for OSPF network monitoring. Parses received Hello, Database Description and Link State Update packets down to their LSA headers; sends Hello, DD, Link State Request and Link State Acknowledgment. Cannot construct LSA bodies, so it can ask for and acknowledge LSAs but never originate one.",
496            llm_control:
497                "LLM controls Hello sending, Database Description exchange, LSR queries and LSAck. The LSA headers an event reports are the same shape send_link_state_ack and send_link_state_request consume, so the model answers a flood by handing the headers back.",
498            e2e_testing: "Weak, and weaker than it looks. tests/client/ospf/e2e_test.rs skips itself with a pass whenever the process is not root - which is every CI run - and even when it runs it asserts only that the word 'OSPF' appears in the client's own output, which the prompt already contains. The real coverage is tests/client/ospf/command_channel_test.rs, which hard-fails either way: unprivileged it asserts connect() returns Err, leaves no command handle behind and makes a later send_to_client fail fast; privileged it asserts the live wiring. The test that actually multicasts a Hello is #[ignore]d because it needs CAP_NET_RAW and a multicast-capable interface.",
499            notes: Some("Query mode only - topology discovery, not a full OSPF router. No LSDB, no SPF, no periodic Hello timer, no adjacency state machine: it reacts to what arrives. Packet construction is shared with the OSPF server (crate::server::ospf::actions::OspfProtocol), so both directions agree on the wire format by construction."),
500            connectionless: false,
501            // The builder's default. `FailureMode` describes what a *server* puts on the wire
502            // when the model cannot answer; a client has no peer waiting on a reply, so this
503            // says nothing about OSPF either way — it is here because the struct is built by
504            // literal rather than through the builder.
505            failure_mode: FailureMode::Answers,
506            // Likewise the builder's default. `max_inbound_bytes` describes what a *server*
507            // buffers from a peer; this client reads each OSPF packet into one fixed-size raw
508            // socket buffer and accumulates nothing across reads, so there is no peer-chosen
509            // length to declare.
510            max_inbound_bytes: None,
511            // A client listens on nothing, so it has no port of its own to declare.
512            well_known_port: None,
513            well_known_transport: PortTransport::Tcp,
514            // A client has no peers of its own to message; the declaration is a server's.
515            request_only: None,
516        }
517    }
518    fn description(&self) -> &'static str {
519        "OSPF client for network topology discovery and OSPF monitoring"
520    }
521    fn example_prompt(&self) -> &'static str {
522        "Connect to 192.168.1.100 via OSPF in area 0, discover neighbors and query LSDB for topology"
523    }
524    fn group_name(&self) -> &'static str {
525        "VPN & Routing"
526    }
527    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
528        use crate::llm::actions::StartupExamples;
529        use serde_json::json;
530
531        StartupExamples::new(
532            // LLM mode: LLM controls OSPF client for topology discovery
533            json!({
534                "type": "open_client",
535                "remote_addr": "192.168.1.1:0",
536                "base_stack": "ospf",
537                "instruction": "Discover OSPF neighbors in area 0 and request topology info"
538            }),
539            // Script mode: Code-based handling of OSPF events
540            json!({
541                "type": "open_client",
542                "remote_addr": "192.168.1.1:0",
543                "base_stack": "ospf",
544                "event_handlers": [{
545                    "event_pattern": "ospf_hello_received",
546                    "handler": {
547                        "type": "script",
548                        "language": "python",
549                        "code": "<ospf_client_handler>"
550                    }
551                }]
552            }),
553            // Static mode: Fixed OSPF Hello sending
554            json!({
555                "type": "open_client",
556                "remote_addr": "192.168.1.1:0",
557                "base_stack": "ospf",
558                "event_handlers": [
559                    {
560                        "event_pattern": "ospf_client_connected",
561                        "handler": {
562                            "type": "static",
563                            "actions": [{
564                                "type": "send_hello",
565                                "router_id": "1.1.1.1",
566                                "area_id": "0.0.0.0",
567                                "network_mask": "255.255.255.0",
568                                "priority": 0,
569                                "neighbors": [],
570                                "destination": "multicast"
571                            }]
572                        }
573                    },
574                    {
575                        "event_pattern": "ospf_hello_received",
576                        "handler": {
577                            "type": "static",
578                            "actions": [{
579                                "type": "wait_for_more"
580                            }]
581                        }
582                    }
583                ]
584            }),
585        )
586    }
587}
588
589// Implement Client trait (client-specific functionality)
590impl Client for OspfClientProtocol {
591    fn connect(
592        &self,
593        ctx: crate::protocol::ConnectContext,
594    ) -> std::pin::Pin<
595        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
596    > {
597        Box::pin(async move {
598            use crate::client::ospf::OspfClient;
599            OspfClient::connect_with_llm_actions(
600                ctx.remote_addr,
601                ctx.llm_client,
602                ctx.state,
603                ctx.status_tx,
604                ctx.client_id,
605                ctx.startup_params,
606            )
607            .await
608        })
609    }
610    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
611        let action_type = action["type"]
612            .as_str()
613            .context("Missing 'type' field in action")?;
614
615        match action_type {
616            "send_hello" => Ok(ClientActionResult::Custom {
617                name: "ospf_send_hello".to_string(),
618                data: action.clone(),
619            }),
620            "send_database_description" => Ok(ClientActionResult::Custom {
621                name: "ospf_send_dd".to_string(),
622                data: action.clone(),
623            }),
624            "send_link_state_request" => Ok(ClientActionResult::Custom {
625                name: "ospf_send_lsr".to_string(),
626                data: action.clone(),
627            }),
628            "send_link_state_ack" => Ok(ClientActionResult::Custom {
629                name: "ospf_send_lsack".to_string(),
630                data: action.clone(),
631            }),
632            "disconnect" => Ok(ClientActionResult::Disconnect),
633            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
634            _ => Err(anyhow!("Unknown action type: {}", action_type)),
635        }
636    }
637}