Skip to main content

netget/server/vrrp/
mod.rs

1//! VRRP (v2/v3) and CARP server — IP protocol 112, multicast `224.0.0.18`.
2//!
3//! NetGet owns the wire; the model owns the election. An advertisement arrives, it is decoded
4//! into structured fields, an event is raised, and whatever the operator's handler or the
5//! model answers is encoded and sent. That is the whole server.
6//!
7//! # There is deliberately no election state machine
8//!
9//! Nothing here runs a master-down timer, tracks a state, or transmits on its own. A VRRP
10//! implementation that elected itself master and then kept advertising would keep asserting
11//! gateway ownership after the model stopped answering — an automatic master NetGet cannot
12//! back, which is precisely the fail-open shape the root `CLAUDE.md` forbids. NetGet supplies
13//! the wire; the model supplies the decisions, one advertisement at a time.
14//!
15//! # Two transports
16//!
17//! * [`VrrpTransport::Raw`] — a real `SOCK_RAW` socket on IP protocol 112 joined to
18//!   `224.0.0.18`. Needs `CAP_NET_RAW` (Linux) or root. **This path has never been executed**;
19//!   nothing in this tree runs privileged. See `src/server/vrrp/CLAUDE.md`.
20//! * [`VrrpTransport::Udp`] — one complete VRRP or CARP message per UDP datagram, byte-identical
21//!   to what the raw transport would emit. The codec, the event, the handler/LLM dispatch and
22//!   the response encoding are all the real ones; only the IP layer is simulated. This is what
23//!   makes the decision path testable without root, the compromise `ospf` documents.
24//!
25//! # On failure, silence
26//!
27//! VRRP is in the deliberately-silent class. Every message it can emit is a *positive
28//! assertion of gateway ownership*, there is no error or NAK message to send instead, and a
29//! fabricated advertisement does not merely mislead a peer: winning the election makes every
30//! host on the segment send its off-link traffic to a router that will not forward it. So when
31//! the LLM call fails, **nothing goes on the wire** and the failure is recorded in the log with
32//! a `decision=` tag, the way `src/server/radius/` separates its cases. Nothing derived from
33//! the error ever reaches a packet.
34
35pub mod actions;
36pub mod codec;
37
38use anyhow::{anyhow, Context, Result};
39use std::net::{IpAddr, Ipv4Addr, SocketAddr};
40use std::os::unix::io::AsRawFd;
41use std::sync::Arc;
42use tokio::net::UdpSocket;
43use tokio::sync::mpsc;
44use tracing::{debug, error, info, trace, warn};
45
46use crate::llm::action_helper::call_llm;
47use crate::llm::ollama_client::OllamaClient;
48use crate::protocol::Event;
49use crate::server::connection::ConnectionId;
50use crate::state::app_state::AppState;
51use crate::state::ServerId;
52use crate::{console_debug, console_error, console_info, console_trace};
53
54use actions::{
55    VrrpGroupConfig, VrrpProtocol, VrrpTransport, VRRP_ADVERTISEMENT_RECEIVED_EVENT,
56    VRRP_MASTER_RESIGNED_EVENT,
57};
58use codec::{Advertisement, PseudoHeader, Variant, VRRP_MULTICAST_IPV4};
59
60/// The minimum IPv4 header, used to skip past it on the raw transport.
61const IPV4_HEADER_MIN_LEN: usize = 20;
62
63/// Where a reply goes, and what pseudo-header its checksum is built from.
64#[derive(Clone)]
65enum Responder {
66    Udp {
67        socket: Arc<UdpSocket>,
68        peer: SocketAddr,
69        /// The bound address, used as the pseudo-header source for VRRPv3.
70        local: Ipv4Addr,
71    },
72    /// Raw IP-protocol-112 socket. The fd is owned by the receive loop's `AsyncFd`, which
73    /// outlives every task holding this, because the loop is what spawns them.
74    Raw {
75        fd: i32,
76        /// The interface address, used as the pseudo-header source for VRRPv3.
77        source: Ipv4Addr,
78    },
79}
80
81impl Responder {
82    fn source_address(&self) -> Ipv4Addr {
83        match self {
84            Responder::Udp { local, .. } => *local,
85            Responder::Raw { source, .. } => *source,
86        }
87    }
88
89    async fn send(&self, packet: &[u8], destination: Ipv4Addr) -> Result<()> {
90        match self {
91            Responder::Udp { socket, peer, .. } => {
92                // The datagram goes back to whoever sent one; `destination` has already done
93                // its real work by entering the VRRPv3 checksum through the pseudo-header.
94                socket
95                    .send_to(packet, peer)
96                    .await
97                    .with_context(|| format!("failed to send VRRP packet to {peer}"))?;
98                Ok(())
99            }
100            Responder::Raw { fd, .. } => send_raw(*fd, destination, packet),
101        }
102    }
103
104    fn describe(&self, destination: Ipv4Addr) -> String {
105        match self {
106            Responder::Udp { peer, .. } => {
107                format!("{peer} (udp transport, addressed to {destination})")
108            }
109            Responder::Raw { .. } => destination.to_string(),
110        }
111    }
112}
113
114/// VRRP / CARP server.
115pub struct VrrpServer;
116
117impl VrrpServer {
118    /// Start on whichever transport the configuration selects.
119    ///
120    /// Returns only once the transport is genuinely up — the UDP socket bound, or the raw
121    /// socket created, joined and non-blocking — so a failure lands in `ServerStatus::Error`
122    /// rather than leaving a server in `Running` that has received nothing. That is the
123    /// ARP/DataLink/ICMP fire-and-forget defect the root `CLAUDE.md` records.
124    pub async fn spawn_with_llm_actions(
125        listen_addr: SocketAddr,
126        config: VrrpGroupConfig,
127        llm_client: OllamaClient,
128        app_state: Arc<AppState>,
129        status_tx: mpsc::UnboundedSender<String>,
130        server_id: ServerId,
131    ) -> Result<SocketAddr> {
132        match config.transport {
133            VrrpTransport::Udp => {
134                Self::spawn_udp(
135                    listen_addr,
136                    config,
137                    llm_client,
138                    app_state,
139                    status_tx,
140                    server_id,
141                )
142                .await
143            }
144            VrrpTransport::Raw => {
145                Self::spawn_raw(
146                    listen_addr,
147                    config,
148                    llm_client,
149                    app_state,
150                    status_tx,
151                    server_id,
152                )
153                .await
154            }
155        }
156    }
157
158    // -----------------------------------------------------------------------
159    // UDP test transport
160    // -----------------------------------------------------------------------
161
162    async fn spawn_udp(
163        listen_addr: SocketAddr,
164        config: VrrpGroupConfig,
165        llm_client: OllamaClient,
166        app_state: Arc<AppState>,
167        status_tx: mpsc::UnboundedSender<String>,
168        server_id: ServerId,
169    ) -> Result<SocketAddr> {
170        let socket = Arc::new(
171            UdpSocket::bind(listen_addr)
172                .await
173                .with_context(|| format!("failed to bind VRRP UDP transport to {listen_addr}"))?,
174        );
175        let local_addr = socket.local_addr()?;
176        let local_v4 = match local_addr.ip() {
177            IpAddr::V4(ip) => ip,
178            IpAddr::V6(_) => {
179                return Err(anyhow!(
180                    "VRRP carries IPv4 virtual addresses only; the UDP transport must bind an \
181                     IPv4 address, got {local_addr}"
182                ))
183            }
184        };
185
186        console_info!(
187            status_tx,
188            "VRRP server listening on {} (UDP transport: one complete {} message per datagram, \
189             vrid {}, priority {})",
190            local_addr,
191            config.variant.as_str(),
192            config.vrid,
193            config.priority
194        );
195
196        let protocol = Arc::new(VrrpProtocol::new());
197        let config = Arc::new(config);
198        let task_registrar = app_state.clone();
199        let recv_socket = socket.clone();
200
201        let accept_handle = tokio::spawn(async move {
202            // A VRRP advertisement with 255 addresses is 1036 octets; read an MTU so anything
203            // longer is refused by the decoder with a real message rather than truncated into
204            // something that happens to parse.
205            let mut buffer = vec![0u8; 2048];
206            loop {
207                match recv_socket.recv_from(&mut buffer).await {
208                    Ok((n, peer)) => {
209                        let packet = buffer[..n].to_vec();
210                        console_debug!(status_tx, "VRRP received {} byte message from {}", n, peer);
211                        console_trace!(status_tx, "VRRP message (hex): {}", hex::encode(&packet));
212
213                        let peer_v4 = match peer.ip() {
214                            IpAddr::V4(ip) => ip,
215                            IpAddr::V6(_) => {
216                                console_debug!(
217                                    status_tx,
218                                    "VRRP ignoring datagram from IPv6 peer {}: no IPv6 transport",
219                                    peer
220                                );
221                                continue;
222                            }
223                        };
224
225                        let responder = Responder::Udp {
226                            socket: socket.clone(),
227                            peer,
228                            local: local_v4,
229                        };
230                        // Over UDP there is no IP header, so the pseudo-header a VRRPv3 sender
231                        // would have used is reconstructed by convention: its own address, and
232                        // the VRRP multicast group. Documented in `src/server/vrrp/CLAUDE.md`.
233                        let inbound_pseudo = PseudoHeader::new(peer_v4, VRRP_MULTICAST_IPV4);
234
235                        Self::spawn_handler(
236                            packet,
237                            peer_v4,
238                            inbound_pseudo,
239                            n,
240                            responder,
241                            &llm_client,
242                            &app_state,
243                            &status_tx,
244                            &protocol,
245                            &config,
246                            server_id,
247                            local_addr,
248                            peer,
249                        )
250                        .await;
251                    }
252                    Err(e) => {
253                        console_error!(status_tx, "VRRP UDP receive error: {}", e);
254                        break;
255                    }
256                }
257            }
258            warn!("VRRP UDP receive loop terminated");
259        });
260
261        task_registrar
262            .register_server_task(server_id, accept_handle)
263            .await;
264
265        Ok(local_addr)
266    }
267
268    // -----------------------------------------------------------------------
269    // Raw IP protocol 112 transport
270    // -----------------------------------------------------------------------
271
272    /// NEVER EXECUTED in this tree — needs `CAP_NET_RAW` or root. Written to the same shape as
273    /// `ospf`, including reporting the failure rather than starting a server that receives
274    /// nothing.
275    async fn spawn_raw(
276        listen_addr: SocketAddr,
277        config: VrrpGroupConfig,
278        llm_client: OllamaClient,
279        app_state: Arc<AppState>,
280        status_tx: mpsc::UnboundedSender<String>,
281        server_id: ServerId,
282    ) -> Result<SocketAddr> {
283        let interface_ip = match listen_addr.ip() {
284            IpAddr::V4(ip) => ip,
285            IpAddr::V6(_) => {
286                return Err(anyhow!(
287                    "VRRP over IPv6 (FF02::12) is not implemented; bind an IPv4 interface \
288                     address instead of {listen_addr}"
289                ))
290            }
291        };
292
293        // Creating the socket IS the privileged step and it is synchronous, so the refusal
294        // reaches the caller directly. Nothing is spawned before this succeeds.
295        let raw_socket = create_vrrp_raw_socket(interface_ip).with_context(|| {
296            format!(
297                "failed to open a raw IP-protocol-112 socket on {interface_ip}. VRRP and CARP \
298                 ride directly on IP, so this needs CAP_NET_RAW on Linux or root elsewhere. Use \
299                 startup_params.transport = \"udp\" to exercise the protocol without privilege."
300            )
301        })?;
302        let socket_fd = raw_socket.as_raw_fd();
303
304        console_info!(
305            status_tx,
306            "VRRP server on {} (raw IP protocol 112, joined {}, variant {}, vrid {})",
307            interface_ip,
308            VRRP_MULTICAST_IPV4,
309            config.variant.as_str(),
310            config.vrid
311        );
312
313        let async_socket = tokio::io::unix::AsyncFd::new(raw_socket)
314            .context("failed to register the VRRP raw socket with the tokio reactor")?;
315
316        let protocol = Arc::new(VrrpProtocol::new());
317        let config = Arc::new(config);
318        let task_registrar = app_state.clone();
319
320        let accept_handle = tokio::spawn(async move {
321            let mut buffer = vec![0u8; 65535];
322            loop {
323                let mut guard = match async_socket.readable().await {
324                    Ok(guard) => guard,
325                    Err(e) => {
326                        console_error!(status_tx, "VRRP raw socket error: {}", e);
327                        break;
328                    }
329                };
330
331                let read = guard.try_io(|inner| {
332                    let fd = inner.as_raw_fd();
333                    // SAFETY: `fd` is owned by `async_socket` for the whole loop and `buffer`
334                    // is a live allocation of the length passed.
335                    let n = unsafe {
336                        libc::recv(
337                            fd,
338                            buffer.as_mut_ptr() as *mut libc::c_void,
339                            buffer.len(),
340                            0,
341                        )
342                    };
343                    if n < 0 {
344                        Err(std::io::Error::last_os_error())
345                    } else {
346                        Ok(n as usize)
347                    }
348                });
349
350                let n = match read {
351                    Ok(Ok(n)) => n,
352                    Ok(Err(e)) => {
353                        console_error!(status_tx, "VRRP raw receive error: {}", e);
354                        continue;
355                    }
356                    Err(_would_block) => continue,
357                };
358
359                // A raw IPv4 socket delivers the IP header too.
360                if n < IPV4_HEADER_MIN_LEN {
361                    continue;
362                }
363                let header_len = ((buffer[0] & 0x0f) as usize) * 4;
364                if header_len < IPV4_HEADER_MIN_LEN || n <= header_len {
365                    continue;
366                }
367                let source = Ipv4Addr::new(buffer[12], buffer[13], buffer[14], buffer[15]);
368                let destination = Ipv4Addr::new(buffer[16], buffer[17], buffer[18], buffer[19]);
369                let packet = buffer[header_len..n].to_vec();
370                let payload_len = packet.len();
371
372                console_debug!(
373                    status_tx,
374                    "VRRP received {} byte message from {} to {}",
375                    payload_len,
376                    source,
377                    destination
378                );
379                console_trace!(status_tx, "VRRP message (hex): {}", hex::encode(&packet));
380
381                let responder = Responder::Raw {
382                    fd: socket_fd,
383                    source: interface_ip,
384                };
385                let inbound_pseudo = PseudoHeader::new(source, destination);
386
387                Self::spawn_handler(
388                    packet,
389                    source,
390                    inbound_pseudo,
391                    payload_len,
392                    responder,
393                    &llm_client,
394                    &app_state,
395                    &status_tx,
396                    &protocol,
397                    &config,
398                    server_id,
399                    SocketAddr::new(IpAddr::V4(interface_ip), 0),
400                    SocketAddr::new(IpAddr::V4(source), 0),
401                )
402                .await;
403            }
404            warn!("VRRP raw receive loop terminated");
405        });
406
407        task_registrar
408            .register_server_task(server_id, accept_handle)
409            .await;
410
411        Ok(SocketAddr::new(IpAddr::V4(interface_ip), 0))
412    }
413
414    // -----------------------------------------------------------------------
415    // Message -> event
416    // -----------------------------------------------------------------------
417
418    #[allow(clippy::too_many_arguments)]
419    async fn spawn_handler(
420        packet: Vec<u8>,
421        source: Ipv4Addr,
422        inbound_pseudo: PseudoHeader,
423        bytes: usize,
424        responder: Responder,
425        llm_client: &OllamaClient,
426        app_state: &Arc<AppState>,
427        status_tx: &mpsc::UnboundedSender<String>,
428        protocol: &Arc<VrrpProtocol>,
429        config: &Arc<VrrpGroupConfig>,
430        server_id: ServerId,
431        local_addr: SocketAddr,
432        peer_addr: SocketAddr,
433    ) {
434        let llm = llm_client.clone();
435        let state = app_state.clone();
436        let status = status_tx.clone();
437        let proto = protocol.clone();
438        let cfg = config.clone();
439        // Tracked, not detached: stop_server must abort this task too.
440        let task_owner = app_state.clone();
441        task_owner
442            .spawn_server_task(server_id, async move {
443                Self::record_connection(&state, server_id, local_addr, peer_addr, bytes).await;
444                Self::handle_packet(
445                    packet,
446                    source,
447                    inbound_pseudo,
448                    responder,
449                    llm,
450                    state,
451                    status,
452                    proto,
453                    cfg,
454                    server_id,
455                )
456                .await;
457            })
458            .await;
459    }
460
461    /// Per-remote-address bookkeeping so the dashboard shows who is advertising.
462    ///
463    /// These entries have no lifecycle of their own — nothing ever "closes" a VRRP speaker —
464    /// which is exactly the case `ProtocolMetadataV2::connectionless` exists for, and why this
465    /// protocol declares it: the runtime reaps them once `last_activity` goes stale.
466    async fn record_connection(
467        state: &Arc<AppState>,
468        server_id: ServerId,
469        local_addr: SocketAddr,
470        peer_addr: SocketAddr,
471        bytes: usize,
472    ) {
473        use crate::state::server::{
474            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
475        };
476        let connection_id = ConnectionId::new(state.get_next_unified_id().await);
477        let now = crate::utils::clock::Instant::now();
478        state
479            .add_connection_to_server(
480                server_id,
481                ServerConnectionState {
482                    id: connection_id,
483                    remote_addr: peer_addr,
484                    local_addr,
485                    bytes_sent: 0,
486                    bytes_received: bytes as u64,
487                    packets_sent: 0,
488                    packets_received: 1,
489                    last_activity: now,
490                    status: ConnectionStatus::Active,
491                    status_changed_at: now,
492                    protocol_info: ProtocolConnectionInfo::empty(),
493                },
494            )
495            .await;
496    }
497
498    #[allow(clippy::too_many_arguments)]
499    async fn handle_packet(
500        packet: Vec<u8>,
501        source: Ipv4Addr,
502        inbound_pseudo: PseudoHeader,
503        responder: Responder,
504        llm_client: OllamaClient,
505        app_state: Arc<AppState>,
506        status_tx: mpsc::UnboundedSender<String>,
507        protocol: Arc<VrrpProtocol>,
508        config: Arc<VrrpGroupConfig>,
509        server_id: ServerId,
510    ) {
511        let decoded = match Advertisement::decode(config.variant, &packet) {
512            Ok(decoded) => decoded,
513            Err(e) => {
514                console_debug!(status_tx, "VRRP ignoring message from {}: {:#}", source, e);
515                return;
516            }
517        };
518
519        let (event_type, data) = match &decoded {
520            Advertisement::Vrrp(advertisement) => {
521                let scope = advertisement.checksum_scope();
522                let checksum_valid = match scope {
523                    codec::ChecksumScope::Message => codec::checksum_is_valid(&packet, None),
524                    codec::ChecksumScope::PseudoHeaderAndMessage => {
525                        codec::checksum_is_valid(&packet, Some(&inbound_pseudo))
526                    }
527                };
528                let mut data = serde_json::json!({
529                    "variant": Variant::Vrrp.as_str(),
530                    "version": advertisement.version,
531                    "vrid": advertisement.vrid,
532                    "priority": advertisement.priority,
533                    "advert_interval": advertisement.advert_interval_seconds,
534                    "addresses": advertisement.addresses.iter().map(|a| a.to_string())
535                        .collect::<Vec<_>>(),
536                    "address_count": advertisement.addresses.len(),
537                    "source_address": source.to_string(),
538                    "checksum_valid": checksum_valid,
539                    "checksum_scope": scope.as_str(),
540                    "auth_type": advertisement.auth_type,
541                    "is_address_owner": advertisement.is_address_owner(),
542                    "is_resignation": advertisement.is_resignation(),
543                });
544                merge(&mut data, config.local_summary());
545                if advertisement.is_resignation() {
546                    (&*VRRP_MASTER_RESIGNED_EVENT, data)
547                } else {
548                    (&*VRRP_ADVERTISEMENT_RECEIVED_EVENT, data)
549                }
550            }
551            Advertisement::Carp(advertisement) => {
552                // CARP's checksum covers the message alone; there is no pseudo-header.
553                let checksum_valid = codec::checksum_is_valid(&packet, None);
554                // With no configured passphrase there is no key to check against, and
555                // reporting `true` would be the fail-open shape this codebase forbids: the
556                // model must be able to tell "verified" from "not checked".
557                let hmac_valid = if config.carp_passphrase.is_empty() {
558                    serde_json::Value::Null
559                } else {
560                    serde_json::Value::Bool(
561                        codec::carp_hmac(
562                            config.carp_passphrase.as_bytes(),
563                            advertisement.vhid,
564                            &config.addresses,
565                            advertisement.counter,
566                        ) == advertisement.hmac,
567                    )
568                };
569                let mut data = serde_json::json!({
570                    "variant": Variant::Carp.as_str(),
571                    "version": advertisement.version,
572                    "vrid": advertisement.vhid,
573                    "advert_interval": advertisement.interval_seconds(),
574                    "advskew": advertisement.advskew,
575                    "advbase": advertisement.advbase,
576                    "demote": advertisement.demote,
577                    "counter": advertisement.counter,
578                    "source_address": source.to_string(),
579                    "checksum_valid": checksum_valid,
580                    "checksum_scope": codec::ChecksumScope::Message.as_str(),
581                    "hmac_valid": hmac_valid,
582                });
583                merge(&mut data, config.local_summary());
584                (&*VRRP_ADVERTISEMENT_RECEIVED_EVENT, data)
585            }
586        };
587
588        info!(
589            "VRRP {} from {}: vrid={} priority={:?}",
590            data.get("variant").and_then(|v| v.as_str()).unwrap_or("?"),
591            source,
592            data.get("vrid").and_then(|v| v.as_u64()).unwrap_or(0),
593            data.get("priority").and_then(|v| v.as_u64()),
594        );
595
596        let event = Event::new(event_type, data);
597        Self::dispatch_event(
598            event, responder, llm_client, app_state, status_tx, protocol, config, server_id,
599        )
600        .await;
601    }
602
603    /// Ask the operator's handler or the model what to answer, and put exactly that — or
604    /// nothing — on the wire.
605    #[allow(clippy::too_many_arguments)]
606    async fn dispatch_event(
607        event: Event,
608        responder: Responder,
609        llm_client: OllamaClient,
610        app_state: Arc<AppState>,
611        status_tx: mpsc::UnboundedSender<String>,
612        protocol: Arc<VrrpProtocol>,
613        config: Arc<VrrpGroupConfig>,
614        server_id: ServerId,
615    ) {
616        let event_id = event.event_type.id.clone();
617
618        // Whether to take part in an election at all — and at what priority — is policy, not
619        // something the received advertisement determines. With no operator policy (no
620        // instruction and no handler) the safe answer is to observe and say nothing, and to do
621        // that *without* an LLM round-trip: a VRRP group advertises once a second by default,
622        // so consulting the model per packet would cost one call per second forever.
623        if !operator_wants_dynamic(&app_state, server_id, &event_id).await {
624            debug!(
625                "VRRP decision=no_policy: {} observed, no operator policy configured (no \
626                 instruction and no handler), nothing transmitted and no LLM call",
627                event_id
628            );
629            let _ = status_tx.send(format!(
630                "VRRP decision=no_policy: {} observed passively (no policy configured, no LLM)",
631                event_id
632            ));
633            return;
634        }
635
636        match call_llm(
637            &llm_client,
638            &app_state,
639            server_id,
640            None,
641            &event,
642            protocol.as_ref(),
643        )
644        .await
645        {
646            Ok(execution_result) => {
647                for message in &execution_result.messages {
648                    info!("{}", message);
649                    let _ = status_tx.send(format!("[INFO] {}", message));
650                }
651
652                let mut sent = 0usize;
653                for result in execution_result.protocol_results {
654                    let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
655                        &result
656                    else {
657                        continue;
658                    };
659                    if name.as_str() != "vrrp_action" {
660                        continue;
661                    }
662
663                    match Self::packet_from_action(&config, data, responder.source_address()) {
664                        Ok((packet, destination)) => {
665                            match responder.send(&packet, destination).await {
666                                Ok(()) => {
667                                    sent += 1;
668                                    debug!(
669                                        "VRRP sent {} byte advertisement to {}",
670                                        packet.len(),
671                                        responder.describe(destination)
672                                    );
673                                    trace!("VRRP sent (hex): {}", hex::encode(&packet));
674                                    let _ = status_tx.send(format!(
675                                        "[DEBUG] VRRP sent {} byte advertisement to {}",
676                                        packet.len(),
677                                        responder.describe(destination)
678                                    ));
679                                }
680                                Err(e) => {
681                                    error!("VRRP transmit failed: {:#}", e);
682                                    let _ =
683                                        status_tx.send(format!("✗ VRRP transmit failed: {:#}", e));
684                                }
685                            }
686                        }
687                        Err(e) => {
688                            // An advertisement we cannot build is one we must not approximate.
689                            error!("VRRP could not build the requested advertisement: {:#}", e);
690                            let _ = status_tx.send(format!(
691                                "✗ VRRP decision=model_invalid: could not build the requested \
692                                 advertisement, nothing transmitted: {:#}",
693                                e
694                            ));
695                        }
696                    }
697                }
698
699                if sent == 0 {
700                    // Nothing went out. VRRP has no error or NAK message, so an explicit
701                    // refusal, an empty answer and a build failure are identical on the wire;
702                    // the decision tag is the only place the difference survives.
703                    let model_rejected = execution_result.raw_actions.iter().any(|a| {
704                        a.get("type").and_then(|t| t.as_str()) == Some("no_advertisement")
705                    });
706                    let decision = if model_rejected {
707                        "model_reject"
708                    } else {
709                        "model_silent"
710                    };
711                    info!(
712                        "VRRP {} answered with no advertisement: decision={} (VRRP has no error \
713                         message; staying silent is the protocol-correct response)",
714                        event_id, decision
715                    );
716                    let _ = status_tx.send(format!(
717                        "VRRP decision={}: {} answered with no advertisement",
718                        decision, event_id
719                    ));
720                }
721            }
722            Err(e) => {
723                // FAIL CLOSED, AND CLOSED MEANS SILENT. An advertisement is a positive claim
724                // to own the virtual gateway address. Emitting one to signal "netget is
725                // broken" would, if its priority happened to win, make every host on the
726                // segment route through a router that does not forward — a black hole, which
727                // is strictly worse for the network than silence. A peer already handles our
728                // silence correctly: its master-down interval expires and it takes over,
729                // which is the spec-defined outcome for a router that stops speaking.
730                //
731                // Nothing derived from `e` goes anywhere near a packet; it is classified only
732                // to keep an overloaded backend distinguishable from a broken one in the log.
733                let category = crate::utils::WireFailure::classify(&e);
734                let decision = if category.is_overloaded() {
735                    "fail_closed_overloaded"
736                } else {
737                    "fail_closed_llm_error"
738                };
739                error!(
740                    "VRRP decision={}: LLM call failed for {}, nothing transmitted (VRRP has no \
741                     failure message and a fabricated advertisement can black-hole the \
742                     segment): {}",
743                    decision, event_id, e
744                );
745                let _ = status_tx.send(format!(
746                    "✗ VRRP decision={}: {} unanswered ({}), nothing transmitted: {}",
747                    decision,
748                    event_id,
749                    category.text(),
750                    e
751                ));
752            }
753        }
754    }
755
756    /// Turn one validated action into wire octets plus the address they are addressed to.
757    ///
758    /// The operator's configured group is layered in first, so a model that names only
759    /// `priority` still emits an advertisement carrying this server's VRID, interval and
760    /// virtual addresses.
761    fn packet_from_action(
762        config: &VrrpGroupConfig,
763        data: &serde_json::Value,
764        source: Ipv4Addr,
765    ) -> Result<(Vec<u8>, Ipv4Addr)> {
766        let action_type = data
767            .get("type")
768            .and_then(|v| v.as_str())
769            .context("VRRP action has no 'type'")?;
770        if action_type != "send_vrrp_advertisement" {
771            return Err(anyhow!("unknown VRRP action '{action_type}'"));
772        }
773
774        let mut data = data.clone();
775        config.apply_defaults(&mut data);
776        let destination = VrrpProtocol::destination_from_action(&data)?;
777        let advertisement = VrrpProtocol::advertisement_from_action(&data, config)?;
778        // VRRPv3 folds the source and destination into its checksum (RFC 5798 §5.2.8), so the
779        // same body addressed elsewhere is different octets. v2 and CARP ignore this.
780        let pseudo = PseudoHeader::new(source, destination);
781        let packet = advertisement.encode(Some(&pseudo))?;
782        Ok((packet, destination))
783    }
784}
785
786/// Copy every key of `extra` into `target`.
787fn merge(target: &mut serde_json::Value, extra: serde_json::Value) {
788    if let (Some(target), Some(extra)) = (target.as_object_mut(), extra.as_object()) {
789        for (key, value) in extra {
790            target.insert(key.clone(), value.clone());
791        }
792    }
793}
794
795/// True when the operator opted into dynamic (handler- or LLM-driven) responses for this
796/// server: a non-empty instruction, or an event handler matching `event_id`.
797///
798/// When false the server observes and never consults the model. Advertising is a policy
799/// decision with real consequences for somebody's network, and there is no policy to apply.
800async fn operator_wants_dynamic(state: &AppState, server_id: ServerId, event_id: &str) -> bool {
801    state
802        .with_server_mut(server_id, |server| {
803            let has_instruction = !server.instruction.trim().is_empty();
804            let has_handler = server
805                .event_handler_config
806                .as_ref()
807                .map(|c| c.find_handler(event_id).is_some())
808                .unwrap_or(false);
809            has_instruction || has_handler
810        })
811        .await
812        .unwrap_or(false)
813}
814
815// ---------------------------------------------------------------------------
816// Raw socket
817// ---------------------------------------------------------------------------
818
819/// Create the raw IP-protocol-112 socket, joined to `224.0.0.18` on `interface_addr`.
820///
821/// NEVER EXECUTED in this tree: `SOCK_RAW` needs `CAP_NET_RAW` on Linux and root elsewhere,
822/// and nothing here runs privileged. Shaped after `create_ospf_raw_socket`.
823///
824/// The multicast TTL is **255, not 1** — RFC 5798 §5.1.1.3 requires a VRRP sender to use 255
825/// and a receiver to discard anything else, which is what keeps an advertisement from being
826/// accepted off-link: a router more than one hop away could not have sent it with 255 intact.
827/// A TTL of 1 would be the obvious way to say "link-local" and is precisely wrong here; every
828/// conformant peer would drop the packet. See the call to `set_multicast_ttl_v4` below.
829fn create_vrrp_raw_socket(interface_addr: Ipv4Addr) -> Result<socket2::Socket> {
830    use std::os::unix::io::FromRawFd;
831
832    // SAFETY: the fd is checked for validity before being adopted, and `Socket` takes
833    // ownership of it exactly once.
834    let socket = unsafe {
835        let fd = libc::socket(
836            libc::AF_INET,
837            libc::SOCK_RAW,
838            codec::IP_PROTOCOL_VRRP as libc::c_int,
839        );
840        if fd < 0 {
841            return Err(std::io::Error::last_os_error().into());
842        }
843        socket2::Socket::from_raw_fd(fd)
844    };
845
846    socket.set_nonblocking(true)?;
847    socket.set_reuse_address(true)?;
848    socket.join_multicast_v4(&VRRP_MULTICAST_IPV4, &interface_addr)?;
849    // RFC 5798 §5.1.1.3: VRRP packets are sent with TTL 255 and a receiver discards anything
850    // else, but the *multicast* TTL below is the hop limit for our own transmissions; VRRP
851    // never leaves the link.
852    socket.set_multicast_ttl_v4(255)?;
853    socket.set_multicast_if_v4(&interface_addr)?;
854    Ok(socket)
855}
856
857/// `sendto` on the raw socket. Raw IP has no port, so `sin_port` is 0.
858fn send_raw(fd: i32, destination: Ipv4Addr, packet: &[u8]) -> Result<()> {
859    // SAFETY: `fd` is owned by the receive loop's `AsyncFd` for as long as any task holding a
860    // `Responder::Raw` can run, and `packet` is a live slice of the length passed.
861    unsafe {
862        let mut addr = std::mem::zeroed::<libc::sockaddr_in>();
863        #[cfg(target_os = "macos")]
864        {
865            addr.sin_family = libc::AF_INET as libc::sa_family_t;
866            addr.sin_len = std::mem::size_of::<libc::sockaddr_in>() as u8;
867        }
868        #[cfg(not(target_os = "macos"))]
869        {
870            addr.sin_family = libc::AF_INET as u16;
871        }
872        addr.sin_port = 0;
873        addr.sin_addr.s_addr = u32::from(destination).to_be();
874
875        let sent = libc::sendto(
876            fd,
877            packet.as_ptr() as *const libc::c_void,
878            packet.len(),
879            0,
880            &addr as *const _ as *const libc::sockaddr,
881            std::mem::size_of::<libc::sockaddr_in>() as u32,
882        );
883        if sent < 0 {
884            return Err(std::io::Error::last_os_error().into());
885        }
886    }
887    Ok(())
888}