1use crate::llm::actions::{
2 protocol_trait::{ActionResult, Protocol, Server},
3 ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Context, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct EppProtocol;
14impl EppProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21 Parameter {
22 name: name.into(),
23 type_hint: kind.into(),
24 description: description.into(),
25 required,
26 }
27}
28
29pub fn action(
30 name: &str,
31 description: &str,
32 parameters: Vec<Parameter>,
33 example: Value,
34) -> ActionDefinition {
35 ActionDefinition {
36 name: name.into(),
37 description: description.into(),
38 parameters,
39 example,
40 log_template: Some(LogTemplate::new().with_info(format!("EPP {name}"))),
41 }
42}
43
44fn code_param(default: &str) -> Parameter {
45 parameter(
46 "code",
47 "number",
48 &format!("RFC 5730 result code; {default} when omitted"),
49 false,
50 )
51}
52
53fn check_result() -> ActionDefinition {
54 action(
55 "epp_check_result",
56 "Answer a check: whether each name (domain, host) or id (contact) is available",
57 vec![parameter("results", "array", "[{name, available, reason}], one per checked object, reason when not available (e.g. In use)", true)],
58 json!({"type": "epp_check_result", "results": [{"name": "example.com", "available": false, "reason": "In use"}, {"name": "example.net", "available": true}]}),
59 )
60}
61fn info() -> ActionDefinition {
62 action(
63 "epp_info",
64 "Answer an info command with the object's data",
65 vec![parameter("info", "object", "The object's data. Domain: name, roid, status[], registrant, contacts[{type,id}], ns[], cl_id, cr_date, ex_date, auth_info. Host: name, roid, status[], addrs[{ip: v4|v6, addr}], cl_id, cr_date. Contact: id, roid, status[], name, org, street[], city, sp, pc, cc, voice, email, cl_id, cr_date. Dates as 2026-10-04T12:00:00.0Z", true)],
66 json!({"type": "epp_info", "info": {"name": "example.com", "roid": "EXAMPLE1-REP", "status": ["ok"], "registrant": "jd1234", "contacts": [{"type": "admin", "id": "sh8013"}], "ns": ["ns1.example.net"], "cl_id": "ClientX", "cr_date": "2024-04-03T22:00:00.0Z", "ex_date": "2027-04-03T22:00:00.0Z"}}),
67 )
68}
69fn created() -> ActionDefinition {
70 action(
71 "epp_created",
72 "Answer a successful create",
73 vec![
74 parameter(
75 "name",
76 "string",
77 "The created domain or host name (contacts use id)",
78 false,
79 ),
80 parameter("id", "string", "The created contact id", false),
81 parameter(
82 "cr_date",
83 "string",
84 "Creation time, e.g. 2026-10-04T12:00:00.0Z",
85 true,
86 ),
87 parameter("ex_date", "string", "Domain expiry time", false),
88 ],
89 json!({"type": "epp_created", "name": "example.com", "cr_date": "2026-10-04T12:00:00.0Z", "ex_date": "2027-10-04T12:00:00.0Z"}),
90 )
91}
92fn renewed() -> ActionDefinition {
93 action(
94 "epp_renewed",
95 "Answer a successful domain renew with the new expiry",
96 vec![
97 parameter(
98 "name",
99 "string",
100 "The renewed domain name, e.g. example.com",
101 true,
102 ),
103 parameter("ex_date", "string", "The new expiry time", true),
104 ],
105 json!({"type": "epp_renewed", "name": "example.com", "ex_date": "2028-04-03T22:00:00.0Z"}),
106 )
107}
108fn transfer_status() -> ActionDefinition {
109 action(
110 "epp_transfer_status",
111 "Answer a transfer request or query with the transfer's state (code 1001 for a request now pending)",
112 vec![
113 parameter("name", "string", "The domain (contacts use id)", false),
114 parameter("id", "string", "The contact id, for a contact transfer", false),
115 parameter("tr_status", "string", "pending, clientApproved, clientCancelled, clientRejected, serverApproved or serverCancelled", true),
116 parameter("re_id", "string", "Requesting client id", true),
117 parameter("re_date", "string", "When the transfer was requested, e.g. 2026-10-04T12:00:00.0Z", true),
118 parameter("ac_id", "string", "Client that must act", true),
119 parameter("ac_date", "string", "Time by which it must act", true),
120 parameter("ex_date", "string", "Domain expiry after the transfer", false),
121 code_param("1000"),
122 ],
123 json!({"type": "epp_transfer_status", "name": "example.com", "tr_status": "pending", "re_id": "ClientX", "re_date": "2026-10-04T12:00:00.0Z", "ac_id": "ClientY", "ac_date": "2026-10-09T12:00:00.0Z", "code": 1001}),
124 )
125}
126fn result() -> ActionDefinition {
127 action(
128 "epp_result",
129 "Answer with a result code alone: success for update, delete or renew/transfer without data, or a refusal (2302 exists, 2303 does not exist, 2201 authorization, 2202 bad authInfo, 2304 status prohibits, 2306 policy, 2400 failed)",
130 vec![
131 parameter("code", "number", "RFC 5730 result code", true),
132 parameter("reason", "string", "A sentence explaining a refusal, e.g. Domain is locked", false),
133 ],
134 json!({"type": "epp_result", "code": 2303, "reason": "No such domain"}),
135 )
136}
137
138pub static COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
139 EventType::new(
140 "epp_command",
141 "A logged-in registrar sent an object command; answer it as the registry",
142 result().example,
143 )
144 .with_parameters(vec![
145 parameter("command", "string", "check, info, create, renew, transfer, update or delete", true),
146 parameter("object", "string", "domain, host or contact", true),
147 parameter("fields", "object", "The command's content: names for check; name or id (and auth_info) for info and delete; the object's data for create; cur_exp_date and period for renew; op, period and auth_info for transfer; add/rem/chg for update", true),
148 parameter("client_id", "string", "The logged-in registrar", true),
149 parameter("cl_trid", "string", "The client transaction id", false),
150 ])
151 .with_actions(vec![check_result(), info(), created(), renewed(), transfer_status(), result()])
152});
153
154pub fn validate(v: &Value) -> Result<()> {
155 let code_ok = |c: &Value, success_only: bool| -> Result<()> {
156 if c.is_null() {
157 return Ok(());
158 }
159 let c = c.as_u64().context("code is a number")? as u16;
160 ensure!(
161 super::wire::RESULT_CODES.iter().any(|(k, _)| *k == c),
162 "{c} is not an RFC 5730 result code"
163 );
164 ensure!(
165 !success_only || c < 2000,
166 "a data answer has a success code"
167 );
168 Ok(())
169 };
170 match v["type"].as_str() {
171 Some("epp_check_result") => {
172 let r = v["results"].as_array().context("results is an array")?;
173 ensure!(!r.is_empty() && r.len() <= 64, "1 to 64 results");
174 for x in r {
175 ensure!(
176 x["name"].is_string() && x["available"].is_boolean(),
177 "each result has name and available"
178 );
179 }
180 }
181 Some("epp_info") => ensure!(v["info"].is_object(), "info is an object"),
182 Some("epp_created") => ensure!(
183 v["name"].is_string() || v["id"].is_string(),
184 "epp_created names the object"
185 ),
186 Some("epp_renewed") => ensure!(v["name"].is_string(), "epp_renewed names the domain"),
187 Some("epp_transfer_status") => {
188 ensure!(
189 v["name"].is_string() || v["id"].is_string(),
190 "the transfer names its object"
191 );
192 ensure!(v["tr_status"].is_string(), "tr_status is required");
193 code_ok(&v["code"], true)?;
194 }
195 Some("epp_result") => {
196 ensure!(!v["code"].is_null(), "code is required");
197 code_ok(&v["code"], false)?;
198 ensure!(
199 !matches!(v["code"].as_u64(), Some(1500 | 2500 | 2501 | 2502)),
200 "session-ending codes are the server's own"
201 );
202 }
203 Some(other) => bail!("Unknown EPP action {other}"),
204 None => bail!("an action names its type"),
205 }
206 Ok(())
207}
208
209impl Protocol for EppProtocol {
210 fn protocol_name(&self) -> &'static str {
211 "EPP"
212 }
213 fn stack_name(&self) -> &'static str {
214 "ETH>IP>TCP>TLS>EPP"
215 }
216 fn description(&self) -> &'static str {
217 "EPP (RFC 5730/5734) registry server: greeting, login sessions and domain, host and contact commands answered by the handler"
218 }
219 fn keywords(&self) -> Vec<&'static str> {
220 vec![
221 "epp",
222 "epp server",
223 "domain registry",
224 "extensible provisioning protocol",
225 "registrar",
226 ]
227 }
228 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
229 vec![]
230 }
231 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
232 vec![
233 check_result(),
234 info(),
235 created(),
236 renewed(),
237 transfer_status(),
238 result(),
239 ]
240 }
241 fn get_event_types(&self) -> Vec<EventType> {
242 vec![COMMAND_EVENT.clone()]
243 }
244 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
245 let p =
246 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
247 ParameterDefinition {
248 name: name.into(),
249 type_hint: kind.into(),
250 description: description.into(),
251 required: false,
252 example,
253 default,
254 }
255 };
256 vec![
257 p("server_id", "string", "The svID in the greeting", json!("NetGet Registry"), Some(json!(super::DEFAULT_SERVER_ID))),
258 p("clients", "object", "Registrar logins {clID: password}; without it every login is accepted", json!({"ClientX": "foo-BAR2"}), None),
259 p("tls", "boolean", "TLS as RFC 5734 requires; false serves plain TCP", json!(true), Some(json!(true))),
260 p("tls_cert_file", "string", "PEM certificate; without it and tls_key_file a self-signed one for localhost is generated and published as protocol_data.certificate_pem", json!("cert.pem"), None),
261 p("tls_key_file", "string", "PEM private key for tls_cert_file", json!("key.pem"), None),
262 p("idle_timeout_secs", "number", "Close a session idle this long, 1 to 3600 seconds", json!(600), Some(json!(super::IDLE_TIMEOUT.as_secs()))),
263 ]
264 }
265 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
266 use crate::protocol::metadata::*;
267 ProtocolMetadataV2::builder()
268 .state(DevelopmentState::Experimental)
269 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(700))
270 .well_known_port(700)
271 .implementation("tokio-rustls TLS with RFC 5734 framing; RFC 5730 greeting, hello, login/logout, session state and result codes in Rust; domain, host and contact commands parsed into fields and their responses rendered from the handler's answers (quick-xml, bounded, no DTD)")
272 .llm_control("Every object command: availability, object data, creations, renewals, transfers, updates, deletions and refusals")
273 .e2e_testing("tests/server/epp: pyepp 0.2.0 (independent, InternetNZ's Python client) logs in over TLS and checks, creates, reads, renews and transfers")
274 .notes("No extensions (secDNS and others are accepted at login, never acted on), no poll messages (poll answers 1300), no storage: the handler is the registry. 256 KiB frames; three failed logins close the session.")
275 .request_only("EPP is command/response: the server never speaks except to answer a command")
276 .answers_on_failure()
277 .max_inbound_bytes(super::wire::MAX_FRAME)
278 .build()
279 }
280 fn example_prompt(&self) -> &'static str {
281 "EPP registry on port 700 where example.com is taken and every other .com is available"
282 }
283 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
284 let llm = json!({"type":"open_server","base_stack":"epp","port":7000,"instruction":"Be a .com registry: example.com is registered to ClientX, everything else is available","startup_params":{"clients":{"ClientX":"foo-BAR2"}}});
285 let mut static_example = llm.clone();
286 static_example["event_handlers"] = json!([{"event_pattern":"epp_command","handler":{"type":"static","actions":[{"type":"epp_result","code":2303,"reason":"No such object"}]}}]);
287 let mut scripted = llm.clone();
288 scripted["event_handlers"] = json!([{"event_pattern":"epp_command","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nif e['command']=='check':\n print(json.dumps({'actions':[{'type':'epp_check_result','results':[{'name':n,'available':n!='example.com'} for n in e['fields']['names']]}]}))\nelse:\n print(json.dumps({'actions':[{'type':'epp_result','code':2101}]}))"}}]);
289 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
290 }
291 fn group_name(&self) -> &'static str {
292 "Application"
293 }
294}
295
296impl Server for EppProtocol {
297 fn spawn(
298 &self,
299 ctx: SpawnContext,
300 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
301 {
302 Box::pin(super::spawn(ctx))
303 }
304 fn execute_action(&self, v: Value) -> Result<ActionResult> {
305 validate(&v)?;
306 Ok(ActionResult::Custom {
307 name: v["type"].as_str().unwrap_or_default().into(),
308 data: v,
309 })
310 }
311}