Skip to main content

netget/client/ident/
mod.rs

1//! Ident (RFC 1413) client.
2//!
3//! Connect, send `<server-port> , <client-port>`, read one line, close. The whole protocol
4//! fits in a paragraph, so what this file is really about is the two things a *client* can get
5//! wrong:
6//!
7//! 1. **The reply must answer the question that was asked.** RFC 1413 §3 has the client match
8//!    a reply to its query by the port pair. A reply carrying a different pair is an answer
9//!    about somebody else's connection; accepting it would attribute an account to the wrong
10//!    socket. [`parse_ident_reply`] extracts the pair, the session compares it with what went
11//!    out, and a mismatch raises [`actions::IDENT_CLIENT_REPLY_MISMATCH_EVENT`] instead of a
12//!    result. The same applies to a line that cannot be parsed, one that overflows the cap,
13//!    and one that arrives with no query outstanding — rejected, never parsed loosely.
14//! 2. **The model's answer is carried out.** The reply event is put to the model and whatever
15//!    it asks for is executed, including another query — which RFC 1413 requires to be a fresh
16//!    connection, since the server closes after one exchange. That chain is bounded by
17//!    [`MAX_FOLLOWUP_DEPTH`] rather than by silence: the recursive step is boxed, which is what
18//!    makes an action → event → action cycle expressible at all.
19//!
20//! Whitespace around the comma is tolerated in both directions, because the grammar admits it
21//! and real peers send it.
22//!
23//! **Nothing here reads host state.** The port pair is the model's choice, never derived from a
24//! local socket table; the userid that comes back is reported in the event and nowhere else.
25
26pub mod actions;
27
28pub use actions::IdentClientProtocol;
29
30use std::future::Future;
31use std::net::SocketAddr;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::time::Duration;
35
36use anyhow::{Context, Result};
37use tokio::io::{AsyncReadExt, AsyncWriteExt};
38use tokio::net::TcpStream;
39use tokio::sync::{mpsc, Mutex};
40
41use crate::client::llm_budget::call_llm_for_client;
42use crate::llm::actions::client_trait::{Client, ClientActionResult};
43use crate::llm::ollama_client::OllamaClient;
44use crate::llm::ClientLlmResult;
45use crate::logging::emit::Log;
46use crate::protocol::{Event, StartupParams};
47use crate::state::app_state::AppState;
48use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
49use crate::state::{AccessLogOwner, ClientId, ClientStatus};
50
51use actions::{
52    IDENT_CLIENT_CONNECTED_EVENT, IDENT_CLIENT_ERROR_RECEIVED_EVENT,
53    IDENT_CLIENT_REPLY_MISMATCH_EVENT, IDENT_CLIENT_RESPONSE_RECEIVED_EVENT,
54};
55
56/// The only port RFC 1413 defines. A real identd listens here and nowhere else, which is
57/// exactly why `ident_port` exists as an override for a harness.
58pub const DEFAULT_IDENT_PORT: u16 = 113;
59
60/// Default wait for the single reply line.
61const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30;
62
63/// RFC 1413 §5 bounds a reply line; anything past this without a newline is not ident.
64/// Deliberately the same cap the server half applies to queries.
65pub const MAX_REPLY_BYTES: usize = 1024;
66
67/// How many further queries the model may chain off a reply. Each one is a new TCP connection
68/// (RFC 1413 is one exchange per connection), so this bounds real sockets, not just recursion.
69pub const MAX_FOLLOWUP_DEPTH: usize = 4;
70
71/// The four tokens RFC 1413 defines. Anything else is accepted only in the `X`-prefixed
72/// implementation-defined form the RFC reserves.
73const IDENT_ERROR_TOKENS: [&str; 4] = ["INVALID-PORT", "NO-USER", "HIDDEN-USER", "UNKNOWN-ERROR"];
74
75/// The port pair this connection asked about, once something has sent one.
76///
77/// Shared between the session task and the command task, because either can be the thing that
78/// puts a query on the wire and the reply check needs the pair whichever did.
79type PendingQuery = Arc<std::sync::Mutex<Option<(u16, u16)>>>;
80
81/// What a parsed reply line turned out to be.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum IdentReply {
84    /// `<pair> : USERID : <opsys>[,<charset>] : <userid>`
85    Userid {
86        server_port: u16,
87        client_port: u16,
88        opsys: String,
89        charset: Option<String>,
90        userid: String,
91    },
92    /// `<pair> : ERROR : <token>`
93    Error {
94        server_port: u16,
95        client_port: u16,
96        token: String,
97    },
98    /// Not an ident reply. `reason` is a stable token, reported to the model and greppable in
99    /// the log.
100    Malformed { reason: &'static str },
101}
102
103impl IdentReply {
104    /// The port pair the reply claims to be about, when it has one.
105    pub fn port_pair(&self) -> Option<(u16, u16)> {
106        match self {
107            IdentReply::Userid {
108                server_port,
109                client_port,
110                ..
111            }
112            | IdentReply::Error {
113                server_port,
114                client_port,
115                ..
116            } => Some((*server_port, *client_port)),
117            IdentReply::Malformed { .. } => None,
118        }
119    }
120}
121
122/// `<port> , <port>` with any amount of whitespace around the comma.
123///
124/// Both ports must be real TCP ports. `0` is out of range in RFC 1413 (there is no connection
125/// on port 0 to own), and rejecting it here is what keeps a `0 , 0` reply from matching a
126/// query nothing sent.
127fn parse_port_pair(text: &str) -> Option<(u16, u16)> {
128    let (left, right) = text.split_once(',')?;
129    let server_port: u32 = left.trim().parse().ok()?;
130    let client_port: u32 = right.trim().parse().ok()?;
131    if !(1..=65535).contains(&server_port) || !(1..=65535).contains(&client_port) {
132        return None;
133    }
134    Some((server_port as u16, client_port as u16))
135}
136
137/// An error token is one of the four, or an `X`-prefixed one the RFC reserves for
138/// implementations. Anything else is a malformed reply, not free text to hand the model.
139fn is_error_token(token: &str) -> bool {
140    if IDENT_ERROR_TOKENS.contains(&token) {
141        return true;
142    }
143    token.starts_with('X')
144        && token.len() > 1
145        && token
146            .chars()
147            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
148}
149
150/// Parse one reply line.
151///
152/// Strict on purpose. Every field position in RFC 1413's grammar carries meaning to whoever
153/// reads the result, so a line that does not fill them is reported as malformed rather than
154/// half-read: a `USERID` line with no userid must not become an empty username, and an
155/// unrecognised response type must not become an error token.
156///
157/// Whitespace is tolerated everywhere the grammar allows it — around the commas, around the
158/// colons, and at either end of the line.
159pub fn parse_ident_reply(line: &str) -> IdentReply {
160    if line.len() > MAX_REPLY_BYTES {
161        return IdentReply::Malformed {
162            reason: "oversized",
163        };
164    }
165
166    let line = line.trim();
167    let Some((pair_text, rest)) = line.split_once(':') else {
168        return IdentReply::Malformed {
169            reason: "no_response_type",
170        };
171    };
172    let Some((server_port, client_port)) = parse_port_pair(pair_text) else {
173        return IdentReply::Malformed {
174            reason: "port_pair",
175        };
176    };
177    let Some((kind, addl)) = rest.split_once(':') else {
178        return IdentReply::Malformed {
179            reason: "no_addl_info",
180        };
181    };
182
183    match kind.trim().to_ascii_uppercase().as_str() {
184        "USERID" => {
185            // splitn-style: the userid keeps any colon it contains, which RFC 1413 permits.
186            let Some((opsys_field, userid)) = addl.split_once(':') else {
187                return IdentReply::Malformed {
188                    reason: "userid_fields",
189                };
190            };
191            let userid = userid.trim();
192            if userid.is_empty() {
193                return IdentReply::Malformed {
194                    reason: "empty_userid",
195                };
196            }
197            let (opsys, charset) = match opsys_field.split_once(',') {
198                Some((opsys, charset)) => {
199                    let charset = charset.trim();
200                    (
201                        opsys.trim().to_string(),
202                        (!charset.is_empty()).then(|| charset.to_string()),
203                    )
204                }
205                None => (opsys_field.trim().to_string(), None),
206            };
207            if opsys.is_empty() {
208                return IdentReply::Malformed {
209                    reason: "empty_opsys",
210                };
211            }
212            IdentReply::Userid {
213                server_port,
214                client_port,
215                opsys,
216                charset,
217                userid: userid.to_string(),
218            }
219        }
220        "ERROR" => {
221            let token = addl.trim().to_ascii_uppercase();
222            if !is_error_token(&token) {
223                return IdentReply::Malformed {
224                    reason: "error_token",
225                };
226            }
227            IdentReply::Error {
228                server_port,
229                client_port,
230                token,
231            }
232        }
233        _ => IdentReply::Malformed {
234            reason: "response_type",
235        },
236    }
237}
238
239/// Reduce a line to something safe to put in event data and the log: no control characters, no
240/// unbounded length. It has already been rejected by the time this is called; this only makes
241/// it readable.
242fn sanitize_line(line: &str) -> String {
243    let cleaned = crate::utils::sanitize::strip_controls(line);
244    crate::utils::truncate_for_log(cleaned.trim(), 256)
245}
246
247/// Split `host[:port]`, understanding `[::1]:113` and a bare IPv6 literal.
248///
249/// A colon suffix that is not a number is left as part of the host, so the connect failure
250/// names what the caller actually typed rather than silently retargeting port 113.
251fn split_host_port(addr: &str) -> (String, Option<u16>) {
252    if let Some(rest) = addr.strip_prefix('[') {
253        if let Some((host, tail)) = rest.split_once(']') {
254            let port = tail.strip_prefix(':').and_then(|p| p.trim().parse().ok());
255            return (host.to_string(), port);
256        }
257    }
258    match addr.rsplit_once(':') {
259        Some((head, tail)) if !head.contains(':') => match tail.trim().parse::<u16>() {
260            Ok(port) => (head.to_string(), Some(port)),
261            Err(_) => (addr.to_string(), None),
262        },
263        _ => (addr.to_string(), None),
264    }
265}
266
267/// Decide what to actually connect to.
268///
269/// `ident_port` wins over a port written into `remote_addr`, and 113 is the default, because
270/// that is the only port RFC 1413 knows about — `remote_addr` for this protocol is usually
271/// just a host.
272pub fn resolve_target(remote_addr: &str, ident_port: Option<u16>) -> Result<String> {
273    let trimmed = remote_addr.trim();
274    let trimmed = trimmed
275        .strip_prefix("ident://")
276        .unwrap_or(trimmed)
277        .trim_end_matches('/');
278    let (host, addr_port) = split_host_port(trimmed);
279    if host.is_empty() {
280        anyhow::bail!("remote_addr {remote_addr:?} names no host to query");
281    }
282    let port = ident_port.or(addr_port).unwrap_or(DEFAULT_IDENT_PORT);
283    Ok(if host.contains(':') {
284        format!("[{host}]:{port}")
285    } else {
286        format!("{host}:{port}")
287    })
288}
289
290/// What [`IdentClient::apply_action`] did with one action.
291enum Applied {
292    /// Bytes written (0 when the action produced no wire output).
293    Sent(usize),
294    /// The write side was shut down and the session should end.
295    Disconnect,
296}
297
298/// How reading the single reply line ended.
299enum ReplyRead {
300    /// A line (newline-terminated, or everything received before a clean EOF).
301    Line(String),
302    /// The cap was hit with no newline. Rejected without parsing.
303    Oversize(String),
304    /// The peer closed without sending anything.
305    Closed,
306    /// Nothing arrived within the configured timeout.
307    TimedOut,
308    /// The socket errored.
309    Failed(String),
310}
311
312/// Everything a reply handler needs, owned, so the recursive follow-up step has no lifetimes
313/// to thread through a boxed future.
314struct SessionCtx {
315    protocol: Arc<IdentClientProtocol>,
316    llm_client: OllamaClient,
317    app_state: Arc<AppState>,
318    status_tx: mpsc::UnboundedSender<String>,
319    client_id: ClientId,
320    instruction: String,
321    target: String,
322    response_timeout: Duration,
323}
324
325pub struct IdentClient;
326
327impl IdentClient {
328    /// Connect to an ident server and run one query/reply exchange under LLM control.
329    pub async fn connect_with_llm_actions(
330        remote_addr: String,
331        startup_params: Option<StartupParams>,
332        llm_client: OllamaClient,
333        app_state: Arc<AppState>,
334        status_tx: mpsc::UnboundedSender<String>,
335        client_id: ClientId,
336    ) -> Result<SocketAddr> {
337        let ident_port = match &startup_params {
338            Some(params) => params.get_optional_u64("ident_port")?,
339            None => None,
340        };
341        let ident_port = match ident_port {
342            Some(port) if (1..=65535).contains(&port) => Some(port as u16),
343            Some(port) => anyhow::bail!("ident_port must be a TCP port in 1-65535, got {port}"),
344            None => None,
345        };
346        let response_timeout = match &startup_params {
347            Some(params) => params.get_optional_u64("response_timeout_secs")?,
348            None => None,
349        }
350        .filter(|secs| *secs > 0)
351        .unwrap_or(DEFAULT_RESPONSE_TIMEOUT_SECS);
352        let response_timeout = Duration::from_secs(response_timeout);
353
354        let target = resolve_target(&remote_addr, ident_port)?;
355
356        let stream = TcpStream::connect(&target)
357            .await
358            .with_context(|| format!("Failed to connect to ident server at {target}"))?;
359        let local_addr = stream.local_addr()?;
360        let peer_addr = stream.peer_addr()?;
361
362        Log::new(Some(&status_tx)).info(format!(
363            "Ident client {client_id} connected to {peer_addr} (local: {local_addr})"
364        ));
365        app_state
366            .update_client_status(client_id, ClientStatus::Connected)
367            .await;
368        let _ = status_tx.send("__UPDATE_UI__".to_string());
369
370        let (read_half, write_half) = tokio::io::split(stream);
371        let write_half = Arc::new(Mutex::new(write_half));
372        let protocol = Arc::new(IdentClientProtocol::new());
373        let pending: PendingQuery = Arc::new(std::sync::Mutex::new(None));
374
375        // Registered BEFORE the connected-event LLM call. A dashboard-created client can park
376        // that event on a manual rule for minutes, and the operator has to be able to send the
377        // query meanwhile; a separate task also means an injected query never queues behind an
378        // LLM round-trip.
379        let command_rx =
380            crate::client::command_support::register_command_channel(&app_state, client_id).await;
381        let command_task = tokio::spawn(Self::command_loop(
382            command_rx,
383            protocol.clone(),
384            write_half.clone(),
385            pending.clone(),
386            client_id,
387            app_state.clone(),
388            status_tx.clone(),
389        ));
390        app_state
391            .register_client_task(client_id, command_task)
392            .await;
393
394        let session_registrar = app_state.clone();
395        let session_task = tokio::spawn(async move {
396            Self::session(
397                read_half,
398                write_half,
399                protocol,
400                pending,
401                target,
402                response_timeout,
403                llm_client,
404                app_state.clone(),
405                status_tx.clone(),
406                client_id,
407            )
408            .await;
409            // Session over: drop the handle so the rail stops offering [ send ], which also
410            // ends the command task with its channel.
411            app_state.remove_client_handle(client_id).await;
412            let _ = status_tx.send("__UPDATE_UI__".to_string());
413        });
414        session_registrar
415            .register_client_task(client_id, session_task)
416            .await;
417
418        Ok(local_addr)
419    }
420
421    /// Connected event → query → the one reply → the model's answer to it (and any follow-ups).
422    #[allow(clippy::too_many_arguments)]
423    async fn session(
424        mut read_half: tokio::io::ReadHalf<TcpStream>,
425        write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
426        protocol: Arc<IdentClientProtocol>,
427        pending: PendingQuery,
428        target: String,
429        response_timeout: Duration,
430        llm_client: OllamaClient,
431        app_state: Arc<AppState>,
432        status_tx: mpsc::UnboundedSender<String>,
433        client_id: ClientId,
434    ) {
435        let log = Log::new(Some(&status_tx));
436
437        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
438            return;
439        };
440
441        let ctx = Arc::new(SessionCtx {
442            protocol: protocol.clone(),
443            llm_client: llm_client.clone(),
444            app_state: app_state.clone(),
445            status_tx: status_tx.clone(),
446            client_id,
447            instruction: instruction.clone(),
448            target: target.clone(),
449            response_timeout,
450        });
451
452        // === The connected event: the model chooses the port pair ===
453        let event = Event::new(
454            &IDENT_CLIENT_CONNECTED_EVENT,
455            serde_json::json!({ "remote_addr": target }),
456        );
457
458        let memory = app_state
459            .get_memory_for_client(client_id)
460            .await
461            .unwrap_or_default();
462
463        match call_llm_for_client(
464            &llm_client,
465            &app_state,
466            client_id.to_string(),
467            &instruction,
468            &memory,
469            Some(&event),
470            protocol.as_ref(),
471            &status_tx,
472        )
473        .await
474        {
475            Ok(ClientLlmResult {
476                actions,
477                memory_updates,
478            }) => {
479                if let Some(memory) = memory_updates {
480                    app_state.set_memory_for_client(client_id, memory).await;
481                }
482                for action in actions {
483                    let result = match protocol.execute_action(action) {
484                        Ok(result) => result,
485                        Err(e) => {
486                            log.error(format!("Ident client {client_id} rejected action: {e}"));
487                            continue;
488                        }
489                    };
490                    match Self::apply_action(result, &write_half, &pending, client_id, &log).await {
491                        Ok(Applied::Sent(_)) => {}
492                        Ok(Applied::Disconnect) => {
493                            log.info(format!(
494                                "Ident client {client_id} disconnected before asking anything"
495                            ));
496                            app_state
497                                .update_client_status(client_id, ClientStatus::Disconnected)
498                                .await;
499                            let _ = status_tx.send("__UPDATE_UI__".to_string());
500                            return;
501                        }
502                        Err(e) => {
503                            log.error(format!(
504                                "Ident client {client_id} failed to send query: {e}"
505                            ));
506                            app_state
507                                .update_client_status(client_id, ClientStatus::Error(e.to_string()))
508                                .await;
509                            let _ = status_tx.send("__UPDATE_UI__".to_string());
510                            return;
511                        }
512                    }
513                }
514            }
515            Err(e) => {
516                // Stay connected: the operator can still inject the query from the dashboard,
517                // and the server will close on its own otherwise.
518                log.error(format!(
519                    "Ident client {client_id} LLM error on connect: {e} (still connected; a \
520                     query can be injected)"
521                ));
522            }
523        }
524
525        // === The one reply ===
526        match Self::read_reply(&mut read_half, response_timeout).await {
527            ReplyRead::Line(line) => {
528                log.trace(format!("Ident client {client_id} reply line: {line:?}"));
529                let asked = pending.lock().ok().and_then(|p| *p);
530                let reply = parse_ident_reply(&line);
531                Self::handle_reply(ctx, 0, reply, asked, line).await;
532            }
533            ReplyRead::Oversize(line) => {
534                let asked = pending.lock().ok().and_then(|p| *p);
535                log.warn(format!(
536                    "Ident client {client_id} reply exceeded {MAX_REPLY_BYTES} bytes with no \
537                     newline; rejected without parsing"
538                ));
539                Self::handle_reply(
540                    ctx,
541                    0,
542                    IdentReply::Malformed {
543                        reason: "oversized",
544                    },
545                    asked,
546                    line,
547                )
548                .await;
549            }
550            ReplyRead::Closed => {
551                let asked = pending.lock().ok().and_then(|p| *p);
552                match asked {
553                    Some((server_port, client_port)) => log.info(format!(
554                        "Ident client {client_id} got no answer for {server_port},{client_port}: \
555                         the server closed first"
556                    )),
557                    None => log.info(format!(
558                        "Ident client {client_id} was closed by the server before any query went \
559                         out"
560                    )),
561                }
562            }
563            ReplyRead::TimedOut => log.warn(format!(
564                "Ident client {client_id} gave up after {}s with no reply",
565                response_timeout.as_secs()
566            )),
567            ReplyRead::Failed(e) => {
568                log.error(format!("Ident client {client_id} read error: {e}"));
569                app_state
570                    .update_client_status(client_id, ClientStatus::Error(e))
571                    .await;
572                let _ = status_tx.send("__UPDATE_UI__".to_string());
573                return;
574            }
575        }
576
577        // RFC 1413 is one exchange per connection: there is nothing left to do here.
578        let _ = write_half.lock().await.shutdown().await;
579        app_state
580            .update_client_status(client_id, ClientStatus::Disconnected)
581            .await;
582        log.info(format!("Ident client {client_id} disconnected"));
583        let _ = status_tx.send("__UPDATE_UI__".to_string());
584    }
585
586    /// Turn a reply into the right event, put it to the model, and carry out the answer.
587    ///
588    /// Boxed because it is mutually recursive with [`Self::followup_query`]: an `async fn` that
589    /// can reach itself has an infinitely-sized future (E0391), and `+ Send` is spelled out
590    /// because this is awaited inside a `tokio::spawn` and inference will not supply it.
591    fn handle_reply(
592        ctx: Arc<SessionCtx>,
593        depth: usize,
594        reply: IdentReply,
595        asked: Option<(u16, u16)>,
596        line: String,
597    ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
598        Box::pin(async move {
599            let log = Log::new(Some(&ctx.status_tx));
600            let client_id = ctx.client_id;
601
602            // The port-pair check. This is the one correctness property an ident client has:
603            // a reply about a different pair is an answer to a question nobody asked.
604            let event = match (&reply, asked, reply.port_pair()) {
605                (IdentReply::Malformed { reason }, _, _) => {
606                    log.warn(format!(
607                        "Ident client {client_id} rejected a reply ({reason}): {}",
608                        sanitize_line(&line)
609                    ));
610                    Self::mismatch_event(reason, asked, None, &line)
611                }
612                (_, None, got) => {
613                    log.warn(format!(
614                        "Ident client {client_id} received a reply with no query outstanding; \
615                         rejected"
616                    ));
617                    Self::mismatch_event("unsolicited", None, got, &line)
618                }
619                (_, Some(query), Some(got)) if query != got => {
620                    log.warn(format!(
621                        "Ident client {client_id} asked about {},{} and was answered about \
622                         {},{}; rejected (RFC 1413 matches a reply by its port pair)",
623                        query.0, query.1, got.0, got.1
624                    ));
625                    Self::mismatch_event("port_pair_mismatch", asked, Some(got), &line)
626                }
627                (
628                    IdentReply::Userid {
629                        server_port,
630                        client_port,
631                        opsys,
632                        charset,
633                        userid,
634                    },
635                    _,
636                    _,
637                ) => {
638                    log.info(format!(
639                        "Ident client {client_id} {server_port},{client_port} USERID on {opsys}"
640                    ));
641                    let mut data = serde_json::json!({
642                        "server_port": server_port,
643                        "client_port": client_port,
644                        "userid": userid,
645                        "opsys": opsys,
646                    });
647                    if let Some(charset) = charset {
648                        data["charset"] = serde_json::Value::String(charset.clone());
649                    }
650                    Event::new(&IDENT_CLIENT_RESPONSE_RECEIVED_EVENT, data)
651                }
652                (
653                    IdentReply::Error {
654                        server_port,
655                        client_port,
656                        token,
657                    },
658                    _,
659                    _,
660                ) => {
661                    log.info(format!(
662                        "Ident client {client_id} {server_port},{client_port} ERROR {token}"
663                    ));
664                    Event::new(
665                        &IDENT_CLIENT_ERROR_RECEIVED_EVENT,
666                        serde_json::json!({
667                            "server_port": server_port,
668                            "client_port": client_port,
669                            "error_token": token,
670                        }),
671                    )
672                }
673            };
674
675            let memory = ctx
676                .app_state
677                .get_memory_for_client(client_id)
678                .await
679                .unwrap_or_default();
680
681            let outcome = call_llm_for_client(
682                &ctx.llm_client,
683                &ctx.app_state,
684                client_id.to_string(),
685                &ctx.instruction,
686                &memory,
687                Some(&event),
688                ctx.protocol.as_ref(),
689                &ctx.status_tx,
690            )
691            .await;
692
693            let ClientLlmResult {
694                actions,
695                memory_updates,
696            } = match outcome {
697                Ok(result) => result,
698                Err(e) => {
699                    log.error(format!(
700                        "Ident client {client_id} LLM error on {}: {e}",
701                        event.id()
702                    ));
703                    return;
704                }
705            };
706            if let Some(memory) = memory_updates {
707                ctx.app_state.set_memory_for_client(client_id, memory).await;
708            }
709
710            // Carry out what the model asked for. A follow-up query is a real new connection,
711            // because the server closed this one after answering.
712            for action in actions {
713                match ctx.protocol.execute_action(action) {
714                    Ok(ClientActionResult::Custom { name, data }) if name == "ident_query" => {
715                        let (Some(server_port), Some(client_port)) = (
716                            data.get("server_port").and_then(|v| v.as_u64()),
717                            data.get("client_port").and_then(|v| v.as_u64()),
718                        ) else {
719                            continue;
720                        };
721                        if depth + 1 > MAX_FOLLOWUP_DEPTH {
722                            log.warn(format!(
723                                "Ident client {client_id} stopped at follow-up depth \
724                                 {MAX_FOLLOWUP_DEPTH}; the request for {server_port},\
725                                 {client_port} was not sent"
726                            ));
727                            continue;
728                        }
729                        Self::followup_query(
730                            ctx.clone(),
731                            depth + 1,
732                            server_port as u16,
733                            client_port as u16,
734                        )
735                        .await;
736                    }
737                    Ok(ClientActionResult::Disconnect) => {
738                        log.debug(format!(
739                            "Ident client {client_id} was asked to disconnect; the connection is \
740                             already finished"
741                        ));
742                        break;
743                    }
744                    Ok(ClientActionResult::WaitForMore) => log.debug(format!(
745                        "Ident client {client_id} asked to wait, but RFC 1413 sends one line and \
746                         closes; nothing more will arrive"
747                    )),
748                    Ok(_) => {}
749                    Err(e) => log.error(format!("Ident client {client_id} rejected action: {e}")),
750                }
751            }
752        })
753    }
754
755    /// One further query on a **fresh** connection, then straight back into
756    /// [`Self::handle_reply`] so the answer raises its event like any other.
757    ///
758    /// The new connection is not a shortcut: RFC 1413 is one query per connection and the
759    /// server has already closed. Raising the event (rather than logging the answer and
760    /// stopping, which is what `whois` does) is what keeps the chain alive; the depth bound is
761    /// what keeps it finite.
762    async fn followup_query(
763        ctx: Arc<SessionCtx>,
764        depth: usize,
765        server_port: u16,
766        client_port: u16,
767    ) {
768        let log = Log::new(Some(&ctx.status_tx));
769        let client_id = ctx.client_id;
770
771        let mut stream = match TcpStream::connect(&ctx.target).await {
772            Ok(stream) => stream,
773            Err(e) => {
774                log.error(format!(
775                    "Ident client {client_id} follow-up could not reach {}: {e}",
776                    ctx.target
777                ));
778                return;
779            }
780        };
781
782        let query = format!("{server_port} , {client_port}\r\n");
783        if let Err(e) = stream.write_all(query.as_bytes()).await {
784            log.error(format!(
785                "Ident client {client_id} follow-up write failed: {e}"
786            ));
787            return;
788        }
789        if let Err(e) = stream.flush().await {
790            log.error(format!(
791                "Ident client {client_id} follow-up flush failed: {e}"
792            ));
793            return;
794        }
795        log.debug(format!(
796            "Ident client {client_id} follow-up #{depth} asked about {server_port},{client_port}"
797        ));
798
799        let (mut read_half, write_half) = tokio::io::split(stream);
800        let outcome = Self::read_reply(&mut read_half, ctx.response_timeout).await;
801        let mut write_half = write_half;
802        let _ = write_half.shutdown().await;
803
804        match outcome {
805            ReplyRead::Line(line) => {
806                let reply = parse_ident_reply(&line);
807                Self::handle_reply(ctx, depth, reply, Some((server_port, client_port)), line).await;
808            }
809            ReplyRead::Oversize(line) => {
810                Self::handle_reply(
811                    ctx,
812                    depth,
813                    IdentReply::Malformed {
814                        reason: "oversized",
815                    },
816                    Some((server_port, client_port)),
817                    line,
818                )
819                .await;
820            }
821            ReplyRead::Closed => log.info(format!(
822                "Ident client {client_id} follow-up for {server_port},{client_port} was closed \
823                 unanswered"
824            )),
825            ReplyRead::TimedOut => log.warn(format!(
826                "Ident client {client_id} follow-up for {server_port},{client_port} timed out"
827            )),
828            ReplyRead::Failed(e) => log.error(format!(
829                "Ident client {client_id} follow-up read error: {e}"
830            )),
831        }
832    }
833
834    /// Build the "this is not my answer" event.
835    fn mismatch_event(
836        reason: &str,
837        asked: Option<(u16, u16)>,
838        got: Option<(u16, u16)>,
839        line: &str,
840    ) -> Event {
841        let mut data = serde_json::json!({
842            "reason": reason,
843            "reply_line": sanitize_line(line),
844        });
845        if let Some((server_port, client_port)) = asked {
846            data["queried_server_port"] = serde_json::json!(server_port);
847            data["queried_client_port"] = serde_json::json!(client_port);
848        }
849        if let Some((server_port, client_port)) = got {
850            data["reply_server_port"] = serde_json::json!(server_port);
851            data["reply_client_port"] = serde_json::json!(client_port);
852        }
853        Event::new(&IDENT_CLIENT_REPLY_MISMATCH_EVENT, data)
854    }
855
856    /// Accumulate until a newline, EOF, the byte cap, or the timeout.
857    async fn read_reply<R>(reader: &mut R, timeout: Duration) -> ReplyRead
858    where
859        R: tokio::io::AsyncRead + Unpin,
860    {
861        let mut accumulated: Vec<u8> = Vec::new();
862        let mut buffer = vec![0u8; 512];
863
864        loop {
865            let read = match tokio::time::timeout(timeout, reader.read(&mut buffer)).await {
866                Err(_) => return ReplyRead::TimedOut,
867                Ok(read) => read,
868            };
869            match read {
870                Ok(0) => {
871                    if accumulated.is_empty() {
872                        return ReplyRead::Closed;
873                    }
874                    // A peer that omitted the CRLF and hung up still said something; the
875                    // parser gets to judge it.
876                    break;
877                }
878                Ok(n) => {
879                    accumulated.extend_from_slice(&buffer[..n]);
880                    if accumulated.contains(&b'\n') {
881                        break;
882                    }
883                    if accumulated.len() > MAX_REPLY_BYTES {
884                        let text = String::from_utf8_lossy(&accumulated).into_owned();
885                        return ReplyRead::Oversize(text);
886                    }
887                }
888                Err(e) => return ReplyRead::Failed(e.to_string()),
889            }
890        }
891
892        let text = String::from_utf8_lossy(&accumulated).into_owned();
893        // Only the first line matters: RFC 1413 is one reply per connection.
894        ReplyRead::Line(text.lines().next().unwrap_or("").to_string())
895    }
896
897    /// Put one executed action on the wire.
898    ///
899    /// Shared by the LLM path and by injected dashboard commands, so the encoding of
900    /// `send_ident_query` — and the record of which pair was asked about — exists exactly once.
901    async fn apply_action<W>(
902        result: ClientActionResult,
903        write_half: &Arc<Mutex<W>>,
904        pending: &PendingQuery,
905        client_id: ClientId,
906        log: &Log<'_>,
907    ) -> Result<Applied>
908    where
909        W: tokio::io::AsyncWrite + Unpin,
910    {
911        match result {
912            ClientActionResult::Custom { name, data } if name == "ident_query" => {
913                let server_port = data
914                    .get("server_port")
915                    .and_then(|v| v.as_u64())
916                    .context("ident_query without a server_port")?;
917                let server_port =
918                    u16::try_from(server_port).context("ident server_port exceeds u16")?;
919                let client_port = data
920                    .get("client_port")
921                    .and_then(|v| v.as_u64())
922                    .context("ident_query without a client_port")?;
923                let client_port =
924                    u16::try_from(client_port).context("ident client_port exceeds u16")?;
925
926                let query = format!("{server_port} , {client_port}\r\n");
927                {
928                    let mut writer = write_half.lock().await;
929                    writer.write_all(query.as_bytes()).await?;
930                    writer.flush().await?;
931                }
932                log.debug(format!(
933                    "Ident client {client_id} asked about {server_port},{client_port}"
934                ));
935                if let Ok(mut slot) = pending.lock() {
936                    // The first query is the one the reply answers; RFC 1413 allows only one
937                    // per connection, so a second is written but cannot change what we match.
938                    slot.get_or_insert((server_port, client_port));
939                }
940                Ok(Applied::Sent(query.len()))
941            }
942            ClientActionResult::Disconnect => {
943                // Half-close: the server reads EOF and closes, and the read loop then sees 0
944                // and runs its normal path.
945                let _ = write_half.lock().await.shutdown().await;
946                Ok(Applied::Disconnect)
947            }
948            // WaitForMore, NoAction, an unknown Custom, SendData, nested Multiple.
949            _ => Ok(Applied::Sent(0)),
950        }
951    }
952
953    /// Drain injected commands until the channel closes or an injected `disconnect` ends the
954    /// session.
955    ///
956    /// `command_support::handle_stream_client_command` cannot run this vocabulary, because
957    /// `send_ident_query` yields a `Custom` result: it has to be routed through
958    /// [`Self::apply_action`], the same function the LLM path uses, so that the pending port
959    /// pair is recorded either way. The access-log entry and the reply match the generic arm.
960    #[allow(clippy::too_many_arguments)]
961    async fn command_loop<W>(
962        mut command_rx: mpsc::Receiver<ClientCommand>,
963        protocol: Arc<IdentClientProtocol>,
964        write_half: Arc<Mutex<W>>,
965        pending: PendingQuery,
966        client_id: ClientId,
967        app_state: Arc<AppState>,
968        status_tx: mpsc::UnboundedSender<String>,
969    ) where
970        W: tokio::io::AsyncWrite + Unpin,
971    {
972        use crate::llm::actions::protocol_trait::Protocol;
973
974        while let Some(command) = command_rx.recv().await {
975            let log = Log::new(Some(&status_tx));
976            let action = command.action.clone();
977            let outcome = match protocol.execute_action(action.clone()) {
978                Err(e) => Ok(ClientSendOutcome::Rejected {
979                    error: e.to_string(),
980                }),
981                Ok(result) => Self::apply_action(result, &write_half, &pending, client_id, &log)
982                    .await
983                    .map(|applied| match applied {
984                        Applied::Disconnect => ClientSendOutcome::Disconnected,
985                        Applied::Sent(0) => ClientSendOutcome::Executed {
986                            detail: "executed (nothing to write)".to_string(),
987                        },
988                        Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
989                    }),
990            };
991
992            let outcome_json = match &outcome {
993                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
994                Err(e) => serde_json::json!({"error": e.to_string()}),
995            };
996            app_state
997                .record_access_log(
998                    AccessLogOwner::Client(client_id.as_u32()),
999                    protocol.protocol_name(),
1000                    None,
1001                    "injected_action",
1002                    action,
1003                    vec![outcome_json],
1004                )
1005                .await;
1006
1007            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1008            if let Err(e) = &outcome {
1009                log.error(format!(
1010                    "Ident client {client_id} injected action failed: {e}"
1011                ));
1012            }
1013            let _ = status_tx.send("__UPDATE_UI__".to_string());
1014            crate::client::command_support::reply(command, outcome);
1015
1016            if disconnect {
1017                // Do not wait for the server's own FIN: the rail must stop offering [ send ].
1018                app_state.remove_client_handle(client_id).await;
1019                let _ = status_tx.send("__UPDATE_UI__".to_string());
1020                break;
1021            }
1022        }
1023    }
1024}