Skip to main content

netget/client/acme/
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::acme::actions::{action, parameter};
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct AcmeClientProtocol;
14impl AcmeClientProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20fn register() -> ActionDefinition {
21    action(
22        "acme_register",
23        "Create (or find) the account for this client's key with newAccount",
24        vec![
25            parameter(
26                "contact",
27                "array",
28                "mailto: contact URLs, e.g. [\"mailto:ops@example.org\"]",
29                false,
30            ),
31            parameter(
32                "agree_tos",
33                "boolean",
34                "Agree to the CA's terms of service",
35                true,
36            ),
37        ],
38        json!({"type": "acme_register", "contact": ["mailto:ops@example.org"], "agree_tos": true}),
39    )
40}
41fn order() -> ActionDefinition {
42    action(
43        "acme_order",
44        "Open an order for DNS names and fetch its authorizations (challenge types, tokens, and the dns-01 TXT record each would need)",
45        vec![parameter("identifiers", "array", "DNS names, e.g. [\"www.example.test\", \"*.example.test\"]", true)],
46        json!({"type": "acme_order", "identifiers": ["www.example.test"]}),
47    )
48}
49fn validate() -> ActionDefinition {
50    action(
51        "acme_validate",
52        "Answer one authorization's challenge and wait for the CA's verdict. http-01 is served by this client's responder (http01_listen); for dns-01 the TXT record from acme_order must already be published.",
53        vec![
54            parameter("identifier", "string", "The DNS name whose authorization to answer (as in the order)", true),
55            parameter("challenge_type", "string", "http-01 or dns-01", true),
56        ],
57        json!({"type": "acme_validate", "identifier": "www.example.test", "challenge_type": "http-01"}),
58    )
59}
60fn finalize() -> ActionDefinition {
61    action(
62        "acme_finalize",
63        "Generate a P-256 key and a CSR for the order's names, finalize, wait for issuance and download the certificate chain",
64        vec![parameter("key_file", "string", "Path to write the new private key to (PEM, mode 0600); the key is discarded without it", false)],
65        json!({"type": "acme_finalize"}),
66    )
67}
68fn revoke() -> ActionDefinition {
69    action(
70        "acme_revoke",
71        "Revoke the last certificate this client obtained",
72        vec![parameter("reason", "number", "RFC 5280 reason code (0 unspecified, 1 keyCompromise, 4 superseded, 5 cessationOfOperation)", false)],
73        json!({"type": "acme_revoke", "reason": 4}),
74    )
75}
76fn deactivate() -> ActionDefinition {
77    action(
78        "acme_deactivate",
79        "Deactivate the account (it cannot be used again)",
80        vec![],
81        json!({"type": "acme_deactivate"}),
82    )
83}
84fn disconnect() -> ActionDefinition {
85    action(
86        "disconnect",
87        "Stop this client",
88        vec![],
89        json!({"type": "disconnect"}),
90    )
91}
92fn actions() -> Vec<ActionDefinition> {
93    vec![
94        register(),
95        order(),
96        validate(),
97        finalize(),
98        revoke(),
99        deactivate(),
100        disconnect(),
101    ]
102}
103
104pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
105    EventType::new(
106        "acme_connected",
107        "The CA's directory was read and an account key generated",
108        register().example.clone(),
109    )
110    .with_parameters(vec![
111        parameter("directory", "string", "The directory URL", true),
112        parameter(
113            "terms_of_service",
114            "string",
115            "Terms-of-service URL, when the CA has one",
116            false,
117        ),
118        parameter(
119            "external_account_required",
120            "boolean",
121            "Whether the CA requires external account binding (not supported here)",
122            true,
123        ),
124    ])
125    .with_actions(actions())
126});
127pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
128    EventType::new("acme_response", "The CA's answer to the last action", disconnect().example.clone())
129        .with_parameters(vec![
130            parameter("operation", "string", "register, order, validate, finalize, revoke or deactivate", true),
131            parameter("status", "number", "HTTP status of the final request", true),
132            parameter("problem", "object", "The CA's problem document ({type, detail}) when it refused", false),
133            parameter("account", "string", "register: the account URL", false),
134            parameter("order_status", "string", "order and finalize: the order's status", false),
135            parameter("authorizations", "array", "order: [{identifier, status, wildcard, challenges: [{type, status, token, dns_txt_name, dns_txt_value}]}]", false),
136            parameter("challenge_status", "string", "validate: valid, invalid or pending", false),
137            parameter("certificate", "string", "finalize: the PEM certificate chain", false),
138        ])
139        .with_actions(actions())
140});
141
142impl Protocol for AcmeClientProtocol {
143    fn protocol_name(&self) -> &'static str {
144        "ACME"
145    }
146    fn stack_name(&self) -> &'static str {
147        "ETH>IP>TCP>HTTP>ACME"
148    }
149    fn keywords(&self) -> Vec<&'static str> {
150        vec!["acme", "rfc 8555", "certificate", "lets encrypt", "pebble"]
151    }
152    fn description(&self) -> &'static str {
153        "ACME (RFC 8555) client: account, order, http-01 / dns-01 validation, finalization, download and revocation"
154    }
155    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
156        actions()
157    }
158    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
159        vec![]
160    }
161    fn get_event_types(&self) -> Vec<EventType> {
162        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
163    }
164    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
165        let p =
166            |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
167                ParameterDefinition {
168                    name: name.into(),
169                    type_hint: kind.into(),
170                    description: description.into(),
171                    required: false,
172                    example,
173                    default,
174                }
175            };
176        vec![
177            p("scheme", "string", "https (default) or http", json!("https"), Some(json!(super::DEFAULT_SCHEME))),
178            p("directory_path", "string", "Path of the directory on the server", json!("/dir"), Some(json!(super::DEFAULT_DIRECTORY_PATH))),
179            p("ca_file", "string", "PEM certificate(s) to trust for the CA's HTTPS endpoint, in addition to the system roots", json!("/etc/ssl/pebble.minica.pem"), None),
180            p("http01_listen", "string", "host:port where this client serves http-01 key authorizations", json!("127.0.0.1:5002"), None),
181        ]
182    }
183    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
184        use crate::protocol::metadata::*;
185        ProtocolMetadataV2::builder()
186            .state(DevelopmentState::Experimental)
187            .privilege_requirement(PrivilegeRequirement::None)
188            .implementation("reqwest; flattened JWS signed ES256 with ring, nonce handling with badNonce retry, an http-01 responder on hyper, CSRs from rcgen")
189            .llm_control("Which names to order, which challenges to answer, and when to finalize, revoke or deactivate")
190            .e2e_testing("tests/client/acme: Pebble 2.10.1 (independent) with pebble-challtestsrv as its DNS: register, order, http-01 validated by Pebble against this client's responder, dns-01 with the TXT record published through challtestsrv, finalize, download, revoke")
191            .notes("ES256 account keys only; no keyChange, external account binding, pre-authorization, tls-alpn-01, profiles or ARI.")
192            .max_inbound_bytes(super::MAX_RESPONSE)
193            .build()
194    }
195    fn example_prompt(&self) -> &'static str {
196        "Get a certificate for www.example.test from the ACME server at localhost:14000 using http-01 on port 5002"
197    }
198    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
199        let llm = json!({"type":"open_client","protocol":"acme","remote_addr":"localhost:14000","instruction":"Get a certificate for www.example.test","startup_params":{"directory_path":"/dir","http01_listen":"127.0.0.1:5002"}});
200        let mut static_example = llm.clone();
201        static_example["event_handlers"] = json!([
202            {"event_pattern":"acme_connected","handler":{"type":"static","actions":[register().example]}},
203            {"event_pattern":"acme_response","handler":{"type":"static","actions":[]}}
204        ]);
205        let mut scripted = static_example.clone();
206        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\nd=json.load(sys.stdin)['event']\nnext={'register':{'type':'acme_order','identifiers':['www.example.test']},'order':{'type':'acme_validate','identifier':'www.example.test','challenge_type':'http-01'},'validate':{'type':'acme_finalize'}}.get(d['operation'])\nprint(json.dumps({'actions':[next] if next and d['status']<300 else []}))"});
207        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
208    }
209    fn group_name(&self) -> &'static str {
210        "Security"
211    }
212}
213
214impl Client for AcmeClientProtocol {
215    fn connect(
216        &self,
217        ctx: ConnectContext,
218    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
219    {
220        Box::pin(super::connect(ctx))
221    }
222    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
223        ensure!(
224            crate::utils::json_budget::within_budget(&v, 64 * 1024, 2_000, 8),
225            "action exceeds the ACME bounds"
226        );
227        match v["type"].as_str() {
228            Some("acme_register") => {
229                ensure!(v["agree_tos"].is_boolean(), "agree_tos is true or false");
230                if let Some(c) = v.get("contact").filter(|c| !c.is_null()) {
231                    ensure!(
232                        c.as_array().is_some_and(|l| l.len() <= 10
233                            && l.iter().all(|u| u.as_str().is_some_and(
234                                |u| u.len() <= 256 && !crate::utils::sanitize::has_controls(&u)
235                            ))),
236                        "contact is up to 10 URLs"
237                    );
238                }
239            }
240            Some("acme_order") => ensure!(
241                v["identifiers"].as_array().is_some_and(|l| !l.is_empty()
242                    && l.len() <= 100
243                    && l.iter().all(|i| i.as_str().is_some_and(|i| !i.is_empty()
244                        && i.len() <= 253
245                        && i.bytes()
246                            .all(|b| b.is_ascii_alphanumeric() || b".-*".contains(&b))))),
247                "identifiers is 1 to 100 DNS names"
248            ),
249            Some("acme_validate") => {
250                ensure!(
251                    v["identifier"]
252                        .as_str()
253                        .is_some_and(|i| !i.is_empty() && i.len() <= 253),
254                    "identifier is a DNS name from the order"
255                );
256                ensure!(
257                    matches!(v["challenge_type"].as_str(), Some("http-01" | "dns-01")),
258                    "challenge_type is http-01 or dns-01"
259                );
260            }
261            Some("acme_finalize") => {
262                if let Some(k) = v.get("key_file").filter(|k| !k.is_null()) {
263                    ensure!(
264                        k.as_str().is_some_and(|k| !k.is_empty()
265                            && k.len() <= 1024
266                            && !crate::utils::sanitize::has_controls(&k)),
267                        "key_file is a path"
268                    );
269                }
270            }
271            Some("acme_revoke") => {
272                if let Some(r) = v.get("reason").filter(|r| !r.is_null()) {
273                    ensure!(
274                        r.as_u64().is_some_and(|r| r <= 10 && r != 7),
275                        "reason is an RFC 5280 code 0 to 10, not 7"
276                    );
277                }
278            }
279            Some("acme_deactivate") => {}
280            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
281            _ => bail!("Unknown ACME client action"),
282        }
283        Ok(ClientActionResult::Custom {
284            name: v["type"].as_str().unwrap_or_default().into(),
285            data: v,
286        })
287    }
288}