Skip to main content

netget/server/vrrp/
actions.rs

1//! VRRP / CARP vocabulary: what the model sees, the group configuration read from startup
2//! parameters, and the translation from one action to one codec value.
3//!
4//! Everything crossing this boundary is structured. Addresses are dotted quads, intervals are
5//! seconds, priority is a number — no hex, no base64, and nothing the model would have to
6//! assemble by hand. [`super::codec`] turns those values into wire octets.
7
8use crate::llm::actions::{
9    protocol_trait::{ActionResult, Protocol, Server},
10    ActionDefinition, Parameter, ParameterDefinition,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::protocol::StartupParams;
15use crate::state::app_state::AppState;
16use anyhow::{bail, ensure, Context, Result};
17use serde_json::json;
18use std::net::Ipv4Addr;
19use std::sync::LazyLock;
20
21use super::codec::{
22    self, CarpAdvertisement, Variant, VrrpAdvertisement, CARP_AUTH_LEN_WORDS, CARP_VERSION,
23    VRRP_AUTH_TYPE_NONE, VRRP_MULTICAST_IPV4, VRRP_VERSION_2, VRRP_VERSION_3,
24};
25
26// ---------------------------------------------------------------------------
27// Transport
28// ---------------------------------------------------------------------------
29
30/// Which wire this server speaks on.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum VrrpTransport {
33    /// A real `SOCK_RAW` socket on IP protocol 112, joined to `224.0.0.18`. Needs
34    /// `CAP_NET_RAW` on Linux or root elsewhere. **Never executed in this tree.**
35    Raw,
36    /// One complete VRRP (or CARP) message per UDP datagram — the same octets the raw
37    /// transport would put on the wire, with only the IP layer simulated.
38    ///
39    /// This exists so the advertisement → event → model → action → packet path can be
40    /// exercised without privilege. It is the compromise `ospf` documents as "requires
41    /// root/CAP_NET_RAW, tests use UDP", done inside the protocol rather than by substituting
42    /// a generic UDP server in the test.
43    Udp,
44}
45
46impl VrrpTransport {
47    fn from_name(name: &str) -> Result<Self> {
48        match name.trim().to_ascii_lowercase().as_str() {
49            "raw" | "rawip" | "ip" | "ip112" => Ok(VrrpTransport::Raw),
50            "udp" | "test" => Ok(VrrpTransport::Udp),
51            other => bail!("unknown transport '{other}' (expected 'raw' or 'udp')"),
52        }
53    }
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            VrrpTransport::Raw => "raw",
58            VrrpTransport::Udp => "udp",
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Group configuration
65// ---------------------------------------------------------------------------
66
67/// This server's own virtual-router identity, from the startup parameters.
68///
69/// Every declared parameter lands here and is read twice, which is what keeps the declared
70/// set and the used set identical:
71///
72/// 1. [`VrrpGroupConfig::apply_defaults`] fills in whatever the model's action left out,
73///    immediately before the packet is built.
74/// 2. [`VrrpGroupConfig::local_summary`] is attached to every event as `local_*`, so the
75///    model can compare what arrived against what this router is configured to claim — which
76///    *is* the election question.
77#[derive(Debug, Clone, PartialEq)]
78pub struct VrrpGroupConfig {
79    pub transport: VrrpTransport,
80    pub variant: Variant,
81    /// 2 or 3. Ignored when `variant` is CARP, which has its own version number.
82    pub version: u8,
83    /// Virtual Router Identifier (VRRP) or virtual host id (CARP).
84    pub vrid: u8,
85    /// VRRP only. 0 resigns, 255 claims address ownership, higher wins.
86    pub priority: u8,
87    /// Advertisement interval in seconds. CARP reads the whole-second part as `advbase`.
88    pub advert_interval_seconds: f64,
89    /// The virtual addresses this router advertises.
90    pub addresses: Vec<Ipv4Addr>,
91    /// CARP only. Lower wins — the inverse of VRRP's priority.
92    pub advskew: u8,
93    /// CARP only. Zero-padded to 20 octets and used as the HMAC key; empty means no key was
94    /// configured, so outgoing advertisements carry an HMAC over an all-zero key and inbound
95    /// ones cannot be verified.
96    pub carp_passphrase: String,
97}
98
99impl Default for VrrpGroupConfig {
100    fn default() -> Self {
101        Self {
102            transport: VrrpTransport::Raw,
103            variant: Variant::Vrrp,
104            // RFC 5798 is the current VRRP specification, so v3 is the default. v2 remains
105            // selectable because most deployed equipment still speaks it.
106            version: VRRP_VERSION_3,
107            vrid: 1,
108            // RFC 3768 §5.3.4: the default priority for a router that is not the address
109            // owner. Deliberately not 255 — claiming ownership by default would be a
110            // fail-open: the server would assert it holds addresses it does not.
111            priority: 100,
112            advert_interval_seconds: 1.0,
113            addresses: Vec::new(),
114            advskew: 0,
115            carp_passphrase: String::new(),
116        }
117    }
118}
119
120impl VrrpGroupConfig {
121    /// Read every declared startup parameter. Errors propagate with `?`; nothing is unwrapped.
122    pub fn from_startup_params(params: Option<&StartupParams>) -> Result<Self> {
123        let mut config = Self::default();
124        let Some(params) = params else {
125            return Ok(config);
126        };
127
128        if let Some(v) = params.get_optional_string("transport")? {
129            config.transport = VrrpTransport::from_name(&v)?;
130        }
131        if let Some(v) = params.get_optional_string("variant")? {
132            config.variant = Variant::from_name(&v)?;
133        }
134        if let Some(v) = params.get_optional_i64("version")? {
135            config.version = match v {
136                2 => VRRP_VERSION_2,
137                3 => VRRP_VERSION_3,
138                other => bail!(
139                    "unsupported VRRP version {other} (expected 2 for RFC 3768 or 3 for RFC 5798)"
140                ),
141            };
142        }
143        if let Some(v) = params.get_optional_i64("vrid")? {
144            ensure!(
145                (1..=255).contains(&v),
146                "vrid must be 1..=255 (0 is not a valid virtual router id), got {v}"
147            );
148            config.vrid = v as u8;
149        }
150        if let Some(v) = params.get_optional_i64("priority")? {
151            ensure!(
152                (0..=255).contains(&v),
153                "priority must be 0..=255 (0 resigns, 255 claims address ownership), got {v}"
154            );
155            config.priority = v as u8;
156        }
157        if let Some(v) = params.get_optional_i64("advert_interval")? {
158            // Whole seconds here. `StartupParams` has no fractional accessor, and adding one
159            // means editing a shared file; the sub-second intervals VRRPv3 allows are reachable
160            // through the `advert_interval` field of `send_vrrp_advertisement`, which is raw
161            // JSON. Declared and documented as whole seconds so the limit is visible rather
162            // than discovered.
163            ensure!(
164                (1..=255).contains(&v),
165                "advert_interval must be 1..=255 whole seconds as a startup parameter, got {v}"
166            );
167            config.advert_interval_seconds = v as f64;
168        }
169        if let Some(v) = params.get_optional_array("addresses")? {
170            let mut addresses = Vec::with_capacity(v.len());
171            for entry in v {
172                let text = entry.as_str().with_context(|| {
173                    format!("addresses must be dotted-quad strings, got {entry}")
174                })?;
175                addresses.push(parse_ipv4(text)?);
176            }
177            config.addresses = addresses;
178        }
179        if let Some(v) = params.get_optional_i64("advskew")? {
180            ensure!(
181                (0..=255).contains(&v),
182                "advskew must be 0..=255 (lower wins the CARP election), got {v}"
183            );
184            config.advskew = v as u8;
185        }
186        if let Some(v) = params.get_optional_string("carp_passphrase")? {
187            config.carp_passphrase = v;
188        }
189
190        // Refuse at startup rather than on every advertisement the server later tries to
191        // send: a group whose identity cannot be encoded is misconfigured, not unlucky.
192        config.template_advertisement()?.validate_shape()?;
193        Ok(config)
194    }
195
196    /// A representative advertisement built purely from the configuration, used to prove at
197    /// startup that this identity can be put on the wire.
198    fn template_advertisement(&self) -> Result<ConfiguredAdvertisement> {
199        match self.variant {
200            Variant::Vrrp => Ok(ConfiguredAdvertisement::Vrrp(VrrpAdvertisement {
201                version: self.version,
202                vrid: self.vrid,
203                priority: self.priority,
204                advert_interval_seconds: self.advert_interval_seconds,
205                auth_type: VRRP_AUTH_TYPE_NONE,
206                addresses: self.addresses.clone(),
207                checksum: 0,
208            })),
209            Variant::Carp => Ok(ConfiguredAdvertisement::Carp(self.carp_template()?)),
210        }
211    }
212
213    fn carp_template(&self) -> Result<CarpAdvertisement> {
214        let advbase = self.advert_interval_seconds.round();
215        ensure!(
216            (1.0..=255.0).contains(&advbase),
217            "CARP advbase is a whole-second octet, so advert_interval must be 1..=255 \
218             seconds; got {}",
219            self.advert_interval_seconds
220        );
221        Ok(CarpAdvertisement {
222            version: CARP_VERSION,
223            vhid: self.vrid,
224            advskew: self.advskew,
225            auth_length_words: CARP_AUTH_LEN_WORDS,
226            demote: 0,
227            advbase: advbase as u8,
228            counter: 0,
229            hmac: codec::carp_hmac(
230                self.carp_passphrase.as_bytes(),
231                self.vrid,
232                &self.addresses,
233                0,
234            ),
235            checksum: 0,
236        })
237    }
238
239    /// The `local_*` block attached to every event.
240    pub fn local_summary(&self) -> serde_json::Value {
241        json!({
242            "local_transport": self.transport.as_str(),
243            "local_variant": self.variant.as_str(),
244            "local_version": self.version,
245            "local_vrid": self.vrid,
246            "local_priority": self.priority,
247            "local_advert_interval": self.advert_interval_seconds,
248            "local_addresses": self.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
249            "local_advskew": self.advskew,
250            // The passphrase itself never reaches the model or the log — only whether one
251            // exists, which is what decides if an inbound HMAC can be checked at all.
252            "local_carp_passphrase_configured": !self.carp_passphrase.is_empty(),
253        })
254    }
255
256    /// Fill in whatever the model's action left out.
257    ///
258    /// An action that names a field keeps its own value, so a prompt can still deliberately
259    /// advertise a different identity from the configured one. Without this the builders
260    /// would fall back to protocol constants and the operator's configured group would never
261    /// reach the wire — the `ospf` defect the root `CLAUDE.md` records for four of its six
262    /// parameters.
263    pub fn apply_defaults(&self, action: &mut serde_json::Value) {
264        let Some(object) = action.as_object_mut() else {
265            return;
266        };
267        let mut set = |key: &str, value: serde_json::Value| {
268            object.entry(key.to_string()).or_insert(value);
269        };
270        set("variant", json!(self.variant.as_str()));
271        set("vrid", json!(self.vrid));
272        set("advert_interval", json!(self.advert_interval_seconds));
273        set(
274            "addresses",
275            json!(self
276                .addresses
277                .iter()
278                .map(|a| a.to_string())
279                .collect::<Vec<_>>()),
280        );
281        match self.variant {
282            Variant::Vrrp => {
283                set("version", json!(self.version));
284                set("priority", json!(self.priority));
285            }
286            Variant::Carp => {
287                set("advskew", json!(self.advskew));
288            }
289        }
290    }
291}
292
293/// An advertisement of either variant, assembled but not yet checksummed.
294#[derive(Debug, Clone, PartialEq)]
295pub enum ConfiguredAdvertisement {
296    Vrrp(VrrpAdvertisement),
297    Carp(CarpAdvertisement),
298}
299
300impl ConfiguredAdvertisement {
301    /// Every check that does **not** need a pseudo-header, so an action can be rejected where
302    /// the error reaches the model rather than only a log.
303    pub fn validate_shape(&self) -> Result<()> {
304        match self {
305            ConfiguredAdvertisement::Vrrp(advertisement) => advertisement.validate(),
306            ConfiguredAdvertisement::Carp(advertisement) => advertisement.validate(),
307        }
308    }
309
310    /// Serialise. `pseudo` is required for VRRPv3 and ignored otherwise.
311    pub fn encode(&self, pseudo: Option<&codec::PseudoHeader>) -> Result<Vec<u8>> {
312        match self {
313            ConfiguredAdvertisement::Vrrp(advertisement) => advertisement.encode(pseudo),
314            ConfiguredAdvertisement::Carp(advertisement) => advertisement.encode(),
315        }
316    }
317
318    pub fn variant(&self) -> Variant {
319        match self {
320            ConfiguredAdvertisement::Vrrp(_) => Variant::Vrrp,
321            ConfiguredAdvertisement::Carp(_) => Variant::Carp,
322        }
323    }
324}
325
326fn parse_ipv4(text: &str) -> Result<Ipv4Addr> {
327    text.trim()
328        .parse::<Ipv4Addr>()
329        .with_context(|| format!("'{text}' is not a dotted-quad IPv4 address"))
330}
331
332fn field_u8(
333    action: &serde_json::Value,
334    key: &str,
335    range: std::ops::RangeInclusive<i64>,
336) -> Result<Option<u8>> {
337    let Some(value) = action.get(key) else {
338        return Ok(None);
339    };
340    if value.is_null() {
341        return Ok(None);
342    }
343    let number = value
344        .as_i64()
345        .with_context(|| format!("'{key}' must be a whole number, got {value}"))?;
346    ensure!(
347        range.contains(&number),
348        "'{key}' must be {}..={}, got {number}",
349        range.start(),
350        range.end()
351    );
352    Ok(Some(number as u8))
353}
354
355fn field_addresses(action: &serde_json::Value, key: &str) -> Result<Option<Vec<Ipv4Addr>>> {
356    let Some(value) = action.get(key) else {
357        return Ok(None);
358    };
359    if value.is_null() {
360        return Ok(None);
361    }
362    let array = value
363        .as_array()
364        .with_context(|| format!("'{key}' must be an array of dotted-quad strings, got {value}"))?;
365    let mut out = Vec::with_capacity(array.len());
366    for entry in array {
367        let text = entry
368            .as_str()
369            .with_context(|| format!("'{key}' entries must be strings, got {entry}"))?;
370        out.push(parse_ipv4(text)?);
371    }
372    Ok(Some(out))
373}
374
375fn field_seconds(action: &serde_json::Value, key: &str) -> Result<Option<f64>> {
376    let Some(value) = action.get(key) else {
377        return Ok(None);
378    };
379    if value.is_null() {
380        return Ok(None);
381    }
382    let seconds = value
383        .as_f64()
384        .with_context(|| format!("'{key}' must be a number of seconds, got {value}"))?;
385    Ok(Some(seconds))
386}
387
388// ---------------------------------------------------------------------------
389// Protocol
390// ---------------------------------------------------------------------------
391
392/// VRRP / CARP protocol handler.
393pub struct VrrpProtocol;
394
395impl VrrpProtocol {
396    pub fn new() -> Self {
397        Self
398    }
399
400    /// Which variant an action asks for, defaulting to the server's configured one.
401    pub fn variant_from_action(action: &serde_json::Value, default: Variant) -> Result<Variant> {
402        match action.get("variant").and_then(|v| v.as_str()) {
403            Some(name) => Variant::from_name(name),
404            None => Ok(default),
405        }
406    }
407
408    /// The variant an action implies when the server's configuration is **not** in scope.
409    ///
410    /// `execute_action` is called with nothing but the action — by the executor, and by
411    /// `tests/executable_examples_test.rs` — so it cannot know that a CARP server is asking.
412    /// A model talking to a CARP server has no reason to spell `variant` out, and rejecting
413    /// its `advskew` as "a CARP field in a VRRP advertisement" would be nonsense.
414    ///
415    /// So: an explicit `variant` wins; otherwise a CARP-only field with no VRRP-only field
416    /// implies CARP. An action carrying **both** families' fields stays VRRP, so the
417    /// contradiction is reported rather than silently resolved.
418    ///
419    /// This is a fallback for validation only. On the wire the configured variant always
420    /// decides, because [`VrrpGroupConfig::apply_defaults`] writes it into the action before
421    /// the transport re-validates.
422    pub fn inferred_variant(action: &serde_json::Value) -> Result<Variant> {
423        if let Some(name) = action.get("variant").and_then(|v| v.as_str()) {
424            return Variant::from_name(name);
425        }
426        let present = |key: &str| {
427            action
428                .get(key)
429                .map(|value| !value.is_null())
430                .unwrap_or(false)
431        };
432        let carp_only = ["advskew", "demote", "counter"].iter().any(|k| present(k));
433        let vrrp_only = ["priority", "version"].iter().any(|k| present(k));
434        Ok(if carp_only && !vrrp_only {
435            Variant::Carp
436        } else {
437            Variant::Vrrp
438        })
439    }
440
441    /// Where the packet goes: `"multicast"` (224.0.0.18) or a unicast dotted quad.
442    ///
443    /// This is not cosmetic for VRRPv3 — the destination address is folded into the checksum
444    /// through the pseudo-header, so the same message body addressed elsewhere is different
445    /// octets.
446    pub fn destination_from_action(action: &serde_json::Value) -> Result<Ipv4Addr> {
447        match action.get("destination").and_then(|v| v.as_str()) {
448            None | Some("multicast") | Some("") => Ok(VRRP_MULTICAST_IPV4),
449            Some(text) => parse_ipv4(text),
450        }
451    }
452
453    /// Build the advertisement one action asks for, layering the configuration underneath.
454    ///
455    /// Nothing here touches a socket or a checksum, so it is callable from `execute_action`
456    /// with no context at all — which is what makes the declared examples executable.
457    pub fn advertisement_from_action(
458        action: &serde_json::Value,
459        config: &VrrpGroupConfig,
460    ) -> Result<ConfiguredAdvertisement> {
461        let variant = Self::variant_from_action(action, config.variant)?;
462        let vrid = field_u8(action, "vrid", 1..=255)?.unwrap_or(config.vrid);
463        let addresses =
464            field_addresses(action, "addresses")?.unwrap_or_else(|| config.addresses.clone());
465        let interval =
466            field_seconds(action, "advert_interval")?.unwrap_or(config.advert_interval_seconds);
467
468        match variant {
469            Variant::Vrrp => {
470                // CARP-only fields on a VRRP advertisement are refused rather than dropped:
471                // silently ignoring a field the model deliberately set is how a model's
472                // decision becomes invisible.
473                for carp_only in ["advskew", "demote", "counter"] {
474                    ensure!(
475                        action.get(carp_only).map(|v| v.is_null()).unwrap_or(true),
476                        "'{carp_only}' is a CARP field and has no place in a VRRP \
477                         advertisement. VRRP elects on 'priority' (higher wins); set \
478                         variant to 'carp' if you meant CARP."
479                    );
480                }
481                let version = match field_u8(action, "version", 2..=3)? {
482                    Some(v) => v,
483                    None => config.version,
484                };
485                let priority = field_u8(action, "priority", 0..=255)?.unwrap_or(config.priority);
486                let advertisement = VrrpAdvertisement {
487                    version,
488                    vrid,
489                    priority,
490                    advert_interval_seconds: interval,
491                    auth_type: VRRP_AUTH_TYPE_NONE,
492                    addresses,
493                    checksum: 0,
494                };
495                advertisement.validate()?;
496                Ok(ConfiguredAdvertisement::Vrrp(advertisement))
497            }
498            Variant::Carp => {
499                ensure!(
500                    action.get("priority").map(|v| v.is_null()).unwrap_or(true),
501                    "'priority' is a VRRP field and CARP has no equivalent. CARP elects on \
502                     'advskew', where LOWER wins — the opposite direction from VRRP priority."
503                );
504                ensure!(
505                    action.get("version").map(|v| v.is_null()).unwrap_or(true),
506                    "'version' selects between VRRPv2 and VRRPv3 and does not apply to CARP, \
507                     whose version is fixed at {CARP_VERSION}."
508                );
509                let advskew = field_u8(action, "advskew", 0..=255)?.unwrap_or(config.advskew);
510                let demote = field_u8(action, "demote", 0..=255)?.unwrap_or(0);
511                let counter = match action.get("counter") {
512                    Some(value) if !value.is_null() => value.as_u64().with_context(|| {
513                        format!("'counter' must be a whole number, got {value}")
514                    })?,
515                    _ => 0,
516                };
517                let advbase = interval.round();
518                ensure!(
519                    (1.0..=255.0).contains(&advbase),
520                    "CARP's advbase is a whole-second octet, so advert_interval must be \
521                     1..=255 seconds; got {interval}"
522                );
523                let advertisement = CarpAdvertisement {
524                    version: CARP_VERSION,
525                    vhid: vrid,
526                    advskew,
527                    auth_length_words: CARP_AUTH_LEN_WORDS,
528                    demote,
529                    advbase: advbase as u8,
530                    counter,
531                    hmac: codec::carp_hmac(
532                        config.carp_passphrase.as_bytes(),
533                        vrid,
534                        &addresses,
535                        counter,
536                    ),
537                    checksum: 0,
538                };
539                advertisement.validate()?;
540                Ok(ConfiguredAdvertisement::Carp(advertisement))
541            }
542        }
543    }
544}
545
546impl Default for VrrpProtocol {
547    fn default() -> Self {
548        Self::new()
549    }
550}
551
552impl Protocol for VrrpProtocol {
553    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
554        // The raw transport reads the host as the interface address it binds the raw socket
555        // to; the UDP test transport binds host:port as an ordinary socket.
556        Some(crate::protocol::BindingDefaults {
557            mac_address: None,
558            interface: None,
559            host: Some("127.0.0.1".to_string()),
560            port: Some(0),
561        })
562    }
563
564    fn protocol_name(&self) -> &'static str {
565        "VRRP"
566    }
567
568    fn description(&self) -> &'static str {
569        "VRRP v2/v3 and CARP first-hop redundancy server (IP protocol 112)"
570    }
571
572    fn stack_name(&self) -> &'static str {
573        "ETH>IP(112)>VRRP"
574    }
575
576    fn group_name(&self) -> &'static str {
577        "VPN & Routing"
578    }
579
580    fn keywords(&self) -> Vec<&'static str> {
581        vec![
582            "vrrp",
583            "carp",
584            "virtual router redundancy protocol",
585            "common address redundancy protocol",
586            "first hop redundancy",
587            "gateway failover",
588        ]
589    }
590
591    fn example_prompt(&self) -> &'static str {
592        "Listen for VRRP advertisements on 192.168.1.10 for VRID 1 and report which router is \
593         master, without advertising anything yourself"
594    }
595
596    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
597        // Nothing here is user-triggered: an advertisement is only ever a response to what
598        // arrived on the segment, and there is no periodic transmitter to configure. See the
599        // note on `metadata()` about deliberately not owning an election state machine.
600        vec![]
601    }
602
603    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
604        vrrp_actions()
605    }
606
607    fn get_event_types(&self) -> Vec<EventType> {
608        vec![
609            VRRP_ADVERTISEMENT_RECEIVED_EVENT.clone(),
610            VRRP_MASTER_RESIGNED_EVENT.clone(),
611        ]
612    }
613
614    fn startup_privilege_requirement(
615        &self,
616
617        startup_params: Option<&serde_json::Value>,
618    ) -> crate::protocol::metadata::PrivilegeRequirement {
619        // The UDP test transport carries the same frames over an ordinary socket.
620
621        crate::llm::actions::protocol_trait::unless_udp_transport(
622            self.metadata().privilege_requirement,
623            startup_params,
624        )
625    }
626
627    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
628        use crate::protocol::metadata::{
629            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
630        };
631
632        ProtocolMetadataV2::builder()
633            .state(DevelopmentState::Experimental)
634            // A SOCK_RAW socket on IP protocol 112. CAP_NET_RAW is sufficient on Linux, so
635            // declaring Root would refuse a capability-only process that could in fact run it.
636            .privilege_requirement(PrivilegeRequirement::RawSockets)
637            // The reaper in AppState::cleanup_old_connections exists for exactly this shape:
638            // per-remote-address entries with no connection lifecycle that nothing ever closes.
639            .connectionless()
640            // Deliberately silent: VRRP has no error form. An advertisement asserts mastership of
641            // the virtual router's addresses, so a fabricated one can take over the segment's
642            // gateway; backups already treat silence as the normal state.
643            .deliberately_silent()
644            .implementation(
645                "Hand-written codec for VRRPv2 (RFC 3768), VRRPv3 (RFC 5798) and OpenBSD CARP, \
646                 which share IP protocol 112 and nothing else — CARP's first octet is 0x21, the \
647                 same as a VRRPv2 advertisement, so the packet format is selected by the \
648                 'variant' startup parameter rather than sniffed. codec.rs is pure (no I/O, no \
649                 async) and holds the version-dependent interval scaling (v2 whole seconds, v3 \
650                 centiseconds), the RFC 1071 checksum, VRRPv3's RFC 5798 §5.2.8 pseudo-header, \
651                 CARP's 36-octet layout and CARP's HMAC-SHA1 authentication field (the sha1 \
652                 crate for the hash; the RFC 2104 construction over it is local because no HMAC \
653                 crate is reachable from this feature). Two transports sit over it: a raw \
654                 IP-protocol-112 socket \
655                 joined to 224.0.0.18, and a UDP transport carrying one complete message per \
656                 datagram for unprivileged testing.",
657            )
658            .llm_control(
659                "The model chooses the election. Priority, VRID, advertisement interval, virtual \
660                 addresses and destination all come from its action, or from the startup \
661                 parameters for whatever it omits. There is NO election state machine and no \
662                 periodic transmitter: NetGet never advertises on its own, so a model that stops \
663                 answering stops speaking rather than continuing to hold a mastership it cannot \
664                 serve. Whether to take part at all is policy — with no server instruction and no \
665                 event handler the server observes passively and makes no LLM call, which also \
666                 keeps a segment carrying one advertisement per second per group from costing one \
667                 round-trip per second.",
668            )
669            .e2e_testing(
670                "The codec is proved against literal specification bytes in \
671                 tests/server/vrrp/codec_test.rs: complete VRRPv2 and VRRPv3 advertisements \
672                 hand-derived from RFC 3768 §5.1 and RFC 5798 §5.1, the one-second interval as \
673                 0x01 under v2 and 0x0064 under v3, both checksums (v3 including its \
674                 pseudo-header) computed by hand, and CARP's 36-octet layout. The hash is the \
675                 sha1 crate's, so the FIPS 180 / RFC 3174 and RFC 2202 vectors are kept to prove \
676                 this code DRIVES it correctly — right buffer, no dropped update, correct HMAC \
677                 key padding and inner/outer ordering — rather than to prove SHA-1 itself. \
678                 tests/server/vrrp/e2e_test.rs drives the \
679                 whole advertisement -> event -> model -> action -> packet path over the UDP \
680                 transport, unprivileged, and asserts that an LLM failure puts NOTHING on the \
681                 wire. No third-party VRRP or CARP peer has ever spoken to this server.",
682            )
683            .notes(
684                "PROVEN: the codec, against literal specification bytes in both directions, and \
685                 the full decision path over the UDP transport. UNPROVEN: the raw IP-protocol-112 \
686                 transport has NEVER been executed — nothing in this tree runs privileged — and \
687                 no keepalived, no OpenBSD carp interface and no real router has ever exchanged a \
688                 packet with it. CARP's HMAC construction is derived from reading OpenBSD's \
689                 sys/netinet/ip_carp.c, not from any specification (there is no CARP RFC), and no \
690                 live peer has accepted it; only the HMAC-SHA1 primitive underneath is pinned to \
691                 published vectors. IPv6 (FF02::12) is not implemented in either direction. \
692                 THE HAZARD: a sufficiently high priority WINS THE ELECTION and makes NetGet the \
693                 default gateway for the segment, so every host's traffic is sent to a router \
694                 that does not forward. That is the protocol's point and its danger; use it on an \
695                 isolated segment. ON LLM FAILURE THIS SERVER IS SILENT: an advertisement is a \
696                 positive assertion of gateway ownership, VRRP has no error or NAK message, and a \
697                 fabricated advertisement can black-hole a segment by winning an election NetGet \
698                 cannot serve. The peer's own master-down interval already handles a router that \
699                 goes quiet. The failure is recorded in the log only, tagged \
700                 decision=fail_closed_llm_error / fail_closed_overloaded, distinct from \
701                 decision=model_reject (the model chose no_advertisement) and \
702                 decision=model_silent (the model returned nothing usable).",
703            )
704            .build()
705    }
706
707    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
708        vec![
709            ParameterDefinition {
710                name: "transport".to_string(),
711                type_hint: "string".to_string(),
712                description:
713                    "'raw' (default) opens a SOCK_RAW socket on IP protocol 112 and joins \
714                     224.0.0.18; it needs CAP_NET_RAW or root. 'udp' carries one complete VRRP \
715                     or CARP message per UDP datagram on the configured host/port — the same \
716                     octets, no privilege, no IP layer — for testing and for driving the \
717                     protocol from a script."
718                        .to_string(),
719                required: false,
720                example: json!("raw"),
721                default: None,
722            },
723            ParameterDefinition {
724                name: "variant".to_string(),
725                type_hint: "string".to_string(),
726                description:
727                    "'vrrp' (default, RFC 3768 / RFC 5798) or 'carp' (OpenBSD's Common Address \
728                     Redundancy Protocol). They share IP protocol 112 and NOTHING ELSE — even \
729                     the first octet is 0x21 for both a VRRPv2 advertisement and a CARP one, so \
730                     received packets are decoded according to this setting rather than sniffed."
731                        .to_string(),
732                required: false,
733                example: json!("vrrp"),
734                default: None,
735            },
736            ParameterDefinition {
737                name: "version".to_string(),
738                type_hint: "number".to_string(),
739                description:
740                    "2 (RFC 3768) or 3 (RFC 5798, the default). The difference is not cosmetic: \
741                     v2 carries the advertisement interval in whole seconds and eight octets of \
742                     zeroed authentication data, while v3 carries centiseconds in a 12-bit field \
743                     and folds an IP pseudo-header into the checksum. Ignored when variant is \
744                     'carp'."
745                        .to_string(),
746                required: false,
747                example: json!(3),
748                default: None,
749            },
750            ParameterDefinition {
751                name: "vrid".to_string(),
752                type_hint: "number".to_string(),
753                description:
754                    "Virtual Router Identifier, 1..=255 (default 1). CARP calls the same field \
755                     the virtual host id (vhid). Routers only contend with others sharing this \
756                     value."
757                        .to_string(),
758                required: false,
759                example: json!(1),
760                default: None,
761            },
762            ParameterDefinition {
763                name: "priority".to_string(),
764                type_hint: "number".to_string(),
765                description:
766                    "VRRP priority, 0..=255 (default 100). HIGHER WINS THE ELECTION, and the \
767                     winner becomes the default gateway for every host on the segment. 255 is \
768                     reserved for the router that genuinely owns the virtual addresses; 0 means \
769                     'I am resigning' and makes every backup take over immediately. Not used by \
770                     CARP, which elects on advskew."
771                        .to_string(),
772                required: false,
773                example: json!(100),
774                default: None,
775            },
776            ParameterDefinition {
777                name: "advert_interval".to_string(),
778                type_hint: "number".to_string(),
779                description:
780                    "Advertisement interval in WHOLE SECONDS, 1..=255 (default 1). VRRPv2 encodes \
781                     whole seconds in one octet and CARP uses the value as advbase; VRRPv3 \
782                     encodes centiseconds in twelve bits, and its sub-second range \
783                     (0.01..=40.95) is reachable through the send_vrrp_advertisement action, \
784                     which takes a fractional number. NetGet does not transmit on a timer — this \
785                     value is what outgoing advertisements advertise, and what a peer uses to \
786                     size its master-down interval."
787                        .to_string(),
788                required: false,
789                example: json!(1),
790                default: None,
791            },
792            ParameterDefinition {
793                name: "addresses".to_string(),
794                type_hint: "array".to_string(),
795                description:
796                    "The virtual IPv4 addresses this router advertises, as dotted quads (e.g. \
797                     [\"192.168.1.1\"]). These are the addresses hosts on the segment use as \
798                     their default gateway. Empty by default, because advertising an address by \
799                     default would claim one nobody asked for."
800                        .to_string(),
801                required: false,
802                example: json!(["192.168.1.1"]),
803                default: None,
804            },
805            ParameterDefinition {
806                name: "advskew".to_string(),
807                type_hint: "number".to_string(),
808                description:
809                    "CARP advertisement skew, 0..=255 (default 0). The effective interval is \
810                     advbase + advskew/256 seconds, and the host that advertises soonest wins — \
811                     so LOWER WINS, the opposite direction from VRRP priority. Ignored when \
812                     variant is 'vrrp'."
813                        .to_string(),
814                required: false,
815                example: json!(0),
816                default: None,
817            },
818            ParameterDefinition {
819                name: "carp_passphrase".to_string(),
820                type_hint: "string".to_string(),
821                description:
822                    "CARP group passphrase. Zero-padded (or truncated) to 20 octets and used as \
823                     the HMAC-SHA1 key over version, type, vhid, the virtual addresses and the \
824                     counter, exactly as OpenBSD's ifconfig does. With none configured, outgoing \
825                     advertisements are keyed with all zeros and inbound HMACs are reported as \
826                     unverifiable rather than as valid. Ignored when variant is 'vrrp'."
827                        .to_string(),
828                required: false,
829                example: json!("lab-passphrase"),
830                default: None,
831            },
832        ]
833    }
834
835    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
836        use crate::llm::actions::StartupExamples;
837
838        StartupExamples::new(
839            // LLM mode: the model decides the election.
840            json!({
841                "type": "open_server",
842                "host": "192.168.1.10",
843                "base_stack": "vrrp",
844                "instruction": "You are VRRP router for VRID 1 on an isolated lab segment. \
845                                Answer advertisements from lower-priority routers with your own \
846                                at priority 200 for 192.168.1.1. If anything is unclear, answer \
847                                with no_advertisement.",
848                "startup_params": {"vrid": 1, "priority": 200, "addresses": ["192.168.1.1"]}
849            }),
850            // Script mode: a deterministic answer, no LLM round-trip per advertisement.
851            json!({
852                "type": "open_server",
853                "host": "192.168.1.10",
854                "base_stack": "vrrp",
855                "startup_params": {"vrid": 1, "addresses": ["192.168.1.1"]},
856                "event_handlers": [{
857                    "event_pattern": "vrrp_master_resigned",
858                    "handler": {
859                        "type": "script",
860                        "language": "python",
861                        "code": "return {'type': 'send_vrrp_advertisement', 'priority': 200}"
862                    }
863                }]
864            }),
865            // Static mode: observe only. The safe default and the one to reach for first.
866            json!({
867                "type": "open_server",
868                "host": "192.168.1.10",
869                "base_stack": "vrrp",
870                "event_handlers": [{
871                    "event_pattern": "*",
872                    "handler": {
873                        "type": "static",
874                        "actions": [{"type": "no_advertisement"}]
875                    }
876                }]
877            }),
878        )
879    }
880}
881
882impl Server for VrrpProtocol {
883    fn spawn(
884        &self,
885        ctx: crate::protocol::SpawnContext,
886    ) -> std::pin::Pin<
887        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
888    > {
889        Box::pin(async move {
890            use super::VrrpServer;
891
892            let config = VrrpGroupConfig::from_startup_params(ctx.startup_params.as_ref())?;
893            let listen_addr = ctx.legacy_listen_addr();
894
895            VrrpServer::spawn_with_llm_actions(
896                listen_addr,
897                config,
898                ctx.llm_client,
899                ctx.state,
900                ctx.status_tx,
901                ctx.server_id,
902            )
903            .await
904        })
905    }
906
907    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
908        let action_type = action
909            .get("type")
910            .and_then(|v| v.as_str())
911            .context("Missing 'type' field in action")?;
912
913        match action_type {
914            "send_vrrp_advertisement" => {
915                // Validate now, against the protocol defaults, so a malformed action is
916                // rejected where the error reaches the model. The transport rebuilds it with
917                // the operator's configured group underneath and computes the checksum there,
918                // which is the only step that needs the source and destination addresses.
919                let config = VrrpGroupConfig {
920                    variant: VrrpProtocol::inferred_variant(&action)?,
921                    ..VrrpGroupConfig::default()
922                };
923                VrrpProtocol::advertisement_from_action(&action, &config)?;
924                VrrpProtocol::destination_from_action(&action)?;
925                Ok(ActionResult::Custom {
926                    name: "vrrp_action".to_string(),
927                    data: action,
928                })
929            }
930            "no_advertisement" => Ok(ActionResult::NoAction),
931            other => Err(anyhow::anyhow!("Unknown VRRP action: {}", other)),
932        }
933    }
934}
935
936// ---------------------------------------------------------------------------
937// Action definitions
938// ---------------------------------------------------------------------------
939
940fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
941    Parameter {
942        name: name.to_string(),
943        type_hint: type_hint.to_string(),
944        description: description.to_string(),
945        required,
946    }
947}
948
949/// The actions every VRRP event may be answered with.
950pub fn vrrp_actions() -> Vec<ActionDefinition> {
951    vec![send_vrrp_advertisement_action(), no_advertisement_action()]
952}
953
954fn send_vrrp_advertisement_action() -> ActionDefinition {
955    ActionDefinition {
956        name: "send_vrrp_advertisement".to_string(),
957        description: "Transmit one VRRP (or CARP) advertisement. DANGEROUS ON A REAL NETWORK: an \
958             advertisement is a positive claim to own the virtual gateway address, and a \
959             priority higher than the current master's wins the election — every host on the \
960             segment then sends its off-link traffic to NetGet, which does not forward it. \
961             Every field is optional; anything omitted comes from the server's startup \
962             parameters. NetGet sends this once, when you ask; it never advertises on a timer."
963            .to_string(),
964        parameters: vec![
965            param(
966                "variant",
967                "string",
968                "'vrrp' (RFC 3768/5798) or 'carp' (OpenBSD). Defaults to the server's \
969                 configured variant. They share IP protocol 112 and no packet fields.",
970                false,
971            ),
972            param(
973                "version",
974                "number",
975                "VRRP only: 2 (RFC 3768) or 3 (RFC 5798). Defaults to the server's configured \
976                 version. Note the advertisement interval field differs between them — whole \
977                 seconds in v2, centiseconds in v3 — but you always give seconds here and \
978                 NetGet scales it.",
979                false,
980            ),
981            param(
982                "vrid",
983                "number",
984                "Virtual Router Identifier, 1..=255 (CARP: the vhid). Only routers sharing \
985                 this value contend with each other.",
986                false,
987            ),
988            param(
989                "priority",
990                "number",
991                "VRRP only, 0..=255. HIGHER WINS. 255 asserts this router genuinely owns the \
992                 virtual addresses — do not claim it unless that is true. 0 means resign, \
993                 which makes every backup take over at once instead of waiting out its \
994                 master-down interval. CARP has no equivalent; use advskew.",
995                false,
996            ),
997            param(
998                "advert_interval",
999                "number",
1000                "Advertisement interval in seconds. VRRPv2 allows whole seconds 1..=255; \
1001                 VRRPv3 allows 0.01..=40.95; CARP uses the whole-second part as advbase.",
1002                false,
1003            ),
1004            param(
1005                "addresses",
1006                "array",
1007                "The virtual IPv4 addresses being claimed, as dotted quads, e.g. \
1008                 [\"192.168.1.1\"].",
1009                false,
1010            ),
1011            param(
1012                "advskew",
1013                "number",
1014                "CARP only, 0..=255. The effective interval is advbase + advskew/256 seconds \
1015                 and the earliest advertiser wins, so LOWER WINS — the opposite direction from \
1016                 VRRP priority.",
1017                false,
1018            ),
1019            param(
1020                "demote",
1021                "number",
1022                "CARP only, 0..=255. The sender's demotion counter; a higher value tells peers \
1023                 this host is less able to serve the group.",
1024                false,
1025            ),
1026            param(
1027                "counter",
1028                "number",
1029                "CARP only. The replay counter covered by the HMAC. Echo back one higher than \
1030                 the counter you were told about, or leave it out for 0.",
1031                false,
1032            ),
1033            param(
1034                "destination",
1035                "string",
1036                "'multicast' (default, 224.0.0.18) or a unicast dotted quad. This is not \
1037                 cosmetic under VRRPv3: the destination address is folded into the checksum \
1038                 through the RFC 5798 pseudo-header.",
1039                false,
1040            ),
1041        ],
1042        example: json!({
1043            "type": "send_vrrp_advertisement",
1044            "variant": "vrrp",
1045            "version": 3,
1046            "vrid": 1,
1047            "priority": 200,
1048            "advert_interval": 1,
1049            "addresses": ["192.168.1.1"],
1050            "destination": "multicast"
1051        }),
1052        log_template: Some(
1053            LogTemplate::new()
1054                .with_info("-> VRRP advertisement vrid={vrid} priority={priority}")
1055                .with_debug(
1056                    "VRRP send_vrrp_advertisement: variant={variant} version={version} \
1057                     vrid={vrid} priority={priority} interval={advert_interval} \
1058                     addresses={addresses} dest={destination}",
1059                )
1060                .with_trace("VRRP advertisement action: {json_pretty(.)}"),
1061        ),
1062    }
1063}
1064
1065fn no_advertisement_action() -> ActionDefinition {
1066    ActionDefinition {
1067        name: "no_advertisement".to_string(),
1068        description:
1069            "Answer with nothing. This is a real decision, not a failure: a VRRP listener that \
1070             observes without advertising cannot perturb the election, and it is the correct \
1071             answer whenever you are unsure. Prefer it to guessing a priority — an \
1072             advertisement that wins an election NetGet cannot serve black-holes every host on \
1073             the segment."
1074                .to_string(),
1075        parameters: vec![],
1076        example: json!({ "type": "no_advertisement" }),
1077        log_template: Some(
1078            LogTemplate::new()
1079                .with_info("-> VRRP silent (no advertisement)")
1080                .with_debug("VRRP no_advertisement: observed, nothing transmitted"),
1081        ),
1082    }
1083}
1084
1085// ---------------------------------------------------------------------------
1086// Event types
1087// ---------------------------------------------------------------------------
1088
1089fn advertisement_event_parameters() -> Vec<Parameter> {
1090    vec![
1091        param(
1092            "variant",
1093            "string",
1094            "'vrrp' or 'carp' — which of the two protocols sharing IP protocol 112 this was \
1095             decoded as, per the server's configuration.",
1096            true,
1097        ),
1098        param(
1099            "version",
1100            "number",
1101            "2 or 3 for VRRP; 2 for CARP (its own version number, unrelated to VRRP's).",
1102            true,
1103        ),
1104        param(
1105            "vrid",
1106            "number",
1107            "Virtual Router Identifier (CARP: vhid).",
1108            true,
1109        ),
1110        param(
1111            "priority",
1112            "number",
1113            "VRRP only. 0 = the sender is resigning, 255 = the sender claims to own the \
1114             addresses, higher wins. Absent for CARP.",
1115            false,
1116        ),
1117        param(
1118            "advert_interval",
1119            "number",
1120            "The interval the sender advertises, in seconds, already converted from whichever \
1121             unit its version uses.",
1122            true,
1123        ),
1124        param(
1125            "addresses",
1126            "array",
1127            "The virtual IPv4 addresses the sender is claiming, as dotted quads.",
1128            false,
1129        ),
1130        param(
1131            "source_address",
1132            "string",
1133            "Where the advertisement came from.",
1134            true,
1135        ),
1136        param(
1137            "checksum_valid",
1138            "boolean",
1139            "Whether the packet's checksum recomputes. Null when it could not be checked.",
1140            false,
1141        ),
1142        param(
1143            "checksum_scope",
1144            "string",
1145            "'message' for VRRPv2 and CARP; 'pseudo_header_and_message' for VRRPv3, whose \
1146             checksum covers the IP source and destination too.",
1147            true,
1148        ),
1149        param(
1150            "auth_type",
1151            "number",
1152            "VRRPv2 only. 0 in anything conformant with RFC 3768; non-zero means the sender is \
1153             using RFC 2338-era authentication.",
1154            false,
1155        ),
1156        param(
1157            "advskew",
1158            "number",
1159            "CARP only. Lower wins the election.",
1160            false,
1161        ),
1162        param(
1163            "advbase",
1164            "number",
1165            "CARP only. Base interval in seconds.",
1166            false,
1167        ),
1168        param(
1169            "demote",
1170            "number",
1171            "CARP only. The sender's demotion counter.",
1172            false,
1173        ),
1174        param(
1175            "counter",
1176            "number",
1177            "CARP only. The sender's replay counter.",
1178            false,
1179        ),
1180        param(
1181            "hmac_valid",
1182            "boolean",
1183            "CARP only. Whether the sender's HMAC matches the configured passphrase. Null when \
1184             no passphrase is configured, so it could not be checked — never assume true.",
1185            false,
1186        ),
1187        param(
1188            "local_priority",
1189            "number",
1190            "This server's own configured priority, for comparison with the sender's.",
1191            false,
1192        ),
1193        param(
1194            "local_vrid",
1195            "number",
1196            "This server's own configured VRID. An advertisement for a different VRID is not \
1197             contending with this router at all.",
1198            false,
1199        ),
1200        param(
1201            "local_addresses",
1202            "array",
1203            "The virtual addresses this server is configured to claim.",
1204            false,
1205        ),
1206    ]
1207}
1208
1209pub static VRRP_ADVERTISEMENT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1210    EventType::new(
1211        "vrrp_advertisement_received",
1212        "A VRRP or CARP advertisement arrived: another router is claiming the virtual gateway \
1213         address for this group. Compare its priority with local_priority — higher wins, and \
1214         the winner becomes the default gateway for every host on the segment. Answer with \
1215         no_advertisement unless you intend to contest the election, because winning it makes \
1216         NetGet the gateway for traffic it cannot forward.",
1217        json!({ "type": "no_advertisement" }),
1218    )
1219    .with_parameters(advertisement_event_parameters())
1220    .with_actions(vrrp_actions())
1221    .with_alternative_example(json!({
1222        "type": "send_vrrp_advertisement",
1223        "vrid": 1,
1224        "priority": 200,
1225        "addresses": ["192.168.1.1"],
1226        "destination": "multicast"
1227    }))
1228    .with_log_template(
1229        LogTemplate::new()
1230            .with_info("VRRP advertisement from {source_address}: vrid={vrid} priority={priority}")
1231            .with_debug(
1232                "VRRP {variant}v{version} from {source_address}: vrid={vrid} \
1233                 priority={priority} interval={advert_interval} addresses={addresses} \
1234                 checksum_valid={checksum_valid}",
1235            )
1236            .with_trace("VRRP advertisement: {json_pretty(.)}"),
1237    )
1238});
1239
1240pub static VRRP_MASTER_RESIGNED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1241    EventType::new(
1242        "vrrp_master_resigned",
1243        "The master resigned: it sent an advertisement with priority 0, which exists precisely \
1244         so backups take over IMMEDIATELY instead of waiting out their master-down interval \
1245         (RFC 3768 §6.4.3). The election is open right now. Answering with \
1246         send_vrrp_advertisement at a high priority will very likely win it — and make NetGet \
1247         the default gateway for the segment. Answer with no_advertisement unless that is what \
1248         you intend.",
1249        json!({ "type": "no_advertisement" }),
1250    )
1251    .with_parameters(advertisement_event_parameters())
1252    .with_actions(vrrp_actions())
1253    .with_alternative_example(json!({
1254        "type": "send_vrrp_advertisement",
1255        "vrid": 1,
1256        "priority": 200,
1257        "addresses": ["192.168.1.1"],
1258        "destination": "multicast"
1259    }))
1260    .with_log_template(
1261        LogTemplate::new()
1262            .with_info("VRRP master {source_address} resigned (priority 0) for vrid={vrid}")
1263            .with_debug(
1264                "VRRP master resigned: source={source_address} vrid={vrid} \
1265                 addresses={addresses} local_priority={local_priority}",
1266            )
1267            .with_trace("VRRP master resigned: {json_pretty(.)}"),
1268    )
1269});