1use crate::llm::actions::{
9 protocol_trait::{ActionResult, Protocol, Server},
10 ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{Context, Result};
16use serde_json::json;
17use std::sync::LazyLock;
18
19pub const DEFAULT_ANSWER_FORWARD_QUERIES: bool = false;
25
26pub const MAX_QUERY_BYTES: usize = 1024;
31
32pub struct FingerProtocol;
33
34impl FingerProtocol {
35 pub fn new() -> Self {
36 Self
37 }
38}
39
40impl Default for FingerProtocol {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct FingerQuery {
60 pub username: Option<String>,
62 pub verbose: bool,
64 pub forward_host: Option<String>,
66 pub list_all: bool,
68}
69
70fn strip_controls(s: &str) -> String {
77 crate::utils::sanitize::strip_controls(s)
78}
79
80fn strip_controls_multiline(s: &str) -> String {
86 crate::utils::sanitize::multiline(&s.replace("\r\n", "\n").replace('\r', "\n"))
87}
88
89fn to_crlf(s: &str) -> String {
91 let mut out = String::with_capacity(s.len() + 2);
92 let normalised = s.replace("\r\n", "\n").replace('\r', "\n");
93 for (i, line) in normalised.split('\n').enumerate() {
94 if i > 0 {
95 out.push_str("\r\n");
96 }
97 out.push_str(line);
98 }
99 if !out.ends_with("\r\n") {
100 out.push_str("\r\n");
101 }
102 out
103}
104
105impl FingerQuery {
106 pub fn parse(line: &str) -> Self {
112 let line = strip_controls(line);
113 let mut rest = line.trim();
114
115 let verbose = rest.len() >= 2 && rest[..2].eq_ignore_ascii_case("/W");
116 if verbose {
117 rest = rest[2..].trim();
118 }
119
120 let (user_part, forward_host) = match rest.find('@') {
121 Some(idx) => {
122 let host = rest[idx + 1..].trim();
123 (
124 rest[..idx].trim(),
125 if host.is_empty() {
126 None
127 } else {
128 Some(host.to_string())
129 },
130 )
131 }
132 None => (rest, None),
133 };
134
135 let username = if user_part.is_empty() {
136 None
137 } else {
138 Some(user_part.to_string())
139 };
140
141 Self {
142 list_all: username.is_none(),
143 username,
144 verbose,
145 forward_host,
146 }
147 }
148
149 pub fn to_event_data(&self) -> serde_json::Value {
151 json!({
152 "username": self.username,
153 "verbose": self.verbose,
154 "forward_host": self.forward_host,
155 "list_all": self.list_all,
156 })
157 }
158}
159
160impl Protocol for FingerProtocol {
161 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
162 Vec::new()
164 }
165
166 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
167 finger_actions()
168 }
169
170 fn protocol_name(&self) -> &'static str {
171 "FINGER"
172 }
173
174 fn get_event_types(&self) -> Vec<EventType> {
175 get_finger_event_types()
176 }
177
178 fn stack_name(&self) -> &'static str {
179 "ETH>IP>TCP>FINGER"
180 }
181
182 fn keywords(&self) -> Vec<&'static str> {
183 vec!["finger", "rfc1288"]
184 }
185
186 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
187 vec![crate::llm::actions::ParameterDefinition {
188 name: "answer_forward_queries".to_string(),
189 type_hint: "boolean".to_string(),
190 description: "Answer 'user@host' forwarding queries locally with invented \
191 information instead of refusing them (default: false). NetGet never \
192 connects to the named host at any setting - there is no outbound \
193 code path - so 'true' means only that the query reaches the model as \
194 a finger_query event carrying forward_host. RFC 1288 section 3.2.1 \
195 calls forwarding a security risk and recommends refusing it, which \
196 is why the default is false."
197 .to_string(),
198 required: false,
199 example: json!(false),
200 default: None,
201 }]
202 }
203
204 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
205 use crate::protocol::metadata::{
206 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
207 };
208
209 ProtocolMetadataV2::builder()
210 .state(DevelopmentState::Experimental)
211 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(79))
212 .well_known_port(79)
213 .implementation(
214 "Hand-rolled tokio TCP loop; RFC 1288 {Q1}/{Q2} query grammar parsed in \
215 FingerQuery::parse. No library, no dependency.",
216 )
217 .llm_control(
218 "Every byte of the answer: the user block (login, name, tty, idle, login time, \
219 office, shell, plan, project), free text, and the error line.",
220 )
221 .e2e_testing(
222 "tests/server/finger/e2e_test.rs, 6 LLM calls, all against a raw TCP socket.",
223 )
224 .notes(
225 "EXPERIMENTAL, and specifically NOT validated against a real client. The real \
226 client is finger(1), which exists on macOS/BSD and Linux - but its usage is \
227 `finger [-46gklmpsho] [user ...] [user@host ...]` with no port option, and \
228 `user@host:port` is rejected as a hostname, so it resolves the 'finger' \
229 service and always connects to TCP 79. Validating against it therefore needs \
230 a privileged run binding port 79, which no test here does. The e2e suite uses \
231 a raw socket, which proves the bytes but not that a real client accepts them. \
232 An #[ignore]d root test would not change this rating. \
233 Forwarding (user@host) is refused by default per RFC 1288 section 3.2.1 and \
234 is never proxied at any setting. The server answers exactly one query and \
235 then closes, which is what RFC 1288 specifies and what finger(1) expects, so \
236 close_connection is an early exit rather than a requirement. Nothing local is \
237 read: no passwd, no utmp, no .plan.",
238 )
239 .max_inbound_bytes(crate::server::finger::actions::MAX_QUERY_BYTES)
240 .build()
241 }
242
243 fn description(&self) -> &'static str {
244 "Finger (RFC 1288) user information server"
245 }
246
247 fn example_prompt(&self) -> &'static str {
248 "Finger server on port 79 - invent a plausible user record for any login that is asked for"
249 }
250
251 fn group_name(&self) -> &'static str {
252 "Core"
253 }
254
255 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
256 use crate::llm::actions::StartupExamples;
257 StartupExamples::new(
258 json!({
259 "type": "open_server",
260 "port": 79,
261 "base_stack": "finger",
262 "instruction": "Finger server. Invent a plausible record for whatever login is asked for, and answer the empty query with a short list of two users."
263 }),
264 json!({
265 "type": "open_server",
266 "port": 79,
267 "base_stack": "finger",
268 "event_handlers": [{
269 "event_pattern": "finger_query",
270 "handler": {
271 "type": "script",
272 "language": "python",
273 "code": "user = event.get('username')\nif not user:\n respond([{'type': 'send_finger_response', 'text': 'Login Name Tty Idle Login Time\\nalice Alice Smith * 2 Mon 09:12\\nbob Bob Jones con - Mon 08:40'}])\nelse:\n respond([{'type': 'send_finger_user', 'login': user, 'name': 'Test User', 'tty': 'ttys002', 'idle': '5 minutes', 'login_time': 'Mon Sep 1 09:12', 'office': 'Room 101', 'office_phone': 'x1234', 'shell': '/bin/sh', 'plan': 'No plan.'}])"
274 }
275 }]
276 }),
277 json!({
278 "type": "open_server",
279 "port": 79,
280 "base_stack": "finger",
281 "event_handlers": [{
282 "event_pattern": "finger_query",
283 "handler": {
284 "type": "static",
285 "actions": [{
286 "type": "send_finger_user",
287 "login": "alice",
288 "name": "Alice Smith",
289 "tty": "ttys002",
290 "idle": "5 minutes",
291 "login_time": "Mon Sep 1 09:12",
292 "office": "Room 101",
293 "office_phone": "x1234",
294 "shell": "/bin/sh",
295 "project": "Networking",
296 "plan": "Ship the finger server."
297 }]
298 }
299 }]
300 }),
301 )
302 }
303}
304
305impl Server for FingerProtocol {
306 fn spawn(
307 &self,
308 ctx: crate::protocol::SpawnContext,
309 ) -> std::pin::Pin<
310 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
311 > {
312 Box::pin(async move {
313 use crate::server::finger::FingerServer;
314
315 let answer_forward_queries = ctx
318 .startup_params
319 .as_ref()
320 .map(|p| p.get_optional_bool("answer_forward_queries"))
321 .transpose()?
322 .flatten()
323 .unwrap_or(DEFAULT_ANSWER_FORWARD_QUERIES);
324
325 FingerServer::spawn_with_llm_actions(
326 ctx.legacy_listen_addr(),
327 ctx.llm_client,
328 ctx.state,
329 ctx.status_tx,
330 ctx.server_id,
331 answer_forward_queries,
332 )
333 .await
334 })
335 }
336
337 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
338 let action_type = action
339 .get("type")
340 .and_then(|v| v.as_str())
341 .context("Missing 'type' field in action")?;
342
343 match action_type {
344 "send_finger_user" => self.execute_send_finger_user(action),
345 "send_finger_response" => self.execute_send_finger_response(action),
346 "send_finger_error" => self.execute_send_finger_error(action),
347 "close_connection" => Ok(ActionResult::CloseConnection),
348 _ => Err(anyhow::anyhow!("Unknown FINGER action: {}", action_type)),
349 }
350 }
351}
352
353impl FingerProtocol {
354 fn execute_send_finger_user(&self, action: serde_json::Value) -> Result<ActionResult> {
360 let field = |key: &str| -> Option<String> {
361 action
362 .get(key)
363 .and_then(|v| v.as_str())
364 .map(strip_controls)
365 .map(|s| s.trim().to_string())
366 .filter(|s| !s.is_empty())
367 };
368 let multiline = |key: &str| -> Option<String> {
369 action
370 .get(key)
371 .and_then(|v| v.as_str())
372 .map(strip_controls_multiline)
373 .map(|s| s.trim_end().to_string())
374 .filter(|s| !s.is_empty())
375 };
376
377 let login = action
378 .get("login")
379 .and_then(|v| v.as_str())
380 .map(strip_controls)
381 .map(|s| s.trim().to_string())
382 .filter(|s| !s.is_empty())
383 .context("Missing 'login' parameter")?;
384
385 let mut out = String::new();
386
387 let mut first = format!("Login: {}", login);
390 if let Some(name) = field("name") {
391 while first.len() < 40 {
392 first.push(' ');
393 }
394 first.push_str(&format!("Name: {}", name));
395 }
396 out.push_str(&first);
397 out.push_str("\r\n");
398
399 if let Some(shell) = field("shell") {
400 out.push_str(&format!("Shell: {}\r\n", shell));
401 }
402
403 match (field("office"), field("office_phone")) {
404 (Some(o), Some(p)) => out.push_str(&format!("Office: {}, {}\r\n", o, p)),
405 (Some(o), None) => out.push_str(&format!("Office: {}\r\n", o)),
406 (None, Some(p)) => out.push_str(&format!("Office Phone: {}\r\n", p)),
407 (None, None) => {}
408 }
409
410 let mut presence = String::new();
412 if let Some(lt) = field("login_time") {
413 presence.push_str(&format!("On since {}", lt));
414 }
415 if let Some(tty) = field("tty") {
416 if presence.is_empty() {
417 presence.push_str("On");
418 }
419 presence.push_str(&format!(" on {}", tty));
420 }
421 if let Some(idle) = field("idle") {
422 if presence.is_empty() {
423 presence.push_str(&format!("Idle {}", idle));
424 } else {
425 presence.push_str(&format!(", idle {}", idle));
426 }
427 }
428 if !presence.is_empty() {
429 out.push_str(&presence);
430 out.push_str("\r\n");
431 }
432
433 if let Some(project) = multiline("project") {
434 out.push_str("Project:\r\n");
435 out.push_str(&to_crlf(&project));
436 }
437 if let Some(plan) = multiline("plan") {
438 out.push_str("Plan:\r\n");
439 out.push_str(&to_crlf(&plan));
440 }
441
442 Ok(ActionResult::Output(out.into_bytes()))
443 }
444
445 fn execute_send_finger_response(&self, action: serde_json::Value) -> Result<ActionResult> {
446 let text = action
447 .get("text")
448 .and_then(|v| v.as_str())
449 .context("Missing 'text' parameter")?;
450
451 Ok(ActionResult::Output(
452 to_crlf(&strip_controls_multiline(text)).into_bytes(),
453 ))
454 }
455
456 fn execute_send_finger_error(&self, action: serde_json::Value) -> Result<ActionResult> {
457 let message = action
458 .get("message")
459 .and_then(|v| v.as_str())
460 .map(strip_controls)
461 .map(|s| s.trim().to_string())
462 .filter(|s| !s.is_empty())
463 .unwrap_or_else(|| "no such user".to_string());
464
465 Ok(ActionResult::Output(
466 format!("finger: {}\r\n", message).into_bytes(),
467 ))
468 }
469}
470
471fn send_finger_user_action() -> ActionDefinition {
472 ActionDefinition {
473 name: "send_finger_user".to_string(),
474 description: "Send one user's information as the conventional finger block (Login/Name, \
475 Shell, Office, the 'On since ... idle ...' line, Project and Plan). Only \
476 'login' is required; every field you omit omits its line rather than \
477 printing a placeholder. Invent the values - this server reads nothing \
478 from the host. The server closes the connection once the response is \
479 written (RFC 1288), so close_connection is only needed to answer with \
480 nothing at all."
481 .to_string(),
482 parameters: vec![
483 Parameter {
484 name: "login".to_string(),
485 type_hint: "string".to_string(),
486 description: "Login name".to_string(),
487 required: true,
488 },
489 Parameter {
490 name: "name".to_string(),
491 type_hint: "string".to_string(),
492 description: "Real name".to_string(),
493 required: false,
494 },
495 Parameter {
496 name: "tty".to_string(),
497 type_hint: "string".to_string(),
498 description: "Terminal the user is logged in on, e.g. 'ttys002'".to_string(),
499 required: false,
500 },
501 Parameter {
502 name: "idle".to_string(),
503 type_hint: "string".to_string(),
504 description: "Idle time as text, e.g. '5 minutes' or '2:11'".to_string(),
505 required: false,
506 },
507 Parameter {
508 name: "login_time".to_string(),
509 type_hint: "string".to_string(),
510 description: "Login time as text, e.g. 'Mon Sep 1 09:12'".to_string(),
511 required: false,
512 },
513 Parameter {
514 name: "office".to_string(),
515 type_hint: "string".to_string(),
516 description: "Office location".to_string(),
517 required: false,
518 },
519 Parameter {
520 name: "office_phone".to_string(),
521 type_hint: "string".to_string(),
522 description: "Office phone number".to_string(),
523 required: false,
524 },
525 Parameter {
526 name: "shell".to_string(),
527 type_hint: "string".to_string(),
528 description: "Login shell, e.g. '/bin/sh'".to_string(),
529 required: false,
530 },
531 Parameter {
532 name: "plan".to_string(),
533 type_hint: "string".to_string(),
534 description: "Contents of the user's .plan, printed under a 'Plan:' heading. \
535 May be several lines."
536 .to_string(),
537 required: false,
538 },
539 Parameter {
540 name: "project".to_string(),
541 type_hint: "string".to_string(),
542 description: "Contents of the user's .project, printed under a 'Project:' \
543 heading. May be several lines."
544 .to_string(),
545 required: false,
546 },
547 ],
548 example: json!({
549 "type": "send_finger_user",
550 "login": "alice",
551 "name": "Alice Smith",
552 "tty": "ttys002",
553 "idle": "5 minutes",
554 "login_time": "Mon Sep 1 09:12",
555 "office": "Room 101",
556 "office_phone": "x1234",
557 "shell": "/bin/sh",
558 "project": "Networking",
559 "plan": "Ship the finger server."
560 }),
561 log_template: Some(
562 LogTemplate::new()
563 .with_info("-> FINGER user {login}")
564 .with_debug("FINGER send_finger_user: login={login}, tty={tty}, idle={idle}"),
565 ),
566 }
567}
568
569fn send_finger_response_action() -> ActionDefinition {
570 ActionDefinition {
571 name: "send_finger_response".to_string(),
572 description: "Send free text as the finger response - a listing of several users, a \
573 header line, or anything the user block does not fit. Line endings are \
574 normalised to CRLF. The server closes once the response is written."
575 .to_string(),
576 parameters: vec![Parameter {
577 name: "text".to_string(),
578 type_hint: "string".to_string(),
579 description: "The text to send. Newlines are allowed and become CRLF.".to_string(),
580 required: true,
581 }],
582 example: json!({
583 "type": "send_finger_response",
584 "text": "Login Name Tty Idle Login Time\nalice Alice Smith * 2 Mon 09:12\nbob Bob Jones con - Mon 08:40"
585 }),
586 log_template: Some(
587 LogTemplate::new()
588 .with_info("-> FINGER response ({text_len}B)")
589 .with_debug("FINGER send_finger_response: {text_len} bytes"),
590 ),
591 }
592}
593
594fn send_finger_error_action() -> ActionDefinition {
595 ActionDefinition {
596 name: "send_finger_error".to_string(),
597 description: "Refuse the query with a one-line 'finger: <message>' notice - an unknown \
598 login, a refused listing, a refused forwarding attempt."
599 .to_string(),
600 parameters: vec![Parameter {
601 name: "message".to_string(),
602 type_hint: "string".to_string(),
603 description: "Reason, e.g. 'no such user' (default) or 'listing all users is not \
604 permitted'"
605 .to_string(),
606 required: false,
607 }],
608 example: json!({
609 "type": "send_finger_error",
610 "message": "no such user"
611 }),
612 log_template: Some(
613 LogTemplate::new()
614 .with_info("-> FINGER error: {message}")
615 .with_debug("FINGER send_finger_error: {message}"),
616 ),
617 }
618}
619
620fn close_connection_action() -> ActionDefinition {
621 ActionDefinition {
622 name: "close_connection".to_string(),
623 description: "Close the connection without writing anything further. The server \
624 already closes after each response (RFC 1288), so this is only needed \
625 to answer with nothing at all."
626 .to_string(),
627 parameters: vec![],
628 example: json!({"type": "close_connection"}),
629 log_template: Some(
630 LogTemplate::new()
631 .with_info("FINGER connection closed")
632 .with_debug("FINGER close_connection"),
633 ),
634 }
635}
636
637fn finger_actions() -> Vec<ActionDefinition> {
638 vec![
639 send_finger_user_action(),
640 send_finger_response_action(),
641 send_finger_error_action(),
642 close_connection_action(),
643 ]
644}
645
646pub static FINGER_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
652 EventType::new(
653 "finger_query",
654 "Client sent a Finger (RFC 1288) query",
655 json!({
658 "type": "send_finger_user",
659 "login": "alice",
660 "name": "Alice Smith",
661 "tty": "ttys002",
662 "idle": "5 minutes",
663 "login_time": "Mon Sep 1 09:12",
664 "office": "Room 101",
665 "office_phone": "x1234",
666 "shell": "/bin/sh",
667 "plan": "Ship the finger server."
668 }),
669 )
670 .with_parameters(vec![
671 Parameter {
672 name: "username".to_string(),
673 type_hint: "string|null".to_string(),
674 description: "The requested login name, or null when the query was empty".to_string(),
675 required: false,
676 },
677 Parameter {
678 name: "verbose".to_string(),
679 type_hint: "boolean".to_string(),
680 description: "The client sent '/W', asking for the long format".to_string(),
681 required: true,
682 },
683 Parameter {
684 name: "forward_host".to_string(),
685 type_hint: "string|null".to_string(),
686 description: "The '@host' of a forwarding query, or null. NetGet never contacts \
687 this host; the field exists so the attempt is visible."
688 .to_string(),
689 required: false,
690 },
691 Parameter {
692 name: "list_all".to_string(),
693 type_hint: "boolean".to_string(),
694 description: "No username was given: the client asked for every user".to_string(),
695 required: true,
696 },
697 ])
698 .with_log_template(
699 LogTemplate::new()
700 .with_info("FINGER query user={username} verbose={verbose} list_all={list_all}")
701 .with_debug("FINGER query: user={username} forward_host={forward_host}")
702 .with_trace("FINGER: {json_pretty(.)}"),
703 )
704 .with_actions(finger_actions())
705 .with_alternative_example(json!({
706 "type": "send_finger_error",
707 "message": "no such user"
708 }))
709});
710
711pub fn get_finger_event_types() -> Vec<EventType> {
712 vec![FINGER_QUERY_EVENT.clone()]
713}