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}