1use 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
37pub const IDENT_ERROR_TOKENS: [&str; 4] =
39 ["INVALID-PORT", "NO-USER", "HIDDEN-USER", "UNKNOWN-ERROR"];
40
41const MAX_USERID_LEN: usize = 512;
44
45const 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
63fn 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
74fn 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 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
101pub 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 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
142pub 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
147pub 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 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 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 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 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 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 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
524pub 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}