Skip to main content

netget/client/epp/
actions.rs

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
306/// (command, object, the command element) for an action, or None for one that sends nothing.
307pub 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
500/// A whole command document.
501pub 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}