Skip to main content

netget/server/finger/
actions.rs

1//! Finger (RFC 1288) protocol actions.
2//!
3//! The wire format is free text, so every action here is a *formatter*: the model supplies
4//! structured fields and this file turns them into the conventional block a finger client
5//! prints verbatim. Nothing is read from the host — no `passwd`, no `utmp`, no `.plan` file.
6//! A real finger daemon leaks real accounts; this one invents every user.
7
8use 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
19/// Default for the `answer_forward_queries` startup parameter.
20///
21/// RFC 1288 §3.2.1 names forwarding (`user@host`) as a security risk and recommends against
22/// supporting it. NetGet refuses by default and, at *any* setting, never opens an outbound
23/// connection to the named host — see [`FingerProtocol::get_startup_parameters`].
24pub const DEFAULT_ANSWER_FORWARD_QUERIES: bool = false;
25
26/// Maximum bytes accepted for one query line before the connection is refused.
27///
28/// RFC 1288 sets no limit; an unbounded accumulator is a memory sink for anything that
29/// connects and never sends a newline.
30pub 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/// One parsed `{Q1}` / `{Q2}` query.
47///
48/// RFC 1288 §2.3:
49///
50/// ```text
51/// {Q1} ::= [{W}|{W}{S}{U}] {C}
52/// {Q2} ::= [{W}{S}][{U}]{H}{C}
53/// {W}  ::= "/W"
54/// {U}  ::= username
55/// {H}  ::= @hostname | @hostname{H}
56/// {C}  ::= CRLF
57/// ```
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59pub struct FingerQuery {
60    /// The requested login name, or `None` for the empty ("list everyone") query.
61    pub username: Option<String>,
62    /// The `/W` token was present: the client asked for the long/verbose format.
63    pub verbose: bool,
64    /// The `@host` part of a forwarding query, host chain included, or `None`.
65    pub forward_host: Option<String>,
66    /// No username was given, so the query is for every user on the host.
67    pub list_all: bool,
68}
69
70/// Drop control characters.
71///
72/// Applied to everything parsed off the wire before it reaches the event, and to every
73/// single-line field the model fills in before it reaches the wire. In a line-oriented free
74/// text protocol a stray CR or LF forges a line, and the peer cannot tell a forged line from
75/// a real one.
76fn strip_controls(s: &str) -> String {
77    crate::utils::sanitize::strip_controls(s)
78}
79
80/// Same, but keeping newlines and tabs: for the genuinely multi-line fields (`plan`, `project`).
81///
82/// Line endings are normalised first so a lone CR becomes the line break the model meant —
83/// `to_crlf` below would have done that afterwards, and `utils::sanitize::multiline` drops a
84/// bare CR rather than letting it overwrite a line a reader has already seen.
85fn strip_controls_multiline(s: &str) -> String {
86    crate::utils::sanitize::multiline(&s.replace("\r\n", "\n").replace('\r', "\n"))
87}
88
89/// Normalise arbitrary line endings to CRLF and guarantee a trailing one.
90fn 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    /// Parse one query line (terminator already removed).
107    ///
108    /// Lenient in the two ways that cost nothing: `/W` is matched case-insensitively, and
109    /// surrounding whitespace is ignored. Strict where it matters — an `@` anywhere makes the
110    /// query a forwarding query, whatever else is on the line.
111    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    /// The JSON the `finger_query` event carries.
150    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        // Finger is purely reactive: the server says nothing until the client asks.
163        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            // `?`, never unwrap: these values come from the model or an MCP client, and a
316            // panic here kills the request task before it can report anything.
317            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    /// Format the conventional finger user block.
355    ///
356    /// Every field except `login` is optional and a missing field omits its line entirely,
357    /// rather than printing a placeholder — an invented "Never logged in." would be a
358    /// positive assertion the model never made.
359    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        // `Login: x` padded to column 40 then `Name: y`, which is what BSD fingerd emits and
388        // what a human reading raw finger output expects to see.
389        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        // Presence line, assembled only from the parts that were supplied.
411        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
646/// Raised once per connection, when the client's `{Q1}`/`{Q2}` line has been read.
647///
648/// `forward_host` is present exactly when the client sent `user@host`. It is surfaced rather
649/// than hidden so the attempt is visible to the model, to a handler and in the TUI - but
650/// nothing NetGet does with the answer ever contacts that host.
651pub static FINGER_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
652    EventType::new(
653        "finger_query",
654        "Client sent a Finger (RFC 1288) query",
655        // Rendered verbatim into the documentation the model reads, so it must be an action
656        // this protocol's executor actually accepts.
657        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}