1use 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
14pub 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
39pub 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
112pub 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
155pub 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#[derive(Default)]
216pub struct OspfClientProtocol;
217
218impl OspfClientProtocol {
219 pub fn new() -> Self {
220 Self
221 }
222}
223
224impl 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 failure_mode: FailureMode::Answers,
506 max_inbound_bytes: None,
511 well_known_port: None,
513 well_known_transport: PortTransport::Tcp,
514 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 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 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 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
589impl 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}