1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum VrrpTransport {
33 Raw,
36 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#[derive(Debug, Clone, PartialEq)]
78pub struct VrrpGroupConfig {
79 pub transport: VrrpTransport,
80 pub variant: Variant,
81 pub version: u8,
83 pub vrid: u8,
85 pub priority: u8,
87 pub advert_interval_seconds: f64,
89 pub addresses: Vec<Ipv4Addr>,
91 pub advskew: u8,
93 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 version: VRRP_VERSION_3,
107 vrid: 1,
108 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 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 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 config.template_advertisement()?.validate_shape()?;
193 Ok(config)
194 }
195
196 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 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 "local_carp_passphrase_configured": !self.carp_passphrase.is_empty(),
253 })
254 }
255
256 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#[derive(Debug, Clone, PartialEq)]
295pub enum ConfiguredAdvertisement {
296 Vrrp(VrrpAdvertisement),
297 Carp(CarpAdvertisement),
298}
299
300impl ConfiguredAdvertisement {
301 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 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
388pub struct VrrpProtocol;
394
395impl VrrpProtocol {
396 pub fn new() -> Self {
397 Self
398 }
399
400 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 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 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 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 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 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 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 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 .privilege_requirement(PrivilegeRequirement::RawSockets)
637 .connectionless()
640 .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 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 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 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 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
936fn 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
949pub 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
1085fn 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});