Skip to main content

netget/server/hsrp/
mod.rs

1//! HSRP (Hot Standby Router Protocol) speaker — RFC 2281 for v1, Cisco's TLV format for v2.
2//!
3//! HSRP is a first-hop redundancy protocol: a group of routers share one virtual IP, elect one
4//! of themselves Active, and that one answers for the address every host on the segment has
5//! configured as its default gateway. Every message the protocol defines — Hello, Coup,
6//! Resign — is a **positive claim about who owns that address**.
7//!
8//! That single fact decides the whole design of this file:
9//!
10//! * **An LLM failure produces SILENCE.** HSRP is in the deliberately-silent class the root
11//!   `CLAUDE.md` catalogues, and its case is one of the strongest in it. There is no error
12//!   frame to send — the protocol has no negative message at all — and the only thing this
13//!   server *could* emit is a Hello, which asserts gateway ownership. A fabricated Hello during
14//!   a backend outage can win an election NetGet cannot serve, and every host on the link then
15//!   sends its off-subnet traffic into a black hole. So on any failure path, zero bytes go out,
16//!   and the reason goes in the log instead, exactly as `src/server/radius/` does.
17//! * **There is no election state machine here, deliberately.** A real HSRP router runs timers
18//!   and keeps advertising on its own. This one advertises only in reply to something that
19//!   actually arrived, because an autonomous Active router that keeps announcing after the
20//!   model stops answering is precisely the fail-open shape this repo forbids.
21//!
22//! **The model chooses the priority and may send a Coup.** Winning makes NetGet the active
23//! gateway for the segment. See `src/server/hsrp/CLAUDE.md`.
24
25pub mod actions;
26pub mod codec;
27
28use crate::llm::action_helper::call_llm;
29use crate::llm::ollama_client::OllamaClient;
30use crate::logging::emit::Log;
31use crate::protocol::{Event, SpawnContext};
32use crate::server::connection::ConnectionId;
33use crate::state::app_state::AppState;
34use crate::state::ServerId;
35use actions::{event_for_opcode, HsrpProtocol, HSRP_V1_GROUP, HSRP_V2_GROUP, HSRP_V6_GROUP};
36use anyhow::{Context, Result};
37use codec::{HsrpMessage, HsrpVersion};
38use std::net::{IpAddr, Ipv4Addr, SocketAddr};
39use std::sync::Arc;
40use tokio::net::UdpSocket;
41use tokio::sync::mpsc;
42use tracing::{debug, error, info, trace, warn};
43
44/// Largest datagram this speaker will read.
45///
46/// An HSRPv1 packet is 20 bytes and a v2 one with both authentication TLVs is under 100, so
47/// this is pure headroom for unknown TLVs while bounding a hostile datagram.
48pub const MAX_DATAGRAM_LEN: usize = 4096;
49
50/// How this speaker arrived at what it did (or did not) put on the wire.
51///
52/// **The point of this enum is that "the model declined to join the election" and "the model
53/// could not be reached" produce identical bytes — none — and must never be identical in the
54/// log.** That collapse is the OAuth2 failure the root `CLAUDE.md` records; here it would hide
55/// a total backend outage as protocol-correct quiet, indefinitely, because a silent HSRP
56/// speaker is completely normal.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Decision {
59    /// The model advertised. Bytes went out.
60    ModelResponse,
61    /// The model chose `no_advertisement`: it looked at the election and stayed out of it.
62    /// A deliberate answer, and the safe one.
63    ModelSilent,
64    /// The model returned no usable action at all.
65    FailClosedNoAction,
66    /// The model produced actions but none of them encoded — a bad state name, a priority that
67    /// does not fit v1's single byte, an MD5 TLV that cannot be generated.
68    FailClosedActionError,
69    /// The LLM call itself failed — outage, overload, unusable output.
70    FailClosedLlmError,
71}
72
73impl Decision {
74    /// Stable, grep-able token. An operator looking for advertisements the model did not
75    /// actually authorise greps `decision=fail_closed_`.
76    ///
77    /// **There is deliberately no `model_reject` token here, unlike `radius`.** RADIUS can
78    /// distinguish a refusal from silence because it has an Access-Reject packet to send. HSRP
79    /// has no negative message of any kind: the only way a speaker declines to participate is
80    /// by not advertising. So "the model refused" and "the model chose to stay out" are the
81    /// same act, and `model_silent` is it. What must stay distinguishable — and does — is that
82    /// act versus every `fail_closed_*`.
83    pub fn as_str(&self) -> &'static str {
84        match self {
85            Decision::ModelResponse => "model_response",
86            Decision::ModelSilent => "model_silent",
87            Decision::FailClosedNoAction => "fail_closed_no_action",
88            Decision::FailClosedActionError => "fail_closed_action_error",
89            Decision::FailClosedLlmError => "fail_closed_llm_error",
90        }
91    }
92
93    /// True when the *server*, not the model, chose to say nothing.
94    pub fn is_fail_closed(&self) -> bool {
95        matches!(
96            self,
97            Decision::FailClosedNoAction
98                | Decision::FailClosedActionError
99                | Decision::FailClosedLlmError
100        )
101    }
102}
103
104/// Everything handling one datagram needs, gathered so the handler is not a seven-argument
105/// function.
106#[derive(Clone)]
107struct Speaker {
108    llm_client: OllamaClient,
109    state: Arc<AppState>,
110    status_tx: mpsc::UnboundedSender<String>,
111    server_id: ServerId,
112    local_addr: SocketAddr,
113    socket: Arc<UdpSocket>,
114    /// The version this instance was started for. Reported to the model on every event; it
115    /// also chose which multicast group was joined.
116    configured_version: HsrpVersion,
117}
118
119pub struct HsrpServer;
120
121impl HsrpServer {
122    /// Bind the speaker and start listening.
123    ///
124    /// Returns `Err` if the UDP socket cannot be bound, so `server_startup` marks the instance
125    /// `ServerStatus::Error` rather than showing a speaker that is not listening. The multicast
126    /// join is best-effort by design — see `join_hsrp_group`.
127    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
128        let listen_addr = ctx.legacy_listen_addr();
129        let SpawnContext {
130            llm_client,
131            state,
132            status_tx,
133            server_id,
134            startup_params,
135            ..
136        } = ctx;
137
138        // Every accessor is fallible because the values come from the model or an MCP client.
139        // Propagate with `?` so an undeclared key or a wrong type produces a clean startup
140        // error naming the key, rather than a panic in the spawning task.
141        let (version_param, join_multicast, multicast_interface) = match startup_params {
142            Some(params) => (
143                params.get_optional_u64("version")?,
144                params.get_optional_bool("join_multicast")?.unwrap_or(true),
145                params.get_optional_string("multicast_interface")?,
146            ),
147            None => (None, true, None),
148        };
149        let configured_version = match version_param {
150            Some(value) => HsrpVersion::from_number(value)?,
151            None => HsrpVersion::V1,
152        };
153
154        let socket = Arc::new(
155            UdpSocket::bind(listen_addr)
156                .await
157                .with_context(|| format!("HSRP failed to bind UDP {listen_addr}"))?,
158        );
159        let local_addr = socket
160            .local_addr()
161            .context("HSRP could not read its own UDP address")?;
162
163        let log = Log::new(Some(&status_tx));
164        info!(
165            "HSRP speaker listening on UDP {local_addr}, configured for HSRPv{}",
166            configured_version.as_number()
167        );
168        log.info(format!(
169            "HSRP speaker listening on {local_addr} (configured for HSRPv{})",
170            configured_version.as_number()
171        ));
172
173        if join_multicast {
174            join_hsrp_group(
175                &socket,
176                local_addr,
177                configured_version,
178                multicast_interface.as_deref(),
179                &status_tx,
180            );
181        } else {
182            debug!("HSRP multicast join skipped (join_multicast=false)");
183        }
184
185        let speaker = Speaker {
186            llm_client,
187            state: state.clone(),
188            status_tx: status_tx.clone(),
189            server_id,
190            local_addr,
191            socket: socket.clone(),
192            configured_version,
193        };
194
195        let handle = tokio::spawn(async move {
196            let mut buffer = vec![0u8; MAX_DATAGRAM_LEN];
197            loop {
198                let (n, peer_addr) = match speaker.socket.recv_from(&mut buffer).await {
199                    Ok(pair) => pair,
200                    Err(e) => {
201                        Log::new(Some(&speaker.status_tx))
202                            .error(format!("HSRP UDP receive error: {e}"));
203                        break;
204                    }
205                };
206
207                let data = buffer[..n].to_vec();
208                let s = speaker.clone();
209                let task = tokio::spawn(async move {
210                    s.handle_datagram(data, peer_addr).await;
211                });
212                // Register even these short-lived tasks: `register_server_task` prunes finished
213                // ones on every call so the list stays bounded, and `stop_server` then cancels
214                // an in-flight LLM call instead of letting it advertise after the socket is
215                // gone. Aborting the receive loop alone does not abort what it spawned.
216                speaker.state.register_server_task(server_id, task).await;
217            }
218        });
219        state.register_server_task(server_id, handle).await;
220
221        Ok(local_addr)
222    }
223}
224
225impl Speaker {
226    /// Parse one datagram, raise its event, ask the model, decide, and — only on
227    /// `ModelResponse` — send.
228    async fn handle_datagram(&self, data: Vec<u8>, peer_addr: SocketAddr) {
229        let log = Log::new(Some(&self.status_tx));
230        trace!(
231            "HSRP {} bytes from {peer_addr}: {}",
232            data.len(),
233            hex::encode(&data)
234        );
235
236        let message = match codec::decode(&data) {
237            Ok(m) => m,
238            Err(e) => {
239                // A multicast group carries other protocols' traffic and plain noise. An
240                // unparseable datagram is normal background, not an error worth the status
241                // stream, and it certainly is not a reason to advertise anything.
242                debug!("HSRP dropped unparseable datagram from {peer_addr}: {e}");
243                return;
244            }
245        };
246
247        let connection_id = self.record_connection(peer_addr, data.len()).await;
248        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
249
250        let event_type = event_for_opcode(message.opcode);
251        let event = Event::new(event_type, self.event_data(&message, peer_addr));
252
253        debug!(
254            "HSRPv{} {} group={} state={} priority={} vip={} from {peer_addr}",
255            message.version.as_number(),
256            message.opcode.as_str(),
257            message.group,
258            message.state.as_str(),
259            message.priority,
260            message.virtual_ip
261        );
262
263        let protocol = HsrpProtocol::new();
264        let llm_outcome = call_llm(
265            &self.llm_client,
266            &self.state,
267            self.server_id,
268            None,
269            &event,
270            &protocol,
271        )
272        .await;
273
274        let (decision, reply) = self.decide(llm_outcome, peer_addr);
275
276        // Log the decision before acting on it. `model_silent` and every `fail_closed_*`
277        // produce the same zero bytes, so this line is the only place the difference survives.
278        // `opcode=` is included so an operator can grep the inbound side too.
279        let summary = format!(
280            "HSRPv{} {} group={} from {peer_addr} decision={}",
281            message.version.as_number(),
282            message.opcode.as_str(),
283            message.group,
284            decision.as_str()
285        );
286        if decision.is_fail_closed() {
287            error!(
288                "{summary} (nothing sent: no usable answer was produced. An HSRP Hello asserts \
289                 ownership of the segment's gateway address, so a guessed one can win an \
290                 election NetGet cannot serve and black-hole the link)"
291            );
292            log.error(format!(
293                "{summary} - staying silent rather than guessing a gateway claim"
294            ));
295        } else {
296            log.info(&summary);
297        }
298
299        let Some(reply) = reply else {
300            return;
301        };
302
303        // Be loud when the model has just claimed the gateway. This is the one thing in the
304        // protocol that changes where a whole segment's traffic goes, and it must never be
305        // something an operator has to reconstruct from a packet capture afterwards.
306        if let Ok(sent_message) = codec::decode(&reply) {
307            if sent_message.opcode == codec::Opcode::Coup || sent_message.state.claims_gateway() {
308                warn!(
309                    "HSRP is claiming the gateway role: opcode={} state={} priority={} group={} \
310                     vip={} to {peer_addr}. If this election is won, hosts on the segment will \
311                     send their off-subnet traffic to NetGet, which does not forward it.",
312                    sent_message.opcode.as_str(),
313                    sent_message.state.as_str(),
314                    sent_message.priority,
315                    sent_message.group,
316                    sent_message.virtual_ip
317                );
318                log.warn(format!(
319                    "HSRP {} claiming the gateway role for {} (priority {}) - NetGet does not \
320                     forward traffic",
321                    sent_message.opcode.as_str(),
322                    sent_message.virtual_ip,
323                    sent_message.priority
324                ));
325            }
326        }
327
328        // Unicast back to whoever spoke to us, rather than to the multicast group.
329        //
330        // Two reasons, and the second is the load-bearing one. Loopback carries no multicast
331        // route, so `send_to(224.0.0.2:1985)` from a socket bound to 127.0.0.1 fails with
332        // EADDRNOTAVAIL and this speaker would be unobservable in any local test. And a reply
333        // aimed at the peer that prompted it is the only advertisement this server has any
334        // business sending: it never speaks unprompted, so it never has a reason to address
335        // the whole group. `CLAUDE.md` records this as a deviation from a real HSRP router.
336        match self.socket.send_to(&reply, peer_addr).await {
337            Ok(sent) => {
338                trace!(
339                    "HSRP sent {sent} bytes to {peer_addr}: {}",
340                    hex::encode(&reply)
341                );
342                self.count_sent_for(connection_id, sent).await;
343                log.debug(format!("HSRP sent {sent} bytes to {peer_addr}"));
344            }
345            Err(e) => {
346                Log::new(Some(&self.status_tx))
347                    .error(format!("HSRP failed to reply to {peer_addr}: {e}"));
348            }
349        }
350    }
351
352    /// Everything the model is told about an advertisement. Structured fields only — no raw
353    /// bytes, no base64, no version-dependent state numbers.
354    fn event_data(&self, message: &HsrpMessage, peer_addr: SocketAddr) -> serde_json::Value {
355        serde_json::json!({
356            "version": message.version.as_number(),
357            "opcode": message.opcode.as_str(),
358            "state": message.state.as_str(),
359            "priority": message.priority,
360            "group": message.group,
361            "hellotime": message.hellotime_secs,
362            "holdtime": message.holdtime_secs,
363            "virtual_ip": message.virtual_ip.to_string(),
364            "auth_data": message.auth_data,
365            "identifier": message.identifier_str(),
366            "md5_auth": message.md5_auth.map(|md5| serde_json::json!({
367                "algorithm": md5.algorithm,
368                "flags": md5.flags,
369                "sender_address": md5.sender_address.to_string(),
370                "key_id": md5.key_id,
371            })),
372            "source_address": peer_addr.to_string(),
373            "configured_version": self.configured_version.as_number(),
374        })
375    }
376
377    /// **The fail-closed rule: nothing usable means nothing on the wire.**
378    ///
379    /// Most UDP protocols in this repo answer a backend failure with an error frame, because
380    /// silence costs the peer its own timeout. HSRP cannot: it has no error frame, and its
381    /// success frame is a claim of gateway ownership. A peer that hears nothing simply keeps
382    /// its own view of the election, which is the correct outcome when NetGet has nothing
383    /// authorised to say.
384    fn decide(
385        &self,
386        llm_outcome: Result<crate::llm::ExecutionResult>,
387        peer_addr: SocketAddr,
388    ) -> (Decision, Option<Vec<u8>>) {
389        let log = Log::new(Some(&self.status_tx));
390
391        let execution = match llm_outcome {
392            Ok(result) => {
393                for message in &result.messages {
394                    log.info(message);
395                }
396                result
397            }
398            Err(e) => {
399                // Classified for the log and never rendered onto the wire. There is no wire
400                // text here at all — HSRP is fixed binary — but the category is still what
401                // tells an operator whether to wait or to look.
402                let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
403                    crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
404                    crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
405                };
406                error!("HSRP LLM call failed for {peer_addr} (category={category}): {e}");
407                return (Decision::FailClosedLlmError, None);
408            }
409        };
410
411        // Take the first advertisement that encoded. Extra ones are a model error: two
412        // advertisements for one event look to the segment like two speakers, and if they
413        // disagree about priority they fight each other's election.
414        let mut chosen: Option<Vec<u8>> = None;
415        let mut extra = 0usize;
416        for protocol_result in &execution.protocol_results {
417            for output in protocol_result.get_all_output() {
418                if chosen.is_none() {
419                    chosen = Some(output);
420                } else {
421                    extra += 1;
422                }
423            }
424        }
425        if extra > 0 {
426            warn!(
427                "HSRP ignored {extra} extra advertisement(s) for the datagram from {peer_addr}; \
428                 one event gets one advertisement"
429            );
430        }
431
432        if let Some(bytes) = chosen {
433            return (Decision::ModelResponse, Some(bytes));
434        }
435
436        // No bytes. Three different reasons, and they must stay apart.
437        if execution.raw_actions.is_empty() {
438            return (Decision::FailClosedNoAction, None);
439        }
440        if execution
441            .raw_actions
442            .iter()
443            .any(|a| a.get("type").and_then(|t| t.as_str()) == Some("no_advertisement"))
444        {
445            // The model looked at the election and stayed out of it. This is the protocol
446            // working, and it is the safe answer.
447            return (Decision::ModelSilent, None);
448        }
449        (Decision::FailClosedActionError, None)
450    }
451
452    /// Record the datagram as a connection entry so the dashboard shows the neighbour.
453    ///
454    /// Nothing ever closes these — HSRP has no connection — which is why `metadata()` declares
455    /// `.connectionless()`: the 10-second idle sweep is what reaps them.
456    async fn record_connection(&self, peer_addr: SocketAddr, bytes: usize) -> ConnectionId {
457        use crate::state::server::{
458            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
459        };
460        let connection_id = ConnectionId::new(self.state.get_next_unified_id().await);
461        let now = crate::utils::clock::Instant::now();
462        let conn_state = ServerConnectionState {
463            id: connection_id,
464            remote_addr: peer_addr,
465            local_addr: self.local_addr,
466            bytes_sent: 0,
467            bytes_received: bytes as u64,
468            packets_sent: 0,
469            packets_received: 1,
470            last_activity: now,
471            status: ConnectionStatus::Active,
472            status_changed_at: now,
473            protocol_info: ProtocolConnectionInfo::empty(),
474        };
475        self.state
476            .add_connection_to_server(self.server_id, conn_state)
477            .await;
478        connection_id
479    }
480
481    /// Keep the rail's `↑` counter in step with what actually went out.
482    async fn count_sent_for(&self, connection_id: ConnectionId, bytes: usize) {
483        self.state
484            .update_connection_stats(
485                self.server_id,
486                connection_id,
487                None,
488                Some(bytes as u64),
489                None,
490                Some(1),
491            )
492            .await;
493    }
494}
495
496/// Join the HSRP multicast group, non-fatally.
497///
498/// **Deliberately best-effort, and this is measured rather than assumed.** On macOS, a socket
499/// bound to `127.0.0.1` can *join* a group successfully and still fail to *send* to one with
500/// `EADDRNOTAVAIL`, because loopback carries no multicast route. A speaker that refused to
501/// start over a join would be unusable for exactly the local testing this repo does — while
502/// still being perfectly able to handle a datagram sent straight to its port, which is how the
503/// e2e suite drives it. The failure is logged on both channels so it is never *silently*
504/// absent.
505///
506/// The group depends on the configured version, not just the address family: HSRPv2 moved off
507/// the all-routers group (`224.0.0.2`) onto `224.0.0.102` precisely so v1 speakers do not see
508/// its TLVs.
509fn join_hsrp_group(
510    socket: &UdpSocket,
511    local_addr: SocketAddr,
512    configured_version: HsrpVersion,
513    multicast_interface: Option<&str>,
514    status_tx: &mpsc::UnboundedSender<String>,
515) {
516    let log = Log::new(Some(status_tx));
517
518    match local_addr.ip() {
519        IpAddr::V4(_) => {
520            let group = match configured_version {
521                HsrpVersion::V1 => HSRP_V1_GROUP,
522                HsrpVersion::V2 => HSRP_V2_GROUP,
523            };
524            let interface = match multicast_interface {
525                Some(value) => match value.parse::<Ipv4Addr>() {
526                    Ok(ip) => ip,
527                    Err(e) => {
528                        warn!(
529                            "HSRP multicast_interface '{value}' is not an IPv4 address ({e}); \
530                             letting the host choose"
531                        );
532                        Ipv4Addr::UNSPECIFIED
533                    }
534                },
535                None => Ipv4Addr::UNSPECIFIED,
536            };
537            match socket.join_multicast_v4(group, interface) {
538                Ok(()) => {
539                    info!("HSRP joined {group} on interface {interface}");
540                    log.info(format!("HSRP joined multicast group {group}"));
541                }
542                Err(e) => {
543                    warn!(
544                        "HSRP could not join {group} on interface {interface}: {e}; datagrams \
545                         sent directly to this port are still handled"
546                    );
547                    log.warn(format!(
548                        "HSRP multicast join failed ({e}); handling direct datagrams only"
549                    ));
550                }
551            }
552        }
553        IpAddr::V6(_) => {
554            // HSRPv2 over IPv6 uses FF02::66 on port 2029. Interface index 0 lets the kernel
555            // pick; there is no address-based selector for IPv6 joins, so `multicast_interface`
556            // (an IPv4 address) has no meaning here.
557            match socket.join_multicast_v6(&HSRP_V6_GROUP, 0) {
558                Ok(()) => {
559                    info!("HSRP joined {HSRP_V6_GROUP}");
560                    log.info(format!("HSRP joined multicast group {HSRP_V6_GROUP}"));
561                }
562                Err(e) => {
563                    warn!(
564                        "HSRP could not join {HSRP_V6_GROUP}: {e}; datagrams sent directly to \
565                         this port are still handled"
566                    );
567                    log.warn(format!(
568                        "HSRP multicast join failed ({e}); handling direct datagrams only"
569                    ));
570                }
571            }
572        }
573    }
574}