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, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct AcmeProtocol;
14impl AcmeProtocol {
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!("-> ACME {name}"))),
41 }
42}
43
44pub const REJECT_ERRORS: &[(&str, u16)] = &[
46 ("rejectedIdentifier", 400),
47 ("unsupportedIdentifier", 400),
48 ("unauthorized", 403),
49 ("caa", 403),
50 ("badCSR", 400),
51 ("invalidContact", 400),
52 ("unsupportedContact", 400),
53 ("incorrectResponse", 403),
54 ("userActionRequired", 403),
55 ("rateLimited", 429),
56];
57
58fn accept() -> ActionDefinition {
59 action(
60 "acme_accept",
61 "Approve the request: register the account, create the order, mark the challenge valid, issue the certificate or revoke it. Rust builds every ACME object, URL, nonce and certificate.",
62 vec![],
63 json!({"type": "acme_accept"}),
64 )
65}
66fn reject() -> ActionDefinition {
67 action(
68 "acme_reject",
69 "Refuse the request with an RFC 8555 problem document. For a challenge this marks it (and its authorization and order) invalid.",
70 vec![
71 parameter("error", "string", "rejectedIdentifier, unsupportedIdentifier, unauthorized, caa, badCSR, invalidContact, unsupportedContact, incorrectResponse, userActionRequired or rateLimited", true),
72 parameter("detail", "string", "Human-readable detail, up to 256 characters", true),
73 ],
74 json!({"type": "acme_reject", "error": "rejectedIdentifier", "detail": "this CA does not issue for that domain"}),
75 )
76}
77
78pub static NEW_ACCOUNT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
79 EventType::new("acme_new_account", "A new key asks to register an account. The JWS signature, nonce and URL were checked by Rust.", accept().example.clone())
80 .with_parameters(vec![
81 parameter("contact", "array", "Contact URLs, e.g. [\"mailto:admin@example.org\"]", true),
82 parameter("terms_of_service_agreed", "boolean", "Whether the client agreed to the terms of service", true),
83 parameter("key_type", "string", "P-256, P-384, RSA or Ed25519", true),
84 parameter("thumbprint", "string", "RFC 7638 thumbprint of the account key", true),
85 ])
86 .with_actions(vec![accept(), reject()])
87});
88pub static NEW_ORDER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89 EventType::new(
90 "acme_new_order",
91 "An account asks for a certificate for these identifiers",
92 accept().example.clone(),
93 )
94 .with_parameters(vec![
95 parameter("account", "string", "The account URL", true),
96 parameter("contact", "array", "The account's contact URLs", true),
97 parameter(
98 "identifiers",
99 "array",
100 "DNS names requested, lower-cased (wildcards as *.example.org)",
101 true,
102 ),
103 parameter(
104 "not_before",
105 "string",
106 "Requested notBefore, when given",
107 false,
108 ),
109 parameter(
110 "not_after",
111 "string",
112 "Requested notAfter, when given",
113 false,
114 ),
115 ])
116 .with_actions(vec![accept(), reject()])
117});
118pub static VALIDATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
119 EventType::new("acme_validate", "The client asks for a challenge to be validated. When Rust could check (http-01 with http01_target) it reports what it fetched; a mismatch is refused before this event.", accept().example.clone())
120 .with_parameters(vec![
121 parameter("account", "string", "The account URL", true),
122 parameter("identifier", "string", "The DNS name being proven", true),
123 parameter("challenge_type", "string", "http-01 or dns-01", true),
124 parameter("token", "string", "The challenge token", true),
125 parameter("key_authorization", "string", "The expected key authorization (token.thumbprint); for dns-01 the TXT value is its SHA-256, base64url", true),
126 parameter("verified", "boolean", "true when Rust fetched the expected key authorization; null when Rust did not check", true),
127 ])
128 .with_actions(vec![accept(), reject()])
129});
130pub static FINALIZE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
131 EventType::new("acme_finalize", "A ready order submits its CSR. Rust verified the CSR signature and that it names exactly the order's identifiers.", accept().example.clone())
132 .with_parameters(vec![
133 parameter("account", "string", "The account URL", true),
134 parameter("order", "string", "The URL of the order being finalized on this CA, e.g. https://ca.example/order/AbC123", true),
135 parameter("identifiers", "array", "The names the certificate will carry", true),
136 parameter("validity_days", "number", "Days the certificate will be valid", true),
137 ])
138 .with_actions(vec![accept(), reject()])
139});
140pub static REVOKE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
141 EventType::new(
142 "acme_revoke",
143 "An account asks to revoke a certificate it holds",
144 accept().example.clone(),
145 )
146 .with_parameters(vec![
147 parameter("account", "string", "The account URL", true),
148 parameter(
149 "serial",
150 "string",
151 "The certificate serial number, hex",
152 true,
153 ),
154 parameter("identifiers", "array", "The names on the certificate", true),
155 parameter(
156 "reason",
157 "number",
158 "RFC 5280 reason code, when given",
159 false,
160 ),
161 ])
162 .with_actions(vec![accept(), reject()])
163});
164
165fn startup(
166 name: &str,
167 kind: &str,
168 description: &str,
169 example: Value,
170 default: Option<Value>,
171) -> ParameterDefinition {
172 ParameterDefinition {
173 name: name.into(),
174 type_hint: kind.into(),
175 description: description.into(),
176 required: false,
177 example,
178 default,
179 }
180}
181
182impl Protocol for AcmeProtocol {
183 fn protocol_name(&self) -> &'static str {
184 "ACME"
185 }
186 fn stack_name(&self) -> &'static str {
187 "ETH>IP>TCP>HTTP>ACME"
188 }
189 fn description(&self) -> &'static str {
190 "ACME (RFC 8555) certificate authority: accounts, orders, http-01 and dns-01 challenges, finalization, certificates and revocation"
191 }
192 fn keywords(&self) -> Vec<&'static str> {
193 vec![
194 "acme",
195 "rfc 8555",
196 "certificate authority",
197 "lets encrypt",
198 "certbot",
199 "lego",
200 "pebble",
201 ]
202 }
203 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
204 vec![]
205 }
206 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
207 vec![accept(), reject()]
208 }
209 fn get_event_types(&self) -> Vec<EventType> {
210 vec![
211 NEW_ACCOUNT_EVENT.clone(),
212 NEW_ORDER_EVENT.clone(),
213 VALIDATE_EVENT.clone(),
214 FINALIZE_EVENT.clone(),
215 REVOKE_EVENT.clone(),
216 ]
217 }
218 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
219 vec![
220 startup("ca_name", "string", "Common name of the CA generated at startup", json!("Example Test CA"), Some(json!(super::DEFAULT_CA_NAME))),
221 startup("validity_days", "integer", "Lifetime of issued certificates in days (1 to 397)", json!(30), Some(json!(super::DEFAULT_VALIDITY_DAYS))),
222 startup("challenge_types", "array", "Challenge types offered for each identifier: http-01 and/or dns-01", json!(["http-01"]), Some(json!(super::DEFAULT_CHALLENGES))),
223 startup("http01_target", "string", "host:port Rust connects to for http-01 validation, sending the identifier as Host; without it Rust does not fetch and the handler decides alone", json!("127.0.0.1:5002"), None),
224 startup("terms_of_service", "string", "Terms-of-service URL announced in the directory; when set, new accounts must agree", json!("https://example.org/tos"), None),
225 startup("tls_cert_file", "string", "PEM certificate chain to serve HTTPS with (with tls_key_file); plain HTTP without", json!("/etc/netget/acme.pem"), None),
226 startup("tls_key_file", "string", "PEM private key for tls_cert_file", json!("/etc/netget/acme.key"), None),
227 ]
228 }
229 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
230 use crate::protocol::metadata::*;
231 ProtocolMetadataV2::builder()
232 .state(DevelopmentState::Experimental)
233 .privilege_requirement(PrivilegeRequirement::None)
234 .implementation("hyper HTTP/1.1 (optional rustls); RFC 8555 directory, nonces, flattened JWS verified with ring (ES256, ES384, RS256, EdDSA), RFC 7638 thumbprints, account/order/authorization/challenge state, http-01 fetching, CSR checks and issuance from a P-256 CA generated at startup with rcgen")
235 .llm_control("Which accounts, orders, challenge validations, issuances and revocations to approve")
236 .e2e_testing("tests/server/acme: lego 4.35.2 and certbot 5.8.0 (independent) register, order, answer http-01 (verified by Rust) and dns-01, finalize, download and revoke; refusals and JWS/nonce errors from the wire")
237 .notes("In-memory protocol state (accounts by key, orders, authorizations, nonces, issued certificates), bounded and lost on stop. No keyChange, external account binding, pre-authorization, tls-alpn-01, OCSP, CRLs, ARI or profiles; dns-01 is never checked by Rust. Use only as a test CA.")
238 .answers_on_failure()
239 .max_inbound_bytes(super::MAX_BODY)
240 .request_only("ACME answers each HTTP request; the CA pushes nothing")
241 .build()
242 }
243 fn example_prompt(&self) -> &'static str {
244 "Test ACME CA on port 14000 that issues for *.test names and refuses everything else"
245 }
246 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
247 let llm = json!({"type":"open_server","base_stack":"acme","port":14000,"instruction":"Issue certificates only for names under .test","startup_params":{"validity_days":30}});
248 let mut static_example = llm.clone();
249 static_example["event_handlers"] = json!([
250 {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"acme_accept"}]}}
251 ]);
252 let mut scripted = llm.clone();
253 scripted["event_handlers"] = json!([
254 {"event_pattern":"acme_new_order","handler":{"type":"script","language":"python","code":"import json,sys\nd=json.load(sys.stdin)\nok=all(i.endswith('.test') for i in d['event']['identifiers'])\nprint(json.dumps({'actions':[{'type':'acme_accept'} if ok else {'type':'acme_reject','error':'rejectedIdentifier','detail':'only .test names'}]}))"}},
255 {"event_pattern":"*","handler":{"type":"static","actions":[{"type":"acme_accept"}]}}
256 ]);
257 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
258 }
259 fn group_name(&self) -> &'static str {
260 "Security"
261 }
262}
263
264impl Server for AcmeProtocol {
265 fn spawn(
266 &self,
267 ctx: SpawnContext,
268 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
269 {
270 Box::pin(super::spawn(ctx))
271 }
272 fn execute_action(&self, v: Value) -> Result<ActionResult> {
273 check_answer(&v)?;
274 Ok(ActionResult::Custom {
275 name: v["type"].as_str().unwrap_or_default().into(),
276 data: v,
277 })
278 }
279}
280
281pub fn check_answer(v: &Value) -> Result<()> {
282 match v["type"].as_str() {
283 Some("acme_accept") => {}
284 Some("acme_reject") => {
285 let e = v["error"].as_str().unwrap_or_default();
286 ensure!(
287 REJECT_ERRORS.iter().any(|(n, _)| *n == e),
288 "unknown ACME error type {e:?}"
289 );
290 ensure!(
291 v["detail"].as_str().is_some_and(|d| !d.is_empty()
292 && d.len() <= 256
293 && !crate::utils::sanitize::has_controls(&d)),
294 "detail is 1 to 256 printable characters"
295 );
296 }
297 _ => bail!("Unknown ACME server action"),
298 }
299 Ok(())
300}