pub mod actions;
pub mod codec;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::{Event, SpawnContext};
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use crate::state::ServerId;
use actions::{event_for_opcode, HsrpProtocol, HSRP_V1_GROUP, HSRP_V2_GROUP, HSRP_V6_GROUP};
use anyhow::{Context, Result};
use codec::{HsrpMessage, HsrpVersion};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
pub const MAX_DATAGRAM_LEN: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
ModelResponse,
ModelSilent,
FailClosedNoAction,
FailClosedActionError,
FailClosedLlmError,
}
impl Decision {
pub fn as_str(&self) -> &'static str {
match self {
Decision::ModelResponse => "model_response",
Decision::ModelSilent => "model_silent",
Decision::FailClosedNoAction => "fail_closed_no_action",
Decision::FailClosedActionError => "fail_closed_action_error",
Decision::FailClosedLlmError => "fail_closed_llm_error",
}
}
pub fn is_fail_closed(&self) -> bool {
matches!(
self,
Decision::FailClosedNoAction
| Decision::FailClosedActionError
| Decision::FailClosedLlmError
)
}
}
#[derive(Clone)]
struct Speaker {
llm_client: OllamaClient,
state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: ServerId,
local_addr: SocketAddr,
socket: Arc<UdpSocket>,
configured_version: HsrpVersion,
}
pub struct HsrpServer;
impl HsrpServer {
pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
let listen_addr = ctx.legacy_listen_addr();
let SpawnContext {
llm_client,
state,
status_tx,
server_id,
startup_params,
..
} = ctx;
let (version_param, join_multicast, multicast_interface) = match startup_params {
Some(params) => (
params.get_optional_u64("version")?,
params.get_optional_bool("join_multicast")?.unwrap_or(true),
params.get_optional_string("multicast_interface")?,
),
None => (None, true, None),
};
let configured_version = match version_param {
Some(value) => HsrpVersion::from_number(value)?,
None => HsrpVersion::V1,
};
let socket = Arc::new(
UdpSocket::bind(listen_addr)
.await
.with_context(|| format!("HSRP failed to bind UDP {listen_addr}"))?,
);
let local_addr = socket
.local_addr()
.context("HSRP could not read its own UDP address")?;
let log = Log::new(Some(&status_tx));
info!(
"HSRP speaker listening on UDP {local_addr}, configured for HSRPv{}",
configured_version.as_number()
);
log.info(format!(
"HSRP speaker listening on {local_addr} (configured for HSRPv{})",
configured_version.as_number()
));
if join_multicast {
join_hsrp_group(
&socket,
local_addr,
configured_version,
multicast_interface.as_deref(),
&status_tx,
);
} else {
debug!("HSRP multicast join skipped (join_multicast=false)");
}
let speaker = Speaker {
llm_client,
state: state.clone(),
status_tx: status_tx.clone(),
server_id,
local_addr,
socket: socket.clone(),
configured_version,
};
let handle = tokio::spawn(async move {
let mut buffer = vec![0u8; MAX_DATAGRAM_LEN];
loop {
let (n, peer_addr) = match speaker.socket.recv_from(&mut buffer).await {
Ok(pair) => pair,
Err(e) => {
Log::new(Some(&speaker.status_tx))
.error(format!("HSRP UDP receive error: {e}"));
break;
}
};
let data = buffer[..n].to_vec();
let s = speaker.clone();
let task = tokio::spawn(async move {
s.handle_datagram(data, peer_addr).await;
});
speaker.state.register_server_task(server_id, task).await;
}
});
state.register_server_task(server_id, handle).await;
Ok(local_addr)
}
}
impl Speaker {
async fn handle_datagram(&self, data: Vec<u8>, peer_addr: SocketAddr) {
let log = Log::new(Some(&self.status_tx));
trace!(
"HSRP {} bytes from {peer_addr}: {}",
data.len(),
hex::encode(&data)
);
let message = match codec::decode(&data) {
Ok(m) => m,
Err(e) => {
debug!("HSRP dropped unparseable datagram from {peer_addr}: {e}");
return;
}
};
let connection_id = self.record_connection(peer_addr, data.len()).await;
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
let event_type = event_for_opcode(message.opcode);
let event = Event::new(event_type, self.event_data(&message, peer_addr));
debug!(
"HSRPv{} {} group={} state={} priority={} vip={} from {peer_addr}",
message.version.as_number(),
message.opcode.as_str(),
message.group,
message.state.as_str(),
message.priority,
message.virtual_ip
);
let protocol = HsrpProtocol::new();
let llm_outcome = call_llm(
&self.llm_client,
&self.state,
self.server_id,
None,
&event,
&protocol,
)
.await;
let (decision, reply) = self.decide(llm_outcome, peer_addr);
let summary = format!(
"HSRPv{} {} group={} from {peer_addr} decision={}",
message.version.as_number(),
message.opcode.as_str(),
message.group,
decision.as_str()
);
if decision.is_fail_closed() {
error!(
"{summary} (nothing sent: no usable answer was produced. An HSRP Hello asserts \
ownership of the segment's gateway address, so a guessed one can win an \
election NetGet cannot serve and black-hole the link)"
);
log.error(format!(
"{summary} - staying silent rather than guessing a gateway claim"
));
} else {
log.info(&summary);
}
let Some(reply) = reply else {
return;
};
if let Ok(sent_message) = codec::decode(&reply) {
if sent_message.opcode == codec::Opcode::Coup || sent_message.state.claims_gateway() {
warn!(
"HSRP is claiming the gateway role: opcode={} state={} priority={} group={} \
vip={} to {peer_addr}. If this election is won, hosts on the segment will \
send their off-subnet traffic to NetGet, which does not forward it.",
sent_message.opcode.as_str(),
sent_message.state.as_str(),
sent_message.priority,
sent_message.group,
sent_message.virtual_ip
);
log.warn(format!(
"HSRP {} claiming the gateway role for {} (priority {}) - NetGet does not \
forward traffic",
sent_message.opcode.as_str(),
sent_message.virtual_ip,
sent_message.priority
));
}
}
match self.socket.send_to(&reply, peer_addr).await {
Ok(sent) => {
trace!(
"HSRP sent {sent} bytes to {peer_addr}: {}",
hex::encode(&reply)
);
self.count_sent_for(connection_id, sent).await;
log.debug(format!("HSRP sent {sent} bytes to {peer_addr}"));
}
Err(e) => {
Log::new(Some(&self.status_tx))
.error(format!("HSRP failed to reply to {peer_addr}: {e}"));
}
}
}
fn event_data(&self, message: &HsrpMessage, peer_addr: SocketAddr) -> serde_json::Value {
serde_json::json!({
"version": message.version.as_number(),
"opcode": message.opcode.as_str(),
"state": message.state.as_str(),
"priority": message.priority,
"group": message.group,
"hellotime": message.hellotime_secs,
"holdtime": message.holdtime_secs,
"virtual_ip": message.virtual_ip.to_string(),
"auth_data": message.auth_data,
"identifier": message.identifier_str(),
"md5_auth": message.md5_auth.map(|md5| serde_json::json!({
"algorithm": md5.algorithm,
"flags": md5.flags,
"sender_address": md5.sender_address.to_string(),
"key_id": md5.key_id,
})),
"source_address": peer_addr.to_string(),
"configured_version": self.configured_version.as_number(),
})
}
fn decide(
&self,
llm_outcome: Result<crate::llm::ExecutionResult>,
peer_addr: SocketAddr,
) -> (Decision, Option<Vec<u8>>) {
let log = Log::new(Some(&self.status_tx));
let execution = match llm_outcome {
Ok(result) => {
for message in &result.messages {
log.info(message);
}
result
}
Err(e) => {
let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
};
error!("HSRP LLM call failed for {peer_addr} (category={category}): {e}");
return (Decision::FailClosedLlmError, None);
}
};
let mut chosen: Option<Vec<u8>> = None;
let mut extra = 0usize;
for protocol_result in &execution.protocol_results {
for output in protocol_result.get_all_output() {
if chosen.is_none() {
chosen = Some(output);
} else {
extra += 1;
}
}
}
if extra > 0 {
warn!(
"HSRP ignored {extra} extra advertisement(s) for the datagram from {peer_addr}; \
one event gets one advertisement"
);
}
if let Some(bytes) = chosen {
return (Decision::ModelResponse, Some(bytes));
}
if execution.raw_actions.is_empty() {
return (Decision::FailClosedNoAction, None);
}
if execution
.raw_actions
.iter()
.any(|a| a.get("type").and_then(|t| t.as_str()) == Some("no_advertisement"))
{
return (Decision::ModelSilent, None);
}
(Decision::FailClosedActionError, None)
}
async fn record_connection(&self, peer_addr: SocketAddr, bytes: usize) -> ConnectionId {
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
};
let connection_id = ConnectionId::new(self.state.get_next_unified_id().await);
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr: self.local_addr,
bytes_sent: 0,
bytes_received: bytes as u64,
packets_sent: 0,
packets_received: 1,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
self.state
.add_connection_to_server(self.server_id, conn_state)
.await;
connection_id
}
async fn count_sent_for(&self, connection_id: ConnectionId, bytes: usize) {
self.state
.update_connection_stats(
self.server_id,
connection_id,
None,
Some(bytes as u64),
None,
Some(1),
)
.await;
}
}
fn join_hsrp_group(
socket: &UdpSocket,
local_addr: SocketAddr,
configured_version: HsrpVersion,
multicast_interface: Option<&str>,
status_tx: &mpsc::UnboundedSender<String>,
) {
let log = Log::new(Some(status_tx));
match local_addr.ip() {
IpAddr::V4(_) => {
let group = match configured_version {
HsrpVersion::V1 => HSRP_V1_GROUP,
HsrpVersion::V2 => HSRP_V2_GROUP,
};
let interface = match multicast_interface {
Some(value) => match value.parse::<Ipv4Addr>() {
Ok(ip) => ip,
Err(e) => {
warn!(
"HSRP multicast_interface '{value}' is not an IPv4 address ({e}); \
letting the host choose"
);
Ipv4Addr::UNSPECIFIED
}
},
None => Ipv4Addr::UNSPECIFIED,
};
match socket.join_multicast_v4(group, interface) {
Ok(()) => {
info!("HSRP joined {group} on interface {interface}");
log.info(format!("HSRP joined multicast group {group}"));
}
Err(e) => {
warn!(
"HSRP could not join {group} on interface {interface}: {e}; datagrams \
sent directly to this port are still handled"
);
log.warn(format!(
"HSRP multicast join failed ({e}); handling direct datagrams only"
));
}
}
}
IpAddr::V6(_) => {
match socket.join_multicast_v6(&HSRP_V6_GROUP, 0) {
Ok(()) => {
info!("HSRP joined {HSRP_V6_GROUP}");
log.info(format!("HSRP joined multicast group {HSRP_V6_GROUP}"));
}
Err(e) => {
warn!(
"HSRP could not join {HSRP_V6_GROUP}: {e}; datagrams sent directly to \
this port are still handled"
);
log.warn(format!(
"HSRP multicast join failed ({e}); handling direct datagrams only"
));
}
}
}
}
}