1use 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
28pub 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
49pub 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
102pub 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
137pub 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
208fn 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
274pub 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
289fn 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 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 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 .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 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 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 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 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}