Skip to main content

netget/server/ident/
actions.rs

1//! Ident (RFC 1413) protocol actions.
2//!
3//! The wire protocol is two lines. The client sends a port pair; the server answers with
4//! either a USERID line or an ERROR line. Everything interesting is in what may go into
5//! those lines, and this module is deliberately strict about it:
6//!
7//! * **The port pair is echoed, never invented.** RFC 1413 §3 has the client match a reply
8//!   to its query by the port pair, so a reply carrying a different pair is unmatchable —
9//!   indistinguishable, to the client, from no reply at all. Both wire actions take the pair
10//!   as required parameters, and `mod.rs` additionally rewrites it to the pair actually
11//!   received (see `enforce_port_pair` there).
12//! * **Every field the model supplies is sanitised.** A `userid` containing CRLF would let a
13//!   model append a second reply line to the stream; `sanitize_token` removes control
14//!   characters and the `:` separator from the fields where the grammar forbids them.
15//! * **The four error tokens are a closed set.** RFC 1413 §6 defines exactly `INVALID-PORT`,
16//!   `NO-USER`, `HIDDEN-USER` and `UNKNOWN-ERROR`. Anything else is rejected with an error
17//!   naming the accepted set rather than being passed through — a client parses this field,
18//!   it is not free text.
19//!
20//! **NetGet never looks up a real user.** Ident's entire purpose is disclosing which local
21//! account owns a TCP connection, so a NetGet ident server that consulted the host would be
22//! disclosing the operator's own accounts to whoever asked. Nothing here reads `/etc/passwd`,
23//! socket ownership, `getpwuid`, or any other OS state: the model invents the answer, and
24//! that is the only source of a userid.
25
26use crate::llm::actions::{
27    protocol_trait::{ActionResult, Protocol, Server},
28    ActionDefinition, Parameter,
29};
30use crate::protocol::log_template::LogTemplate;
31use crate::protocol::EventType;
32use crate::state::app_state::AppState;
33use anyhow::{Context, Result};
34use serde_json::json;
35use std::sync::LazyLock;
36
37/// The four error tokens RFC 1413 §6 defines. Not extensible: a client parses this field.
38pub const IDENT_ERROR_TOKENS: [&str; 4] =
39    ["INVALID-PORT", "NO-USER", "HIDDEN-USER", "UNKNOWN-ERROR"];
40
41/// RFC 1413 §6 caps a userid at 512 octets. Longer values are truncated rather than
42/// rejected, because a reply is better than a timeout and the model has already decided.
43const MAX_USERID_LEN: usize = 512;
44
45/// `opsys` and `charset` are short tokens from the Assigned Numbers "SYSTEM NAMES" and
46/// character-set registries; nothing legitimate is anywhere near this long.
47const MAX_TOKEN_LEN: usize = 64;
48
49pub struct IdentProtocol;
50
51impl IdentProtocol {
52    pub fn new() -> Self {
53        Self
54    }
55}
56
57impl Default for IdentProtocol {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63/// Strip everything the reply grammar cannot carry.
64///
65/// The reply is a single CRLF-terminated line whose fields are separated by `:`, so a value
66/// containing CR, LF or `:` would either forge a second reply or shift every field after it.
67/// Control characters go too — this text is printed by whatever the client is (historically,
68/// an IRC daemon's log).
69fn sanitize_token(raw: &str, max_len: usize) -> String {
70    let without_separator: String = raw.chars().filter(|c| *c != ':').collect();
71    crate::utils::sanitize::token(&without_separator, max_len)
72}
73
74/// A port the RFC will accept: `1..=65535`. Zero and out-of-range are `INVALID-PORT`
75/// territory, which is a parse verdict rather than something to put on the wire as a port.
76fn port_param(action: &serde_json::Value, key: &str) -> Result<u16> {
77    let raw = action
78        .get(key)
79        .with_context(|| format!("Missing '{}' parameter", key))?;
80
81    // Accept a JSON number or a numeric string: small models produce both, and rejecting
82    // "113" while accepting 113 would be a gratuitous failure on a value we can read.
83    let n = match raw.as_i64() {
84        Some(n) => n,
85        None => raw
86            .as_str()
87            .and_then(|s| s.trim().parse::<i64>().ok())
88            .with_context(|| format!("'{}' must be an integer TCP port, got {}", key, raw))?,
89    };
90
91    if !(1..=65535).contains(&n) {
92        return Err(anyhow::anyhow!(
93            "'{}' must be a TCP port in 1..=65535, got {}",
94            key,
95            n
96        ));
97    }
98    Ok(n as u16)
99}
100
101/// Build the RFC 1413 §3 success reply.
102///
103/// `<port-on-server> , <port-on-client> : USERID : <opsys>[,<charset>] : <userid>`
104pub fn format_userid_reply(
105    server_port: u16,
106    client_port: u16,
107    opsys: &str,
108    charset: Option<&str>,
109    userid: &str,
110) -> Vec<u8> {
111    let opsys = {
112        let s = sanitize_token(opsys, MAX_TOKEN_LEN);
113        if s.is_empty() {
114            "UNIX".to_string()
115        } else {
116            s
117        }
118    };
119    // The charset, when present, is appended to opsys after a comma — it is not its own
120    // field, so a comma inside it would create a third one.
121    let opsys_field = match charset {
122        Some(cs) => {
123            let cs = sanitize_token(cs, MAX_TOKEN_LEN).replace(',', "");
124            if cs.is_empty() {
125                opsys
126            } else {
127                format!("{},{}", opsys, cs)
128            }
129        }
130        None => opsys,
131    };
132
133    let userid = sanitize_token(userid, MAX_USERID_LEN);
134
135    format!(
136        "{} , {} : USERID : {} : {}\r\n",
137        server_port, client_port, opsys_field, userid
138    )
139    .into_bytes()
140}
141
142/// Build the RFC 1413 §6 error reply. `token` must already be one of [`IDENT_ERROR_TOKENS`].
143pub fn format_error_reply(server_port: u16, client_port: u16, token: &str) -> Vec<u8> {
144    format!("{} , {} : ERROR : {}\r\n", server_port, client_port, token).into_bytes()
145}
146
147/// Error reply for a query whose port pair could not be parsed at all.
148///
149/// The pair is echoed as the client wrote it (sanitised), because there are no numbers to
150/// echo and the RFC's reply grammar still wants that position filled.
151pub fn format_error_reply_raw_pair(echo: &str, token: &str) -> Vec<u8> {
152    format!("{} : ERROR : {}\r\n", echo, token).into_bytes()
153}
154
155impl Protocol for IdentProtocol {
156    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
157        // Ident is purely reactive: the server says nothing until a query arrives, and the
158        // connection is over one reply later. There is nothing an operator could usefully
159        // push into it out of band.
160        Vec::new()
161    }
162
163    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
164        vec![
165            send_ident_userid_action(),
166            send_ident_error_action(),
167            close_connection_action(),
168        ]
169    }
170
171    fn protocol_name(&self) -> &'static str {
172        "Ident"
173    }
174
175    fn get_event_types(&self) -> Vec<EventType> {
176        get_ident_event_types()
177    }
178
179    fn stack_name(&self) -> &'static str {
180        "ETH>IP>TCP>Ident"
181    }
182
183    fn keywords(&self) -> Vec<&'static str> {
184        vec!["ident", "identd", "rfc1413", "auth"]
185    }
186
187    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
188        use crate::protocol::metadata::{
189            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
190        };
191
192        ProtocolMetadataV2::builder()
193            .state(DevelopmentState::Experimental)
194            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(113))
195            .well_known_port(113)
196            .implementation(
197                "Hand-rolled tokio TCP line reader; no library. One query, one reply, close, \
198                 per RFC 1413. INVALID-PORT is decided in Rust without an LLM call.",
199            )
200            .llm_control(
201                "The userid, the opsys/charset tokens, and whether the answer is a USERID or \
202                 one of the four RFC 1413 error tokens. The port pair is echoed by NetGet, \
203                 not chosen by the model.",
204            )
205            .e2e_testing(
206                "tests/server/ident/e2e_test.rs, 3 tests, 9 LLM calls, raw TCP sockets only. \
207                 Covers the USERID line, the error tokens, whitespace tolerance, INVALID-PORT \
208                 refused with no LLM call (asserted by call count), and a wrong port pair \
209                 from the handler being rewritten to the queried one. No third-party ident \
210                 client validated this server - see notes.",
211            )
212            .notes(
213                "Experimental, and it is unlikely to move: no runnable third-party ident \
214                 CLIENT exists to validate it against. crates.io has no RFC 1413 client (the \
215                 'ident' namespace is entirely identity/identifier crates); there is no \
216                 ident/identd client binary on macOS and no Homebrew formula for one; PyPI \
217                 has no working ident package. The realistic real-world client is an IRC \
218                 daemon (ngircd links libident), but every ident client hardcodes \
219                 destination port 113 because RFC 1413 has no notion of a configurable \
220                 server port - so none can be pointed at an ephemeral loopback port, and 113 \
221                 needs root. Homebrew's libident is a C library with no CLI and the same port \
222                 limitation. This is the dhcp situation: the strongest evidence the protocol \
223                 admits is an in-test client written from the wire format, which per this \
224                 repo's bar is an independent reading of the spec, not an independent \
225                 implementation. NetGet never consults the host: no /etc/passwd, no socket \
226                 ownership, no getpwuid. The model invents every userid, which is the only \
227                 safe way to run a protocol whose purpose is disclosing account identity.",
228            )
229            .max_inbound_bytes(crate::server::ident::MAX_QUERY_BYTES)
230            .build()
231    }
232
233    fn description(&self) -> &'static str {
234        "Ident (RFC 1413) identification server - answers 'who owns this TCP connection'"
235    }
236
237    fn example_prompt(&self) -> &'static str {
238        "Ident server on port 113 - answer every query with the userid 'nobody' on UNIX"
239    }
240
241    fn group_name(&self) -> &'static str {
242        "Core"
243    }
244
245    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
246        use crate::llm::actions::StartupExamples;
247        StartupExamples::new(
248            // LLM-driven
249            json!({
250                "type": "open_server",
251                "port": 113,
252                "base_stack": "ident",
253                "instruction": "Ident server. Answer queries for port 6667 with the userid 'ircuser' on UNIX; answer everything else with ERROR NO-USER."
254            }),
255            // Script handler - echo the pair the event carries, which is the whole trick.
256            json!({
257                "type": "open_server",
258                "port": 113,
259                "base_stack": "ident",
260                "event_handlers": [{
261                    "event_pattern": "ident_query",
262                    "handler": {
263                        "type": "script",
264                        "language": "python",
265                        "code": "# Echo the port pair back verbatim - the client matches on it.\nrespond([{'type': 'send_ident_userid', 'server_port': event['server_port'], 'client_port': event['client_port'], 'opsys': 'UNIX', 'userid': 'nobody'}])"
266                    }
267                }]
268            }),
269            // Static handler. A static reply cannot echo the pair, so it is only correct for
270            // a client whose port pair you already know; the script form above is the
271            // general answer.
272            json!({
273                "type": "open_server",
274                "port": 113,
275                "base_stack": "ident",
276                "event_handlers": [{
277                    "event_pattern": "ident_query",
278                    "handler": {
279                        "type": "static",
280                        "actions": [{
281                            "type": "send_ident_error",
282                            "server_port": 113,
283                            "client_port": 1234,
284                            "error": "HIDDEN-USER"
285                        }]
286                    }
287                }]
288            }),
289        )
290    }
291}
292
293impl Server for IdentProtocol {
294    fn spawn(
295        &self,
296        ctx: crate::protocol::SpawnContext,
297    ) -> std::pin::Pin<
298        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
299    > {
300        Box::pin(async move {
301            use crate::server::ident::IdentServer;
302            IdentServer::spawn_with_llm_actions(
303                ctx.legacy_listen_addr(),
304                ctx.llm_client,
305                ctx.state,
306                ctx.status_tx,
307                ctx.server_id,
308            )
309            .await
310        })
311    }
312
313    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
314        let action_type = action
315            .get("type")
316            .and_then(|v| v.as_str())
317            .context("Missing 'type' field in action")?;
318
319        match action_type {
320            "send_ident_userid" => self.execute_send_ident_userid(action),
321            "send_ident_error" => self.execute_send_ident_error(action),
322            "close_connection" => Ok(ActionResult::CloseConnection),
323            _ => Err(anyhow::anyhow!("Unknown Ident action: {}", action_type)),
324        }
325    }
326}
327
328impl IdentProtocol {
329    fn execute_send_ident_userid(&self, action: serde_json::Value) -> Result<ActionResult> {
330        let server_port = port_param(&action, "server_port")?;
331        let client_port = port_param(&action, "client_port")?;
332
333        let userid = action
334            .get("userid")
335            .and_then(|v| v.as_str())
336            .context("Missing 'userid' parameter")?;
337
338        if sanitize_token(userid, MAX_USERID_LEN).is_empty() {
339            return Err(anyhow::anyhow!(
340                "'userid' is empty after removing characters the RFC 1413 reply line cannot \
341                 carry (control characters and ':'); supply a plain account name"
342            ));
343        }
344
345        // RFC 1413 §6: opsys should come from the Assigned Numbers SYSTEM NAMES list.
346        // "UNIX" is what essentially every real identd answers, so it is the default.
347        let opsys = action
348            .get("opsys")
349            .and_then(|v| v.as_str())
350            .unwrap_or("UNIX");
351
352        let charset = action.get("charset").and_then(|v| v.as_str());
353
354        Ok(ActionResult::Output(format_userid_reply(
355            server_port,
356            client_port,
357            opsys,
358            charset,
359            userid,
360        )))
361    }
362
363    fn execute_send_ident_error(&self, action: serde_json::Value) -> Result<ActionResult> {
364        let server_port = port_param(&action, "server_port")?;
365        let client_port = port_param(&action, "client_port")?;
366
367        let raw = action
368            .get("error")
369            .and_then(|v| v.as_str())
370            .context("Missing 'error' parameter")?;
371
372        // Closed set. A client parses this field, so an unrecognised token is worse than a
373        // rejected action: the reply would look well-formed and mean nothing.
374        let upper = raw.trim().to_ascii_uppercase();
375        let token = IDENT_ERROR_TOKENS
376            .iter()
377            .find(|t| **t == upper)
378            .with_context(|| {
379                format!(
380                    "'{}' is not an RFC 1413 error token. Use one of: {}",
381                    raw,
382                    IDENT_ERROR_TOKENS.join(", ")
383                )
384            })?;
385
386        Ok(ActionResult::Output(format_error_reply(
387            server_port,
388            client_port,
389            token,
390        )))
391    }
392}
393
394fn port_pair_parameters() -> Vec<Parameter> {
395    vec![
396        Parameter {
397            name: "server_port".to_string(),
398            type_hint: "integer".to_string(),
399            description: "The port on THIS server from the query, echoed back unchanged. Copy \
400                          it from the event's server_port - the client matches the reply to \
401                          its query by the port pair, so a different value is unmatchable"
402                .to_string(),
403            required: true,
404        },
405        Parameter {
406            name: "client_port".to_string(),
407            type_hint: "integer".to_string(),
408            description: "The port on the querying client from the query, echoed back \
409                          unchanged. Copy it from the event's client_port"
410                .to_string(),
411            required: true,
412        },
413    ]
414}
415
416fn send_ident_userid_action() -> ActionDefinition {
417    let mut parameters = port_pair_parameters();
418    parameters.extend([
419        Parameter {
420            name: "userid".to_string(),
421            type_hint: "string".to_string(),
422            description: "The account name to claim owns the connection. Invent it - NetGet \
423                          never looks up a real local user, and must not be asked to"
424                .to_string(),
425            required: true,
426        },
427        Parameter {
428            name: "opsys".to_string(),
429            type_hint: "string".to_string(),
430            description: "Operating system token from the Assigned Numbers SYSTEM NAMES list, \
431                          e.g. UNIX, WIN32, OTHER. Defaults to UNIX"
432                .to_string(),
433            required: false,
434        },
435        Parameter {
436            name: "charset".to_string(),
437            type_hint: "string".to_string(),
438            description: "Optional character set appended to opsys after a comma, e.g. \
439                          US-ASCII, giving 'UNIX,US-ASCII'"
440                .to_string(),
441            required: false,
442        },
443    ]);
444
445    ActionDefinition {
446        name: "send_ident_userid".to_string(),
447        description: "Answer an ident query with a userid: '<server_port> , <client_port> : \
448                      USERID : <opsys> : <userid>'. The server closes the connection after \
449                      the reply, as RFC 1413 requires"
450            .to_string(),
451        parameters,
452        example: json!({
453            "type": "send_ident_userid",
454            "server_port": 6193,
455            "client_port": 23,
456            "opsys": "UNIX",
457            "userid": "stjohns"
458        }),
459        log_template: Some(
460            LogTemplate::new()
461                .with_info("-> IDENT {server_port},{client_port} USERID {userid}")
462                .with_debug(
463                    "IDENT send_ident_userid: {server_port},{client_port} opsys={opsys} \
464                     userid={userid}",
465                ),
466        ),
467    }
468}
469
470fn send_ident_error_action() -> ActionDefinition {
471    let mut parameters = port_pair_parameters();
472    parameters.push(Parameter {
473        name: "error".to_string(),
474        type_hint: "string".to_string(),
475        description: "One of exactly four RFC 1413 tokens: INVALID-PORT (a port was out of \
476                      range or unparseable), NO-USER (no connection matches the pair), \
477                      HIDDEN-USER (it matches but the owner declined to be named), \
478                      UNKNOWN-ERROR (anything else). No other value is accepted"
479            .to_string(),
480        required: true,
481    });
482
483    ActionDefinition {
484        name: "send_ident_error".to_string(),
485        description: "Refuse an ident query: '<server_port> , <client_port> : ERROR : <token>'. \
486                      HIDDEN-USER is the right answer when you do not want to name anyone; \
487                      NO-USER says the connection does not exist. The server closes after \
488                      the reply"
489            .to_string(),
490        parameters,
491        example: json!({
492            "type": "send_ident_error",
493            "server_port": 6193,
494            "client_port": 23,
495            "error": "NO-USER"
496        }),
497        log_template: Some(
498            LogTemplate::new()
499                .with_info("-> IDENT {server_port},{client_port} ERROR {error}")
500                .with_debug("IDENT send_ident_error: {server_port},{client_port} {error}"),
501        ),
502    }
503}
504
505fn close_connection_action() -> ActionDefinition {
506    ActionDefinition {
507        name: "close_connection".to_string(),
508        description: "Close the connection. RFC 1413 has the server close after every reply \
509                      and NetGet does so automatically, so this is only needed to hang up \
510                      from the dashboard - and answering with it ALONE produces no reply, \
511                      which NetGet then fills in as ERROR : UNKNOWN-ERROR rather than \
512                      leaving the client to time out"
513            .to_string(),
514        parameters: vec![],
515        example: json!({"type": "close_connection"}),
516        log_template: Some(
517            LogTemplate::new()
518                .with_info("IDENT connection closed")
519                .with_debug("IDENT close_connection"),
520        ),
521    }
522}
523
524/// Raised once per well-formed query. A query whose ports do not parse never reaches the
525/// model — that is a parse verdict (`INVALID-PORT`), not a decision, and `mod.rs` answers it
526/// in Rust without an LLM call.
527pub static IDENT_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
528    EventType::new(
529        "ident_query",
530        "A client asked which local account owns the TCP connection identified by this port \
531         pair. Answer with an invented userid or one of the four RFC 1413 error tokens; echo \
532         server_port and client_port back unchanged.",
533        json!({
534            "type": "send_ident_userid",
535            "server_port": 113,
536            "client_port": 49152,
537            "opsys": "UNIX",
538            "userid": "nobody"
539        }),
540    )
541    .with_parameters(vec![
542        Parameter {
543            name: "server_port".to_string(),
544            type_hint: "integer".to_string(),
545            description: "Port on this server named in the query. Echo it back unchanged"
546                .to_string(),
547            required: true,
548        },
549        Parameter {
550            name: "client_port".to_string(),
551            type_hint: "integer".to_string(),
552            description: "Port on the querying host named in the query. Echo it back unchanged"
553                .to_string(),
554            required: true,
555        },
556        Parameter {
557            name: "source_addr".to_string(),
558            type_hint: "string".to_string(),
559            description: "Address:port the query itself arrived from".to_string(),
560            required: false,
561        },
562    ])
563    .with_log_template(
564        LogTemplate::new()
565            .with_info("IDENT query {server_port},{client_port}")
566            .with_debug("IDENT query: {server_port},{client_port} from {source_addr}")
567            .with_trace("IDENT: {json_pretty(.)}"),
568    )
569    .with_actions(vec![
570        send_ident_userid_action(),
571        send_ident_error_action(),
572        close_connection_action(),
573    ])
574    .with_alternative_example(json!({
575        "type": "send_ident_error",
576        "server_port": 113,
577        "client_port": 49152,
578        "error": "HIDDEN-USER"
579    }))
580});
581
582pub fn get_ident_event_types() -> Vec<EventType> {
583    vec![IDENT_QUERY_EVENT.clone()]
584}