1pub mod actions;
25
26pub use actions::FingerClientProtocol;
27
28use anyhow::{Context, Result};
29use std::net::SocketAddr;
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::io::{AsyncReadExt, AsyncWriteExt};
33use tokio::net::TcpStream;
34use tokio::sync::{mpsc, Mutex};
35use tracing::{debug, error, info, trace, warn};
36
37use crate::client::llm_budget::call_llm_for_client;
38use crate::llm::actions::client_trait::{Client, ClientActionResult};
39use crate::llm::actions::protocol_trait::Protocol;
40use crate::llm::ollama_client::OllamaClient;
41use crate::llm::ClientLlmResult;
42use crate::protocol::Event;
43use crate::state::app_state::AppState;
44use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
45use crate::state::{AccessLogOwner, ClientId, ClientStatus};
46use crate::utils::truncate_for_log;
47
48use actions::{
49 best_effort_fields, sanitize_response, FingerQuerySpec, DEFAULT_FINGER_PORT,
50 FINGER_CLIENT_CONNECTED_EVENT, FINGER_CLIENT_RESPONSE_RECEIVED_EVENT, MAX_FOLLOWUP_DEPTH,
51 MAX_RESPONSE_BYTES,
52};
53
54#[derive(Clone, Copy, Debug)]
56pub struct FingerClientConfig {
57 pub default_port: u16,
59 pub allow_forwarding: bool,
61 pub response_timeout_secs: u64,
63}
64
65impl Default for FingerClientConfig {
66 fn default() -> Self {
67 Self {
68 default_port: DEFAULT_FINGER_PORT,
69 allow_forwarding: actions::DEFAULT_ALLOW_FORWARDING,
70 response_timeout_secs: actions::DEFAULT_RESPONSE_TIMEOUT_SECS,
71 }
72 }
73}
74
75impl FingerClientConfig {
76 fn response_timeout(&self) -> Option<Duration> {
77 if self.response_timeout_secs == 0 {
78 None
79 } else {
80 Some(Duration::from_secs(self.response_timeout_secs))
81 }
82 }
83}
84
85type SentQuery = Arc<std::sync::Mutex<Option<FingerQuerySpec>>>;
89
90enum Applied {
92 Sent(usize),
94 Refused(String),
96 Disconnect,
98}
99
100#[derive(Clone, Debug)]
102struct Response {
103 text: String,
105 bytes: usize,
107 eof: bool,
109 truncated: bool,
111}
112
113struct SessionCtx {
116 protocol: Arc<FingerClientProtocol>,
117 llm_client: OllamaClient,
118 app_state: Arc<AppState>,
119 status_tx: mpsc::UnboundedSender<String>,
120 client_id: ClientId,
121 target: String,
122 instruction: String,
123 config: FingerClientConfig,
124}
125
126pub fn resolve_target(remote_addr: &str, default_port: u16) -> String {
132 let addr = remote_addr.trim();
133
134 if let Some(rest) = addr.strip_prefix('[') {
135 return match rest.find(']') {
137 Some(close) if addr.len() > close + 2 && addr.as_bytes()[close + 2] == b':' => {
138 addr.to_string()
139 }
140 Some(_) => format!("{addr}:{default_port}"),
141 None => format!("{addr}:{default_port}"),
142 };
143 }
144
145 match addr.matches(':').count() {
146 0 => format!("{addr}:{default_port}"),
147 1 => addr.to_string(),
148 _ => format!("[{addr}]:{default_port}"),
150 }
151}
152
153pub struct FingerClient;
154
155impl FingerClient {
156 pub async fn connect_with_llm_actions(
158 remote_addr: String,
159 config: FingerClientConfig,
160 llm_client: OllamaClient,
161 app_state: Arc<AppState>,
162 status_tx: mpsc::UnboundedSender<String>,
163 client_id: ClientId,
164 ) -> Result<SocketAddr> {
165 let target = resolve_target(&remote_addr, config.default_port);
166
167 let stream = TcpStream::connect(&target)
168 .await
169 .with_context(|| format!("Failed to connect to Finger server at {target}"))?;
170
171 let local_addr = stream.local_addr()?;
172 let remote_sock_addr = stream.peer_addr()?;
173
174 info!(
175 "Finger client {} connected to {} (local: {})",
176 client_id, remote_sock_addr, local_addr
177 );
178 if config.allow_forwarding {
179 warn!(
180 "Finger client {} allow_forwarding=true: 'user@host' queries will be put on the \
181 wire, asking {} to relay on our behalf (RFC 1288 3.2.1 calls this a security \
182 risk)",
183 client_id, target
184 );
185 let _ = status_tx.send(format!(
186 "[CLIENT] ⚠ Finger client {} will send forwarding queries (allow_forwarding=true)",
187 client_id
188 ));
189 }
190
191 app_state
192 .update_client_status(client_id, ClientStatus::Connected)
193 .await;
194 let _ = status_tx.send(format!("[CLIENT] Finger client {} connected", client_id));
195 let _ = status_tx.send("__UPDATE_UI__".to_string());
196
197 let (read_half, write_half) = tokio::io::split(stream);
198 let write_half = Arc::new(Mutex::new(write_half));
199 let protocol = Arc::new(FingerClientProtocol::new());
200 let sent_query: SentQuery = Arc::new(std::sync::Mutex::new(None));
201
202 let command_rx =
206 crate::client::command_support::register_command_channel(&app_state, client_id).await;
207 let cmd_task = tokio::spawn(Self::command_loop(
208 command_rx,
209 protocol.clone(),
210 write_half.clone(),
211 sent_query.clone(),
212 config,
213 client_id,
214 app_state.clone(),
215 status_tx.clone(),
216 ));
217 app_state.register_client_task(client_id, cmd_task).await;
218
219 let task_registrar = app_state.clone();
220 let session_state = app_state.clone();
221 let session_status = status_tx.clone();
222 let task_handle = tokio::spawn(async move {
223 Self::session(
224 read_half,
225 write_half,
226 protocol,
227 sent_query,
228 target,
229 config,
230 llm_client,
231 session_state.clone(),
232 session_status.clone(),
233 client_id,
234 )
235 .await;
236
237 session_state.remove_client_handle(client_id).await;
240 session_state
241 .update_client_status(client_id, ClientStatus::Disconnected)
242 .await;
243 let _ =
244 session_status.send(format!("[CLIENT] Finger client {} disconnected", client_id));
245 let _ = session_status.send("__UPDATE_UI__".to_string());
246 });
247 task_registrar
248 .register_client_task(client_id, task_handle)
249 .await;
250
251 Ok(local_addr)
252 }
253
254 #[allow(clippy::too_many_arguments)]
256 async fn session<R, W>(
257 read_half: R,
258 write_half: Arc<Mutex<W>>,
259 protocol: Arc<FingerClientProtocol>,
260 sent_query: SentQuery,
261 target: String,
262 config: FingerClientConfig,
263 llm_client: OllamaClient,
264 app_state: Arc<AppState>,
265 status_tx: mpsc::UnboundedSender<String>,
266 client_id: ClientId,
267 ) where
268 R: tokio::io::AsyncRead + Unpin,
269 W: tokio::io::AsyncWrite + Unpin,
270 {
271 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
272 warn!(
273 "Finger client {} has no instruction; nothing will be asked",
274 client_id
275 );
276 return;
277 };
278
279 let ctx = Arc::new(SessionCtx {
280 protocol: protocol.clone(),
281 llm_client,
282 app_state: app_state.clone(),
283 status_tx: status_tx.clone(),
284 client_id,
285 target: target.clone(),
286 instruction,
287 config,
288 });
289
290 let event = Event::new(
291 &FINGER_CLIENT_CONNECTED_EVENT,
292 serde_json::json!({
293 "remote_addr": target,
294 "allow_forwarding": config.allow_forwarding,
295 }),
296 );
297
298 let memory = app_state
299 .get_memory_for_client(client_id)
300 .await
301 .unwrap_or_default();
302
303 let mut hung_up = false;
304 match call_llm_for_client(
305 &ctx.llm_client,
306 &app_state,
307 client_id.to_string(),
308 &ctx.instruction,
309 &memory,
310 Some(&event),
311 protocol.as_ref(),
312 &status_tx,
313 )
314 .await
315 {
316 Ok(ClientLlmResult {
317 actions,
318 memory_updates,
319 }) => {
320 if let Some(mem) = memory_updates {
321 app_state.set_memory_for_client(client_id, mem).await;
322 }
323
324 for action in actions {
327 let result = match protocol.execute_action(action.clone()) {
328 Ok(result) => result,
329 Err(e) => {
330 error!("Finger client {} rejected action: {}", client_id, e);
331 let _ = status_tx.send(format!(
332 "[CLIENT] ✖ Finger client {} rejected an action: {}",
333 client_id, e
334 ));
335 continue;
336 }
337 };
338 match Self::apply_action(result, &write_half, &sent_query, config, client_id)
339 .await
340 {
341 Ok(Applied::Sent(_)) => {}
342 Ok(Applied::Refused(reason)) => {
343 warn!("Finger client {} {}", client_id, reason);
344 let _ = status_tx
345 .send(format!("[CLIENT] ⚠ Finger client {} {}", client_id, reason));
346 }
347 Ok(Applied::Disconnect) => {
348 info!(
349 "Finger client {} disconnected before sending a query",
350 client_id
351 );
352 hung_up = true;
353 break;
354 }
355 Err(e) => {
356 error!("Finger client {} failed to send query: {}", client_id, e);
357 app_state
358 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
359 .await;
360 let _ = status_tx.send("__UPDATE_UI__".to_string());
361 return;
362 }
363 }
364 }
365 }
366 Err(e) => {
367 error!("LLM error for Finger client {}: {}", client_id, e);
370 let _ = status_tx.send(format!(
371 "[CLIENT] ⚠ Finger client {} LLM error: {} (still connected; a query can be \
372 injected)",
373 client_id, e
374 ));
375 }
376 }
377
378 if hung_up {
379 let _ = write_half.lock().await.shutdown().await;
380 return;
381 }
382
383 if sent_query.lock().map(|q| q.is_none()).unwrap_or(true) {
384 info!(
385 "Finger client {} has no query on the wire yet; waiting for an injected one or \
386 for {} to close",
387 client_id, target
388 );
389 }
390
391 let response = Self::read_response(read_half, config.response_timeout(), client_id).await;
392 debug!(
393 "Finger client {} read {} bytes (eof={}, truncated={})",
394 client_id, response.bytes, response.eof, response.truncated
395 );
396 trace!(
397 "Finger client {} response: {}",
398 client_id,
399 truncate_for_log(&response.text, 4096)
400 );
401
402 app_state.remove_client_handle(client_id).await;
405 let _ = status_tx.send("__UPDATE_UI__".to_string());
406
407 let spec = sent_query.lock().ok().and_then(|q| q.clone());
408 match spec {
409 Some(spec) => {
410 let _ = status_tx.send(format!(
411 "[CLIENT] Finger client {} received {} bytes for {:?}",
412 client_id,
413 response.bytes,
414 spec.query_text()
415 ));
416 Self::handle_response(ctx, spec, response, 0).await;
417 }
418 None => {
419 info!(
420 "Finger client {} closed with no query ever sent ({} bytes read)",
421 client_id, response.bytes
422 );
423 }
424 }
425 }
426
427 fn handle_response(
437 ctx: Arc<SessionCtx>,
438 spec: FingerQuerySpec,
439 response: Response,
440 depth: usize,
441 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
442 Box::pin(async move {
443 let client_id = ctx.client_id;
444
445 let event = Event::new(
446 &FINGER_CLIENT_RESPONSE_RECEIVED_EVENT,
447 serde_json::json!({
448 "response": response.text,
449 "query": spec.query_text(),
450 "username": spec.username,
451 "verbose": spec.verbose,
452 "forward_host": spec.forward_host,
453 "bytes": response.bytes,
454 "eof": response.eof,
455 "truncated": response.truncated,
456 "best_effort": best_effort_fields(&response.text),
457 }),
458 );
459
460 let memory = ctx
461 .app_state
462 .get_memory_for_client(client_id)
463 .await
464 .unwrap_or_default();
465
466 let result = call_llm_for_client(
467 &ctx.llm_client,
468 &ctx.app_state,
469 client_id.to_string(),
470 &ctx.instruction,
471 &memory,
472 Some(&event),
473 ctx.protocol.as_ref(),
474 &ctx.status_tx,
475 )
476 .await;
477
478 let ClientLlmResult {
479 actions,
480 memory_updates,
481 } = match result {
482 Ok(result) => result,
483 Err(e) => {
484 error!(
485 "LLM error for Finger client {} on response event: {}",
486 client_id, e
487 );
488 return;
489 }
490 };
491
492 if let Some(mem) = memory_updates {
493 ctx.app_state.set_memory_for_client(client_id, mem).await;
494 }
495
496 let mut followed = false;
500 for action in actions {
501 let result = match ctx.protocol.execute_action(action.clone()) {
502 Ok(result) => result,
503 Err(e) => {
504 error!("Finger client {} rejected action: {}", client_id, e);
505 continue;
506 }
507 };
508
509 match result {
510 ClientActionResult::Custom { name, data } if name == "finger_query" => {
511 let next = match FingerQuerySpec::from_json(&data) {
512 Ok(next) => next,
513 Err(e) => {
514 error!("Finger client {} built an invalid query: {}", client_id, e);
515 continue;
516 }
517 };
518
519 if next.is_forwarding() && !ctx.config.allow_forwarding {
520 warn!(
521 "Finger client {} decision=forward_refused query={:?} \
522 forward_host={:?}: RFC 1288 3.2.1 calls forwarding a security \
523 risk; start the client with allow_forwarding=true to permit it",
524 client_id,
525 next.query_text(),
526 next.forward_host
527 );
528 let _ = ctx.status_tx.send(format!(
529 "[CLIENT] ⚠ Finger client {} refused a forwarding follow-up",
530 client_id
531 ));
532 continue;
533 }
534
535 if followed {
536 warn!(
537 "Finger client {} ignoring extra follow-up query {:?}: one query \
538 per turn, because each needs its own connection",
539 client_id,
540 next.query_text()
541 );
542 continue;
543 }
544 if depth + 1 >= MAX_FOLLOWUP_DEPTH {
545 warn!(
546 "Finger client {} reached MAX_FOLLOWUP_DEPTH ({}); not following \
547 up with {:?}",
548 client_id,
549 MAX_FOLLOWUP_DEPTH,
550 next.query_text()
551 );
552 let _ = ctx.status_tx.send(format!(
553 "[CLIENT] ⚠ Finger client {} hit the follow-up depth limit",
554 client_id
555 ));
556 continue;
557 }
558 followed = true;
559
560 info!(
561 "Finger client {} following up with {:?} on a new connection to {} \
562 (depth {})",
563 client_id,
564 next.query_text(),
565 ctx.target,
566 depth + 1
567 );
568
569 match Self::run_query(&ctx.target, &next, ctx.config.response_timeout())
570 .await
571 {
572 Ok(next_response) => {
573 let _ = ctx.status_tx.send(format!(
574 "[CLIENT] Finger client {} received {} bytes for {:?}",
575 client_id,
576 next_response.bytes,
577 next.query_text()
578 ));
579 Self::handle_response(ctx.clone(), next, next_response, depth + 1)
580 .await;
581 }
582 Err(e) => {
583 error!(
584 "Finger client {} follow-up query {:?} failed: {}",
585 client_id,
586 next.query_text(),
587 e
588 );
589 let _ = ctx.status_tx.send(format!(
590 "[CLIENT] ✖ Finger client {} follow-up failed: {}",
591 client_id, e
592 ));
593 }
594 }
595 }
596 ClientActionResult::Disconnect => {
597 debug!(
598 "Finger client {} asked to disconnect; the server already closed",
599 client_id
600 );
601 }
602 ClientActionResult::WaitForMore => {
603 debug!(
604 "Finger client {} waiting; the server already closed this connection",
605 client_id
606 );
607 }
608 other => {
609 trace!(
610 "Finger client {} produced an inert result: {:?}",
611 client_id,
612 other
613 );
614 }
615 }
616 }
617 })
618 }
619
620 async fn run_query(
625 target: &str,
626 spec: &FingerQuerySpec,
627 timeout: Option<Duration>,
628 ) -> Result<Response> {
629 let stream = TcpStream::connect(target)
630 .await
631 .with_context(|| format!("Finger follow-up could not reach {target}"))?;
632 let (read_half, mut write_half) = tokio::io::split(stream);
633
634 write_half
635 .write_all(spec.to_wire_line().as_bytes())
636 .await
637 .context("Finger follow-up failed to write the query")?;
638 write_half
639 .flush()
640 .await
641 .context("Finger follow-up failed to flush the query")?;
642
643 Ok(Self::read_response_inner(read_half, timeout).await)
644 }
645
646 async fn read_response<R>(
653 read_half: R,
654 timeout: Option<Duration>,
655 client_id: ClientId,
656 ) -> Response
657 where
658 R: tokio::io::AsyncRead + Unpin,
659 {
660 let response = Self::read_response_inner(read_half, timeout).await;
661 if !response.eof && !response.truncated {
662 warn!(
663 "Finger client {} stopped reading after {} bytes without seeing EOF; the server \
664 did not close",
665 client_id, response.bytes
666 );
667 }
668 if response.truncated {
669 warn!(
670 "Finger client {} truncated the response at {} bytes",
671 client_id, MAX_RESPONSE_BYTES
672 );
673 }
674 response
675 }
676
677 async fn read_response_inner<R>(mut read_half: R, timeout: Option<Duration>) -> Response
678 where
679 R: tokio::io::AsyncRead + Unpin,
680 {
681 let mut raw: Vec<u8> = Vec::new();
682 let mut truncated = false;
683
684 let read_loop = async {
685 let mut buf = vec![0u8; 4096];
686 loop {
687 match read_half.read(&mut buf).await {
688 Ok(0) => return true,
689 Ok(n) => {
690 let room = MAX_RESPONSE_BYTES.saturating_sub(raw.len());
691 if n >= room {
692 raw.extend_from_slice(&buf[..room]);
693 truncated = true;
694 return false;
695 }
696 raw.extend_from_slice(&buf[..n]);
697 }
698 Err(e) => {
699 debug!("Finger client read error: {}", e);
700 return false;
701 }
702 }
703 }
704 };
705
706 let eof = match timeout {
707 Some(duration) => tokio::time::timeout(duration, read_loop)
708 .await
709 .unwrap_or(false),
710 None => read_loop.await,
711 };
712
713 let bytes = raw.len();
714 Response {
715 text: sanitize_response(&String::from_utf8_lossy(&raw)),
716 bytes,
717 eof,
718 truncated,
719 }
720 }
721
722 async fn apply_action<W>(
729 result: ClientActionResult,
730 write_half: &Arc<Mutex<W>>,
731 sent_query: &SentQuery,
732 config: FingerClientConfig,
733 client_id: ClientId,
734 ) -> Result<Applied>
735 where
736 W: tokio::io::AsyncWrite + Unpin,
737 {
738 match result {
739 ClientActionResult::Custom { name, data } if name == "finger_query" => {
740 let spec = FingerQuerySpec::from_json(&data)?;
741
742 if spec.is_forwarding() && !config.allow_forwarding {
743 let reason = format!(
744 "decision=forward_refused query={:?} forward_host={:?}: RFC 1288 3.2.1 \
745 calls 'user@host' forwarding a security risk, and this client was not \
746 started with allow_forwarding=true. Nothing was sent.",
747 spec.query_text(),
748 spec.forward_host
749 );
750 return Ok(Applied::Refused(reason));
751 }
752
753 let line = spec.to_wire_line();
754 debug!(
755 "Finger client {} querying: {:?}",
756 client_id,
757 spec.query_text()
758 );
759 {
760 let mut writer = write_half.lock().await;
761 writer.write_all(line.as_bytes()).await?;
762 writer.flush().await?;
763 }
764 if let Ok(mut slot) = sent_query.lock() {
765 slot.get_or_insert(spec);
768 }
769 Ok(Applied::Sent(line.len()))
770 }
771 ClientActionResult::Disconnect => {
772 debug!("Finger client {} disconnecting", client_id);
773 let _ = write_half.lock().await.shutdown().await;
776 Ok(Applied::Disconnect)
777 }
778 _ => Ok(Applied::Sent(0)),
780 }
781 }
782
783 #[allow(clippy::too_many_arguments)]
792 async fn command_loop<W>(
793 mut command_rx: mpsc::Receiver<ClientCommand>,
794 protocol: Arc<FingerClientProtocol>,
795 write_half: Arc<Mutex<W>>,
796 sent_query: SentQuery,
797 config: FingerClientConfig,
798 client_id: ClientId,
799 app_state: Arc<AppState>,
800 status_tx: mpsc::UnboundedSender<String>,
801 ) where
802 W: tokio::io::AsyncWrite + Unpin,
803 {
804 while let Some(command) = command_rx.recv().await {
805 let action = command.action.clone();
806 let outcome = match protocol.execute_action(action.clone()) {
807 Err(e) => Ok(ClientSendOutcome::Rejected {
808 error: e.to_string(),
809 }),
810 Ok(result) => {
811 Self::apply_action(result, &write_half, &sent_query, config, client_id)
812 .await
813 .map(|applied| match applied {
814 Applied::Disconnect => ClientSendOutcome::Disconnected,
815 Applied::Refused(reason) => {
816 ClientSendOutcome::Rejected { error: reason }
817 }
818 Applied::Sent(0) => ClientSendOutcome::Executed {
819 detail: "executed (nothing to write)".to_string(),
820 },
821 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
822 })
823 }
824 };
825
826 let outcome_json = match &outcome {
827 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
828 Err(e) => serde_json::json!({"error": e.to_string()}),
829 };
830 app_state
831 .record_access_log(
832 AccessLogOwner::Client(client_id.as_u32()),
833 protocol.protocol_name(),
834 None,
835 "injected_action",
836 action,
837 vec![outcome_json],
838 )
839 .await;
840
841 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
842 match &outcome {
843 Ok(ClientSendOutcome::Rejected { error }) => {
844 warn!(
845 "Finger client {} refused an injected action: {}",
846 client_id, error
847 );
848 let _ = status_tx.send(format!(
849 "[CLIENT] ⚠ Finger client {} refused an injected action: {}",
850 client_id, error
851 ));
852 }
853 Err(e) => {
854 error!("Finger client {} injected action failed: {}", client_id, e);
855 let _ = status_tx.send(format!(
856 "[CLIENT] ✖ Finger client {} injected action failed: {}",
857 client_id, e
858 ));
859 }
860 _ => {}
861 }
862 let _ = status_tx.send("__UPDATE_UI__".to_string());
863 crate::client::command_support::reply(command, outcome);
864
865 if disconnect {
866 app_state.remove_client_handle(client_id).await;
869 let _ = status_tx.send("__UPDATE_UI__".to_string());
870 break;
871 }
872 }
873 }
874}