Skip to main content

netget/client/ident/
actions.rs

1//! Ident (RFC 1413) **client** actions, events and metadata.
2//!
3//! The querying half of RFC 1413. NetGet asks another host "which account owns the TCP
4//! connection between these two ports?" and reads back one line. The vocabulary is
5//! deliberately tiny — there is exactly one thing a client may say on the wire — and the
6//! interesting work is in `mod.rs`, which owns the parse and the port-pair check.
7//!
8//! ## Nothing here consults the local system
9//!
10//! The server half's rule ("NetGet invents every userid, it never reads `/etc/passwd`") has a
11//! mirror image on this side: NetGet is the *querier*, so it learns about somebody else's
12//! accounts. The port pair the model asks about is the model's own choice — it is never
13//! derived from a local socket table, `getpwuid`, or any other host state — and the userid
14//! that comes back is reported once, in the event, and nowhere else. It is not written into
15//! memory automatically, not echoed into a status line, and not used to name anything.
16
17use crate::llm::actions::{
18    client_trait::{Client, ClientActionResult},
19    protocol_trait::Protocol,
20    ActionDefinition, Parameter, ParameterDefinition,
21};
22use crate::protocol::EventType;
23use crate::state::app_state::AppState;
24use anyhow::{Context, Result};
25use serde_json::json;
26use std::sync::LazyLock;
27
28/// Raised once, immediately after the TCP connection to the ident server is up.
29///
30/// This is the model's cue to choose a port pair and answer with `send_ident_query`. Nothing
31/// goes on the wire until it does: an ident client speaks first, and NetGet does not invent
32/// the question.
33pub static IDENT_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34    EventType::new(
35        "ident_connected",
36        "Connected to an ident (RFC 1413) server. Answer with send_ident_query naming the \
37         port pair to ask about; nothing is sent until you do.",
38        json!({}),
39    )
40    .with_parameters(vec![Parameter {
41        name: "remote_addr".to_string(),
42        type_hint: "string".to_string(),
43        description: "The ident server actually connected to, host:port".to_string(),
44        required: true,
45    }])
46    .with_actions(client_action_set())
47});
48
49/// A well-formed `USERID` reply **whose port pair matches the query it answers**.
50///
51/// A reply carrying a different pair never reaches this event — see
52/// [`IDENT_CLIENT_REPLY_MISMATCH_EVENT`].
53pub static IDENT_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
54    EventType::new(
55        "ident_response_received",
56        "The ident server named an account: '<server_port> , <client_port> : USERID : \
57         <opsys>[,<charset>] : <userid>'. The port pair has already been checked against the \
58         query, so this really is the answer to what was asked.",
59        json!({}),
60    )
61    .with_parameters(vec![
62        Parameter {
63            name: "server_port".to_string(),
64            type_hint: "integer".to_string(),
65            description: "Server-side port of the pair that was asked about".to_string(),
66            required: true,
67        },
68        Parameter {
69            name: "client_port".to_string(),
70            type_hint: "integer".to_string(),
71            description: "Client-side port of the pair that was asked about".to_string(),
72            required: true,
73        },
74        Parameter {
75            name: "userid".to_string(),
76            type_hint: "string".to_string(),
77            description: "The account the remote host claims owns that connection. It is an \
78                          unverified assertion by a stranger's host; treat it as a hint, never \
79                          as authentication"
80                .to_string(),
81            required: true,
82        },
83        Parameter {
84            name: "opsys".to_string(),
85            type_hint: "string".to_string(),
86            description: "Operating-system token the server reported, e.g. UNIX, WIN32, OTHER"
87                .to_string(),
88            required: true,
89        },
90        Parameter {
91            name: "charset".to_string(),
92            type_hint: "string".to_string(),
93            description: "Character set the server appended to opsys after a comma, e.g. \
94                          US-ASCII. Absent when the server did not send one"
95                .to_string(),
96            required: false,
97        },
98    ])
99    .with_actions(client_action_set())
100});
101
102/// A well-formed `ERROR` reply whose port pair matches the query it answers.
103pub static IDENT_CLIENT_ERROR_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
104    EventType::new(
105        "ident_error_received",
106        "The ident server refused: '<server_port> , <client_port> : ERROR : <token>'. \
107         NO-USER means no connection matches the pair, HIDDEN-USER means it matches but the \
108         owner declined to be named, INVALID-PORT means a port was rejected, UNKNOWN-ERROR is \
109         everything else. This is a normal answer, not a failure.",
110        json!({}),
111    )
112    .with_parameters(vec![
113        Parameter {
114            name: "server_port".to_string(),
115            type_hint: "integer".to_string(),
116            description: "Server-side port of the pair that was asked about".to_string(),
117            required: true,
118        },
119        Parameter {
120            name: "client_port".to_string(),
121            type_hint: "integer".to_string(),
122            description: "Client-side port of the pair that was asked about".to_string(),
123            required: true,
124        },
125        Parameter {
126            name: "error_token".to_string(),
127            type_hint: "string".to_string(),
128            description: "NO-USER, INVALID-PORT, HIDDEN-USER, UNKNOWN-ERROR, or an X-prefixed \
129                          implementation-defined token"
130                .to_string(),
131            required: true,
132        },
133    ])
134    .with_actions(client_action_set())
135});
136
137/// Something came back that is **not an answer to the question that was asked**.
138///
139/// RFC 1413 §3 has the client match a reply to its query by the port pair, and this is the one
140/// correctness property an ident client really has. A reply carrying a different pair is not
141/// "slightly wrong" — it is an answer about a different connection, and accepting it would
142/// attribute a stranger's account to the wrong socket. The same event covers a reply that
143/// cannot be parsed at all, one that overflows the line cap, and one that arrives before any
144/// query was sent: in every case the honest report is "this is not my answer", never a
145/// loosely-parsed result.
146pub static IDENT_CLIENT_REPLY_MISMATCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
147    EventType::new(
148        "ident_reply_mismatch",
149        "The server sent something that is not an answer to the query: a different port pair, \
150         an unparseable line, an oversized line, or a reply with no query outstanding. It has \
151         been rejected rather than parsed loosely.",
152        json!({}),
153    )
154    .with_parameters(vec![
155        Parameter {
156            name: "reason".to_string(),
157            type_hint: "string".to_string(),
158            description: "Why it was rejected: port_pair_mismatch, unsolicited, oversized, or \
159                          one of the parse verdicts (port_pair, response_type, userid_fields, \
160                          error_token, …)"
161                .to_string(),
162            required: true,
163        },
164        Parameter {
165            name: "queried_server_port".to_string(),
166            type_hint: "integer".to_string(),
167            description: "Server-side port this client asked about. Absent when no query was \
168                          outstanding"
169                .to_string(),
170            required: false,
171        },
172        Parameter {
173            name: "queried_client_port".to_string(),
174            type_hint: "integer".to_string(),
175            description: "Client-side port this client asked about. Absent when no query was \
176                          outstanding"
177                .to_string(),
178            required: false,
179        },
180        Parameter {
181            name: "reply_server_port".to_string(),
182            type_hint: "integer".to_string(),
183            description: "Server-side port the reply carried. Absent when the reply had no \
184                          parseable pair"
185                .to_string(),
186            required: false,
187        },
188        Parameter {
189            name: "reply_client_port".to_string(),
190            type_hint: "integer".to_string(),
191            description: "Client-side port the reply carried. Absent when the reply had no \
192                          parseable pair"
193                .to_string(),
194            required: false,
195        },
196        Parameter {
197            name: "reply_line".to_string(),
198            type_hint: "string".to_string(),
199            description: "The offending line as text, control characters stripped and \
200                          truncated. Diagnostic only — it has already been rejected"
201                .to_string(),
202            required: true,
203        },
204    ])
205    .with_actions(client_action_set())
206});
207
208/// The client's entire vocabulary, defined **once**.
209///
210/// It is returned by `get_async_actions()` and attached to every event type, which are two
211/// mechanisms for two different readers and not a duplicated list:
212///
213/// * `client_llm_action_set` (`src/llm/actions/client_trait.rs`) unions async ∪ sync ∪ the
214///   firing event's actions and deduplicates by name, so the model sees each verb once. A
215///   client has one LLM entry point and therefore cannot express a narrowing, which is why
216///   `get_sync_actions()` is empty here rather than a second copy.
217/// * `validate_static_action_names` (`src/events/handler.rs`) builds its catalogue from
218///   `get_sync_actions()` **plus the matching event types' actions**, and never from the async
219///   list. Without the `with_actions` attachment below, a perfectly correct
220///   `{"type": "static", "actions": [{"type": "send_ident_query", …}]}` routing rule is
221///   rejected at creation as an unknown action — which is what the operator, the dashboard's
222///   routing editor and `get_startup_examples()` all try to write.
223fn client_action_set() -> Vec<ActionDefinition> {
224    vec![
225        ActionDefinition {
226            name: "send_ident_query".to_string(),
227            description: "Ask the ident server who owns the TCP connection between two ports: \
228                          sends '<server_port> , <client_port>'. server_port is the port on \
229                          the machine being queried; client_port is the port on the far end \
230                          of that connection. The reply must echo this exact pair or NetGet \
231                          rejects it. RFC 1413 allows one query per connection, so asking \
232                          again opens a fresh one."
233                .to_string(),
234            parameters: vec![
235                Parameter {
236                    name: "server_port".to_string(),
237                    type_hint: "integer".to_string(),
238                    description: "Port on the queried host, 1-65535".to_string(),
239                    required: true,
240                },
241                Parameter {
242                    name: "client_port".to_string(),
243                    type_hint: "integer".to_string(),
244                    description: "Port on the other end of that connection, 1-65535".to_string(),
245                    required: true,
246                },
247            ],
248            example: json!({
249                "type": "send_ident_query",
250                "server_port": 6193,
251                "client_port": 23
252            }),
253            log_template: None,
254        },
255        ActionDefinition {
256            name: "wait_for_more".to_string(),
257            description: "Say nothing and keep reading. Rarely right here: an ident server \
258                          sends exactly one line and closes."
259                .to_string(),
260            parameters: vec![],
261            example: json!({"type": "wait_for_more"}),
262            log_template: None,
263        },
264        ActionDefinition {
265            name: "disconnect".to_string(),
266            description: "Close the connection to the ident server.".to_string(),
267            parameters: vec![],
268            example: json!({"type": "disconnect"}),
269            log_template: None,
270        },
271    ]
272}
273
274/// Ident client protocol handler.
275pub struct IdentClientProtocol;
276
277impl Default for IdentClientProtocol {
278    fn default() -> Self {
279        Self
280    }
281}
282
283impl IdentClientProtocol {
284    pub fn new() -> Self {
285        Self
286    }
287}
288
289/// Read a port out of an action parameter.
290///
291/// Accepts an integer or a decimal string, because models produce both, and insists on
292/// `1..=65535` — an ident query names two real TCP ports and nothing else is answerable.
293///
294/// The error text deliberately avoids the words "unknown"/"unsupported": those are how
295/// `tests/event_action_declarations_test.rs` recognises an *action name* the executor cannot
296/// run, and a missing-parameter complaint must not be mistaken for one.
297fn port_field(action: &serde_json::Value, key: &str) -> Result<u16> {
298    let raw = action
299        .get(key)
300        .with_context(|| format!("send_ident_query requires '{key}' (a TCP port, 1-65535)"))?;
301
302    let value = match raw {
303        serde_json::Value::Number(n) => n.as_i64(),
304        serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
305        _ => None,
306    };
307
308    match value {
309        Some(v) if (1..=65535).contains(&v) => Ok(v as u16),
310        _ => Err(anyhow::anyhow!(
311            "send_ident_query needs '{key}' to be a TCP port in 1-65535, got {raw}"
312        )),
313    }
314}
315
316impl Protocol for IdentClientProtocol {
317    /// Both parameters are read in `mod.rs`: `ident_port` by `resolve_target`, and
318    /// `response_timeout_secs` by the reply read.
319    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
320        vec![
321            ParameterDefinition {
322                name: "ident_port".to_string(),
323                type_hint: "integer".to_string(),
324                description: "TCP port of the ident server. Defaults to 113, which is the only \
325                              port RFC 1413 defines and the only one a real identd listens on \
326                              — override it for a test harness or a non-standard deployment, \
327                              not in the field. Wins over any port written into remote_addr."
328                    .to_string(),
329                required: false,
330                example: json!(113),
331                default: None,
332            },
333            ParameterDefinition {
334                name: "response_timeout_secs".to_string(),
335                type_hint: "integer".to_string(),
336                description: "How long to wait for the single reply line before giving up \
337                              (default 30). An ident server that never answers is common; the \
338                              connection is closed rather than held open."
339                    .to_string(),
340                required: false,
341                example: json!(30),
342                default: None,
343            },
344        ]
345    }
346
347    /// One wire verb plus the two lifecycle verbs.
348    ///
349    /// `get_sync_actions()` is deliberately empty rather than a copy of this list:
350    /// `client_llm_action_set` unions async, sync and the firing event's own actions, so a
351    /// client cannot express a narrowing and duplicating the list buys nothing. See
352    /// `src/llm/actions/client_trait.rs`.
353    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
354        client_action_set()
355    }
356
357    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
358        vec![]
359    }
360
361    fn protocol_name(&self) -> &'static str {
362        "Ident"
363    }
364
365    fn get_event_types(&self) -> Vec<EventType> {
366        vec![
367            IDENT_CLIENT_CONNECTED_EVENT.clone(),
368            IDENT_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
369            IDENT_CLIENT_ERROR_RECEIVED_EVENT.clone(),
370            IDENT_CLIENT_REPLY_MISMATCH_EVENT.clone(),
371        ]
372    }
373
374    fn stack_name(&self) -> &'static str {
375        "ETH>IP>TCP>Ident"
376    }
377
378    fn keywords(&self) -> Vec<&'static str> {
379        vec![
380            "ident",
381            "identd",
382            "rfc1413",
383            "auth",
384            "ident client",
385            "ident lookup",
386        ]
387    }
388
389    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
390        use crate::protocol::metadata::{
391            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
392        };
393
394        ProtocolMetadataV2::builder()
395            .state(DevelopmentState::Experimental)
396            // Connecting *out* to port 113 needs nothing; only listening on it would.
397            .privilege_requirement(PrivilegeRequirement::None)
398            .implementation(
399                "Hand-rolled tokio TCP: one line out, one line in, close. No library exists — \
400                 there is no RFC 1413 crate on crates.io. The reply's port pair is checked \
401                 against the query and a mismatch is rejected, never parsed as a result.",
402            )
403            .llm_control(
404                "Which port pair to ask about, and what to do with the answer — including \
405                 asking again on a fresh connection, up to a depth bound.",
406            )
407            .e2e_testing(
408                "Driven against NetGet's own ident server and against hand-written loopback \
409                 peers that reply with a wrong port pair, a malformed line and an oversized \
410                 line.",
411            )
412            .notes(
413                "Experimental, and hard to move: no third-party ident client or server binary \
414                 with a configurable port exists (see src/server/ident/CLAUDE.md for the \
415                 search), because RFC 1413 has no notion of one. Same-project evidence only.",
416            )
417            .build()
418    }
419
420    fn description(&self) -> &'static str {
421        "Ident (RFC 1413) client: asks a host which account owns a given TCP connection"
422    }
423
424    fn example_prompt(&self) -> &'static str {
425        "Connect to the ident server at 127.0.0.1:113 and ask who owns the connection between \
426         port 6667 and port 49152"
427    }
428
429    fn group_name(&self) -> &'static str {
430        "Network"
431    }
432
433    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
434        use crate::llm::actions::StartupExamples;
435
436        StartupExamples::new(
437            // LLM mode.
438            json!({
439                "type": "open_client",
440                "remote_addr": "127.0.0.1:113",
441                "base_stack": "ident",
442                "instruction": "Ask who owns the connection between server port 6667 and \
443                                client port 49152, and report the userid."
444            }),
445            // Script mode: the connect event chooses the pair, the reply is handled in code.
446            json!({
447                "type": "open_client",
448                "remote_addr": "127.0.0.1:113",
449                "base_stack": "ident",
450                "event_handlers": [{
451                    "event_pattern": "ident_response_received",
452                    "handler": {
453                        "type": "script",
454                        "language": "python",
455                        "code": "<ident_client_handler>"
456                    }
457                }]
458            }),
459            // Static mode: a fixed query on connect, then hang up on the answer. A static
460            // handler cannot read the event, which is fine here because the query's port pair
461            // is chosen by us rather than echoed from anything.
462            json!({
463                "type": "open_client",
464                "remote_addr": "127.0.0.1:113",
465                "base_stack": "ident",
466                "event_handlers": [
467                    {
468                        "event_pattern": "ident_connected",
469                        "handler": {
470                            "type": "static",
471                            "actions": [{
472                                "type": "send_ident_query",
473                                "server_port": 6667,
474                                "client_port": 49152
475                            }]
476                        }
477                    },
478                    {
479                        "event_pattern": "ident_response_received",
480                        "handler": {
481                            "type": "static",
482                            "actions": [{"type": "disconnect"}]
483                        }
484                    }
485                ]
486            }),
487        )
488    }
489}
490
491impl Client for IdentClientProtocol {
492    fn connect(
493        &self,
494        ctx: crate::protocol::ConnectContext,
495    ) -> std::pin::Pin<
496        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
497    > {
498        Box::pin(async move {
499            crate::client::ident::IdentClient::connect_with_llm_actions(
500                ctx.remote_addr,
501                ctx.startup_params,
502                ctx.llm_client,
503                ctx.state,
504                ctx.status_tx,
505                ctx.client_id,
506            )
507            .await
508        })
509    }
510
511    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
512        let action_type = action
513            .get("type")
514            .and_then(|v| v.as_str())
515            .context("Missing 'type' field in action")?;
516
517        match action_type {
518            "send_ident_query" => {
519                let server_port = port_field(&action, "server_port")?;
520                let client_port = port_field(&action, "client_port")?;
521                // Custom rather than SendData: the connection loop has to remember which pair
522                // was asked about in order to check the reply against it, and bytes alone
523                // cannot carry that.
524                Ok(ClientActionResult::Custom {
525                    name: "ident_query".to_string(),
526                    data: json!({
527                        "server_port": server_port,
528                        "client_port": client_port,
529                    }),
530                })
531            }
532            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
533            "disconnect" => Ok(ClientActionResult::Disconnect),
534            _ => Err(anyhow::anyhow!(
535                "Unknown Ident client action: {}",
536                action_type
537            )),
538        }
539    }
540}