Skip to main content

netget/server/epp/
actions.rs

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}