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}