Skip to main content

netget/server/acme/
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, 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
44/// RFC 8555 ยง6.7 error types a handler may refuse with, and the HTTP status each carries.
45pub 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}