1use crate::llm::actions::{
11 client_trait::{Client, ClientActionResult},
12 protocol_trait::Protocol,
13 ActionDefinition, Parameter, ParameterDefinition,
14};
15use crate::protocol::log_template::LogTemplate;
16use crate::protocol::EventType;
17use crate::state::app_state::AppState;
18use anyhow::{bail, Context, Result};
19use serde_json::json;
20use std::sync::LazyLock;
21
22pub const DEFAULT_FINGER_PORT: u16 = 79;
26
27pub const DEFAULT_ALLOW_FORWARDING: bool = false;
33
34pub const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30;
36
37pub const MAX_RESPONSE_BYTES: usize = 65_536;
42
43pub const MAX_FOLLOWUP_DEPTH: usize = 4;
49
50pub fn strip_controls(s: &str) -> String {
57 crate::utils::sanitize::strip_controls(s)
58}
59
60pub fn sanitize_response(s: &str) -> String {
65 crate::utils::sanitize::multiline(&s.replace("\r\n", "\n").replace('\r', "\n"))
66}
67
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
81pub struct FingerQuerySpec {
82 pub username: Option<String>,
84 pub verbose: bool,
86 pub forward_host: Option<String>,
88}
89
90impl FingerQuerySpec {
91 pub fn from_action(action: &serde_json::Value) -> Result<Self> {
99 let username = match action.get("username").and_then(|v| v.as_str()) {
100 Some(raw) => {
101 let cleaned = strip_controls(raw).trim().to_string();
102 if cleaned.is_empty() {
103 None
104 } else {
105 if cleaned.contains('@') {
106 bail!(
107 "'username' must not contain '@' ({cleaned:?}). A 'user@host' query \
108 asks the server to relay on your behalf, which RFC 1288 section \
109 3.2.1 calls a security risk; put the host in the separate \
110 'forward_host' parameter, which is refused unless the client was \
111 started with allow_forwarding=true."
112 );
113 }
114 if cleaned.starts_with('/') || cleaned.chars().any(|c| c.is_whitespace()) {
115 bail!(
116 "'username' must not contain whitespace or start with '/' \
117 ({cleaned:?}): the query is a single line and either would forge \
118 an extra field, such as the '/W' verbose token. Use the 'verbose' \
119 parameter to ask for the long format."
120 );
121 }
122 Some(cleaned)
123 }
124 }
125 None => None,
126 };
127
128 let forward_host = match action.get("forward_host").and_then(|v| v.as_str()) {
129 Some(raw) => {
130 let cleaned = strip_controls(raw)
131 .trim()
132 .trim_start_matches('@')
133 .to_string();
134 if cleaned.is_empty() {
135 None
136 } else {
137 if cleaned.chars().any(|c| c.is_whitespace()) {
138 bail!(
139 "'forward_host' must not contain whitespace ({cleaned:?}): the \
140 query is a single line and whitespace would forge an extra field."
141 );
142 }
143 Some(cleaned)
144 }
145 }
146 None => None,
147 };
148
149 let verbose = action
150 .get("verbose")
151 .and_then(|v| v.as_bool())
152 .unwrap_or(false);
153
154 Ok(Self {
155 username,
156 verbose,
157 forward_host,
158 })
159 }
160
161 pub fn to_json(&self) -> serde_json::Value {
163 json!({
164 "username": self.username,
165 "verbose": self.verbose,
166 "forward_host": self.forward_host,
167 })
168 }
169
170 pub fn from_json(value: &serde_json::Value) -> Result<Self> {
173 Self::from_action(value)
174 }
175
176 pub fn query_text(&self) -> String {
178 let mut line = String::new();
179 if self.verbose {
180 line.push_str("/W");
181 }
182 let mut who = String::new();
183 if let Some(user) = &self.username {
184 who.push_str(user);
185 }
186 if let Some(host) = &self.forward_host {
187 who.push('@');
188 who.push_str(host);
189 }
190 if !who.is_empty() {
191 if self.verbose {
192 line.push(' ');
193 }
194 line.push_str(&who);
195 }
196 line
197 }
198
199 pub fn to_wire_line(&self) -> String {
201 format!("{}\r\n", self.query_text())
202 }
203
204 pub fn is_forwarding(&self) -> bool {
206 self.forward_host.is_some()
207 }
208}
209
210pub fn best_effort_fields(text: &str) -> serde_json::Value {
217 let mut logins: Vec<String> = Vec::new();
218 for line in text.lines() {
219 if logins.len() >= 32 {
220 break;
221 }
222 if let Some(rest) = line.strip_prefix("Login:") {
223 if let Some(first) = rest.split_whitespace().next() {
224 if !logins.iter().any(|l| l == first) {
225 logins.push(first.to_string());
226 }
227 }
228 }
229 }
230 json!({
231 "logins": logins,
232 "line_count": text.lines().count(),
233 "note": "GUESS ONLY. RFC 1288 defines no response format; 'logins' is scraped from \
234 lines beginning 'Login:' and is wrong against any daemon that prints \
235 something else. Read 'response' - it is the text the server actually sent.",
236 })
237}
238
239pub static FINGER_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
241 EventType::new(
242 "finger_connected",
243 "Finger client connected; the server is waiting for one query line",
244 json!({
245 "type": "send_finger_query",
246 "username": "alice"
247 }),
248 )
249 .with_parameters(vec![
250 Parameter {
251 name: "remote_addr".to_string(),
252 type_hint: "string".to_string(),
253 description: "The finger server this client is connected to, host:port".to_string(),
254 required: true,
255 },
256 Parameter {
257 name: "allow_forwarding".to_string(),
258 type_hint: "boolean".to_string(),
259 description: "Whether this client will put a 'user@host' forwarding query on the \
260 wire. False by default; a send_finger_query carrying forward_host is \
261 refused and never sent when this is false."
262 .to_string(),
263 required: true,
264 },
265 ])
266 .with_log_template(
267 LogTemplate::new()
268 .with_info("FINGER client connected to {remote_addr}")
269 .with_debug(
270 "FINGER client connected to {remote_addr} allow_forwarding={allow_forwarding}",
271 ),
272 )
273 .with_actions(finger_client_actions())
274 .with_alternative_example(json!({
275 "type": "send_finger_query",
276 "verbose": true
277 }))
278});
279
280pub static FINGER_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
282 EventType::new(
283 "finger_response_received",
284 "The finger server answered and closed the connection",
285 json!({
286 "type": "disconnect"
287 }),
288 )
289 .with_parameters(vec![
290 Parameter {
291 name: "response".to_string(),
292 type_hint: "string".to_string(),
293 description: "The server's answer, verbatim apart from control characters being \
294 stripped and line endings normalised to LF. RFC 1288 defines no \
295 format for it - read it as prose. This field is authoritative."
296 .to_string(),
297 required: true,
298 },
299 Parameter {
300 name: "query".to_string(),
301 type_hint: "string".to_string(),
302 description: "The query line that produced this response, without its CRLF"
303 .to_string(),
304 required: true,
305 },
306 Parameter {
307 name: "username".to_string(),
308 type_hint: "string|null".to_string(),
309 description: "The login that was asked about, or null for the empty list-all query"
310 .to_string(),
311 required: false,
312 },
313 Parameter {
314 name: "verbose".to_string(),
315 type_hint: "boolean".to_string(),
316 description: "'/W' was sent with the query".to_string(),
317 required: true,
318 },
319 Parameter {
320 name: "forward_host".to_string(),
321 type_hint: "string|null".to_string(),
322 description: "The '@host' of the query, or null. Only ever non-null when the \
323 client was started with allow_forwarding=true."
324 .to_string(),
325 required: false,
326 },
327 Parameter {
328 name: "bytes".to_string(),
329 type_hint: "number".to_string(),
330 description: "How many bytes were read from the server".to_string(),
331 required: true,
332 },
333 Parameter {
334 name: "eof".to_string(),
335 type_hint: "boolean".to_string(),
336 description: "The server closed the connection, so the answer is complete. False \
337 means the read stopped first - see 'truncated' and the log."
338 .to_string(),
339 required: true,
340 },
341 Parameter {
342 name: "truncated".to_string(),
343 type_hint: "boolean".to_string(),
344 description: "The response hit the client's size cap and the rest was discarded"
345 .to_string(),
346 required: true,
347 },
348 Parameter {
349 name: "best_effort".to_string(),
350 type_hint: "object".to_string(),
351 description: "A GUESS at structure, nothing more: 'logins' scraped from lines \
352 beginning 'Login:', plus 'line_count'. Wrong against any daemon that \
353 prints a different layout. Always prefer 'response'."
354 .to_string(),
355 required: false,
356 },
357 ])
358 .with_log_template(
359 LogTemplate::new()
360 .with_info("FINGER client got {bytes}B for query {query}")
361 .with_debug("FINGER client response: query={query} bytes={bytes} eof={eof} truncated={truncated}")
362 .with_trace("FINGER client: {json_pretty(.)}"),
363 )
364 .with_actions(finger_client_actions())
365 .with_alternative_example(json!({
366 "type": "send_finger_query",
367 "username": "bob",
368 "verbose": true
369 }))
370});
371
372fn send_finger_query_action() -> ActionDefinition {
373 ActionDefinition {
374 name: "send_finger_query".to_string(),
375 description: "Ask the finger server about a user. Omit 'username' to send the empty \
376 query, which RFC 1288 defines as 'list everyone'. The server answers with \
377 free text and closes, so exactly one query fits on one connection - \
378 sending this again in reply to finger_response_received opens a fresh \
379 connection to the same server (bounded, so a chain cannot run away). \
380 'username' must not contain '@': a 'user@host' query asks the server to \
381 relay, which is refused unless the client was started with \
382 allow_forwarding=true, and it must be requested through 'forward_host' so \
383 it can never happen by accident."
384 .to_string(),
385 parameters: vec![
386 Parameter {
387 name: "username".to_string(),
388 type_hint: "string".to_string(),
389 description: "Login to ask about, e.g. 'alice'. Omit for the empty query, which \
390 asks for every user. No '@', no whitespace, no leading '/'."
391 .to_string(),
392 required: false,
393 },
394 Parameter {
395 name: "verbose".to_string(),
396 type_hint: "boolean".to_string(),
397 description: "Send RFC 1288's '/W' token, asking for the long format. Servers \
398 are free to ignore it. Default false."
399 .to_string(),
400 required: false,
401 },
402 Parameter {
403 name: "forward_host".to_string(),
404 type_hint: "string".to_string(),
405 description: "Ask the server to RELAY this query to another host ('user@host'). \
406 RFC 1288 section 3.2.1 calls forwarding a security risk. Refused \
407 and never sent unless the client was started with \
408 allow_forwarding=true; leave it out for a normal query."
409 .to_string(),
410 required: false,
411 },
412 ],
413 example: json!({
414 "type": "send_finger_query",
415 "username": "alice",
416 "verbose": false
417 }),
418 log_template: Some(
419 LogTemplate::new()
420 .with_info("-> FINGER query {username}")
421 .with_debug("FINGER send_finger_query: username={username} verbose={verbose} forward_host={forward_host}"),
422 ),
423 }
424}
425
426fn wait_for_more_action() -> ActionDefinition {
427 ActionDefinition {
428 name: "wait_for_more".to_string(),
429 description: "Send nothing and keep waiting. On finger_connected this leaves the \
430 connection open with no query on it, which is useful only when a query is \
431 going to be injected from the dashboard. On finger_response_received the \
432 server has already closed, so this simply ends the session."
433 .to_string(),
434 parameters: vec![],
435 example: json!({"type": "wait_for_more"}),
436 log_template: Some(
437 LogTemplate::new()
438 .with_info("FINGER client waiting")
439 .with_debug("FINGER wait_for_more"),
440 ),
441 }
442}
443
444fn disconnect_action() -> ActionDefinition {
445 ActionDefinition {
446 name: "disconnect".to_string(),
447 description: "Close the connection to the finger server. A finger server closes on its \
448 own once it has answered (RFC 1288), so this is mainly for hanging up \
449 before sending any query."
450 .to_string(),
451 parameters: vec![],
452 example: json!({"type": "disconnect"}),
453 log_template: Some(
454 LogTemplate::new()
455 .with_info("FINGER client disconnecting")
456 .with_debug("FINGER disconnect"),
457 ),
458 }
459}
460
461pub fn finger_client_actions() -> Vec<ActionDefinition> {
468 vec![
469 send_finger_query_action(),
470 wait_for_more_action(),
471 disconnect_action(),
472 ]
473}
474
475pub struct FingerClientProtocol;
477
478impl Default for FingerClientProtocol {
479 fn default() -> Self {
480 Self
481 }
482}
483
484impl FingerClientProtocol {
485 pub fn new() -> Self {
486 Self
487 }
488}
489
490impl Protocol for FingerClientProtocol {
491 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
492 finger_client_actions()
493 }
494
495 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
496 Vec::new()
500 }
501
502 fn protocol_name(&self) -> &'static str {
503 "Finger"
504 }
505
506 fn get_event_types(&self) -> Vec<EventType> {
507 vec![
508 FINGER_CLIENT_CONNECTED_EVENT.clone(),
509 FINGER_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
510 ]
511 }
512
513 fn stack_name(&self) -> &'static str {
514 "ETH>IP>TCP>FINGER"
515 }
516
517 fn keywords(&self) -> Vec<&'static str> {
518 vec!["finger", "finger client", "rfc1288", "user lookup"]
519 }
520
521 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
522 vec![
523 ParameterDefinition {
524 name: "port".to_string(),
525 type_hint: "number".to_string(),
526 description: format!(
527 "TCP port to connect to when 'remote_addr' carries no port of its own \
528 (default {DEFAULT_FINGER_PORT}, the assigned finger port). An explicit \
529 port in remote_addr always wins. This exists because the real finger(1) \
530 has no port option at all and is locked to 79, so a test server on a high \
531 port is only reachable through NetGet."
532 ),
533 required: false,
534 example: json!(79),
535 default: None,
536 },
537 ParameterDefinition {
538 name: "allow_forwarding".to_string(),
539 type_hint: "boolean".to_string(),
540 description: "Allow this client to send 'user@host' forwarding queries, asking \
541 the server to relay on our behalf (default false). RFC 1288 \
542 section 3.2.1 calls forwarding a security risk. While false, a \
543 send_finger_query carrying 'forward_host' is refused and no bytes \
544 reach the wire; the refusal is logged as decision=forward_refused."
545 .to_string(),
546 required: false,
547 example: json!(false),
548 default: None,
549 },
550 ParameterDefinition {
551 name: "response_timeout_secs".to_string(),
552 type_hint: "number".to_string(),
553 description: format!(
554 "How long to wait for the server's answer before giving up on the read \
555 (default {DEFAULT_RESPONSE_TIMEOUT_SECS}). A finger client reads until the \
556 server closes, so without this a server that never closes would park the \
557 connection task forever. 0 means wait indefinitely."
558 ),
559 required: false,
560 example: json!(30),
561 default: None,
562 },
563 ]
564 }
565
566 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
567 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
568
569 ProtocolMetadataV2::builder()
570 .state(DevelopmentState::Experimental)
571 .implementation(
572 "Hand-rolled tokio TCP client; the RFC 1288 {Q1}/{Q2} query line is built in \
573 FingerQuerySpec::to_wire_line and the answer is read to EOF. No library, no \
574 dependency. Connecting out needs no privilege even on port 79.",
575 )
576 .llm_control(
577 "The whole query (username, the /W verbose token, and - only when the operator \
578 enabled it - the @host forwarding target), and what to do with the free-text \
579 answer, including asking a follow-up question on a fresh connection.",
580 )
581 .e2e_testing(
582 "tests/client/finger/e2e_test.rs, 10 LLM calls. The round trip runs against \
583 NetGet's own Finger server; the exact query bytes are asserted against a raw \
584 TcpListener.",
585 )
586 .notes(
587 "EXPERIMENTAL, and specifically NOT validated against an independent finger \
588 implementation. The peer in the round-trip test is NetGet's own Finger server, \
589 so it is same-project evidence: it shows the two halves agree, not that either \
590 matches RFC 1288. No fingerd exists on macOS (Apple ships finger(1) only) and \
591 Homebrew has no formula for one, so no third-party daemon was available here. \
592 Promoting this to Beta needs one run against a real finger daemon on loopback - \
593 which, unlike the server's mirror-image problem, is achievable, because a \
594 daemon is inetd-driven and can be told to listen on a high port even though \
595 finger(1) cannot be told to connect to one. \
596 Forwarding (user@host) is refused unless allow_forwarding=true, and the refusal \
597 happens before anything is written. The response is delivered to the model as \
598 text and is deliberately not parsed; the 'best_effort' block is a guess and \
599 says so.",
600 )
601 .build()
602 }
603
604 fn description(&self) -> &'static str {
605 "Finger (RFC 1288) client for looking up user information on a remote host"
606 }
607
608 fn example_prompt(&self) -> &'static str {
609 "Connect to the finger server at 127.0.0.1:79 and ask about 'alice'"
610 }
611
612 fn group_name(&self) -> &'static str {
613 "Core"
614 }
615
616 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
617 use crate::llm::actions::StartupExamples;
618
619 StartupExamples::new(
620 json!({
622 "type": "open_client",
623 "protocol": "finger",
624 "remote_addr": "127.0.0.1:79",
625 "instruction": "Ask the finger server about 'alice'. Read the free-text answer and report her real name, terminal and plan if the server gave them; say so plainly if it did not."
626 }),
627 json!({
629 "type": "open_client",
630 "protocol": "finger",
631 "remote_addr": "127.0.0.1:79",
632 "instruction": "Finger alice",
633 "event_handlers": [{
634 "event_pattern": "finger_connected",
635 "handler": {
636 "type": "script",
637 "language": "python",
638 "code": "respond([{'type': 'send_finger_query', 'username': 'alice'}])"
639 }
640 }]
641 }),
642 json!({
644 "type": "open_client",
645 "protocol": "finger",
646 "remote_addr": "127.0.0.1:79",
647 "instruction": "Finger alice",
648 "event_handlers": [
649 {
650 "event_pattern": "finger_connected",
651 "handler": {
652 "type": "static",
653 "actions": [{"type": "send_finger_query", "username": "alice"}]
654 }
655 },
656 {
657 "event_pattern": "finger_response_received",
658 "handler": {
659 "type": "static",
660 "actions": [{"type": "disconnect"}]
661 }
662 }
663 ]
664 }),
665 )
666 }
667}
668
669impl Client for FingerClientProtocol {
670 fn connect(
671 &self,
672 ctx: crate::protocol::ConnectContext,
673 ) -> std::pin::Pin<
674 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
675 > {
676 Box::pin(async move {
677 use crate::client::finger::{FingerClient, FingerClientConfig};
678
679 let params = ctx.startup_params.as_ref();
682
683 let port = params
684 .map(|p| p.get_optional_u32("port"))
685 .transpose()?
686 .flatten();
687 let port = match port {
688 Some(p) if p == 0 || p > u16::MAX as u32 => {
689 bail!("'port' must be between 1 and 65535, got {p}")
690 }
691 Some(p) => Some(p as u16),
692 None => None,
693 };
694
695 let allow_forwarding = params
696 .map(|p| p.get_optional_bool("allow_forwarding"))
697 .transpose()?
698 .flatten()
699 .unwrap_or(DEFAULT_ALLOW_FORWARDING);
700
701 let response_timeout_secs = params
702 .map(|p| p.get_optional_u64("response_timeout_secs"))
703 .transpose()?
704 .flatten()
705 .unwrap_or(DEFAULT_RESPONSE_TIMEOUT_SECS);
706
707 let config = FingerClientConfig {
708 default_port: port.unwrap_or(DEFAULT_FINGER_PORT),
709 allow_forwarding,
710 response_timeout_secs,
711 };
712
713 FingerClient::connect_with_llm_actions(
714 ctx.remote_addr,
715 config,
716 ctx.llm_client,
717 ctx.state,
718 ctx.status_tx,
719 ctx.client_id,
720 )
721 .await
722 })
723 }
724
725 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
726 let action_type = action
727 .get("type")
728 .and_then(|v| v.as_str())
729 .context("Missing 'type' field in action")?;
730
731 match action_type {
732 "send_finger_query" => {
733 let spec = FingerQuerySpec::from_action(&action)?;
737 Ok(ClientActionResult::Custom {
738 name: "finger_query".to_string(),
739 data: spec.to_json(),
740 })
741 }
742 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
743 "disconnect" => Ok(ClientActionResult::Disconnect),
744 _ => Err(anyhow::anyhow!(
745 "Unknown Finger client action: {}",
746 action_type
747 )),
748 }
749 }
750}