Skip to main content

netget/server/ospf/
actions.rs

1//! OSPF protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13use tracing::debug;
14
15/// The interface configuration supplied through `get_startup_parameters()`.
16///
17/// Every one of the six declared startup parameters lands in this struct and is read:
18///
19/// * `router_id` / `area_id` / `network_mask` / `hello_interval` /
20///   `router_dead_interval` / `router_priority` become the **defaults for every packet
21///   the model sends** ([`apply_defaults`](Self::apply_defaults)). An action that names a
22///   field still wins - the model can deliberately lie about its timers - but an action
23///   that omits one now gets the operator's configured value instead of a hardcoded
24///   constant.
25/// * `hello_interval`, `router_dead_interval` and `network_mask` are additionally checked
26///   against every received Hello ([`hello_mismatches`](Self::hello_mismatches)), because
27///   RFC 2328 10.5 makes those three a precondition for accepting a Hello at all.
28///
29/// Before this existed, four of the six parameters were declared to the model and never
30/// read anywhere.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OspfInterfaceConfig {
33    pub router_id: String,
34    pub area_id: String,
35    pub network_mask: String,
36    pub hello_interval: u16,
37    pub router_dead_interval: u32,
38    pub router_priority: u8,
39}
40
41impl Default for OspfInterfaceConfig {
42    fn default() -> Self {
43        // RFC 2328 Appendix C.3 defaults for a broadcast interface.
44        Self {
45            router_id: "0.0.0.0".to_string(),
46            area_id: "0.0.0.0".to_string(),
47            network_mask: "255.255.255.0".to_string(),
48            hello_interval: 10,
49            router_dead_interval: 40,
50            router_priority: 1,
51        }
52    }
53}
54
55impl OspfInterfaceConfig {
56    /// Fill in the fields the model left out of an outgoing packet action.
57    ///
58    /// `router_id` and `area_id` apply to every OSPF packet type; the Hello body fields
59    /// are only added to `send_hello`, where they exist. Fields the action already
60    /// carries are left untouched.
61    pub fn apply_defaults(&self, action: &mut serde_json::Value) {
62        let Some(obj) = action.as_object_mut() else {
63            return;
64        };
65
66        obj.entry("router_id")
67            .or_insert_with(|| json!(self.router_id));
68        obj.entry("area_id").or_insert_with(|| json!(self.area_id));
69
70        if obj.get("type").and_then(|t| t.as_str()) == Some("send_hello") {
71            obj.entry("network_mask")
72                .or_insert_with(|| json!(self.network_mask));
73            obj.entry("hello_interval")
74                .or_insert_with(|| json!(self.hello_interval));
75            obj.entry("router_dead_interval")
76                .or_insert_with(|| json!(self.router_dead_interval));
77            obj.entry("priority")
78                .or_insert_with(|| json!(self.router_priority));
79        }
80    }
81
82    /// RFC 2328 10.5: a received Hello whose HelloInterval, RouterDeadInterval or network
83    /// mask differs from the receiving interface's must be rejected - adjacency cannot form
84    /// across such a mismatch on a broadcast network.
85    ///
86    /// Returns one human-readable line per mismatching field, empty when the Hello is
87    /// compatible. The caller reports these to the model and refuses to advance the
88    /// neighbour state machine, rather than pretending an adjacency is forming that a real
89    /// router would never complete.
90    pub fn hello_mismatches(
91        &self,
92        hello_interval: u16,
93        router_dead_interval: u32,
94        network_mask: &str,
95    ) -> Vec<String> {
96        let mut out = Vec::new();
97        if hello_interval != self.hello_interval {
98            out.push(format!(
99                "hello_interval {} does not match our configured {}",
100                hello_interval, self.hello_interval
101            ));
102        }
103        if router_dead_interval != self.router_dead_interval {
104            out.push(format!(
105                "router_dead_interval {} does not match our configured {}",
106                router_dead_interval, self.router_dead_interval
107            ));
108        }
109        if network_mask != self.network_mask {
110            out.push(format!(
111                "network_mask {} does not match our configured {}",
112                network_mask, self.network_mask
113            ));
114        }
115        out
116    }
117}
118
119/// OSPF protocol action handler
120pub struct OspfProtocol;
121
122impl OspfProtocol {
123    pub fn new() -> Self {
124        Self
125    }
126
127    fn execute_send_hello(&self, action: serde_json::Value) -> Result<ActionResult> {
128        let router_id = action
129            .get("router_id")
130            .and_then(|v| v.as_str())
131            .unwrap_or("0.0.0.0");
132
133        let area_id = action
134            .get("area_id")
135            .and_then(|v| v.as_str())
136            .unwrap_or("0.0.0.0");
137
138        let _network_mask = action
139            .get("network_mask")
140            .and_then(|v| v.as_str())
141            .unwrap_or("255.255.255.0");
142
143        let _hello_interval = action
144            .get("hello_interval")
145            .and_then(|v| v.as_u64())
146            .unwrap_or(10) as u16;
147
148        let _router_dead_interval = action
149            .get("router_dead_interval")
150            .and_then(|v| v.as_u64())
151            .unwrap_or(40) as u32;
152
153        let priority = action.get("priority").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
154
155        let _dr = action
156            .get("dr")
157            .and_then(|v| v.as_str())
158            .unwrap_or("0.0.0.0");
159
160        let _bdr = action
161            .get("bdr")
162            .and_then(|v| v.as_str())
163            .unwrap_or("0.0.0.0");
164
165        let destination = action
166            .get("destination")
167            .and_then(|v| v.as_str())
168            .unwrap_or("multicast")
169            .to_string();
170
171        debug!(
172            "OSPF sending Hello: router_id={}, area={}, priority={}, dest={}",
173            router_id, area_id, priority, destination
174        );
175
176        // Return structured action data - packet will be built in mod.rs
177        Ok(ActionResult::Custom {
178            name: "ospf_action".to_string(),
179            data: action.clone(),
180        })
181    }
182
183    fn execute_send_database_description(&self, action: serde_json::Value) -> Result<ActionResult> {
184        let _router_id = action
185            .get("router_id")
186            .and_then(|v| v.as_str())
187            .unwrap_or("0.0.0.0");
188
189        let _area_id = action
190            .get("area_id")
191            .and_then(|v| v.as_str())
192            .unwrap_or("0.0.0.0");
193
194        let sequence = action.get("sequence").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
195
196        let init = action
197            .get("init")
198            .and_then(|v| v.as_bool())
199            .unwrap_or(false);
200        let more = action
201            .get("more")
202            .and_then(|v| v.as_bool())
203            .unwrap_or(false);
204        let master = action
205            .get("master")
206            .and_then(|v| v.as_bool())
207            .unwrap_or(false);
208
209        let destination = action
210            .get("destination")
211            .and_then(|v| v.as_str())
212            .unwrap_or("multicast")
213            .to_string();
214
215        debug!(
216            "OSPF sending Database Description: seq={}, init={}, more={}, master={}, dest={}",
217            sequence, init, more, master, destination
218        );
219
220        // Return structured action data - packet will be built in mod.rs
221        Ok(ActionResult::Custom {
222            name: "ospf_action".to_string(),
223            data: action.clone(),
224        })
225    }
226
227    fn execute_send_link_state_request(&self, action: serde_json::Value) -> Result<ActionResult> {
228        let _router_id = action
229            .get("router_id")
230            .and_then(|v| v.as_str())
231            .unwrap_or("0.0.0.0");
232
233        let _area_id = action
234            .get("area_id")
235            .and_then(|v| v.as_str())
236            .unwrap_or("0.0.0.0");
237
238        let destination = action
239            .get("destination")
240            .and_then(|v| v.as_str())
241            .unwrap_or("multicast")
242            .to_string();
243
244        debug!("OSPF sending Link State Request to {}", destination);
245
246        // Return structured action data - packet will be built in mod.rs
247        Ok(ActionResult::Custom {
248            name: "ospf_action".to_string(),
249            data: action.clone(),
250        })
251    }
252
253    fn execute_send_link_state_update(&self, action: serde_json::Value) -> Result<ActionResult> {
254        let _router_id = action
255            .get("router_id")
256            .and_then(|v| v.as_str())
257            .unwrap_or("0.0.0.0");
258
259        let _area_id = action
260            .get("area_id")
261            .and_then(|v| v.as_str())
262            .unwrap_or("0.0.0.0");
263
264        let destination = action
265            .get("destination")
266            .and_then(|v| v.as_str())
267            .unwrap_or("multicast")
268            .to_string();
269
270        debug!("OSPF sending Link State Update to {}", destination);
271
272        // Return structured action data - packet will be built in mod.rs
273        Ok(ActionResult::Custom {
274            name: "ospf_action".to_string(),
275            data: action.clone(),
276        })
277    }
278
279    fn execute_send_link_state_ack(&self, action: serde_json::Value) -> Result<ActionResult> {
280        let _router_id = action
281            .get("router_id")
282            .and_then(|v| v.as_str())
283            .unwrap_or("0.0.0.0");
284
285        let _area_id = action
286            .get("area_id")
287            .and_then(|v| v.as_str())
288            .unwrap_or("0.0.0.0");
289
290        let destination = action
291            .get("destination")
292            .and_then(|v| v.as_str())
293            .unwrap_or("multicast")
294            .to_string();
295
296        debug!("OSPF sending Link State Acknowledgment to {}", destination);
297
298        // Return structured action data - packet will be built in mod.rs
299        Ok(ActionResult::Custom {
300            name: "ospf_action".to_string(),
301            data: action.clone(),
302        })
303    }
304
305    /// Parse a dotted-quad into the four bytes every OSPF ID field carries.
306    ///
307    /// Strict on purpose. This used to fall back to `0.0.0.0` for anything it could not
308    /// parse and return `Ok`, so `router_id: "1.1.1"`, `"999.1.1.1"` or a typo'd startup
309    /// parameter produced a well-formed packet advertising Router ID 0.0.0.0 — a different
310    /// router — with nothing logged anywhere. Every caller already uses `?`, so an error
311    /// here surfaces as the "OSPF packet build error" the operator can act on, and no
312    /// packet goes out claiming an identity nobody asked for.
313    fn parse_ipv4(ip: &str) -> Result<[u8; 4]> {
314        ip.parse::<std::net::Ipv4Addr>()
315            .map(|addr| addr.octets())
316            .map_err(|_| {
317                anyhow::anyhow!("'{ip}' is not an IPv4 address in dotted-quad form (e.g. 1.1.1.1)")
318            })
319    }
320
321    /// Parse the fixed 20-byte LSA headers that DD and LSAck packets carry, and that LSU
322    /// packets carry ahead of each LSA body (RFC 2328 A.4.1).
323    ///
324    /// Returns structured fields, never raw bytes - models cannot read a hex blob (see
325    /// CLAUDE.md, action & event design rules). The shape is exactly what
326    /// [`Self::build_link_state_ack_packet`] consumes, so the model can acknowledge an
327    /// update by handing back the `lsa_headers` the event gave it.
328    ///
329    /// Lives here rather than in the server loop because the OSPF *client* parses the same
330    /// headers out of the Link State Updates it receives.
331    pub fn parse_lsa_headers(body: &[u8], max: usize) -> Vec<serde_json::Value> {
332        const LSA_HEADER_LEN: usize = 20;
333        let mut out = Vec::new();
334        let mut offset = 0;
335        while offset + LSA_HEADER_LEN <= body.len() && out.len() < max {
336            let h = &body[offset..offset + LSA_HEADER_LEN];
337            let lsa_type = h[3];
338            out.push(json!({
339                "age": u16::from_be_bytes([h[0], h[1]]),
340                "options": h[2],
341                "lsa_type": lsa_type,
342                "lsa_type_name": match lsa_type {
343                    1 => "router",
344                    2 => "network",
345                    3 => "summary_network",
346                    4 => "summary_asbr",
347                    5 => "as_external",
348                    7 => "nssa_external",
349                    _ => "unknown",
350                },
351                "link_state_id": format!("{}.{}.{}.{}", h[4], h[5], h[6], h[7]),
352                "advertising_router": format!("{}.{}.{}.{}", h[8], h[9], h[10], h[11]),
353                "sequence": u32::from_be_bytes([h[12], h[13], h[14], h[15]]),
354                // The LS checksum must be echoed verbatim in an acknowledgement: RFC 2328
355                // 13.7 matches an LSAck against the retransmission list on the full header,
356                // checksum included. Dropping it here made a correct ack unbuildable.
357                "checksum": u16::from_be_bytes([h[16], h[17]]),
358                "length": u16::from_be_bytes([h[18], h[19]]),
359            }));
360            offset += LSA_HEADER_LEN;
361        }
362        out
363    }
364
365    /// Serialise one LSA header (RFC 2328 A.4.1) from the same JSON shape
366    /// [`Self::parse_lsa_headers`] produces.
367    fn push_lsa_header(msg: &mut Vec<u8>, header: &serde_json::Value) -> Result<()> {
368        let u16_field = |name: &str| header.get(name).and_then(|v| v.as_u64()).unwrap_or(0) as u16;
369        let ip_field = |name: &str| -> Result<[u8; 4]> {
370            Self::parse_ipv4(
371                header
372                    .get(name)
373                    .and_then(|v| v.as_str())
374                    .unwrap_or("0.0.0.0"),
375            )
376        };
377
378        msg.extend_from_slice(&u16_field("age").to_be_bytes());
379        msg.push(header.get("options").and_then(|v| v.as_u64()).unwrap_or(0) as u8);
380        msg.push(header.get("lsa_type").and_then(|v| v.as_u64()).unwrap_or(1) as u8);
381        msg.extend_from_slice(&ip_field("link_state_id")?);
382        msg.extend_from_slice(&ip_field("advertising_router")?);
383        msg.extend_from_slice(
384            &(header
385                .get("sequence")
386                .and_then(|v| v.as_u64())
387                .unwrap_or(0x8000_0001) as u32)
388                .to_be_bytes(),
389        );
390        msg.extend_from_slice(&u16_field("checksum").to_be_bytes());
391        // An LSA header echoed in an ack describes the original LSA's length, so 20 (the
392        // header alone) is only the right default when the caller knows nothing else.
393        let length = match header.get("length").and_then(|v| v.as_u64()) {
394            Some(n) => n as u16,
395            None => 20,
396        };
397        msg.extend_from_slice(&length.to_be_bytes());
398        Ok(())
399    }
400
401    /// Compute the OSPF packet checksum (RFC 2328 Section A.3.1).
402    ///
403    /// This is the **standard IP (one's complement) checksum** over the entire packet with the
404    /// checksum field itself treated as zero and the 64-bit authentication field (header bytes
405    /// 16..24) excluded. It is *not* the Fletcher checksum of Section D.4 — that one applies to
406    /// LSA headers, not to packet headers. A previous implementation here used Fletcher, which
407    /// meant every packet NetGet emitted failed the receiver's validity check and was silently
408    /// dropped by real routers (FRR/BIRD).
409    ///
410    /// The defining property, and how a receiver validates: recomputing this sum over the packet
411    /// **with the checksum field left in place** must yield 0.
412    ///
413    /// That property is why this function does not special-case bytes 12..14. It sums the packet
414    /// exactly as given, which makes one function serve both directions of the standard
415    /// one's-complement idiom:
416    ///
417    /// * **Sending** — leave the checksum field zero, call this, store the result there. Every
418    ///   `build_*_packet` here does that, and their headers already lay the field down as
419    ///   `[0, 0]`.
420    /// * **Receiving** — call this over the packet as it arrived. A valid packet yields 0.
421    ///
422    /// Zeroing bytes 12..14 unconditionally, as an earlier version did, collapses those two into
423    /// one: the receiver then recomputes the sender's value instead of 0, so the validity check
424    /// can never pass and a corrupted packet is indistinguishable from a good one. The doc
425    /// comment claimed the property above while the code made it unsatisfiable.
426    pub fn calculate_checksum(data: &[u8]) -> u16 {
427        let mut sum: u32 = 0;
428        // Accumulate 16-bit big-endian words, skipping the auth field. `pending` holds the
429        // high byte of a word still being assembled, which matters because excluding bytes
430        // 16..24 keeps 16-bit alignment intact only because that range is itself even-aligned
431        // and even-length.
432        let mut pending: Option<u8> = None;
433        for (i, &byte) in data.iter().enumerate() {
434            // Skip the 64-bit authentication field entirely.
435            if (16..24).contains(&i) {
436                continue;
437            }
438            match pending.take() {
439                None => pending = Some(byte),
440                Some(hi) => sum += u16::from_be_bytes([hi, byte]) as u32,
441            }
442        }
443        // Odd-length packet: pad with a zero byte.
444        if let Some(hi) = pending {
445            sum += u16::from_be_bytes([hi, 0]) as u32;
446        }
447        while (sum >> 16) != 0 {
448            sum = (sum & 0xffff) + (sum >> 16);
449        }
450        !(sum as u16)
451    }
452
453    /// Build OSPF Hello packet from action data
454    pub fn build_hello_packet(action: &serde_json::Value) -> Result<Vec<u8>> {
455        let router_id = action
456            .get("router_id")
457            .and_then(|v| v.as_str())
458            .unwrap_or("0.0.0.0");
459        let area_id = action
460            .get("area_id")
461            .and_then(|v| v.as_str())
462            .unwrap_or("0.0.0.0");
463        let network_mask = action
464            .get("network_mask")
465            .and_then(|v| v.as_str())
466            .unwrap_or("255.255.255.0");
467        let hello_interval = action
468            .get("hello_interval")
469            .and_then(|v| v.as_u64())
470            .unwrap_or(10) as u16;
471        let router_dead_interval = action
472            .get("router_dead_interval")
473            .and_then(|v| v.as_u64())
474            .unwrap_or(40) as u32;
475        let priority = action.get("priority").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
476        let dr = action
477            .get("dr")
478            .and_then(|v| v.as_str())
479            .unwrap_or("0.0.0.0");
480        let bdr = action
481            .get("bdr")
482            .and_then(|v| v.as_str())
483            .unwrap_or("0.0.0.0");
484
485        let mut msg = Vec::new();
486
487        // OSPF Header (24 bytes)
488        msg.push(2); // Version = 2 (OSPFv2)
489        msg.push(1); // Type = 1 (Hello)
490        msg.extend_from_slice(&[0, 0]); // Packet Length (placeholder)
491        msg.extend_from_slice(&Self::parse_ipv4(router_id)?);
492        msg.extend_from_slice(&Self::parse_ipv4(area_id)?);
493        msg.extend_from_slice(&[0, 0]); // Checksum (placeholder)
494        msg.extend_from_slice(&[0, 0]); // AuType = 0 (no authentication)
495        msg.extend_from_slice(&[0; 8]); // Authentication (8 bytes, zeros)
496
497        // Hello packet body
498        msg.extend_from_slice(&Self::parse_ipv4(network_mask)?);
499        msg.extend_from_slice(&hello_interval.to_be_bytes());
500        msg.push(0); // Options
501        msg.push(priority);
502        msg.extend_from_slice(&router_dead_interval.to_be_bytes());
503        msg.extend_from_slice(&Self::parse_ipv4(dr)?);
504        msg.extend_from_slice(&Self::parse_ipv4(bdr)?);
505
506        // Neighbor list
507        if let Some(neighbors) = action.get("neighbors").and_then(|v| v.as_array()) {
508            for neighbor in neighbors {
509                if let Some(neighbor_id) = neighbor.as_str() {
510                    msg.extend_from_slice(&Self::parse_ipv4(neighbor_id)?);
511                }
512            }
513        }
514
515        // Update packet length and checksum
516        let packet_len = msg.len() as u16;
517        msg[2..4].copy_from_slice(&packet_len.to_be_bytes());
518        // Computed with its own field zeroed, so that a receiver summing the packet with the
519        // checksum in place gets 0. The header lays the field down as [0, 0]; this makes that
520        // a local guarantee rather than an assumption about code 30 lines up.
521        msg[12..14].copy_from_slice(&[0, 0]);
522        let checksum = Self::calculate_checksum(&msg);
523        msg[12..14].copy_from_slice(&checksum.to_be_bytes());
524
525        Ok(msg)
526    }
527
528    /// Build OSPF Database Description packet from action data
529    pub fn build_database_description_packet(action: &serde_json::Value) -> Result<Vec<u8>> {
530        let router_id = action
531            .get("router_id")
532            .and_then(|v| v.as_str())
533            .unwrap_or("0.0.0.0");
534        let area_id = action
535            .get("area_id")
536            .and_then(|v| v.as_str())
537            .unwrap_or("0.0.0.0");
538        let sequence = action.get("sequence").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
539        let init = action
540            .get("init")
541            .and_then(|v| v.as_bool())
542            .unwrap_or(false);
543        let more = action
544            .get("more")
545            .and_then(|v| v.as_bool())
546            .unwrap_or(false);
547        let master = action
548            .get("master")
549            .and_then(|v| v.as_bool())
550            .unwrap_or(false);
551
552        let mut msg = Vec::new();
553
554        // OSPF Header
555        msg.push(2); // Version
556        msg.push(2); // Type = 2 (Database Description)
557        msg.extend_from_slice(&[0, 0]); // Packet Length (placeholder)
558        msg.extend_from_slice(&Self::parse_ipv4(router_id)?);
559        msg.extend_from_slice(&Self::parse_ipv4(area_id)?);
560        msg.extend_from_slice(&[0, 0]); // Checksum (placeholder)
561        msg.extend_from_slice(&[0, 0]); // AuType
562        msg.extend_from_slice(&[0; 8]); // Authentication
563
564        // DD packet body
565        msg.extend_from_slice(&[0, 0]); // Interface MTU
566        msg.push(0); // Options
567        let mut flags: u8 = 0;
568        if init {
569            flags |= 0x04;
570        }
571        if more {
572            flags |= 0x02;
573        }
574        if master {
575            flags |= 0x01;
576        }
577        msg.push(flags);
578        msg.extend_from_slice(&sequence.to_be_bytes());
579
580        // Database summary: the LSA headers this router is advertising, in the shape
581        // parse_lsa_headers emits.
582        if let Some(headers) = action.get("lsa_headers").and_then(|v| v.as_array()) {
583            for header in headers {
584                Self::push_lsa_header(&mut msg, header)?;
585            }
586        }
587
588        // Update packet length and checksum
589        let packet_len = msg.len() as u16;
590        msg[2..4].copy_from_slice(&packet_len.to_be_bytes());
591        // Computed with its own field zeroed, so that a receiver summing the packet with the
592        // checksum in place gets 0. The header lays the field down as [0, 0]; this makes that
593        // a local guarantee rather than an assumption about code 30 lines up.
594        msg[12..14].copy_from_slice(&[0, 0]);
595        let checksum = Self::calculate_checksum(&msg);
596        msg[12..14].copy_from_slice(&checksum.to_be_bytes());
597
598        Ok(msg)
599    }
600
601    /// Build OSPF Link State Request packet from action data (RFC 2328 A.3.4).
602    ///
603    /// Body is a repeating triple: LSType(4) | LinkStateID(4) | AdvertisingRouter(4), taken
604    /// from the action's `requests` array. The `ospf_link_state_update` event hands the model
605    /// LSA headers carrying exactly those three fields, so a request can name real LSAs.
606    pub fn build_link_state_request_packet(action: &serde_json::Value) -> Result<Vec<u8>> {
607        let router_id = action
608            .get("router_id")
609            .and_then(|v| v.as_str())
610            .unwrap_or("0.0.0.0");
611        let area_id = action
612            .get("area_id")
613            .and_then(|v| v.as_str())
614            .unwrap_or("0.0.0.0");
615
616        let mut msg = Vec::new();
617
618        // OSPF Header
619        msg.push(2); // Version
620        msg.push(3); // Type = 3 (Link State Request)
621        msg.extend_from_slice(&[0, 0]); // Packet Length (placeholder)
622        msg.extend_from_slice(&Self::parse_ipv4(router_id)?);
623        msg.extend_from_slice(&Self::parse_ipv4(area_id)?);
624        msg.extend_from_slice(&[0, 0]); // Checksum (placeholder)
625        msg.extend_from_slice(&[0, 0]); // AuType
626        msg.extend_from_slice(&[0; 8]); // Authentication
627
628        // LSR body: one 12-byte triple per requested LSA.
629        if let Some(requests) = action.get("requests").and_then(|v| v.as_array()) {
630            for request in requests {
631                let lsa_type = request
632                    .get("lsa_type")
633                    .and_then(|v| v.as_u64())
634                    .unwrap_or(1) as u32;
635                msg.extend_from_slice(&lsa_type.to_be_bytes());
636                msg.extend_from_slice(&Self::parse_ipv4(
637                    request
638                        .get("link_state_id")
639                        .and_then(|v| v.as_str())
640                        .unwrap_or("0.0.0.0"),
641                )?);
642                msg.extend_from_slice(&Self::parse_ipv4(
643                    request
644                        .get("advertising_router")
645                        .and_then(|v| v.as_str())
646                        .unwrap_or("0.0.0.0"),
647                )?);
648            }
649        }
650
651        // Update packet length and checksum
652        let packet_len = msg.len() as u16;
653        msg[2..4].copy_from_slice(&packet_len.to_be_bytes());
654        // Computed with its own field zeroed, so that a receiver summing the packet with the
655        // checksum in place gets 0. The header lays the field down as [0, 0]; this makes that
656        // a local guarantee rather than an assumption about code 30 lines up.
657        msg[12..14].copy_from_slice(&[0, 0]);
658        let checksum = Self::calculate_checksum(&msg);
659        msg[12..14].copy_from_slice(&checksum.to_be_bytes());
660
661        Ok(msg)
662    }
663
664    /// Build OSPF Link State Update packet from action data
665    pub fn build_link_state_update_packet(action: &serde_json::Value) -> Result<Vec<u8>> {
666        let router_id = action
667            .get("router_id")
668            .and_then(|v| v.as_str())
669            .unwrap_or("0.0.0.0");
670        let area_id = action
671            .get("area_id")
672            .and_then(|v| v.as_str())
673            .unwrap_or("0.0.0.0");
674
675        let mut msg = Vec::new();
676
677        // OSPF Header
678        msg.push(2); // Version
679        msg.push(4); // Type = 4 (Link State Update)
680        msg.extend_from_slice(&[0, 0]); // Packet Length (placeholder)
681        msg.extend_from_slice(&Self::parse_ipv4(router_id)?);
682        msg.extend_from_slice(&Self::parse_ipv4(area_id)?);
683        msg.extend_from_slice(&[0, 0]); // Checksum (placeholder)
684        msg.extend_from_slice(&[0, 0]); // AuType
685        msg.extend_from_slice(&[0; 8]); // Authentication
686
687        // Number of LSAs
688        msg.extend_from_slice(&[0, 0, 0, 0]); // 0 LSAs (simplified)
689
690        // LSAs would go here
691
692        // Update packet length and checksum
693        let packet_len = msg.len() as u16;
694        msg[2..4].copy_from_slice(&packet_len.to_be_bytes());
695        // Computed with its own field zeroed, so that a receiver summing the packet with the
696        // checksum in place gets 0. The header lays the field down as [0, 0]; this makes that
697        // a local guarantee rather than an assumption about code 30 lines up.
698        msg[12..14].copy_from_slice(&[0, 0]);
699        let checksum = Self::calculate_checksum(&msg);
700        msg[12..14].copy_from_slice(&checksum.to_be_bytes());
701
702        Ok(msg)
703    }
704
705    /// Build OSPF Link State Acknowledgment packet from action data (RFC 2328 A.3.6).
706    ///
707    /// The body is the list of 20-byte LSA headers being acknowledged, taken from the
708    /// action's `lsa_headers` array in the shape [`Self::parse_lsa_headers`] emits — so the
709    /// model acknowledges an update by handing back the `lsa_headers` the
710    /// `ospf_link_state_update` event gave it.
711    ///
712    /// An LSAck with an empty body is not a no-op: RFC 2328 13.7 matches an acknowledgement
713    /// against the neighbour's retransmission list header by header, so a body-less LSAck
714    /// acknowledges nothing and the peer keeps retransmitting every LSA each RxmtInterval
715    /// until the adjacency fails. That is what this used to emit.
716    pub fn build_link_state_ack_packet(action: &serde_json::Value) -> Result<Vec<u8>> {
717        let router_id = action
718            .get("router_id")
719            .and_then(|v| v.as_str())
720            .unwrap_or("0.0.0.0");
721        let area_id = action
722            .get("area_id")
723            .and_then(|v| v.as_str())
724            .unwrap_or("0.0.0.0");
725
726        let mut msg = Vec::new();
727
728        // OSPF Header
729        msg.push(2); // Version
730        msg.push(5); // Type = 5 (Link State Acknowledgment)
731        msg.extend_from_slice(&[0, 0]); // Packet Length (placeholder)
732        msg.extend_from_slice(&Self::parse_ipv4(router_id)?);
733        msg.extend_from_slice(&Self::parse_ipv4(area_id)?);
734        msg.extend_from_slice(&[0, 0]); // Checksum (placeholder)
735        msg.extend_from_slice(&[0, 0]); // AuType
736        msg.extend_from_slice(&[0; 8]); // Authentication
737
738        // LSA headers being acknowledged.
739        if let Some(headers) = action.get("lsa_headers").and_then(|v| v.as_array()) {
740            for header in headers {
741                Self::push_lsa_header(&mut msg, header)?;
742            }
743        }
744
745        // Update packet length and checksum
746        let packet_len = msg.len() as u16;
747        msg[2..4].copy_from_slice(&packet_len.to_be_bytes());
748        // Computed with its own field zeroed, so that a receiver summing the packet with the
749        // checksum in place gets 0. The header lays the field down as [0, 0]; this makes that
750        // a local guarantee rather than an assumption about code 30 lines up.
751        msg[12..14].copy_from_slice(&[0, 0]);
752        let checksum = Self::calculate_checksum(&msg);
753        msg[12..14].copy_from_slice(&checksum.to_be_bytes());
754
755        Ok(msg)
756    }
757}
758
759// ============================================================================
760// Action Definitions (shared between get_sync_actions() and the event types below).
761//
762// `call_llm` builds the model's tool list from `EventType::actions`, NOT from
763// get_sync_actions(), so every event below must list the actions it accepts.
764// ============================================================================
765
766fn send_hello_action() -> ActionDefinition {
767    ActionDefinition {
768                name: "send_hello".to_string(),
769                description: "Send OSPF Hello packet to discover/maintain neighbors".to_string(),
770                parameters: vec![
771                    Parameter {
772                        name: "router_id".to_string(),
773                        type_hint: "string".to_string(),
774                        description: "OSPF router ID (IPv4 format)".to_string(),
775                        required: true,
776                    },
777                    Parameter {
778                        name: "area_id".to_string(),
779                        type_hint: "string".to_string(),
780                        description: "OSPF area ID (IPv4 format, 0.0.0.0 = backbone)".to_string(),
781                        required: true,
782                    },
783                    Parameter {
784                        name: "network_mask".to_string(),
785                        type_hint: "string".to_string(),
786                        description: "Network mask (e.g., 255.255.255.0)".to_string(),
787                        required: false,
788                    },
789                    Parameter {
790                        name: "hello_interval".to_string(),
791                        type_hint: "number".to_string(),
792                        description: "Hello interval in seconds (default 10)".to_string(),
793                        required: false,
794                    },
795                    Parameter {
796                        name: "router_dead_interval".to_string(),
797                        type_hint: "number".to_string(),
798                        description: "Router dead interval in seconds (default 40)".to_string(),
799                        required: false,
800                    },
801                    Parameter {
802                        name: "priority".to_string(),
803                        type_hint: "number".to_string(),
804                        description: "Router priority for DR election (0-255, default 1)".to_string(),
805                        required: false,
806                    },
807                    Parameter {
808                        name: "dr".to_string(),
809                        type_hint: "string".to_string(),
810                        description: "Designated Router IP (0.0.0.0 if none)".to_string(),
811                        required: false,
812                    },
813                    Parameter {
814                        name: "bdr".to_string(),
815                        type_hint: "string".to_string(),
816                        description: "Backup Designated Router IP (0.0.0.0 if none)".to_string(),
817                        required: false,
818                    },
819                    Parameter {
820                        name: "neighbors".to_string(),
821                        type_hint: "array".to_string(),
822                        description: "List of neighbor router IDs".to_string(),
823                        required: false,
824                    },
825                    Parameter {
826                        name: "destination".to_string(),
827                        type_hint: "string".to_string(),
828                        description: "Destination IP: 'multicast' (default, 224.0.0.5), 'dr_multicast' (224.0.0.6), or unicast IP".to_string(),
829                        required: false,
830                    },
831                ],
832                example: json!({
833                    "type": "send_hello",
834                    "router_id": "1.1.1.1",
835                    "area_id": "0.0.0.0",
836                    "priority": 1,
837                    "neighbors": ["2.2.2.2"],
838                    "destination": "multicast"
839                }),
840                log_template: Some(
841                    LogTemplate::new()
842                        .with_info("-> OSPF Hello router={router_id} area={area_id}")
843                        .with_debug("OSPF send_hello: router_id={router_id} area={area_id} priority={priority}"),
844                ),
845            }
846}
847
848fn send_database_description_action() -> ActionDefinition {
849    ActionDefinition {
850        name: "send_database_description".to_string(),
851        description: "Send OSPF Database Description packet".to_string(),
852        parameters: vec![
853            Parameter {
854                name: "router_id".to_string(),
855                type_hint: "string".to_string(),
856                description: "OSPF router ID".to_string(),
857                required: true,
858            },
859            Parameter {
860                name: "area_id".to_string(),
861                type_hint: "string".to_string(),
862                description: "OSPF area ID".to_string(),
863                required: true,
864            },
865            Parameter {
866                name: "sequence".to_string(),
867                type_hint: "number".to_string(),
868                description: "DD sequence number".to_string(),
869                required: true,
870            },
871            Parameter {
872                name: "init".to_string(),
873                type_hint: "boolean".to_string(),
874                description: "Init flag (true for first DD packet)".to_string(),
875                required: false,
876            },
877            Parameter {
878                name: "more".to_string(),
879                type_hint: "boolean".to_string(),
880                description: "More flag (true if more DD packets follow)".to_string(),
881                required: false,
882            },
883            Parameter {
884                name: "master".to_string(),
885                type_hint: "boolean".to_string(),
886                description: "Master flag (true if this router is master)".to_string(),
887                required: false,
888            },
889            Parameter {
890                name: "lsa_headers".to_string(),
891                type_hint: "array".to_string(),
892                description: "LSA headers summarising the database, each an object with \
893                              lsa_type, link_state_id, advertising_router, sequence, age, \
894                              options, checksum and length - the same shape the \
895                              ospf_link_state_update event delivers. Omit for an empty \
896                              summary."
897                    .to_string(),
898                required: false,
899            },
900            Parameter {
901                name: "destination".to_string(),
902                type_hint: "string".to_string(),
903                description: "Destination IP: 'multicast' (default) or unicast IP".to_string(),
904                required: false,
905            },
906        ],
907        example: json!({
908            "type": "send_database_description",
909            "router_id": "1.1.1.1",
910            "area_id": "0.0.0.0",
911            "sequence": 1,
912            "init": true,
913            "master": true,
914            "destination": "192.168.1.2"
915        }),
916        log_template: Some(
917            LogTemplate::new()
918                .with_info("-> OSPF DD seq={sequence}")
919                .with_debug(
920                "OSPF send_database_description: router_id={router_id} seq={sequence} init={init}",
921            ),
922        ),
923    }
924}
925
926fn send_link_state_request_action() -> ActionDefinition {
927    ActionDefinition {
928        name: "send_link_state_request".to_string(),
929        description: "Send OSPF Link State Request packet asking a neighbour for named LSAs"
930            .to_string(),
931        parameters: vec![
932            Parameter {
933                name: "router_id".to_string(),
934                type_hint: "string".to_string(),
935                description: "OSPF router ID".to_string(),
936                required: true,
937            },
938            Parameter {
939                name: "area_id".to_string(),
940                type_hint: "string".to_string(),
941                description: "OSPF area ID".to_string(),
942                required: true,
943            },
944            Parameter {
945                name: "requests".to_string(),
946                type_hint: "array".to_string(),
947                description: "LSAs to request, each an object with lsa_type (number), \
948                              link_state_id and advertising_router (dotted quads). These are \
949                              the fields the ospf_database_description and \
950                              ospf_link_state_update events report for every LSA header. A \
951                              request with no entries asks for nothing."
952                    .to_string(),
953                required: false,
954            },
955            Parameter {
956                name: "destination".to_string(),
957                type_hint: "string".to_string(),
958                description: "Destination IP: 'multicast' (default) or unicast IP".to_string(),
959                required: false,
960            },
961        ],
962        example: json!({
963            "type": "send_link_state_request",
964            "router_id": "1.1.1.1",
965            "area_id": "0.0.0.0",
966            "requests": [{
967                "lsa_type": 1,
968                "link_state_id": "2.2.2.2",
969                "advertising_router": "2.2.2.2"
970            }],
971            "destination": "192.168.1.2"
972        }),
973        log_template: Some(
974            LogTemplate::new()
975                .with_info("-> OSPF LSR to {destination}")
976                .with_debug(
977                    "OSPF send_link_state_request: router_id={router_id} dest={destination}",
978                ),
979        ),
980    }
981}
982
983fn send_link_state_update_action() -> ActionDefinition {
984    ActionDefinition {
985        name: "send_link_state_update".to_string(),
986        description: "Send OSPF Link State Update packet".to_string(),
987        parameters: vec![
988            Parameter {
989                name: "router_id".to_string(),
990                type_hint: "string".to_string(),
991                description: "OSPF router ID".to_string(),
992                required: true,
993            },
994            Parameter {
995                name: "area_id".to_string(),
996                type_hint: "string".to_string(),
997                description: "OSPF area ID".to_string(),
998                required: true,
999            },
1000            Parameter {
1001                name: "destination".to_string(),
1002                type_hint: "string".to_string(),
1003                description: "Destination IP: 'multicast' (default) or unicast IP".to_string(),
1004                required: false,
1005            },
1006        ],
1007        example: json!({
1008            "type": "send_link_state_update",
1009            "router_id": "1.1.1.1",
1010            "area_id": "0.0.0.0",
1011            "destination": "multicast"
1012        }),
1013        log_template: Some(
1014            LogTemplate::new()
1015                .with_info("-> OSPF LSU to {destination}")
1016                .with_debug(
1017                    "OSPF send_link_state_update: router_id={router_id} dest={destination}",
1018                ),
1019        ),
1020    }
1021}
1022
1023fn send_link_state_ack_action() -> ActionDefinition {
1024    ActionDefinition {
1025        name: "send_link_state_ack".to_string(),
1026        description: "Send OSPF Link State Acknowledgment for the LSAs a neighbour flooded"
1027            .to_string(),
1028        parameters: vec![
1029            Parameter {
1030                name: "router_id".to_string(),
1031                type_hint: "string".to_string(),
1032                description: "OSPF router ID".to_string(),
1033                required: true,
1034            },
1035            Parameter {
1036                name: "area_id".to_string(),
1037                type_hint: "string".to_string(),
1038                description: "OSPF area ID".to_string(),
1039                required: true,
1040            },
1041            Parameter {
1042                name: "lsa_headers".to_string(),
1043                type_hint: "array".to_string(),
1044                description: "The LSA headers being acknowledged - pass back the \
1045                              'lsa_headers' array exactly as the ospf_link_state_update \
1046                              event delivered it. An acknowledgement is matched header by \
1047                              header, so an empty list acknowledges nothing and the \
1048                              neighbour keeps retransmitting."
1049                    .to_string(),
1050                required: true,
1051            },
1052            Parameter {
1053                name: "destination".to_string(),
1054                type_hint: "string".to_string(),
1055                description: "Destination IP: 'multicast' (default) or unicast IP".to_string(),
1056                required: false,
1057            },
1058        ],
1059        example: json!({
1060            "type": "send_link_state_ack",
1061            "router_id": "1.1.1.1",
1062            "area_id": "0.0.0.0",
1063            "lsa_headers": [{
1064                "age": 1,
1065                "options": 2,
1066                "lsa_type": 1,
1067                "link_state_id": "2.2.2.2",
1068                "advertising_router": "2.2.2.2",
1069                "sequence": 2147483649_u32,
1070                "checksum": 65262,
1071                "length": 48
1072            }],
1073            "destination": "192.168.1.2"
1074        }),
1075        log_template: Some(
1076            LogTemplate::new()
1077                .with_info("-> OSPF LSAck to {destination}")
1078                .with_debug("OSPF send_link_state_ack: router_id={router_id} dest={destination}"),
1079        ),
1080    }
1081}
1082
1083fn wait_for_more_action() -> ActionDefinition {
1084    ActionDefinition {
1085        name: "wait_for_more".to_string(),
1086        description: "Wait for more OSPF packets before responding".to_string(),
1087        parameters: vec![],
1088        example: json!({
1089            "type": "wait_for_more"
1090        }),
1091        log_template: Some(
1092            LogTemplate::new()
1093                .with_info("-> OSPF wait for more")
1094                .with_debug("OSPF wait_for_more"),
1095        ),
1096    }
1097}
1098
1099// ============================================================================
1100// Event Types
1101//
1102// Every event advertises the actions it accepts. `call_llm` builds the model's
1103// tool list from `EventType::actions`; an event that leaves it empty offers the
1104// model nothing and trips a debug_assert. The response_example is rendered
1105// verbatim into the prompt, so it must be a real, executable action - never a
1106// `{"type": "placeholder"}` stub.
1107// ============================================================================
1108
1109/// Actions any OSPF neighbor event can respond with.
1110fn ospf_response_actions() -> Vec<ActionDefinition> {
1111    vec![
1112        send_hello_action(),
1113        send_database_description_action(),
1114        send_link_state_request_action(),
1115        send_link_state_update_action(),
1116        send_link_state_ack_action(),
1117        wait_for_more_action(),
1118    ]
1119}
1120
1121pub static OSPF_HELLO_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1122    EventType::new(
1123        "ospf_hello",
1124        "OSPF Hello received. Answer with send_hello listing this neighbour in your own \
1125         neighbor list — until your Hello names it back, the neighbour stays in Init and the \
1126         adjacency never forms (RFC 2328 §10.5).",
1127        json!({
1128            "type": "send_hello",
1129            "router_id": "1.1.1.1",
1130            "area_id": "0.0.0.0",
1131            "network_mask": "255.255.255.0",
1132            "priority": 1,
1133            "dr": "0.0.0.0",
1134            "bdr": "0.0.0.0",
1135            "neighbors": ["2.2.2.2"],
1136            "destination": "multicast"
1137        }),
1138    )
1139    .with_actions(ospf_response_actions())
1140    .with_log_template(
1141        LogTemplate::new()
1142            .with_info("OSPF Hello from {neighbor_id}")
1143            .with_debug(
1144                "OSPF Hello: neighbor={neighbor_id} area={area_id} priority={router_priority}",
1145            )
1146            .with_trace("OSPF Hello: {json_pretty(.)}"),
1147    )
1148});
1149
1150pub static OSPF_DATABASE_DESCRIPTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1151    EventType::new(
1152        "ospf_database_description",
1153        "OSPF Database Description received (adjacency Exchange phase). Answer with \
1154         send_database_description to continue the exchange; silence leaves both routers in \
1155         ExStart retransmitting their DD packets.",
1156        json!({
1157            "type": "send_database_description",
1158            "router_id": "1.1.1.1",
1159            "area_id": "0.0.0.0",
1160            "sequence": 1,
1161            "init": false,
1162            "more": false,
1163            "master": false,
1164            "destination": "192.168.1.2"
1165        }),
1166    )
1167    .with_actions(ospf_response_actions())
1168    .with_log_template(
1169        LogTemplate::new()
1170            .with_info("OSPF DD from {neighbor_id} seq={dd_sequence}")
1171            .with_debug(
1172                "OSPF Database Description: neighbor={neighbor_id} seq={dd_sequence} \
1173                 init={init} more={more} master={master}",
1174            )
1175            .with_trace("OSPF DD: {json_pretty(.)}"),
1176    )
1177});
1178
1179pub static OSPF_LINK_STATE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1180    EventType::new(
1181        "ospf_link_state_request",
1182        "OSPF Link State Request received: the neighbor is asking for LSAs it does not have. \
1183         A request left unanswered stalls the adjacency in Loading and it never reaches Full \
1184         (RFC 2328 §10.9). NetGet cannot build LSA bodies, so send_link_state_update carries \
1185         no LSAs and cannot satisfy the request — this server can hold an adjacency at 2-Way \
1186         but not complete one. Answer honestly rather than pretending: wait_for_more is the \
1187         truthful reply. Describing what you would send is not sending it.",
1188        json!({ "type": "wait_for_more" }),
1189    )
1190    .with_actions(ospf_response_actions())
1191    .with_log_template(
1192        LogTemplate::new()
1193            .with_info("OSPF LSR from {neighbor_id} ({request_count} LSAs)")
1194            .with_debug("OSPF Link State Request: neighbor={neighbor_id} requests={request_count}")
1195            .with_trace("OSPF LSR: {json_pretty(.)}"),
1196    )
1197});
1198
1199pub static OSPF_LINK_STATE_UPDATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1200    EventType::new(
1201        "ospf_link_state_update",
1202        "OSPF Link State Update received: the neighbor is flooding LSAs. Answer with \
1203         send_link_state_ack, passing this event's 'lsa_headers' array straight back as the \
1204         action's 'lsa_headers' — every LSA must be acknowledged by header or the neighbor \
1205         retransmits it every RxmtInterval until the adjacency fails (RFC 2328 §13.5), and an \
1206         acknowledgement with no headers acknowledges nothing. Describing the acknowledgement \
1207         is not sending it: only the action puts a packet on the wire.",
1208        json!({
1209            "type": "send_link_state_ack",
1210            "router_id": "1.1.1.1",
1211            "area_id": "0.0.0.0",
1212            "lsa_headers": [{
1213                "age": 1,
1214                "options": 2,
1215                "lsa_type": 1,
1216                "link_state_id": "2.2.2.2",
1217                "advertising_router": "2.2.2.2",
1218                "sequence": 2147483649_u32,
1219                "checksum": 65262,
1220                "length": 48
1221            }],
1222            "destination": "192.168.1.2"
1223        }),
1224    )
1225    .with_actions(ospf_response_actions())
1226    .with_log_template(
1227        LogTemplate::new()
1228            .with_info("OSPF LSU from {neighbor_id} ({lsa_count} LSAs)")
1229            .with_debug("OSPF Link State Update: neighbor={neighbor_id} lsas={lsa_count}")
1230            .with_trace("OSPF LSU: {json_pretty(.)}"),
1231    )
1232});
1233
1234pub static OSPF_LINK_STATE_ACK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1235    EventType::new(
1236        "ospf_link_state_ack",
1237        "OSPF Link State Acknowledgment packet received",
1238        json!({ "type": "wait_for_more" }),
1239    )
1240    .with_actions(ospf_response_actions())
1241    .with_log_template(
1242        LogTemplate::new()
1243            .with_info("OSPF LSAck from {neighbor_id} ({lsa_count} LSAs)")
1244            .with_debug("OSPF Link State Acknowledgment: neighbor={neighbor_id} lsas={lsa_count}")
1245            .with_trace("OSPF LSAck: {json_pretty(.)}"),
1246    )
1247});
1248
1249// Implement Protocol trait (common functionality)
1250impl Protocol for OspfProtocol {
1251    /// Deliberately empty.
1252    ///
1253    /// This used to advertise `list_neighbors` and `list_lsdb`, and `execute_action` had no arm
1254    /// for either — so the model was offered two verbs and rejected with "Unknown OSPF action
1255    /// type" whenever it chose one. They are removed rather than implemented because neither
1256    /// can be answered honestly: NetGet keeps no neighbour table and no link-state database,
1257    /// and the project rule is that protocols do not implement storage — the model tracks that
1258    /// state in its own memory, where it already has everything these would report.
1259    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
1260        vec![]
1261    }
1262    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
1263        vec![
1264            send_hello_action(),
1265            send_database_description_action(),
1266            send_link_state_request_action(),
1267            send_link_state_update_action(),
1268            send_link_state_ack_action(),
1269            wait_for_more_action(),
1270        ]
1271    }
1272
1273    fn protocol_name(&self) -> &'static str {
1274        "OSPF"
1275    }
1276    fn get_event_types(&self) -> Vec<EventType> {
1277        vec![
1278            OSPF_HELLO_EVENT.clone(),
1279            OSPF_DATABASE_DESCRIPTION_EVENT.clone(),
1280            OSPF_LINK_STATE_REQUEST_EVENT.clone(),
1281            OSPF_LINK_STATE_UPDATE_EVENT.clone(),
1282            OSPF_LINK_STATE_ACK_EVENT.clone(),
1283        ]
1284    }
1285    fn stack_name(&self) -> &'static str {
1286        "ETH>IP(89)>OSPF"
1287    }
1288    fn keywords(&self) -> Vec<&'static str> {
1289        vec!["ospf", "open shortest path first"]
1290    }
1291    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
1292        use crate::protocol::metadata::{
1293            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
1294        };
1295
1296        ProtocolMetadataV2::builder()
1297                .state(DevelopmentState::Experimental)
1298                // Deliberately silent: An OSPF Hello or LSA asserts adjacency and link state;
1299                // there is no error packet in the protocol.
1300                .deliberately_silent()
1301                // Raw socket on IP protocol 89 - CAP_NET_RAW is sufficient, full root is not
1302                // required, so declaring Root would refuse to start on a capability-only process.
1303                .privilege_requirement(PrivilegeRequirement::RawSockets)
1304                // OSPF has no connection to close: a neighbour entry is created on first sight
1305                // of a Router ID and nothing on the wire ever ends it, which is exactly what
1306                // AppState's idle sweep exists for. Without this the entries accumulated for
1307                // the life of the server.
1308                .connectionless()
1309                .implementation("Manual OSPFv2 (RFC 2328) over a raw IP-protocol-89 socket. Hello is parsed in full; DD/LSR/LSU/LSAck are parsed to their 20-byte LSA headers, never to LSA bodies. Outgoing DD, LSR and LSAck carry real bodies built from the model's structured fields (LSA headers per RFC 2328 A.4.1, request triples per A.3.4); outgoing LSU always advertises zero LSAs because NetGet cannot construct an LSA body.")
1310                .llm_control("Optional: whether to engage with an OSPF speaker (respond to a Hello, claim DR/BDR, act as a honeypot) is a policy decision. With no operator policy (no instruction, no handler) the server observes passively and does NOT respond, with no LLM round-trip per packet. When the operator opts in, every received packet type raises an event carrying parsed fields and the LLM chooses the reply packet. The model can acknowledge LSAs and request them by name - the LSA headers an event reports are the same shape send_link_state_ack and send_link_state_request consume - but it cannot supply LSA contents, so a Link State Request from a peer cannot be satisfied. Fields the reply omits are filled from the interface configuration given at startup (router_id, area_id, network_mask, hello_interval, router_dead_interval, router_priority).")
1311                .e2e_testing("None against a real router, and none against this server at all: OSPF needs a raw IP-89 socket, so spawn_with_llm_actions cannot run unprivileged and no test drives it. The three tests in tests/server/ospf/e2e_test.rs named 'E2E' start a *generic UDP server* (open_server protocol=UDP) and exchange OSPF-shaped bytes the test itself built over it, mocking udp_datagram_received/send_udp_response - they exercise src/server/udp/, not this protocol, and no test anywhere mocks an ospf_* event. What IS verified, as unit tests against the real code: the RFC 2328 A.3.1 packet checksum (recomputing over a built packet yields zero), that build_hello_packet writes the configured hello_interval, router_dead_interval, priority and network mask into the Hello body at RFC 2328 A.3.2 offsets when the action omits them and that an action-supplied value overrides them, that LSAck and LSR bodies serialise at the A.4.1/A.3.4 offsets and round-trip through parse_lsa_headers, that a malformed IPv4 field is rejected rather than silently becoming 0.0.0.0, and that the RFC 2328 10.5 mismatch check fires on exactly the three fields the RFC names.")
1312                .notes("Hello-level simulator, not a router. Whether to respond at all is policy, so with no operator policy the server is a passive listener (no response, no LLM call); the passive default is compile-verified since the raw-socket path needs root and no test touches it. No LSDB, no SPF, no routing table, no LSA body construction, no DR/BDR election and no periodic Hello timer. Adjacency cannot progress past 2-Way, and a Hello that fails the RFC 2328 10.5 interval/mask check does not advance it at all - the event still reaches the model, carrying the mismatch, so the refusal is visible rather than silent. Neighbours ARE aged out: one silent for RouterDeadInterval is dropped, which also bounds the neighbour table against a peer spraying Hellos with random Router IDs. On an LLM failure the server deliberately sends nothing: OSPF has no error or NAK packet, and every one of its five packet types is a positive routing assertion, so any fabricated reply would claim an adjacency, DR role or database state netget cannot back - the peer's own RouterDeadInterval covers a router that goes quiet. The failure is reported to the operator only, tagged decision=fail_closed_overloaded / fail_closed_unavailable and distinct from decision=model_wait / model_no_action.")
1313                .build()
1314    }
1315    fn description(&self) -> &'static str {
1316        "OSPF routing protocol server"
1317    }
1318    fn example_prompt(&self) -> &'static str {
1319        "Start an OSPF server on interface 192.168.1.100 as router 1.1.1.1 in area 0.0.0.0"
1320    }
1321    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
1322        // All six are read in ProtocolConfig terms by OspfServer::spawn_with_llm_actions into
1323        // an OspfInterfaceConfig, which supplies the defaults for every outgoing packet and
1324        // the RFC 2328 10.5 acceptance check for every incoming Hello. Four of them used to
1325        // be declared here and read nowhere.
1326        vec![
1327            ParameterDefinition {
1328                name: "router_id".to_string(),
1329                type_hint: "string".to_string(),
1330                description: "OSPF router ID in IPv4 address format (e.g., 1.1.1.1). Used as the Router ID of every packet sent unless the action overrides it. Defaults to the interface address.".to_string(),
1331                required: false,
1332                example: json!("1.1.1.1"),
1333                default: None,
1334            },
1335            ParameterDefinition {
1336                name: "area_id".to_string(),
1337                type_hint: "string".to_string(),
1338                description: "OSPF area ID in IPv4 format (0.0.0.0 = backbone area). Used as the Area ID of every packet sent unless the action overrides it.".to_string(),
1339                required: false,
1340                example: json!("0.0.0.0"),
1341                default: None,
1342            },
1343            ParameterDefinition {
1344                name: "network_mask".to_string(),
1345                type_hint: "string".to_string(),
1346                description: "Network mask of this interface (e.g., 255.255.255.0). Placed in outgoing Hello packets, and compared against incoming ones: RFC 2328 10.5 requires a Hello whose mask differs to be rejected.".to_string(),
1347                required: false,
1348                example: json!("255.255.255.0"),
1349                default: None,
1350            },
1351            ParameterDefinition {
1352                name: "hello_interval".to_string(),
1353                type_hint: "integer".to_string(),
1354                description: "HelloInterval in seconds (default 10). Placed in outgoing Hello packets. A neighbour whose Hello advertises a different value is rejected per RFC 2328 10.5 and its adjacency is not advanced.".to_string(),
1355                required: false,
1356                example: json!(10),
1357                default: None,
1358            },
1359            ParameterDefinition {
1360                name: "router_dead_interval".to_string(),
1361                type_hint: "integer".to_string(),
1362                description: "RouterDeadInterval in seconds (default 40). Placed in outgoing Hello packets and, like hello_interval, must match a neighbour's for its Hello to be accepted.".to_string(),
1363                required: false,
1364                example: json!(40),
1365                default: None,
1366            },
1367            ParameterDefinition {
1368                name: "router_priority".to_string(),
1369                type_hint: "integer".to_string(),
1370                description: "Router priority for DR election (0-255, default 1). Placed in outgoing Hello packets unless the send_hello action sets its own 'priority'. No election algorithm runs - the value is advertised, nothing more.".to_string(),
1371                required: false,
1372                example: json!(1),
1373                default: None,
1374            },
1375        ]
1376    }
1377    fn group_name(&self) -> &'static str {
1378        "VPN & Routing"
1379    }
1380
1381    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
1382        use crate::llm::actions::StartupExamples;
1383
1384        StartupExamples::new(
1385            // LLM mode: LLM simulates OSPF router behavior
1386            json!({
1387                "type": "open_server",
1388                "port": 0,
1389                "base_stack": "ospf",
1390                "instruction": "OSPF router with ID 192.168.1.1 in area 0. Respond to Hello packets from neighbors. Claim DR role with priority 100."
1391            }),
1392            // Script mode: Scripted OSPF Hello response
1393            json!({
1394                "type": "open_server",
1395                "port": 0,
1396                "base_stack": "ospf",
1397                "event_handlers": [{
1398                    "event_pattern": "ospf_hello",
1399                    "handler": {
1400                        "type": "script",
1401                        "language": "python",
1402                        "code": "return {type='send_hello', router_id='192.168.1.1', area_id='0.0.0.0', network_mask='255.255.255.0', priority=100, dr='192.168.1.1', bdr='0.0.0.0', neighbors={event.neighbor_id}}"
1403                    }
1404                }]
1405            }),
1406            // Static mode: Fixed OSPF Hello response
1407            json!({
1408                "type": "open_server",
1409                "port": 0,
1410                "base_stack": "ospf",
1411                "event_handlers": [{
1412                    "event_pattern": "ospf_hello",
1413                    "handler": {
1414                        "type": "static",
1415                        "actions": [{
1416                            "type": "send_hello",
1417                            "router_id": "192.168.1.1",
1418                            "area_id": "0.0.0.0",
1419                            "network_mask": "255.255.255.0",
1420                            "priority": 1,
1421                            "dr": "0.0.0.0",
1422                            "bdr": "0.0.0.0"
1423                        }]
1424                    }
1425                }]
1426            }),
1427        )
1428    }
1429}
1430
1431// Implement Server trait (server-specific functionality)
1432impl Server for OspfProtocol {
1433    fn spawn(
1434        &self,
1435        ctx: crate::protocol::SpawnContext,
1436    ) -> std::pin::Pin<
1437        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
1438    > {
1439        Box::pin(async move {
1440            use crate::server::ospf::OspfServer;
1441            OspfServer::spawn_with_llm_actions(
1442                ctx.legacy_listen_addr(),
1443                ctx.llm_client,
1444                ctx.state,
1445                ctx.status_tx,
1446                ctx.server_id,
1447                ctx.startup_params,
1448            )
1449            .await
1450        })
1451    }
1452    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
1453        let action_type = action
1454            .get("type")
1455            .and_then(|v| v.as_str())
1456            .context("Missing action type")?;
1457
1458        match action_type {
1459            "send_hello" => self.execute_send_hello(action),
1460            "send_database_description" => self.execute_send_database_description(action),
1461            "send_link_state_request" => self.execute_send_link_state_request(action),
1462            "send_link_state_update" => self.execute_send_link_state_update(action),
1463            "send_link_state_ack" => self.execute_send_link_state_ack(action),
1464            "wait_for_more" => Ok(ActionResult::WaitForMore),
1465            _ => Err(anyhow::anyhow!("Unknown OSPF action type: {}", action_type)),
1466        }
1467    }
1468}