1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::epp::actions::{action, parameter};
8use crate::server::epp::xml::{el, escape, CONTACT, DOMAIN, EPP, HOST};
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct EppClientProtocol;
15impl EppClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn object_param() -> crate::llm::actions::Parameter {
22 parameter("object", "string", "domain, host or contact", true)
23}
24
25fn defs() -> Vec<ActionDefinition> {
26 vec![
27 action(
28 "epp_login",
29 "Log in (when the client was not configured to log in by itself)",
30 vec![
31 parameter("client_id", "string", "The registrar's clID", true),
32 parameter("password", "string", "The registrar's EPP password", true),
33 ],
34 json!({"type": "epp_login", "client_id": "ClientX", "password": "foo-BAR2"}),
35 ),
36 action(
37 "epp_check",
38 "Check whether names (domain, host) or ids (contact) are available",
39 vec![
40 object_param(),
41 parameter("names", "array", "Up to 64 names or ids", true),
42 ],
43 json!({"type": "epp_check", "object": "domain", "names": ["example.com", "example.net"]}),
44 ),
45 action(
46 "epp_info",
47 "Read an object's registry data (status, contacts, name servers, dates)",
48 vec![
49 object_param(),
50 parameter(
51 "name",
52 "string",
53 "The domain or host name, or contact id",
54 true,
55 ),
56 parameter(
57 "auth_info",
58 "string",
59 "The object's authInfo password, to read another registrar's object",
60 false,
61 ),
62 ],
63 json!({"type": "epp_info", "object": "domain", "name": "example.com"}),
64 ),
65 action(
66 "epp_create_domain",
67 "Register a domain",
68 vec![
69 parameter(
70 "name",
71 "string",
72 "The domain name to register, e.g. example.com",
73 true,
74 ),
75 parameter(
76 "period",
77 "number",
78 "Registration length in years, 1 to 10",
79 false,
80 ),
81 parameter("registrant", "string", "Registrant contact id", true),
82 parameter(
83 "contacts",
84 "array",
85 "[{type: admin|tech|billing, id}]",
86 false,
87 ),
88 parameter("ns", "array", "Name server host names", false),
89 parameter(
90 "auth_info",
91 "string",
92 "The domain's transfer password",
93 true,
94 ),
95 ],
96 json!({"type": "epp_create_domain", "name": "example.com", "period": 1, "registrant": "jd1234", "contacts": [{"type": "admin", "id": "sh8013"}], "ns": ["ns1.example.net"], "auth_info": "2fooBAR"}),
97 ),
98 action(
99 "epp_create_host",
100 "Create a host (name server)",
101 vec![
102 parameter(
103 "name",
104 "string",
105 "The host (name server) name, e.g. ns1.example.com",
106 true,
107 ),
108 parameter(
109 "addrs",
110 "array",
111 "[{ip: v4|v6, addr}] for hosts inside the registry's zones",
112 false,
113 ),
114 ],
115 json!({"type": "epp_create_host", "name": "ns1.example.com", "addrs": [{"ip": "v4", "addr": "192.0.2.2"}]}),
116 ),
117 action(
118 "epp_create_contact",
119 "Create a contact",
120 vec![
121 parameter(
122 "id",
123 "string",
124 "The new contact's id, 3 to 16 characters, e.g. sh8013",
125 true,
126 ),
127 parameter(
128 "name",
129 "string",
130 "The contact's full name, e.g. John Doe",
131 true,
132 ),
133 parameter(
134 "org",
135 "string",
136 "The contact's organisation, e.g. Example Inc.",
137 false,
138 ),
139 parameter("street", "array", "Up to three street lines", false),
140 parameter(
141 "city",
142 "string",
143 "The postal address city, e.g. Dulles",
144 true,
145 ),
146 parameter("sp", "string", "State or province", false),
147 parameter("pc", "string", "The postal code, e.g. 20166-6503", false),
148 parameter("cc", "string", "Two-letter country code", true),
149 parameter("voice", "string", "Phone, e.g. +1.7035555555", false),
150 parameter(
151 "email",
152 "string",
153 "The contact's email address, e.g. jdoe@example.com",
154 true,
155 ),
156 parameter("auth_info", "string", "The contact's password", true),
157 ],
158 json!({"type": "epp_create_contact", "id": "sh8013", "name": "John Doe", "street": ["123 Example Dr."], "city": "Dulles", "cc": "US", "email": "jdoe@example.com", "auth_info": "2fooBAR"}),
159 ),
160 action(
161 "epp_renew",
162 "Extend a domain's registration from its current expiry",
163 vec![
164 parameter(
165 "name",
166 "string",
167 "The domain name to renew, e.g. example.com",
168 true,
169 ),
170 parameter(
171 "cur_exp_date",
172 "string",
173 "Its current expiry date, YYYY-MM-DD",
174 true,
175 ),
176 parameter(
177 "period",
178 "number",
179 "Years to add to the registration, 1 to 10",
180 false,
181 ),
182 ],
183 json!({"type": "epp_renew", "name": "example.com", "cur_exp_date": "2027-04-03", "period": 1}),
184 ),
185 action(
186 "epp_transfer",
187 "Request, query, approve, reject or cancel a domain transfer",
188 vec![
189 parameter(
190 "op",
191 "string",
192 "request, query, approve, reject or cancel",
193 true,
194 ),
195 parameter(
196 "name",
197 "string",
198 "The domain name being transferred, e.g. example.com",
199 true,
200 ),
201 parameter(
202 "auth_info",
203 "string",
204 "The domain's transfer password (for request)",
205 false,
206 ),
207 parameter("period", "number", "Years added by the transfer", false),
208 ],
209 json!({"type": "epp_transfer", "op": "request", "name": "example.com", "auth_info": "2fooBAR"}),
210 ),
211 action(
212 "epp_delete",
213 "Delete an object",
214 vec![
215 object_param(),
216 parameter(
217 "name",
218 "string",
219 "The domain or host name, or contact id",
220 true,
221 ),
222 ],
223 json!({"type": "epp_delete", "object": "host", "name": "ns1.example.com"}),
224 ),
225 action(
226 "epp_hello",
227 "Ask the server for a fresh greeting (keeps the session alive)",
228 vec![],
229 json!({"type": "epp_hello"}),
230 ),
231 action(
232 "epp_logout",
233 "Log out and close the session",
234 vec![],
235 json!({"type": "epp_logout"}),
236 ),
237 ]
238}
239
240pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
241 EventType::new(
242 "epp_connected",
243 "The server's greeting arrived (and the configured login, if any, was answered)",
244 json!({"type": "epp_check", "object": "domain", "names": ["example.com"]}),
245 )
246 .with_parameters(vec![
247 parameter("sv_id", "string", "The server's name", true),
248 parameter("obj_uris", "array", "The object mappings it serves", true),
249 parameter("ext_uris", "array", "The extensions it serves", true),
250 parameter(
251 "login",
252 "object",
253 "{code, message, reason} of the configured login, or null",
254 false,
255 ),
256 ])
257 .with_actions(defs())
258});
259pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
260 EventType::new("epp_response", "The server answered a command", json!({"type": "epp_info", "object": "domain", "name": "example.com"}))
261 .with_parameters(vec![
262 parameter("command", "string", "The command answered, e.g. check", true),
263 parameter("object", "string", "domain, host or contact, when the command had one", false),
264 parameter("code", "number", "RFC 5730 result code: 1xxx success, 2xxx failure", true),
265 parameter("message", "string", "The result message", true),
266 parameter("reason", "string", "The server's explanation of a failure", false),
267 parameter("data", "object", "The resData: results[] for a check, the object's fields for info, crDate/exDate for create, trStatus and dates for transfer", false),
268 parameter("cl_trid", "string", "The client transaction id", false),
269 parameter("sv_trid", "string", "The server transaction id", false),
270 ])
271 .with_actions(defs())
272});
273
274fn ns_of(object: &str) -> Result<(&'static str, &'static str)> {
275 Ok(match object {
276 "domain" => ("domain", DOMAIN),
277 "host" => ("host", HOST),
278 "contact" => ("contact", CONTACT),
279 other => bail!("object is domain, host or contact, not {other}"),
280 })
281}
282
283fn text<'a>(v: &'a Value, k: &str) -> Result<&'a str> {
284 v[k].as_str()
285 .filter(|s| !s.is_empty() && s.len() <= 255)
286 .with_context(|| format!("{k} is required"))
287}
288
289fn opt(tag: &str, v: &Value, k: &str) -> String {
290 v[k].as_str()
291 .filter(|s| !s.is_empty())
292 .map(|s| el(tag, s))
293 .unwrap_or_default()
294}
295
296fn period(v: &Value, p: &str) -> Result<String> {
297 Ok(match v["period"].as_u64() {
298 None => String::new(),
299 Some(n) => {
300 ensure!((1..=99).contains(&n), "period is 1 to 99 years");
301 format!(r#"<{p}:period unit="y">{n}</{p}:period>"#)
302 }
303 })
304}
305
306pub fn render(v: &Value) -> Result<(String, Option<String>, String)> {
308 let kind = v["type"].as_str().unwrap_or_default();
309 let obj = |p: &str, ns: &str, verb: &str, body: String| {
310 format!(r#"<{verb}><{p}:{verb} xmlns:{p}="{ns}">{body}</{p}:{verb}></{verb}>"#)
311 };
312 Ok(match kind {
313 "epp_login" => (
314 "login".into(),
315 None,
316 login(text(v, "client_id")?, text(v, "password")?),
317 ),
318 "epp_check" => {
319 let object = text(v, "object")?;
320 let (p, ns) = ns_of(object)?;
321 let key = if object == "contact" { "id" } else { "name" };
322 let names = v["names"].as_array().context("names is an array")?;
323 ensure!((1..=64).contains(&names.len()), "1 to 64 names");
324 let body: String = names
325 .iter()
326 .map(|n| {
327 n.as_str()
328 .filter(|s| !s.is_empty())
329 .map(|s| el(&format!("{p}:{key}"), s))
330 .context("names are strings")
331 })
332 .collect::<Result<_>>()?;
333 (
334 "check".into(),
335 Some(object.into()),
336 obj(p, ns, "check", body),
337 )
338 }
339 "epp_info" | "epp_delete" => {
340 let object = text(v, "object")?;
341 let (p, ns) = ns_of(object)?;
342 let key = if object == "contact" { "id" } else { "name" };
343 let verb = if kind == "epp_info" { "info" } else { "delete" };
344 let mut body = el(&format!("{p}:{key}"), text(v, "name")?);
345 if verb == "info" {
346 if let Some(pw) = v["auth_info"].as_str().filter(|s| !s.is_empty()) {
347 body.push_str(&format!(
348 "<{p}:authInfo>{}</{p}:authInfo>",
349 el(&format!("{p}:pw"), pw)
350 ));
351 }
352 }
353 (verb.into(), Some(object.into()), obj(p, ns, verb, body))
354 }
355 "epp_create_domain" => {
356 let mut body = el("domain:name", text(v, "name")?);
357 body.push_str(&period(v, "domain")?);
358 let ns: Vec<String> = v["ns"]
359 .as_array()
360 .into_iter()
361 .flatten()
362 .filter_map(|h| h.as_str().map(|h| el("domain:hostObj", h)))
363 .collect();
364 if !ns.is_empty() {
365 body.push_str(&format!("<domain:ns>{}</domain:ns>", ns.concat()));
366 }
367 body.push_str(&el("domain:registrant", text(v, "registrant")?));
368 for c in v["contacts"].as_array().into_iter().flatten() {
369 let t = c["type"].as_str().unwrap_or("admin");
370 ensure!(
371 matches!(t, "admin" | "tech" | "billing"),
372 "a contact type is admin, tech or billing"
373 );
374 body.push_str(&format!(
375 r#"<domain:contact type="{t}">{}</domain:contact>"#,
376 escape(c["id"].as_str().unwrap_or_default())
377 ));
378 }
379 body.push_str(&format!(
380 "<domain:authInfo>{}</domain:authInfo>",
381 el("domain:pw", text(v, "auth_info")?)
382 ));
383 (
384 "create".into(),
385 Some("domain".into()),
386 obj("domain", DOMAIN, "create", body),
387 )
388 }
389 "epp_create_host" => {
390 let mut body = el("host:name", text(v, "name")?);
391 for a in v["addrs"].as_array().into_iter().flatten() {
392 let ip = a["ip"].as_str().unwrap_or("v4");
393 ensure!(matches!(ip, "v4" | "v6"), "ip is v4 or v6");
394 let addr = a["addr"].as_str().context("each address has addr")?;
395 ensure!(
396 addr.parse::<std::net::IpAddr>().is_ok(),
397 "{addr} is not an IP address"
398 );
399 body.push_str(&format!(
400 r#"<host:addr ip="{ip}">{}</host:addr>"#,
401 escape(addr)
402 ));
403 }
404 (
405 "create".into(),
406 Some("host".into()),
407 obj("host", HOST, "create", body),
408 )
409 }
410 "epp_create_contact" => {
411 let streets: String = v["street"]
412 .as_array()
413 .into_iter()
414 .flatten()
415 .take(3)
416 .filter_map(|s| s.as_str().map(|s| el("contact:street", s)))
417 .collect();
418 let cc = text(v, "cc")?;
419 ensure!(
420 cc.len() == 2 && cc.bytes().all(|b| b.is_ascii_alphabetic()),
421 "cc is a two-letter country code"
422 );
423 let body = format!(
424 r#"{}<contact:postalInfo type="loc">{}{}<contact:addr>{streets}{}{}{}{}</contact:addr></contact:postalInfo>{}{}{}<contact:authInfo>{}</contact:authInfo>"#,
425 el("contact:id", text(v, "id")?),
426 el("contact:name", text(v, "name")?),
427 opt("contact:org", v, "org"),
428 el("contact:city", text(v, "city")?),
429 opt("contact:sp", v, "sp"),
430 opt("contact:pc", v, "pc"),
431 el("contact:cc", cc),
432 opt("contact:voice", v, "voice"),
433 opt("contact:fax", v, "fax"),
434 el("contact:email", text(v, "email")?),
435 el("contact:pw", text(v, "auth_info")?),
436 );
437 (
438 "create".into(),
439 Some("contact".into()),
440 obj("contact", CONTACT, "create", body),
441 )
442 }
443 "epp_renew" => {
444 let date = text(v, "cur_exp_date")?;
445 ensure!(
446 chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_ok(),
447 "cur_exp_date is YYYY-MM-DD"
448 );
449 let body = format!(
450 "{}{}{}",
451 el("domain:name", text(v, "name")?),
452 el("domain:curExpDate", date),
453 period(v, "domain")?
454 );
455 (
456 "renew".into(),
457 Some("domain".into()),
458 obj("domain", DOMAIN, "renew", body),
459 )
460 }
461 "epp_transfer" => {
462 let op = text(v, "op")?;
463 ensure!(
464 matches!(op, "request" | "query" | "approve" | "reject" | "cancel"),
465 "op is request, query, approve, reject or cancel"
466 );
467 let mut body = el("domain:name", text(v, "name")?);
468 body.push_str(&period(v, "domain")?);
469 if let Some(pw) = v["auth_info"].as_str().filter(|s| !s.is_empty()) {
470 body.push_str(&format!(
471 "<domain:authInfo>{}</domain:authInfo>",
472 el("domain:pw", pw)
473 ));
474 }
475 (
476 "transfer".into(),
477 Some("domain".into()),
478 format!(
479 r#"<transfer op="{op}"><domain:transfer xmlns:domain="{DOMAIN}">{body}</domain:transfer></transfer>"#
480 ),
481 )
482 }
483 "epp_logout" => ("logout".into(), None, "<logout/>".into()),
484 "epp_hello" => ("hello".into(), None, String::new()),
485 other => bail!("{other} is not an EPP client action"),
486 })
487}
488
489pub fn login(client_id: &str, password: &str) -> String {
490 format!(
491 "<login>{}{}<options><version>1.0</version><lang>en</lang></options><svcs>{}{}{}</svcs></login>",
492 el("clID", client_id),
493 el("pw", password),
494 el("objURI", DOMAIN),
495 el("objURI", HOST),
496 el("objURI", CONTACT)
497 )
498}
499
500pub fn document(command: &str, element: &str, cl_trid: &str) -> String {
502 if command == "hello" {
503 return format!(
504 r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?><epp xmlns="{EPP}"><hello/></epp>"#
505 );
506 }
507 format!(
508 r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?><epp xmlns="{EPP}"><command>{element}{}</command></epp>"#,
509 el("clTRID", cl_trid)
510 )
511}
512
513impl Protocol for EppClientProtocol {
514 fn protocol_name(&self) -> &'static str {
515 "EPP"
516 }
517 fn stack_name(&self) -> &'static str {
518 "ETH>IP>TCP>TLS>EPP"
519 }
520 fn keywords(&self) -> Vec<&'static str> {
521 vec!["epp", "epp client", "registrar", "domain registration"]
522 }
523 fn description(&self) -> &'static str {
524 "EPP registrar client: logs in to a registry over TLS and checks, reads, creates, renews, transfers and deletes domains, hosts and contacts"
525 }
526 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
527 defs()
528 }
529 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
530 defs()
531 }
532 fn get_event_types(&self) -> Vec<EventType> {
533 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
534 }
535 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
536 let p =
537 |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
538 ParameterDefinition {
539 name: name.into(),
540 type_hint: kind.into(),
541 description: description.into(),
542 required: false,
543 example,
544 default,
545 }
546 };
547 vec![
548 p(
549 "client_id",
550 "string",
551 "Log in as this registrar right after the greeting",
552 json!("ClientX"),
553 None,
554 ),
555 p(
556 "password",
557 "string",
558 "The registrar's password",
559 json!("foo-BAR2"),
560 None,
561 ),
562 p(
563 "tls",
564 "boolean",
565 "TLS as RFC 5734 requires; false for a plain-TCP server",
566 json!(true),
567 Some(json!(true)),
568 ),
569 p(
570 "ca_cert_path",
571 "string",
572 "PEM certificate to trust instead of the public roots",
573 json!("ca.pem"),
574 None,
575 ),
576 p(
577 "server_name",
578 "string",
579 "TLS name to verify; the remote host when omitted",
580 json!("epp.example.net"),
581 None,
582 ),
583 ]
584 }
585 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
586 use crate::protocol::metadata::*;
587 ProtocolMetadataV2::builder()
588 .state(DevelopmentState::Experimental)
589 .privilege_requirement(PrivilegeRequirement::None)
590 .implementation("tokio-rustls with RFC 5734 framing; commands rendered from structured actions and responses (greeting, result, resData, trID) parsed by the server's bounded XML reader")
591 .llm_control("Every command after the greeting and the configured login")
592 .e2e_testing("tests/client/epp: a registry built on the Swedish Internet Foundation's epp-lib v0.2.0 (independent Go EPP server library) answers checks, creates, infos, renews and transfers")
593 .notes("No extensions or poll. Handler-driven commands chain to depth 16.")
594 .build()
595 }
596 fn example_prompt(&self) -> &'static str {
597 "Log in to the EPP registry at epp.example.net:700 as ClientX and check whether example.com is available"
598 }
599 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
600 let llm = json!({"type":"open_client","protocol":"epp","remote_addr":"127.0.0.1:7000","instruction":"Check whether example.com is available and register it if it is","startup_params":{"client_id":"ClientX","password":"foo-BAR2"}});
601 let mut static_example = llm.clone();
602 static_example["event_handlers"] = json!([{"event_pattern":"epp_connected","handler":{"type":"static","actions":[{"type":"epp_check","object":"domain","names":["example.com"]}]}}]);
603 let mut scripted = llm.clone();
604 scripted["event_handlers"] = json!([{"event_pattern":"epp_connected","handler":{"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'epp_check','object':'domain','names':['example.com','example.net']}]}))"}}]);
605 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
606 }
607 fn group_name(&self) -> &'static str {
608 "Application"
609 }
610}
611
612impl Client for EppClientProtocol {
613 fn connect(
614 &self,
615 ctx: ConnectContext,
616 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
617 {
618 Box::pin(super::connect(ctx))
619 }
620 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
621 render(&v)?;
622 Ok(ClientActionResult::Custom {
623 name: v["type"].as_str().unwrap_or_default().into(),
624 data: v,
625 })
626 }
627}