netget/server/rip/mod.rs
1//! RIP (Routing Information Protocol) server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10
11use crate::llm::action_helper::call_llm;
12use crate::llm::ollama_client::OllamaClient;
13use crate::logging::emit::Log;
14use crate::protocol::Event;
15use crate::server::RipProtocol;
16use crate::state::app_state::AppState;
17use actions::RIP_REQUEST_EVENT;
18
19/// RIP server that forwards routing requests to LLM
20pub struct RipServer;
21
22impl RipServer {
23 /// Spawn RIP server with integrated LLM actions
24 pub async fn spawn_with_llm_actions(
25 listen_addr: SocketAddr,
26 llm_client: OllamaClient,
27 app_state: Arc<AppState>,
28 status_tx: mpsc::UnboundedSender<String>,
29 server_id: crate::state::ServerId,
30 ) -> Result<SocketAddr> {
31 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
32 let local_addr = socket.local_addr()?;
33 Log::new(Some(&status_tx)).info(format!("RIP server listening on {}", local_addr));
34
35 let protocol = Arc::new(RipProtocol::new());
36
37 let task_registrar = app_state.clone();
38 let accept_handle = tokio::spawn(async move {
39 // Maximum RIP packet size: 4-byte header + up to 25 route entries (20 bytes each) = 504 bytes
40 let mut buffer = vec![0u8; 512];
41 let log = Log::new(Some(&status_tx));
42
43 loop {
44 match socket.recv_from(&mut buffer).await {
45 Ok((n, peer_addr)) => {
46 let data = buffer[..n].to_vec();
47 let connection_id =
48 ConnectionId::new(app_state.get_next_unified_id().await);
49
50 // Add connection to ServerInstance (RIP "connection" = recent peer)
51 use crate::state::server::{
52 ConnectionState as ServerConnectionState, ConnectionStatus,
53 ProtocolConnectionInfo,
54 };
55 let now = crate::utils::clock::Instant::now();
56 let conn_state = ServerConnectionState {
57 id: connection_id,
58 remote_addr: peer_addr,
59 local_addr,
60 bytes_sent: 0,
61 bytes_received: n as u64,
62 packets_sent: 0,
63 packets_received: 1,
64 last_activity: now,
65 status: ConnectionStatus::Active,
66 status_changed_at: now,
67 protocol_info: ProtocolConnectionInfo::empty(),
68 };
69 app_state
70 .add_connection_to_server(server_id, conn_state)
71 .await;
72 let _ = status_tx.send("__UPDATE_UI__".to_string());
73
74 // Parse RIP packet to determine message type
75 if n < 4 {
76 log.debug(format!(
77 "RIP received invalid packet (too short: {} bytes) from {}",
78 n, peer_addr
79 ));
80 continue;
81 }
82
83 let command = data[0];
84 let version = data[1];
85 let num_entries = (n - 4) / 20;
86
87 // Summary + full payload FileOnly: the rip_request event template
88 // renders the equivalent line to the TUI.
89 log.debug(format!(
90 "RIP received {} bytes from {} (cmd={}, ver={}, entries={})",
91 n, peer_addr, command, version, num_entries
92 ));
93 log.trace(format!("RIP data (hex): {}", hex::encode(&data)));
94
95 let llm_clone = llm_client.clone();
96 let state_clone = app_state.clone();
97 let status_clone = status_tx.clone();
98 let socket_clone = socket.clone();
99 let protocol_clone = protocol.clone();
100
101 // Tracked, not detached: stop_server must abort this task too.
102 let task_owner = app_state.clone();
103 task_owner.spawn_server_task(server_id, async move {
104 let log = Log::new(Some(&status_clone));
105 // Parse RIP message type
106 let message_type = match command {
107 1 => "request",
108 2 => "response",
109 _ => "unknown",
110 };
111
112 // Parse route entries
113 let mut routes = Vec::new();
114 for i in 0..num_entries {
115 let offset = 4 + (i * 20);
116 if offset + 20 <= data.len() {
117 let afi = u16::from_be_bytes([data[offset], data[offset + 1]]);
118 let route_tag =
119 u16::from_be_bytes([data[offset + 2], data[offset + 3]]);
120 let ip = format!(
121 "{}.{}.{}.{}",
122 data[offset + 4],
123 data[offset + 5],
124 data[offset + 6],
125 data[offset + 7]
126 );
127 let subnet_mask = format!(
128 "{}.{}.{}.{}",
129 data[offset + 8],
130 data[offset + 9],
131 data[offset + 10],
132 data[offset + 11]
133 );
134 let next_hop = format!(
135 "{}.{}.{}.{}",
136 data[offset + 12],
137 data[offset + 13],
138 data[offset + 14],
139 data[offset + 15]
140 );
141 let metric = u32::from_be_bytes([
142 data[offset + 16],
143 data[offset + 17],
144 data[offset + 18],
145 data[offset + 19],
146 ]);
147
148 routes.push(serde_json::json!({
149 "afi": afi,
150 "route_tag": route_tag,
151 "ip_address": ip,
152 "subnet_mask": subnet_mask,
153 "next_hop": next_hop,
154 "metric": metric
155 }));
156 }
157 }
158
159 // Create RIP request event
160 let event_data = serde_json::json!({
161 "command": command,
162 "version": version,
163 "message_type": message_type,
164 "routes": routes,
165 "peer_address": peer_addr.to_string(),
166 "bytes_received": data.len()
167 });
168
169 let event = Event::new(&RIP_REQUEST_EVENT, event_data);
170
171 // A RIP response is NOT wire-determined: which routes to advertise
172 // (and their metrics, including 16 = withdraw) is a routing-policy
173 // decision, exactly like DNS resolution or DHCP leasing. There is no
174 // mechanical reply to synthesise. So with no operator policy — no
175 // server instruction and no per-event handler — the spec-safe default
176 // is to advertise nothing and stay silent, WITHOUT burning an LLM
177 // round-trip (which would otherwise be asked to invent routes). The
178 // model is consulted only when the operator opts in with the routing
179 // policy it should apply.
180 if !operator_wants_dynamic(
181 &state_clone,
182 server_id,
183 &event.event_type.id,
184 )
185 .await
186 {
187 log.info(format!(
188 "RIP {} from {} decision=static_default_silent: no routing policy configured (no LLM call)",
189 message_type, peer_addr
190 ));
191 return;
192 }
193
194 log.debug(format!(
195 "RIP calling LLM for {} from {}",
196 message_type, peer_addr
197 ));
198
199 match call_llm(
200 &llm_clone,
201 &state_clone,
202 server_id,
203 None, // RIP uses UDP, no persistent connection
204 &event,
205 protocol_clone.as_ref(),
206 )
207 .await
208 {
209 Ok(execution_result) => {
210 // Display messages from LLM
211 for message in &execution_result.messages {
212 log.info(message);
213 }
214
215 // Three outcomes that all end in the same silence on the
216 // wire must stay apart in the log, because only one of
217 // them is a fault: an explicit `ignore_request` is a
218 // routing decision, an empty answer is the model
219 // declining to decide, and an error is netget failing.
220 // Grep `decision=` to tell them apart.
221 let named_ignore =
222 execution_result.raw_actions.iter().any(|a| {
223 a.get("type").and_then(|v| v.as_str())
224 == Some("ignore_request")
225 });
226 let decision = if named_ignore {
227 "model_ignore"
228 } else if execution_result.raw_actions.is_empty() {
229 "no_answer_silent"
230 } else {
231 "model_advertise"
232 };
233 log.debug(format!(
234 "RIP {} from {} decision={} ({} actions)",
235 message_type,
236 peer_addr,
237 decision,
238 execution_result.raw_actions.len()
239 ));
240
241 // Process protocol results
242 log.debug(format!(
243 "RIP got {} protocol results",
244 execution_result.protocol_results.len()
245 ));
246
247 for protocol_result in execution_result.protocol_results {
248 let outputs = protocol_result.get_all_output();
249 if outputs.is_empty() {
250 log.debug("RIP protocol result has no output data");
251 continue;
252 }
253 // Every datagram the action produced, not just the
254 // first: `get_all_output` flattens
255 // `ActionResult::Multiple`, and taking `.first()`
256 // dropped the rest without saying so.
257 for output_data in &outputs {
258 match socket_clone.send_to(output_data, peer_addr).await
259 {
260 Ok(sent) => {
261 // Summary + full payload FileOnly: the
262 // send_rip_* action template already
263 // reports the send to the TUI.
264 log.debug(format!(
265 "RIP sent {} bytes to {}",
266 sent, peer_addr
267 ));
268 log.trace(format!(
269 "RIP sent (hex): {}",
270 hex::encode(output_data)
271 ));
272 // Keeps the rail's ↑ counter honest; the
273 // entry was added with packets_sent 0 and
274 // nothing ever updated it.
275 state_clone
276 .update_connection_stats(
277 server_id,
278 connection_id,
279 None,
280 Some(sent as u64),
281 None,
282 Some(1),
283 )
284 .await;
285 }
286 // The send failing is the one case where the
287 // old `let _ =` was actively misleading: it
288 // discarded the error and then logged
289 // "RIP sent N bytes" as if it had worked.
290 Err(e) => log.warn(format!(
291 "RIP could not send {} bytes to {}: {}",
292 output_data.len(),
293 peer_addr,
294 e
295 )),
296 }
297 }
298 }
299 }
300 Err(e) => {
301 // RIP has no error message: RFC 2453 defines Request (1)
302 // and Response (2) and nothing else, and a Response is a
303 // routing assertion. Inventing one here would put a
304 // fabricated routing statement into a peer's table, so
305 // the spec-safe answer to an internal failure is the same
306 // silence this server already applies when no routing
307 // policy is configured. The peer is not blocked by it:
308 // RIP is connectionless and re-requests on its own timer.
309 //
310 // The failure is therefore reported to the operator only.
311 // The category is logged separately from the error so a
312 // saturated backend (retry worthwhile) is distinguishable
313 // from a broken one, matching what a protocol with error
314 // codes would put on the wire. The full error goes to the
315 // log and the status stream and never anywhere else.
316 let category = if crate::utils::WireFailure::classify(&e)
317 .is_overloaded()
318 {
319 "overloaded"
320 } else {
321 "unavailable"
322 };
323 log.warn(format!(
324 "RIP {} from {} decision=fail_closed_llm_error category={}, no response sent: {}",
325 message_type, peer_addr, category, e
326 ));
327 }
328 }
329 }).await;
330 }
331 Err(e) => {
332 log.error(format!("RIP receive error: {}", e));
333 break;
334 }
335 }
336 }
337 });
338
339 // Register the accept loop so stop_server can abort it and release the port.
340 task_registrar
341 .register_server_task(server_id, accept_handle)
342 .await;
343
344 Ok(local_addr)
345 }
346}
347
348/// Returns true if the operator opted into dynamic (LLM- or handler-driven) responses for this
349/// server: either a non-empty server instruction was given, or an event handler is configured
350/// for `event_id`. When false the protocol applies its static default and never consults the
351/// model — for a policy protocol like RIP that default is to stay silent, because with no
352/// configured policy there is nothing correct to advertise.
353async fn operator_wants_dynamic(
354 state: &AppState,
355 server_id: crate::state::ServerId,
356 event_id: &str,
357) -> bool {
358 state
359 .with_server_mut(server_id, |server| {
360 let has_instruction = !server.instruction.trim().is_empty();
361 let has_handler = server
362 .event_handler_config
363 .as_ref()
364 .map(|c| c.find_handler(event_id).is_some())
365 .unwrap_or(false);
366 has_instruction || has_handler
367 })
368 .await
369 .unwrap_or(false)
370}