1pub 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
56pub const DEFAULT_IDENT_PORT: u16 = 113;
59
60const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30;
62
63pub const MAX_REPLY_BYTES: usize = 1024;
66
67pub const MAX_FOLLOWUP_DEPTH: usize = 4;
70
71const IDENT_ERROR_TOKENS: [&str; 4] = ["INVALID-PORT", "NO-USER", "HIDDEN-USER", "UNKNOWN-ERROR"];
74
75type PendingQuery = Arc<std::sync::Mutex<Option<(u16, u16)>>>;
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum IdentReply {
84 Userid {
86 server_port: u16,
87 client_port: u16,
88 opsys: String,
89 charset: Option<String>,
90 userid: String,
91 },
92 Error {
94 server_port: u16,
95 client_port: u16,
96 token: String,
97 },
98 Malformed { reason: &'static str },
101}
102
103impl IdentReply {
104 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
122fn 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
137fn 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
150pub 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 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
239fn 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
247fn 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
267pub 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
290enum Applied {
292 Sent(usize),
294 Disconnect,
296}
297
298enum ReplyRead {
300 Line(String),
302 Oversize(String),
304 Closed,
306 TimedOut,
308 Failed(String),
310}
311
312struct 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 ReplyRead::Line(text.lines().next().unwrap_or("").to_string())
895 }
896
897 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 slot.get_or_insert((server_port, client_port));
939 }
940 Ok(Applied::Sent(query.len()))
941 }
942 ClientActionResult::Disconnect => {
943 let _ = write_half.lock().await.shutdown().await;
946 Ok(Applied::Disconnect)
947 }
948 _ => Ok(Applied::Sent(0)),
950 }
951 }
952
953 #[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 app_state.remove_client_handle(client_id).await;
1019 let _ = status_tx.send("__UPDATE_UI__".to_string());
1020 break;
1021 }
1022 }
1023 }
1024}