netget/server/ident/mod.rs
1//! Ident (RFC 1413) server.
2//!
3//! One query, one reply, close. The client sends `<server-port> , <client-port>` and the
4//! server answers either a USERID line or one of four ERROR tokens.
5//!
6//! Three properties this loop is responsible for, none of which live in `actions.rs`:
7//!
8//! 1. **`INVALID-PORT` is decided here, in Rust, with no LLM call.** A port outside
9//! `1..=65535` is a parse verdict, not a judgement anyone should be asked to make. The
10//! model never sees a malformed query.
11//! 2. **The port pair on the wire is the pair that arrived.** RFC 1413 §3 has the client
12//! match a reply to its query by that pair, so a reply carrying a different one is
13//! unmatchable — to the client, indistinguishable from no reply. `enforce_port_pair`
14//! rewrites the model's answer if it echoed the pair wrongly, and says so at WARN.
15//! 3. **Nothing is silent.** Ident has an error frame, so this is not the deliberate-silence
16//! class the root `CLAUDE.md` describes for ARP/OSPF/DHCP: an LLM outage, a model that
17//! answers with nothing, and an action that fails to execute all produce
18//! `ERROR : UNKNOWN-ERROR`. That is also the correct *fail-closed* answer, because it
19//! asserts nothing about any user — unlike a USERID line, which is a positive claim about
20//! an account.
21//!
22//! The distinction an outage would otherwise erase is kept in the **log**, the way
23//! `src/server/radius/` keeps it: `decision=model_reject` is the model refusing,
24//! `decision=model_silent` is the model answering with nothing, and
25//! `decision=fail_closed_llm_error` is the backend being unreachable. All three look
26//! identical on the wire because RFC 1413 gives them one token; grep `decision=` to tell
27//! them apart. No error text ever reaches the peer.
28//!
29//! **NetGet consults no OS state.** See `actions.rs` — the model invents every userid.
30
31pub mod actions;
32
33use crate::llm::action_helper::call_llm;
34use crate::llm::ollama_client::OllamaClient;
35use crate::logging::emit::Log;
36use crate::protocol::Event;
37use crate::server::connection::ConnectionId;
38use crate::state::app_state::AppState;
39use actions::IDENT_QUERY_EVENT;
40use anyhow::Result;
41use std::net::SocketAddr;
42use std::sync::Arc;
43use std::time::Duration;
44use tokio::io::{AsyncReadExt, AsyncWriteExt};
45use tokio::net::TcpListener;
46use tokio::sync::{mpsc, Mutex};
47
48/// RFC 1413 §5 caps a query line at 1000 characters. A peer that sends more without a
49/// newline is not speaking ident; cap the buffer rather than growing it for them.
50pub const MAX_QUERY_BYTES: usize = 1024;
51
52/// How long to wait for the query line before giving up on a connected-but-silent peer.
53/// Only covers the read: once the query has arrived, a manual handler may park the event for
54/// as long as its own timeout allows.
55const QUERY_READ_TIMEOUT: Duration = Duration::from_secs(30);
56
57/// Concurrent connections this server admits before it starts refusing.
58///
59/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`QUERY_READ_TIMEOUT`] bounds
60/// how long one silent peer holds a slot; only a cap bounds how many of them there can be at
61/// once. An ident exchange is one line in and one line out, so nothing here argues for a
62/// number other than the house default.
63const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
64
65/// A peer over [`MAX_CONNECTIONS`] gets a **plain close**, with nothing written.
66///
67/// This is the one place in this server where fail-closed cannot be said out loud, and the
68/// reason is RFC 1413's grammar rather than a shortcut. Every reply the protocol defines —
69/// `USERID` and all four `ERROR` tokens — begins by echoing back the *port pair from the
70/// query*: `<server-port> , <client-port> : ERROR : UNKNOWN-ERROR`. A refused peer has not
71/// sent its query, so there is no pair to echo, and a reply carrying invented ports is worse
72/// than silence: a client matches the pair against its own outstanding query and records a
73/// protocol violation rather than backing off. An ident client that reads EOF instead simply
74/// treats the lookup as unavailable, which is the truth. The refusal is logged
75/// (`decision=fail_closed_connection_cap`) and the log line is the diagnosis.
76const CONNECTION_CAP_REFUSAL: &[u8] = b"";
77
78pub struct IdentServer;
79
80impl IdentServer {
81 /// Bind, then serve ident queries until the accept task is aborted.
82 ///
83 /// Awaits the bind and returns `Err` on failure, so `server_startup` can set
84 /// `ServerStatus::Error` rather than reporting a server that is not listening.
85 pub async fn spawn_with_llm_actions(
86 listen_addr: SocketAddr,
87 llm_client: OllamaClient,
88 app_state: Arc<AppState>,
89 status_tx: mpsc::UnboundedSender<String>,
90 server_id: crate::state::ServerId,
91 ) -> Result<SocketAddr> {
92 let listener = TcpListener::bind(listen_addr).await?;
93 let local_addr = listener.local_addr()?;
94
95 Log::new(Some(&status_tx)).info(format!("IDENT server listening on {}", local_addr));
96
97 let protocol = Arc::new(actions::IdentProtocol::new());
98
99 let task_registrar = app_state.clone();
100 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
101 let accept_handle = tokio::spawn(async move {
102 loop {
103 match crate::server::accept_bounded::accept_bounded(
104 &listener,
105 &limiter,
106 CONNECTION_CAP_REFUSAL,
107 "IDENT",
108 Some(&status_tx),
109 )
110 .await
111 {
112 Ok((socket, peer_addr, permit)) => {
113 let connection_id =
114 ConnectionId::new(app_state.get_next_unified_id().await);
115
116 use crate::state::server::{
117 ConnectionState as ServerConnectionState, ConnectionStatus,
118 ProtocolConnectionInfo,
119 };
120 let now = crate::utils::clock::Instant::now();
121 let conn_state = ServerConnectionState {
122 id: connection_id,
123 remote_addr: peer_addr,
124 local_addr,
125 bytes_sent: 0,
126 bytes_received: 0,
127 packets_sent: 0,
128 packets_received: 0,
129 last_activity: now,
130 status: ConnectionStatus::Active,
131 status_changed_at: now,
132 protocol_info: ProtocolConnectionInfo::empty(),
133 };
134 app_state
135 .add_connection_to_server(server_id, conn_state)
136 .await;
137 let _ = status_tx.send("__UPDATE_UI__".to_string());
138
139 Log::new(Some(&status_tx))
140 .info(format!("IDENT client connected from {}", peer_addr));
141
142 let llm_clone = llm_client.clone();
143 let state_clone = app_state.clone();
144 let status_clone = status_tx.clone();
145 let protocol_clone = protocol.clone();
146
147 let registrar = app_state.clone();
148 let conn_handle = tokio::spawn(async move {
149 // Released when this task ends, and this task is the whole of the
150 // connection — ident spawns nothing else per peer — so
151 // `MAX_CONNECTIONS` caps live connections, not accepts.
152 let _permit = permit;
153 handle_ident_connection(
154 socket,
155 peer_addr,
156 llm_clone,
157 state_clone,
158 status_clone,
159 server_id,
160 protocol_clone,
161 connection_id,
162 )
163 .await
164 });
165
166 // Register every task we spawn, not just the accept loop. Aborting a
167 // task does not abort tasks it spawned, so an unregistered connection
168 // task survives `stop_server` with its socket — and an ident query
169 // parked on a manual handler can sit there for its full timeout.
170 // `register_server_task` prunes finished handles on every call, and
171 // an ident connection is one exchange long, so nothing accumulates.
172 //
173 // (A comment here claimed the repo has no per-connection handle
174 // store. It does, and `finger` and `gopher` next door both use it.)
175 registrar.register_server_task(server_id, conn_handle).await;
176 }
177 Err(e) => {
178 Log::new(Some(&status_tx)).error(format!("IDENT accept error: {}", e));
179 break;
180 }
181 }
182 }
183 });
184
185 task_registrar
186 .register_server_task(server_id, accept_handle)
187 .await;
188
189 Ok(local_addr)
190 }
191}
192
193/// What the client's line turned out to be.
194#[derive(Debug, PartialEq, Eq)]
195enum ParsedQuery {
196 /// Two ports, both in `1..=65535`.
197 Valid { server_port: u16, client_port: u16 },
198 /// Anything else. `echo` is the pair as the client wrote it, sanitised, so the
199 /// `INVALID-PORT` reply can still fill the port-pair position of the reply grammar.
200 Invalid { echo: String },
201}
202
203/// Reduce one query field to something safe to echo into a reply line.
204///
205/// The reply is a single CRLF-terminated line with `:`-separated fields, so a field echoed
206/// verbatim could otherwise forge a second reply or shift the ones after it.
207///
208/// The 16-character bound is applied after trimming, not before, so a value the client padded
209/// with spaces still echoes 16 characters of content rather than 16 characters of padding.
210fn echo_field(raw: &str) -> String {
211 let without_separators: String = raw.chars().filter(|c| *c != ':' && *c != ',').collect();
212 crate::utils::sanitize::token(&without_separators, 16)
213}
214
215/// Parse `<server-port> , <client-port>`.
216///
217/// Whitespace around the comma is legal (RFC 1413 §4's grammar admits it) and is what real
218/// clients send, so it is tolerated rather than rejected.
219fn parse_query(line: &str) -> ParsedQuery {
220 let trimmed = line.trim();
221 let mut halves = trimmed.splitn(2, ',');
222 let left = halves.next().unwrap_or("").trim();
223 let Some(right) = halves.next().map(str::trim) else {
224 // No comma at all: not an ident query. Echo the whole line rather than inventing a
225 // pair, so the client can at least see what the server read.
226 return ParsedQuery::Invalid {
227 echo: echo_field(trimmed),
228 };
229 };
230
231 match (left.parse::<i64>(), right.parse::<i64>()) {
232 (Ok(sp), Ok(cp)) if (1..=65535).contains(&sp) && (1..=65535).contains(&cp) => {
233 ParsedQuery::Valid {
234 server_port: sp as u16,
235 client_port: cp as u16,
236 }
237 }
238 _ => ParsedQuery::Invalid {
239 echo: format!("{} , {}", echo_field(left), echo_field(right)),
240 },
241 }
242}
243
244/// Force the reply's port pair to be the one the client asked about.
245///
246/// Returns `Some(corrected)` only when the reply carried a parseable pair that differed from
247/// the query's. A reply with no parseable pair is left alone: it did not come from this
248/// protocol's own actions (a dashboard injection, say) and mangling it would be worse than
249/// passing it through.
250fn enforce_port_pair(reply: &[u8], server_port: u16, client_port: u16) -> Option<Vec<u8>> {
251 let text = std::str::from_utf8(reply).ok()?;
252 let colon = text.find(':')?;
253 let (pair, rest) = text.split_at(colon);
254
255 let mut halves = pair.splitn(2, ',');
256 let left = halves.next()?.trim().parse::<u16>().ok()?;
257 let right = halves.next()?.trim().parse::<u16>().ok()?;
258
259 if left == server_port && right == client_port {
260 return None;
261 }
262 Some(format!("{} , {} {}", server_port, client_port, rest).into_bytes())
263}
264
265/// Which action the model actually answered with, for the `decision=` log token.
266///
267/// Read off `raw_actions` (the batch as submitted) rather than off the produced bytes, so a
268/// refusal is identified by what the model asked for, not by what the wire happened to carry.
269fn classify_decision(raw_actions: &[serde_json::Value]) -> Option<&'static str> {
270 let mut saw_error = false;
271 for action in raw_actions {
272 match action.get("type").and_then(|v| v.as_str()) {
273 Some("send_ident_userid") => return Some("model_userid"),
274 Some("send_ident_error") => saw_error = true,
275 _ => {}
276 }
277 }
278 saw_error.then_some("model_reject")
279}
280
281#[allow(clippy::too_many_arguments)]
282async fn handle_ident_connection(
283 socket: tokio::net::TcpStream,
284 peer_addr: SocketAddr,
285 llm_client: OllamaClient,
286 app_state: Arc<AppState>,
287 status_tx: mpsc::UnboundedSender<String>,
288 server_id: crate::state::ServerId,
289 protocol: Arc<actions::IdentProtocol>,
290 connection_id: ConnectionId,
291) {
292 let (reader, write_half) = tokio::io::split(socket);
293 let write_half = Arc::new(Mutex::new(write_half));
294
295 // Registered before the first read. An ident server says nothing until asked, and a
296 // dashboard-created instance defaults to a `*` -> manual rule, so the query can park for
297 // minutes waiting for the operator — who must be able to reach the connection meanwhile.
298 let peer_rx = crate::server::peer_support::register_peer_channel(
299 &app_state,
300 server_id,
301 connection_id.as_u32(),
302 )
303 .await;
304 crate::server::peer_support::spawn_peer_command_task(
305 peer_rx,
306 protocol.clone(),
307 app_state.clone(),
308 server_id,
309 connection_id.as_u32(),
310 write_half.clone(),
311 status_tx.clone(),
312 );
313
314 run_ident_session(
315 reader,
316 &write_half,
317 peer_addr,
318 &llm_client,
319 &app_state,
320 &status_tx,
321 server_id,
322 &protocol,
323 connection_id,
324 )
325 .await;
326
327 // Every exit path lands here. Dropping the handle ends the peer command task, which
328 // releases its clone of the write half; the explicit shutdown makes the FIN immediate.
329 // RFC 1413 has the server close once it has answered, and real clients read to EOF.
330 app_state
331 .remove_peer_handle(server_id, connection_id.as_u32())
332 .await;
333 let _ = write_half.lock().await.shutdown().await;
334
335 use crate::state::server::ConnectionStatus;
336 app_state
337 .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
338 .await;
339 let _ = status_tx.send("__UPDATE_UI__".to_string());
340}
341
342/// Write one reply and count it. The guard is dropped before the stats update so nothing
343/// awaits while holding the write half.
344async fn write_counted<W>(
345 write_half: &Arc<Mutex<W>>,
346 data: &[u8],
347 app_state: &AppState,
348 server_id: crate::state::ServerId,
349 connection_id: ConnectionId,
350) -> std::io::Result<()>
351where
352 W: tokio::io::AsyncWrite + Unpin,
353{
354 {
355 let mut writer = write_half.lock().await;
356 writer.write_all(data).await?;
357 writer.flush().await?;
358 }
359 app_state
360 .update_connection_stats(
361 server_id,
362 connection_id,
363 None,
364 Some(data.len() as u64),
365 None,
366 Some(1),
367 )
368 .await;
369 Ok(())
370}
371
372/// Read the query line, answer it, return. One exchange per connection, per RFC 1413.
373#[allow(clippy::too_many_arguments)]
374async fn run_ident_session<R, W>(
375 mut reader: R,
376 write_half: &Arc<Mutex<W>>,
377 peer_addr: SocketAddr,
378 llm_client: &OllamaClient,
379 app_state: &Arc<AppState>,
380 status_tx: &mpsc::UnboundedSender<String>,
381 server_id: crate::state::ServerId,
382 protocol: &Arc<actions::IdentProtocol>,
383 connection_id: ConnectionId,
384) where
385 R: tokio::io::AsyncRead + Unpin,
386 W: tokio::io::AsyncWrite + Unpin,
387{
388 let log = Log::new(Some(status_tx));
389
390 let Some(line) = read_query_line(
391 &mut reader,
392 peer_addr,
393 app_state,
394 server_id,
395 connection_id,
396 &log,
397 )
398 .await
399 else {
400 return;
401 };
402
403 log.trace(format!("IDENT query line from {}: {:?}", peer_addr, line));
404
405 let (server_port, client_port) = match parse_query(&line) {
406 ParsedQuery::Invalid { echo } => {
407 // Decided in Rust: a port outside 1..=65535 (or a line with no port pair at all)
408 // is a parse error, not a decision, so the model is never asked and no LLM
409 // budget is spent.
410 log.warn(format!(
411 "IDENT query from {} decision=invalid_port (rejected in-process, no LLM call)",
412 peer_addr
413 ));
414 let reply = actions::format_error_reply_raw_pair(&echo, "INVALID-PORT");
415 let _ = write_counted(write_half, &reply, app_state, server_id, connection_id).await;
416 return;
417 }
418 ParsedQuery::Valid {
419 server_port,
420 client_port,
421 } => (server_port, client_port),
422 };
423
424 let event = Event::new(
425 &IDENT_QUERY_EVENT,
426 serde_json::json!({
427 "server_port": server_port,
428 "client_port": client_port,
429 "source_addr": peer_addr.to_string(),
430 }),
431 );
432
433 log.debug(format!(
434 "IDENT calling LLM for {},{} from {}",
435 server_port, client_port, peer_addr
436 ));
437
438 let outcome = call_llm(
439 llm_client,
440 app_state,
441 server_id,
442 Some(connection_id),
443 &event,
444 protocol.as_ref(),
445 )
446 .await;
447
448 // `decision` is the token an operator greps for. `reply` is what goes on the wire; it is
449 // never derived from an error, only ever chosen from this protocol's fixed vocabulary.
450 let (decision, reply) = match outcome {
451 Ok(execution_result) => {
452 for message in &execution_result.messages {
453 log.info(message);
454 }
455
456 let mut output: Option<Vec<u8>> = None;
457 for protocol_result in &execution_result.protocol_results {
458 if let crate::llm::actions::protocol_trait::ActionResult::Output(bytes) =
459 protocol_result
460 {
461 // Concatenating rather than taking the first: a handler that answers
462 // with two lines is malformed ident, but dropping the second silently
463 // would hide that from the operator, and the pair check below still runs
464 // on the head of the stream.
465 output.get_or_insert_with(Vec::new).extend_from_slice(bytes);
466 }
467 }
468
469 match output {
470 Some(bytes) => {
471 let decision =
472 classify_decision(&execution_result.raw_actions).unwrap_or("model_answer");
473 // RFC 1413 §3: the client matches on the port pair. A model that echoed
474 // it wrongly would produce a reply the client discards, which looks
475 // exactly like the server never answering.
476 let bytes = match enforce_port_pair(&bytes, server_port, client_port) {
477 Some(corrected) => {
478 log.warn(format!(
479 "IDENT {},{} from {}: the answer carried a different port \
480 pair; rewritten to the one queried, which is what the \
481 client matches on",
482 server_port, client_port, peer_addr
483 ));
484 corrected
485 }
486 None => bytes,
487 };
488 (decision, bytes)
489 }
490 None => {
491 // The model answered with nothing usable (no actions, only a close, or
492 // every action failed). Fail closed: UNKNOWN-ERROR asserts nothing about
493 // any user, and a reply beats leaving the client to time out.
494 log.warn(format!(
495 "IDENT {},{} from {}: {} action(s) failed and nothing reached the wire",
496 server_port,
497 client_port,
498 peer_addr,
499 execution_result.failures.len()
500 ));
501 (
502 "model_silent",
503 actions::format_error_reply(server_port, client_port, "UNKNOWN-ERROR"),
504 )
505 }
506 }
507 }
508 Err(e) => {
509 // The peer gets a category; the log gets the error. Nothing derived from `e`
510 // reaches the wire — UNKNOWN-ERROR is a fixed token from the protocol's own
511 // vocabulary, and is the fail-closed answer because it names no account.
512 let category = if crate::utils::WireFailure::classify(&e).is_overloaded() {
513 "overloaded"
514 } else {
515 "unavailable"
516 };
517 log.error(format!(
518 "IDENT {},{} from {} decision=fail_closed_llm_error category={} (answering \
519 UNKNOWN-ERROR because no decision was produced)",
520 server_port, client_port, peer_addr, category
521 ));
522 log.debug(format!("IDENT LLM call failed: {}", e));
523 (
524 "fail_closed_llm_error",
525 actions::format_error_reply(server_port, client_port, "UNKNOWN-ERROR"),
526 )
527 }
528 };
529
530 // Log the decision before writing it, so a reply that then fails to send is still
531 // attributable. `decision=` tokens are stable: grep `decision=fail_closed_` for every
532 // query the model did not actually answer.
533 let summary = format!(
534 "IDENT {},{} from {} decision={}",
535 server_port, client_port, peer_addr, decision
536 );
537 if decision == "fail_closed_llm_error" || decision == "model_silent" {
538 // Already logged at error/warn above with its reason; keep the summary at debug so
539 // the grep-able token appears exactly once per outcome at INFO or louder.
540 log.debug(&summary);
541 } else {
542 log.info(&summary);
543 }
544
545 log.trace(format!(
546 "IDENT reply to {}: {}",
547 peer_addr,
548 String::from_utf8_lossy(&reply).trim_end()
549 ));
550
551 if let Err(e) = write_counted(write_half, &reply, app_state, server_id, connection_id).await {
552 log.error(format!("IDENT write error to {}: {}", peer_addr, e));
553 }
554}
555
556/// Accumulate until a newline, EOF, the size cap, or the read timeout.
557///
558/// Returns `None` only when there is nobody left to answer — a peer that connected and said
559/// nothing, a peer that hung up before writing, a read error, or the read timeout. An
560/// oversize line is *returned*, not dropped: the parser rejects it and the caller answers
561/// `INVALID-PORT`, which is the one place a client is still waiting.
562async fn read_query_line<R>(
563 reader: &mut R,
564 peer_addr: SocketAddr,
565 app_state: &Arc<AppState>,
566 server_id: crate::state::ServerId,
567 connection_id: ConnectionId,
568 log: &Log<'_>,
569) -> Option<String>
570where
571 R: tokio::io::AsyncRead + Unpin,
572{
573 let mut buffer = vec![0u8; 512];
574 let mut accumulated: Vec<u8> = Vec::new();
575
576 loop {
577 let read = match tokio::time::timeout(QUERY_READ_TIMEOUT, reader.read(&mut buffer)).await {
578 Err(_) => {
579 log.info(format!(
580 "IDENT client {} sent no query within {}s; closing",
581 peer_addr,
582 QUERY_READ_TIMEOUT.as_secs()
583 ));
584 return None;
585 }
586 Ok(result) => result,
587 };
588
589 match read {
590 Ok(0) => {
591 // Half-close after an unterminated query is common enough to tolerate: if
592 // anything arrived, treat it as the line rather than dropping a client that
593 // simply omitted the CRLF.
594 if accumulated.is_empty() {
595 log.info(format!(
596 "IDENT client {} disconnected without sending a query",
597 peer_addr
598 ));
599 return None;
600 }
601 break;
602 }
603 Ok(n) => {
604 app_state
605 .update_connection_stats(
606 server_id,
607 connection_id,
608 Some(n as u64),
609 None,
610 Some(1),
611 None,
612 )
613 .await;
614 log.debug(format!("IDENT received {} bytes from {}", n, peer_addr));
615
616 accumulated.extend_from_slice(&buffer[..n]);
617 if accumulated.contains(&b'\n') {
618 break;
619 }
620 if accumulated.len() > MAX_QUERY_BYTES {
621 // Stop reading, but hand back what we have: `parse_query` will reject it
622 // and the caller answers INVALID-PORT. Returning `None` here would hang
623 // up on a client that is still waiting for a reply.
624 log.warn(format!(
625 "IDENT query from {} exceeded {} bytes with no newline; \
626 answering INVALID-PORT",
627 peer_addr, MAX_QUERY_BYTES
628 ));
629 break;
630 }
631 }
632 Err(e) => {
633 log.error(format!("IDENT read error from {}: {}", peer_addr, e));
634 return None;
635 }
636 }
637 }
638
639 // Only the first line matters: RFC 1413 is one query per connection.
640 let text = String::from_utf8_lossy(&accumulated).to_string();
641 Some(text.lines().next().unwrap_or("").to_string())
642}