use crate::llm::actions::{
client_trait::{Client, ClientActionResult},
protocol_trait::Protocol,
ActionDefinition, Parameter, ParameterDefinition,
};
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{Context, Result};
use serde_json::json;
use std::sync::LazyLock;
pub static IDENT_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"ident_connected",
"Connected to an ident (RFC 1413) server. Answer with send_ident_query naming the \
port pair to ask about; nothing is sent until you do.",
json!({}),
)
.with_parameters(vec![Parameter {
name: "remote_addr".to_string(),
type_hint: "string".to_string(),
description: "The ident server actually connected to, host:port".to_string(),
required: true,
}])
.with_actions(client_action_set())
});
pub static IDENT_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"ident_response_received",
"The ident server named an account: '<server_port> , <client_port> : USERID : \
<opsys>[,<charset>] : <userid>'. The port pair has already been checked against the \
query, so this really is the answer to what was asked.",
json!({}),
)
.with_parameters(vec![
Parameter {
name: "server_port".to_string(),
type_hint: "integer".to_string(),
description: "Server-side port of the pair that was asked about".to_string(),
required: true,
},
Parameter {
name: "client_port".to_string(),
type_hint: "integer".to_string(),
description: "Client-side port of the pair that was asked about".to_string(),
required: true,
},
Parameter {
name: "userid".to_string(),
type_hint: "string".to_string(),
description: "The account the remote host claims owns that connection. It is an \
unverified assertion by a stranger's host; treat it as a hint, never \
as authentication"
.to_string(),
required: true,
},
Parameter {
name: "opsys".to_string(),
type_hint: "string".to_string(),
description: "Operating-system token the server reported, e.g. UNIX, WIN32, OTHER"
.to_string(),
required: true,
},
Parameter {
name: "charset".to_string(),
type_hint: "string".to_string(),
description: "Character set the server appended to opsys after a comma, e.g. \
US-ASCII. Absent when the server did not send one"
.to_string(),
required: false,
},
])
.with_actions(client_action_set())
});
pub static IDENT_CLIENT_ERROR_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"ident_error_received",
"The ident server refused: '<server_port> , <client_port> : ERROR : <token>'. \
NO-USER means no connection matches the pair, HIDDEN-USER means it matches but the \
owner declined to be named, INVALID-PORT means a port was rejected, UNKNOWN-ERROR is \
everything else. This is a normal answer, not a failure.",
json!({}),
)
.with_parameters(vec![
Parameter {
name: "server_port".to_string(),
type_hint: "integer".to_string(),
description: "Server-side port of the pair that was asked about".to_string(),
required: true,
},
Parameter {
name: "client_port".to_string(),
type_hint: "integer".to_string(),
description: "Client-side port of the pair that was asked about".to_string(),
required: true,
},
Parameter {
name: "error_token".to_string(),
type_hint: "string".to_string(),
description: "NO-USER, INVALID-PORT, HIDDEN-USER, UNKNOWN-ERROR, or an X-prefixed \
implementation-defined token"
.to_string(),
required: true,
},
])
.with_actions(client_action_set())
});
pub static IDENT_CLIENT_REPLY_MISMATCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
EventType::new(
"ident_reply_mismatch",
"The server sent something that is not an answer to the query: a different port pair, \
an unparseable line, an oversized line, or a reply with no query outstanding. It has \
been rejected rather than parsed loosely.",
json!({}),
)
.with_parameters(vec![
Parameter {
name: "reason".to_string(),
type_hint: "string".to_string(),
description: "Why it was rejected: port_pair_mismatch, unsolicited, oversized, or \
one of the parse verdicts (port_pair, response_type, userid_fields, \
error_token, …)"
.to_string(),
required: true,
},
Parameter {
name: "queried_server_port".to_string(),
type_hint: "integer".to_string(),
description: "Server-side port this client asked about. Absent when no query was \
outstanding"
.to_string(),
required: false,
},
Parameter {
name: "queried_client_port".to_string(),
type_hint: "integer".to_string(),
description: "Client-side port this client asked about. Absent when no query was \
outstanding"
.to_string(),
required: false,
},
Parameter {
name: "reply_server_port".to_string(),
type_hint: "integer".to_string(),
description: "Server-side port the reply carried. Absent when the reply had no \
parseable pair"
.to_string(),
required: false,
},
Parameter {
name: "reply_client_port".to_string(),
type_hint: "integer".to_string(),
description: "Client-side port the reply carried. Absent when the reply had no \
parseable pair"
.to_string(),
required: false,
},
Parameter {
name: "reply_line".to_string(),
type_hint: "string".to_string(),
description: "The offending line as text, control characters stripped and \
truncated. Diagnostic only — it has already been rejected"
.to_string(),
required: true,
},
])
.with_actions(client_action_set())
});
fn client_action_set() -> Vec<ActionDefinition> {
vec![
ActionDefinition {
name: "send_ident_query".to_string(),
description: "Ask the ident server who owns the TCP connection between two ports: \
sends '<server_port> , <client_port>'. server_port is the port on \
the machine being queried; client_port is the port on the far end \
of that connection. The reply must echo this exact pair or NetGet \
rejects it. RFC 1413 allows one query per connection, so asking \
again opens a fresh one."
.to_string(),
parameters: vec![
Parameter {
name: "server_port".to_string(),
type_hint: "integer".to_string(),
description: "Port on the queried host, 1-65535".to_string(),
required: true,
},
Parameter {
name: "client_port".to_string(),
type_hint: "integer".to_string(),
description: "Port on the other end of that connection, 1-65535".to_string(),
required: true,
},
],
example: json!({
"type": "send_ident_query",
"server_port": 6193,
"client_port": 23
}),
log_template: None,
},
ActionDefinition {
name: "wait_for_more".to_string(),
description: "Say nothing and keep reading. Rarely right here: an ident server \
sends exactly one line and closes."
.to_string(),
parameters: vec![],
example: json!({"type": "wait_for_more"}),
log_template: None,
},
ActionDefinition {
name: "disconnect".to_string(),
description: "Close the connection to the ident server.".to_string(),
parameters: vec![],
example: json!({"type": "disconnect"}),
log_template: None,
},
]
}
pub struct IdentClientProtocol;
impl Default for IdentClientProtocol {
fn default() -> Self {
Self
}
}
impl IdentClientProtocol {
pub fn new() -> Self {
Self
}
}
fn port_field(action: &serde_json::Value, key: &str) -> Result<u16> {
let raw = action
.get(key)
.with_context(|| format!("send_ident_query requires '{key}' (a TCP port, 1-65535)"))?;
let value = match raw {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
_ => None,
};
match value {
Some(v) if (1..=65535).contains(&v) => Ok(v as u16),
_ => Err(anyhow::anyhow!(
"send_ident_query needs '{key}' to be a TCP port in 1-65535, got {raw}"
)),
}
}
impl Protocol for IdentClientProtocol {
fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
vec![
ParameterDefinition {
name: "ident_port".to_string(),
type_hint: "integer".to_string(),
description: "TCP port of the ident server. Defaults to 113, which is the only \
port RFC 1413 defines and the only one a real identd listens on \
— override it for a test harness or a non-standard deployment, \
not in the field. Wins over any port written into remote_addr."
.to_string(),
required: false,
example: json!(113),
default: None,
},
ParameterDefinition {
name: "response_timeout_secs".to_string(),
type_hint: "integer".to_string(),
description: "How long to wait for the single reply line before giving up \
(default 30). An ident server that never answers is common; the \
connection is closed rather than held open."
.to_string(),
required: false,
example: json!(30),
default: None,
},
]
}
fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
client_action_set()
}
fn get_sync_actions(&self) -> Vec<ActionDefinition> {
vec![]
}
fn protocol_name(&self) -> &'static str {
"Ident"
}
fn get_event_types(&self) -> Vec<EventType> {
vec![
IDENT_CLIENT_CONNECTED_EVENT.clone(),
IDENT_CLIENT_RESPONSE_RECEIVED_EVENT.clone(),
IDENT_CLIENT_ERROR_RECEIVED_EVENT.clone(),
IDENT_CLIENT_REPLY_MISMATCH_EVENT.clone(),
]
}
fn stack_name(&self) -> &'static str {
"ETH>IP>TCP>Ident"
}
fn keywords(&self) -> Vec<&'static str> {
vec![
"ident",
"identd",
"rfc1413",
"auth",
"ident client",
"ident lookup",
]
}
fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
use crate::protocol::metadata::{
DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
};
ProtocolMetadataV2::builder()
.state(DevelopmentState::Experimental)
.privilege_requirement(PrivilegeRequirement::None)
.implementation(
"Hand-rolled tokio TCP: one line out, one line in, close. No library exists — \
there is no RFC 1413 crate on crates.io. The reply's port pair is checked \
against the query and a mismatch is rejected, never parsed as a result.",
)
.llm_control(
"Which port pair to ask about, and what to do with the answer — including \
asking again on a fresh connection, up to a depth bound.",
)
.e2e_testing(
"Driven against NetGet's own ident server and against hand-written loopback \
peers that reply with a wrong port pair, a malformed line and an oversized \
line.",
)
.notes(
"Experimental, and hard to move: no third-party ident client or server binary \
with a configurable port exists (see src/server/ident/CLAUDE.md for the \
search), because RFC 1413 has no notion of one. Same-project evidence only.",
)
.build()
}
fn description(&self) -> &'static str {
"Ident (RFC 1413) client: asks a host which account owns a given TCP connection"
}
fn example_prompt(&self) -> &'static str {
"Connect to the ident server at 127.0.0.1:113 and ask who owns the connection between \
port 6667 and port 49152"
}
fn group_name(&self) -> &'static str {
"Network"
}
fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
use crate::llm::actions::StartupExamples;
StartupExamples::new(
json!({
"type": "open_client",
"remote_addr": "127.0.0.1:113",
"base_stack": "ident",
"instruction": "Ask who owns the connection between server port 6667 and \
client port 49152, and report the userid."
}),
json!({
"type": "open_client",
"remote_addr": "127.0.0.1:113",
"base_stack": "ident",
"event_handlers": [{
"event_pattern": "ident_response_received",
"handler": {
"type": "script",
"language": "python",
"code": "<ident_client_handler>"
}
}]
}),
json!({
"type": "open_client",
"remote_addr": "127.0.0.1:113",
"base_stack": "ident",
"event_handlers": [
{
"event_pattern": "ident_connected",
"handler": {
"type": "static",
"actions": [{
"type": "send_ident_query",
"server_port": 6667,
"client_port": 49152
}]
}
},
{
"event_pattern": "ident_response_received",
"handler": {
"type": "static",
"actions": [{"type": "disconnect"}]
}
}
]
}),
)
}
}
impl Client for IdentClientProtocol {
fn connect(
&self,
ctx: crate::protocol::ConnectContext,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
> {
Box::pin(async move {
crate::client::ident::IdentClient::connect_with_llm_actions(
ctx.remote_addr,
ctx.startup_params,
ctx.llm_client,
ctx.state,
ctx.status_tx,
ctx.client_id,
)
.await
})
}
fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
let action_type = action
.get("type")
.and_then(|v| v.as_str())
.context("Missing 'type' field in action")?;
match action_type {
"send_ident_query" => {
let server_port = port_field(&action, "server_port")?;
let client_port = port_field(&action, "client_port")?;
Ok(ClientActionResult::Custom {
name: "ident_query".to_string(),
data: json!({
"server_port": server_port,
"client_port": client_port,
}),
})
}
"wait_for_more" => Ok(ClientActionResult::WaitForMore),
"disconnect" => Ok(ClientActionResult::Disconnect),
_ => Err(anyhow::anyhow!(
"Unknown Ident client action: {}",
action_type
)),
}
}
}