Skip to main content

featherbit/admin/
acme.rs

1//! Admin surface for ACME-managed certificates: read-only status plus a
2//! "renew now" nudge. Configuration stays in `system.yaml` (restart-gated like
3//! every TLS setting), so there is deliberately no CRUD here. Responses never
4//! include key material — only what `CertMeta` records.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use axum::extract::{Path, Query, State};
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use axum::routing::{get, post};
13use axum::{Json, Router};
14
15use crate::acme::manager::RenewOutcome;
16use crate::state::SharedState;
17
18pub fn router() -> Router<Arc<SharedState>> {
19    Router::new()
20        .route("/api/acme/certs", get(list_certs))
21        .route("/api/acme/certs/{id}/renew", post(renew_cert))
22}
23
24/// `GET /api/acme/certs` — every managed certificate with state and metadata.
25/// `{"enabled": false, "certs": []}` when `acme:` is not configured, so the UI
26/// can tell "not configured" from "nothing managed" without a status-code guess.
27async fn list_certs(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
28    let Some(rt) = state.acme.load_full() else {
29        return Json(serde_json::json!({"enabled": false, "certs": []}));
30    };
31    let map = rt.certs.load();
32    let mut certs: Vec<serde_json::Value> = rt
33        .manager
34        .slots()
35        .iter()
36        .filter_map(|slot| map.get(slot.id.as_str()).map(|c| (slot, c)))
37        .map(|(slot, c)| {
38            // A placeholder's `meta` carries the real validity of the
39            // self-signed stand-in (the manager needs it to know when to
40            // re-mint it), but the API contract is that a placeholder has no
41            // certificate dates: `0`, matching the expiry gauge and letting
42            // the UI render "—" instead of an hour-away expiry in red.
43            let placeholder = c.state == crate::acme::CertState::Placeholder;
44            let (not_before, not_after) = if placeholder {
45                (0, 0)
46            } else {
47                (c.meta.not_before, c.meta.not_after)
48            };
49            serde_json::json!({
50                "id": slot.id.as_str(),
51                "domains": c.domains,
52                "state": if rt.manager.in_flight(slot.id.as_str()) && c.state != crate::acme::CertState::Placeholder {
53                    crate::acme::CertState::Renewing
54                } else {
55                    c.state
56                },
57                "not_before": not_before,
58                "not_after": not_after,
59                "issuer": c.meta.issuer,
60                "serial": c.meta.serial,
61                "next_renewal_at": c.meta.next_renewal_at,
62                "last_attempt_at": c.meta.last_attempt_at,
63                "last_error": c.meta.last_error,
64            })
65        })
66        .collect();
67    certs.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
68    Json(serde_json::json!({
69        "enabled": true,
70        "storage": rt.storage_label,
71        "certs": certs,
72    }))
73}
74
75/// `POST /api/acme/certs/{id}/renew[?force=true]` — nudges the renewal manager.
76async fn renew_cert(
77    State(state): State<Arc<SharedState>>,
78    Path(id): Path<String>,
79    Query(params): Query<HashMap<String, String>>,
80) -> impl IntoResponse {
81    let Some(rt) = state.acme.load_full() else {
82        return (
83            StatusCode::NOT_IMPLEMENTED,
84            Json(serde_json::json!({"error": "acme is not configured"})),
85        );
86    };
87    let force = params.get("force").is_some_and(|v| v == "true" || v == "1");
88    // A cert id *is* its normalized domain set, so accept any spelling of it:
89    // `CertId::from_domains` lowercases, sorts and dedups, matching what the
90    // manager keyed the slot under. An id that is not a valid domain list can
91    // never name a managed certificate.
92    let Ok((id, _)) =
93        crate::acme::CertId::from_domains(&id.split(',').map(String::from).collect::<Vec<_>>())
94    else {
95        return (
96            StatusCode::NOT_FOUND,
97            Json(serde_json::json!({"error": "not_found"})),
98        );
99    };
100    match rt.manager.renew_now(id.as_str(), force) {
101        RenewOutcome::Scheduled => (
102            StatusCode::ACCEPTED,
103            Json(serde_json::json!({"scheduled": true})),
104        ),
105        RenewOutcome::NotDue => (
106            StatusCode::OK,
107            Json(serde_json::json!({"scheduled": false, "reason": "not_due"})),
108        ),
109        RenewOutcome::InProgress => (
110            StatusCode::CONFLICT,
111            Json(serde_json::json!({"error": "in_progress"})),
112        ),
113        RenewOutcome::Unknown => (
114            StatusCode::NOT_FOUND,
115            Json(serde_json::json!({"error": "not_found"})),
116        ),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::config::{GatewayConfig, SystemConfig};
124    use crate::config_store::FileConfigStore;
125    use axum::body::Body;
126    use axum::http::{Method, Request};
127    use tower::ServiceExt;
128
129    fn state() -> Arc<SharedState> {
130        let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
131        let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
132        Arc::new(
133            SharedState::new(
134                system,
135                gateway,
136                None,
137                Arc::new(FileConfigStore::new("g.yaml".into())),
138            )
139            .unwrap(),
140        )
141    }
142
143    async fn call(
144        state: Arc<SharedState>,
145        method: Method,
146        uri: &str,
147    ) -> (StatusCode, serde_json::Value) {
148        let resp = router()
149            .with_state(state)
150            .oneshot(
151                Request::builder()
152                    .method(method)
153                    .uri(uri)
154                    .body(Body::empty())
155                    .unwrap(),
156            )
157            .await
158            .unwrap();
159        let status = resp.status();
160        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
161            .await
162            .unwrap();
163        (
164            status,
165            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
166        )
167    }
168
169    #[tokio::test]
170    async fn list_reports_disabled_when_acme_is_absent() {
171        let (status, body) = call(state(), Method::GET, "/api/acme/certs").await;
172        assert_eq!(status, StatusCode::OK);
173        assert_eq!(body["enabled"], false);
174        assert_eq!(body["certs"].as_array().unwrap().len(), 0);
175        let (status, _) = call(state(), Method::POST, "/api/acme/certs/x/renew").await;
176        assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
177    }
178
179    #[tokio::test]
180    async fn list_shows_managed_certs_without_key_material() {
181        let s = state();
182        s.acme.store(Some(crate::acme::testing::issued_runtime(&[
183            "B.example.com",
184            "a.example.com",
185        ])));
186        let (status, body) = call(s, Method::GET, "/api/acme/certs").await;
187        assert_eq!(status, StatusCode::OK);
188        assert_eq!(body["enabled"], true);
189        assert_eq!(body["storage"], "filesystem");
190        let cert = &body["certs"][0];
191        assert_eq!(cert["id"], "a.example.com,b.example.com");
192        assert_eq!(
193            cert["domains"],
194            serde_json::json!(["a.example.com", "b.example.com"])
195        );
196        assert_eq!(cert["state"], "issued");
197        assert!(cert["not_after"].as_i64().unwrap() > 0);
198        assert!(cert.get("key_pem").is_none() && cert.get("chain_pem").is_none());
199        assert!(!body.to_string().contains("PRIVATE KEY"));
200    }
201
202    #[tokio::test]
203    async fn renew_semantics() {
204        let s = state();
205        s.acme.store(Some(crate::acme::testing::issued_runtime(&[
206            "a.example.com",
207        ])));
208        let (status, _) = call(s.clone(), Method::POST, "/api/acme/certs/nope/renew").await;
209        assert_eq!(status, StatusCode::NOT_FOUND);
210        // Freshly issued, long-lived: not due.
211        let (status, body) = call(
212            s.clone(),
213            Method::POST,
214            "/api/acme/certs/a.example.com/renew",
215        )
216        .await;
217        assert_eq!(status, StatusCode::OK);
218        assert_eq!(body["scheduled"], false);
219        assert_eq!(body["reason"], "not_due");
220        let (status, body) = call(
221            s.clone(),
222            Method::POST,
223            "/api/acme/certs/a.example.com/renew?force=true",
224        )
225        .await;
226        assert_eq!(status, StatusCode::ACCEPTED);
227        assert_eq!(body["scheduled"], true);
228        // A placeholder is always due.
229        let p = state();
230        p.acme
231            .store(Some(crate::acme::testing::placeholder_runtime(&[
232                "p.example.com",
233            ])));
234        let (status, _) = call(p, Method::POST, "/api/acme/certs/p.example.com/renew").await;
235        assert_eq!(status, StatusCode::ACCEPTED);
236    }
237
238    /// The documented contract (`ui/src/types/index.ts`, the TLS guide, and the
239    /// `not_after` gauge) is that a placeholder has no certificate dates. The
240    /// manager needs a real `meta.not_after` internally to know when to re-mint
241    /// the stand-in, so the zeroing happens here at the API boundary.
242    #[tokio::test]
243    async fn placeholder_certs_report_zero_validity_dates() {
244        let s = state();
245        s.acme
246            .store(Some(crate::acme::testing::placeholder_runtime(&[
247                "p.example.com",
248            ])));
249        let (status, body) = call(s.clone(), Method::GET, "/api/acme/certs").await;
250        assert_eq!(status, StatusCode::OK);
251        let cert = &body["certs"][0];
252        assert_eq!(cert["state"], "placeholder");
253        assert_eq!(cert["not_after"], 0);
254        assert_eq!(cert["not_before"], 0);
255        // The runtime itself still holds real dates — that is what drives the
256        // hourly re-mint.
257        let rt = s.acme.load_full().unwrap();
258        assert!(rt.certs.load().get("p.example.com").unwrap().meta.not_after > 0);
259    }
260
261    /// A cert id is its normalized domain set, so any spelling of that set has
262    /// to reach the same certificate — the UI and hand-written curl calls both
263    /// pass ids around verbatim from wherever the domains were typed.
264    #[tokio::test]
265    async fn renew_normalizes_the_path_id_to_the_cert_id() {
266        let s = state();
267        s.acme.store(Some(crate::acme::testing::issued_runtime(&[
268            "a.example.com",
269            "b.example.com",
270        ])));
271        // Reversed order, mixed case, a duplicate: still the same certificate.
272        let (status, body) = call(
273            s.clone(),
274            Method::POST,
275            "/api/acme/certs/B.example.com,a.example.com,A.EXAMPLE.com/renew?force=true",
276        )
277        .await;
278        assert_eq!(status, StatusCode::ACCEPTED);
279        assert_eq!(body["scheduled"], true);
280        // Not a domain list at all ⇒ still a 404, not a 500.
281        let (status, _) = call(s, Method::POST, "/api/acme/certs/*.example.com/renew").await;
282        assert_eq!(status, StatusCode::NOT_FOUND);
283    }
284}