1use 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
24async 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 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
75async 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 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 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 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 #[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 let rt = s.acme.load_full().unwrap();
258 assert!(rt.certs.load().get("p.example.com").unwrap().meta.not_after > 0);
259 }
260
261 #[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 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 let (status, _) = call(s, Method::POST, "/api/acme/certs/*.example.com/renew").await;
282 assert_eq!(status, StatusCode::NOT_FOUND);
283 }
284}