1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use ring::digest::{digest, SHA256};
4use ring::rand::SystemRandom;
5use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock, RwLock};
9
10const LE_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
11const LE_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
12
13static ACME_CHALLENGES: OnceLock<Arc<RwLock<HashMap<String, String>>>> = OnceLock::new();
15
16pub fn get_challenge_response(token: &str) -> Option<String> {
17 ACME_CHALLENGES
18 .get()
19 .and_then(|map| map.read().ok())
20 .and_then(|map| map.get(token).cloned())
21}
22
23fn set_challenge(token: String, key_auth: String) {
24 let challenges = ACME_CHALLENGES.get_or_init(|| Arc::new(RwLock::new(HashMap::new())));
25 if let Ok(mut map) = challenges.write() {
26 map.insert(token, key_auth);
27 }
28}
29
30fn remove_challenge(token: &str) {
31 if let Some(challenges) = ACME_CHALLENGES.get() {
32 if let Ok(mut map) = challenges.write() {
33 map.remove(token);
34 }
35 }
36}
37
38static ACME_STATUS: OnceLock<Arc<RwLock<AcmeStatusInfo>>> = OnceLock::new();
43
44#[derive(Debug, Clone)]
45pub enum AcmeState {
46 Idle,
47 Provisioning,
48 Success,
49 Failed,
50}
51
52impl AcmeState {
53 fn as_str(&self) -> &'static str {
54 match self {
55 AcmeState::Idle => "idle",
56 AcmeState::Provisioning => "provisioning",
57 AcmeState::Success => "success",
58 AcmeState::Failed => "failed",
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct AcmeStatusInfo {
65 pub status: AcmeState,
66 pub domain: String,
67 pub subdomains: Vec<String>,
68 pub cert_dir: PathBuf,
69 pub last_attempt: Option<u64>,
70 pub last_success: Option<u64>,
71 pub last_error: Option<String>,
72 pub attempt_count: u32,
73 pub next_check: Option<u64>,
74}
75
76fn get_or_init_status() -> &'static Arc<RwLock<AcmeStatusInfo>> {
77 ACME_STATUS.get_or_init(|| {
78 Arc::new(RwLock::new(AcmeStatusInfo {
79 status: AcmeState::Idle,
80 domain: String::new(),
81 subdomains: Vec::new(),
82 cert_dir: PathBuf::new(),
83 last_attempt: None,
84 last_success: None,
85 last_error: None,
86 attempt_count: 0,
87 next_check: None,
88 }))
89 })
90}
91
92fn now_unix() -> u64 {
93 std::time::SystemTime::now()
94 .duration_since(std::time::UNIX_EPOCH)
95 .unwrap_or_default()
96 .as_secs()
97}
98
99fn init_status(domain: &str, subdomains: &[String], cert_dir: &Path) {
100 let status = get_or_init_status();
101 if let Ok(mut info) = status.write() {
102 info.domain = domain.to_string();
103 info.subdomains = subdomains.to_vec();
104 info.cert_dir = cert_dir.to_path_buf();
105 }
106}
107
108fn update_status(state: AcmeState, error: Option<&str>) {
109 let status = get_or_init_status();
110 if let Ok(mut info) = status.write() {
111 info.last_attempt = Some(now_unix());
112 info.attempt_count += 1;
113 if matches!(state, AcmeState::Success) {
114 info.last_success = Some(now_unix());
115 info.last_error = None;
116 }
117 if let Some(err) = error {
118 info.last_error = Some(err.to_string());
119 }
120 info.status = state;
121 }
122}
123
124fn set_next_check(timestamp: u64) {
125 let status = get_or_init_status();
126 if let Ok(mut info) = status.write() {
127 info.next_check = Some(timestamp);
128 }
129}
130
131pub fn get_acme_status() -> serde_json::Value {
133 let status = get_or_init_status();
134 let info = match status.read() {
135 Ok(i) => i.clone(),
136 Err(_) => return serde_json::json!({"error": "lock poisoned"}),
137 };
138
139 if info.domain.is_empty() {
140 return serde_json::json!({
141 "status": "not_configured",
142 "message": "ACME/Let's Encrypt is not configured"
143 });
144 }
145
146 let cert_path = info.cert_dir.join(format!("{}.fullchain.pem", info.domain));
148 let (cert_exists, age_days, days_until_renewal) = if cert_path.exists() {
149 if let Ok(metadata) = std::fs::metadata(&cert_path) {
150 if let Ok(modified) = metadata.modified() {
151 let age = modified.elapsed().unwrap_or_default();
152 let age_d = age.as_secs() / (24 * 60 * 60);
153 let days_until = 60u64.saturating_sub(age_d) as i64;
155 (true, Some(age_d), Some(days_until))
156 } else {
157 (true, None, None)
158 }
159 } else {
160 (true, None, None)
161 }
162 } else {
163 (false, None, None)
164 };
165
166 let sans_path = info.cert_dir.join(format!("{}.sans.json", info.domain));
168 let cert_sans: Vec<String> = std::fs::read_to_string(&sans_path)
169 .ok()
170 .and_then(|content| serde_json::from_str(&content).ok())
171 .unwrap_or_default();
172
173 let format_ts = |ts: Option<u64>| -> serde_json::Value {
174 match ts {
175 Some(t) => chrono::DateTime::from_timestamp(t as i64, 0)
176 .map(|dt| serde_json::Value::String(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()))
177 .unwrap_or(serde_json::Value::Null),
178 None => serde_json::Value::Null,
179 }
180 };
181
182 serde_json::json!({
183 "status": info.status.as_str(),
184 "domain": info.domain,
185 "subdomains": info.subdomains,
186 "certificate": {
187 "exists": cert_exists,
188 "age_days": age_days,
189 "days_until_renewal": days_until_renewal,
190 "sans": cert_sans,
191 },
192 "last_attempt": format_ts(info.last_attempt),
193 "last_success": format_ts(info.last_success),
194 "last_error": info.last_error,
195 "attempt_count": info.attempt_count,
196 "next_renewal_check": format_ts(info.next_check),
197 })
198}
199
200#[derive(serde::Deserialize)]
201struct AcmeDirectory {
202 #[serde(rename = "newNonce")]
203 new_nonce: String,
204 #[serde(rename = "newAccount")]
205 new_account: String,
206 #[serde(rename = "newOrder")]
207 new_order: String,
208}
209
210#[derive(serde::Deserialize)]
211struct AcmeOrder {
212 status: String,
213 authorizations: Vec<String>,
214 finalize: String,
215 certificate: Option<String>,
216}
217
218#[derive(serde::Deserialize)]
219struct AcmeAuthorization {
220 status: String,
221 challenges: Vec<AcmeChallenge>,
222}
223
224#[derive(serde::Deserialize)]
225struct AcmeChallenge {
226 #[serde(rename = "type")]
227 challenge_type: String,
228 url: String,
229 token: String,
230 #[allow(dead_code)]
231 status: String,
232}
233
234struct AcmeClient {
235 http: reqwest::Client,
236 key_pair: EcdsaKeyPair,
237 rng: SystemRandom,
238 directory: AcmeDirectory,
239 account_url: Option<String>,
240 cert_dir: PathBuf,
241}
242
243impl AcmeClient {
244 async fn new(cert_dir: &Path, staging: bool) -> Result<Self, String> {
245 let rng = SystemRandom::new();
246
247 std::fs::create_dir_all(cert_dir)
248 .map_err(|e| format!("Failed to create cert dir: {}", e))?;
249
250 let account_key_path = cert_dir.join("acme-account.key");
251 let pkcs8_bytes = if account_key_path.exists() {
252 std::fs::read(&account_key_path)
253 .map_err(|e| format!("Failed to read account key: {}", e))?
254 } else {
255 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
256 .map_err(|e| format!("Key generation failed: {}", e))?;
257 let bytes = pkcs8.as_ref().to_vec();
258 std::fs::write(&account_key_path, &bytes)
259 .map_err(|e| format!("Failed to save account key: {}", e))?;
260 bytes
261 };
262
263 let key_pair =
264 EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &pkcs8_bytes, &rng)
265 .map_err(|e| format!("Failed to load key pair: {}", e))?;
266
267 let http = reqwest::Client::builder()
268 .timeout(std::time::Duration::from_secs(30))
269 .build()
270 .map_err(|e| format!("HTTP client failed: {}", e))?;
271
272 let dir_url = if staging { LE_STAGING } else { LE_PRODUCTION };
273 let directory: AcmeDirectory = http
274 .get(dir_url)
275 .send()
276 .await
277 .map_err(|e| format!("Failed to fetch ACME directory: {}", e))?
278 .json()
279 .await
280 .map_err(|e| format!("Invalid ACME directory: {}", e))?;
281
282 Ok(Self {
283 http,
284 key_pair,
285 rng,
286 directory,
287 account_url: None,
288 cert_dir: cert_dir.to_path_buf(),
289 })
290 }
291
292 async fn get_nonce(&self) -> Result<String, String> {
293 let resp = self
294 .http
295 .head(&self.directory.new_nonce)
296 .send()
297 .await
298 .map_err(|e| format!("Nonce request failed: {}", e))?;
299 resp.headers()
300 .get("replay-nonce")
301 .and_then(|v| v.to_str().ok())
302 .map(|s| s.to_string())
303 .ok_or_else(|| "No nonce in response".to_string())
304 }
305
306 fn jwk_thumbprint(&self) -> String {
307 let public_key = self.key_pair.public_key().as_ref();
308 let x = URL_SAFE_NO_PAD.encode(&public_key[1..33]);
309 let y = URL_SAFE_NO_PAD.encode(&public_key[33..65]);
310 let jwk_json = format!(r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, x, y);
312 let hash = digest(&SHA256, jwk_json.as_bytes());
313 URL_SAFE_NO_PAD.encode(hash.as_ref())
314 }
315
316 fn jws_with_jwk(&self, url: &str, payload: &str, nonce: &str) -> Result<String, String> {
317 let public_key = self.key_pair.public_key().as_ref();
318 let x = URL_SAFE_NO_PAD.encode(&public_key[1..33]);
319 let y = URL_SAFE_NO_PAD.encode(&public_key[33..65]);
320
321 let header = serde_json::json!({
322 "alg": "ES256",
323 "jwk": { "crv": "P-256", "kty": "EC", "x": x, "y": y },
324 "nonce": nonce,
325 "url": url
326 });
327
328 self.sign_jws(&header, payload)
329 }
330
331 fn jws_with_kid(&self, url: &str, payload: &str, nonce: &str) -> Result<String, String> {
332 let kid = self.account_url.as_deref().ok_or("No account URL")?;
333
334 let header = serde_json::json!({
335 "alg": "ES256",
336 "kid": kid,
337 "nonce": nonce,
338 "url": url
339 });
340
341 self.sign_jws(&header, payload)
342 }
343
344 fn sign_jws(&self, header: &serde_json::Value, payload: &str) -> Result<String, String> {
345 let protected = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
346 let payload_b64 = if payload.is_empty() {
347 String::new()
348 } else {
349 URL_SAFE_NO_PAD.encode(payload.as_bytes())
350 };
351
352 let signing_input = format!("{}.{}", protected, payload_b64);
353 let signature = self
354 .key_pair
355 .sign(&self.rng, signing_input.as_bytes())
356 .map_err(|e| format!("Signing failed: {}", e))?;
357 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.as_ref());
358
359 let jws = serde_json::json!({
360 "protected": protected,
361 "payload": payload_b64,
362 "signature": sig_b64
363 });
364
365 Ok(jws.to_string())
366 }
367
368 async fn register_account(&mut self, email: &str) -> Result<(), String> {
369 let nonce = self.get_nonce().await?;
370
371 let payload = if email.is_empty() {
372 serde_json::json!({ "termsOfServiceAgreed": true }).to_string()
373 } else {
374 serde_json::json!({
375 "termsOfServiceAgreed": true,
376 "contact": [format!("mailto:{}", email)]
377 })
378 .to_string()
379 };
380
381 let url = self.directory.new_account.clone();
382 let body = self.jws_with_jwk(&url, &payload, &nonce)?;
383
384 let resp = self
385 .http
386 .post(&url)
387 .header("Content-Type", "application/jose+json")
388 .body(body)
389 .send()
390 .await
391 .map_err(|e| format!("Account registration failed: {}", e))?;
392
393 self.account_url = resp
394 .headers()
395 .get("location")
396 .and_then(|v| v.to_str().ok())
397 .map(|s| s.to_string());
398
399 if self.account_url.is_none() {
400 return Err("No account URL in response".to_string());
401 }
402
403 log::info!("ACME account registered");
404 Ok(())
405 }
406
407 async fn request_certificate(&mut self, domain: &str, subdomains: &[String]) -> Result<(), String> {
408 let mut domains: Vec<String> = vec![
412 domain.to_string(),
413 format!("www.{}", domain),
414 ];
415 for sub in subdomains {
416 let fqdn = if sub.contains('.') {
417 sub.clone() } else {
419 format!("{}.{}", sub, domain)
420 };
421 if !domains.contains(&fqdn) {
422 domains.push(fqdn);
423 }
424 }
425 log::info!("ACME: Requesting certificate for {} SANs: {:?}", domains.len(), domains);
426 let identifiers: Vec<serde_json::Value> = domains
427 .iter()
428 .map(|d| serde_json::json!({"type": "dns", "value": d}))
429 .collect();
430
431 let nonce = self.get_nonce().await?;
433 let payload = serde_json::json!({
434 "identifiers": identifiers
435 })
436 .to_string();
437
438 let new_order_url = self.directory.new_order.clone();
439 let body = self.jws_with_kid(&new_order_url, &payload, &nonce)?;
440
441 let resp = self
442 .http
443 .post(&new_order_url)
444 .header("Content-Type", "application/jose+json")
445 .body(body)
446 .send()
447 .await
448 .map_err(|e| format!("Order creation failed: {}", e))?;
449
450 let order_status = resp.status();
451 let order_url = resp
452 .headers()
453 .get("location")
454 .and_then(|v| v.to_str().ok())
455 .map(|s| s.to_string());
456
457 let order_body = resp
458 .text()
459 .await
460 .unwrap_or_else(|_| "no body".to_string());
461
462 if !order_status.is_success() || order_url.is_none() {
463 log::error!(
464 "ACME order creation: status={}, location={:?}, body={}",
465 order_status,
466 order_url,
467 &order_body[..order_body.len().min(500)]
468 );
469 return Err(format!(
470 "Order creation failed ({}): {}",
471 order_status,
472 &order_body[..order_body.len().min(300)]
473 ));
474 }
475
476 let order_url = order_url.unwrap();
477 let order: AcmeOrder = serde_json::from_str(&order_body)
478 .map_err(|e| format!("Invalid order response: {}", e))?;
479
480 if order.authorizations.is_empty() {
481 return Err("No authorizations in order".to_string());
482 }
483
484 let thumbprint = self.jwk_thumbprint();
486 for auth_url in &order.authorizations {
487 let nonce = self.get_nonce().await?;
488 let body = self.jws_with_kid(auth_url, "", &nonce)?;
489
490 let resp = self
491 .http
492 .post(auth_url)
493 .header("Content-Type", "application/jose+json")
494 .body(body)
495 .send()
496 .await
497 .map_err(|e| format!("Authorization fetch failed: {}", e))?;
498
499 let auth: AcmeAuthorization = resp
500 .json()
501 .await
502 .map_err(|e| format!("Invalid authorization: {}", e))?;
503
504 if auth.status == "valid" {
506 log::info!("ACME authorization already valid");
507 continue;
508 }
509
510 let challenge = auth
512 .challenges
513 .iter()
514 .find(|c| c.challenge_type == "http-01")
515 .ok_or("No HTTP-01 challenge found")?;
516
517 let key_auth = format!("{}.{}", challenge.token, thumbprint);
519 log::info!("ACME challenge: token={}", challenge.token);
520 set_challenge(challenge.token.clone(), key_auth);
521
522 let nonce = self.get_nonce().await?;
524 let challenge_url = challenge.url.clone();
525 let body = self.jws_with_kid(&challenge_url, "{}", &nonce)?;
526
527 self.http
528 .post(&challenge_url)
529 .header("Content-Type", "application/jose+json")
530 .body(body)
531 .send()
532 .await
533 .map_err(|e| format!("Challenge response failed: {}", e))?;
534
535 let mut auth_ok = false;
537 for attempt in 0..30 {
538 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
539
540 let nonce = self.get_nonce().await?;
541 let body = self.jws_with_kid(auth_url, "", &nonce)?;
542
543 let resp = self
544 .http
545 .post(auth_url)
546 .header("Content-Type", "application/jose+json")
547 .body(body)
548 .send()
549 .await
550 .map_err(|e| format!("Auth poll failed: {}", e))?;
551
552 let poll_auth: AcmeAuthorization = resp
553 .json()
554 .await
555 .map_err(|e| format!("Invalid auth response: {}", e))?;
556
557 match poll_auth.status.as_str() {
558 "valid" => {
559 log::info!("ACME authorization valid");
560 auth_ok = true;
561 break;
562 }
563 "invalid" => {
564 remove_challenge(&challenge.token);
565 return Err("Authorization failed".to_string());
566 }
567 _ => {
568 log::debug!("ACME auth poll attempt {}: status={}", attempt + 1, poll_auth.status);
569 }
570 }
571 }
572
573 remove_challenge(&challenge.token);
574
575 if !auth_ok {
576 return Err("Authorization timeout".to_string());
577 }
578 }
579
580 let (csr_der, key_pem) = self.generate_csr_and_key_multi(&domains)?;
585
586 let csr_b64 = URL_SAFE_NO_PAD.encode(&csr_der);
588 let nonce = self.get_nonce().await?;
589 let payload = serde_json::json!({"csr": csr_b64}).to_string();
590 let body = self.jws_with_kid(&order.finalize, &payload, &nonce)?;
591
592 let finalize_resp = self
593 .http
594 .post(&order.finalize)
595 .header("Content-Type", "application/jose+json")
596 .body(body)
597 .send()
598 .await
599 .map_err(|e| format!("Finalize failed: {}", e))?;
600
601 let finalize_status = finalize_resp.status();
602 let finalize_body = finalize_resp
603 .text()
604 .await
605 .unwrap_or_else(|_| "no body".to_string());
606
607 log::info!(
608 "ACME finalize response: status={}, body={}",
609 finalize_status,
610 &finalize_body[..finalize_body.len().min(500)]
611 );
612
613 if !finalize_status.is_success() {
614 return Err(format!("Finalize rejected ({}): {}", finalize_status, finalize_body));
615 }
616
617 let cert_url = {
619 let mut result = None;
620 for attempt in 0..30 {
621 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
622
623 let nonce = self.get_nonce().await?;
624 let body = self.jws_with_kid(&order_url, "", &nonce)?;
625
626 let resp = self
627 .http
628 .post(&order_url)
629 .header("Content-Type", "application/jose+json")
630 .body(body)
631 .send()
632 .await
633 .map_err(|e| format!("Order poll failed: {}", e))?;
634
635 let order: AcmeOrder = resp
636 .json()
637 .await
638 .map_err(|e| format!("Invalid order: {}", e))?;
639
640 match order.status.as_str() {
641 "valid" => {
642 result = order.certificate;
643 break;
644 }
645 "invalid" => {
646 return Err("Order became invalid".to_string());
647 }
648 _ => {
649 log::debug!(
650 "Order poll attempt {}: status={}",
651 attempt + 1,
652 order.status
653 );
654 }
655 }
656 }
657 result.ok_or("Certificate URL not received")?
658 };
659
660 let nonce = self.get_nonce().await?;
662 let body = self.jws_with_kid(&cert_url, "", &nonce)?;
663
664 let resp = self
665 .http
666 .post(&cert_url)
667 .header("Content-Type", "application/jose+json")
668 .header("Accept", "application/pem-certificate-chain")
669 .body(body)
670 .send()
671 .await
672 .map_err(|e| format!("Cert download failed: {}", e))?;
673
674 let cert_pem = resp
675 .text()
676 .await
677 .map_err(|e| format!("Failed to read certificate: {}", e))?;
678
679 let cert_path = self.cert_dir.join(format!("{}.fullchain.pem", domain));
683 let key_path = self.cert_dir.join(format!("{}.privkey.pem", domain));
684
685 std::fs::write(&cert_path, &cert_pem)
686 .map_err(|e| format!("Failed to save certificate: {}", e))?;
687 std::fs::write(&key_path, &key_pem)
688 .map_err(|e| format!("Failed to save private key: {}", e))?;
689
690 #[cfg(unix)]
691 {
692 use std::os::unix::fs::PermissionsExt;
693 if let Ok(metadata) = std::fs::metadata(&key_path) {
694 let mut perms = metadata.permissions();
695 perms.set_mode(0o600);
696 let _ = std::fs::set_permissions(&key_path, perms);
697 }
698 }
699
700 log::info!(
701 "Let's Encrypt certificate + key saved for {}: {:?}",
702 domain,
703 cert_path
704 );
705
706 let sans_path = self.cert_dir.join(format!("{}.sans.json", domain));
710 let _ = std::fs::write(
711 &sans_path,
712 serde_json::to_string(&domains).unwrap_or_default(),
713 );
714
715 Ok(())
716 }
717
718 fn generate_csr_and_key_multi(&self, domains: &[String]) -> Result<(Vec<u8>, String), String> {
719 let mut params = rcgen::CertificateParams::new(domains.to_vec());
720 let mut dn = rcgen::DistinguishedName::new();
721 dn.push(rcgen::DnType::CommonName, &domains[0]);
722 params.distinguished_name = dn;
723
724 let cert = rcgen::Certificate::from_params(params)
725 .map_err(|e| format!("CSR generation failed: {}", e))?;
726
727 let csr_der = cert
728 .serialize_request_der()
729 .map_err(|e| format!("CSR serialization failed: {}", e))?;
730
731 let key_pem = cert.serialize_private_key_pem();
732
733 Ok((csr_der, key_pem))
734 }
735}
736
737pub const ACME_DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
740<html lang="en">
741<head>
742<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
743<title>ACME/TLS Status - Rush Sync Server</title>
744<link rel="icon" href="/.rss/favicon.svg" type="image/svg+xml">
745<style>
746*{margin:0;padding:0;box-sizing:border-box}
747body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a0f;color:#e4e4ef;min-height:100vh}
748.container{max-width:900px;margin:0 auto;padding:24px}
749.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
750.header h1{font-size:24px;font-weight:700;letter-spacing:-0.5px}
751.header h1 span{color:#6c63ff}
752.nav-links{display:flex;gap:12px}
753.nav-links a{color:#6c63ff;text-decoration:none;font-size:14px}
754.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px}
755.card{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:20px}
756.card .lbl{font-size:12px;color:#8888a0;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}
757.card .val{font-size:28px;font-weight:700}
758.card .val.green{color:#00d4aa}
759.card .val.red{color:#ff4466}
760.card .val.yellow{color:#ffaa00}
761.card .val.blue{color:#00a8ff}
762.card .val.purple{color:#6c63ff}
763.card .sub{font-size:11px;color:#8888a0;margin-top:4px}
764.section{background:#14141f;border:1px solid #2a2a3a;border-radius:12px;padding:20px;margin-bottom:16px}
765.section h2{font-size:15px;margin-bottom:16px;font-weight:600;color:#c0c0d0}
766.info-row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #1a1a2a;font-size:13px}
767.info-row:last-child{border-bottom:none}
768.info-row .label{color:#8888a0}
769.info-row .value{font-weight:500;color:#e4e4ef}
770.info-row .value.success{color:#00d4aa}
771.info-row .value.error{color:#ff4466}
772.info-row .value.warn{color:#ffaa00}
773.info-row .value code{background:#1a1a2a;padding:2px 6px;border-radius:4px;font-size:12px}
774.status-banner{padding:16px 20px;border-radius:12px;margin-bottom:24px;display:flex;align-items:center;gap:12px;font-weight:600}
775.status-banner .dot{width:12px;height:12px;border-radius:50%;flex-shrink:0}
776.status-banner.success{background:#00d4aa15;border:1px solid #00d4aa40;color:#00d4aa}
777.status-banner.success .dot{background:#00d4aa;box-shadow:0 0 8px #00d4aa80}
778.status-banner.failed{background:#ff446615;border:1px solid #ff446640;color:#ff4466}
779.status-banner.failed .dot{background:#ff4466;box-shadow:0 0 8px #ff446680}
780.status-banner.provisioning{background:#ffaa0015;border:1px solid #ffaa0040;color:#ffaa00}
781.status-banner.provisioning .dot{background:#ffaa00;box-shadow:0 0 8px #ffaa0080;animation:pulse 1.5s infinite}
782.status-banner.idle{background:#6c63ff15;border:1px solid #6c63ff40;color:#6c63ff}
783.status-banner.idle .dot{background:#6c63ff}
784.status-banner.not_configured{background:#55555515;border:1px solid #55555540;color:#888}
785.status-banner.not_configured .dot{background:#555}
786@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
787.san-list{display:flex;flex-wrap:wrap;gap:6px}
788.san-tag{background:#1a1a2a;border:1px solid #2a2a3a;border-radius:6px;padding:4px 10px;font-size:12px;font-family:monospace}
789.error-box{background:#ff446610;border:1px solid #ff446630;border-radius:8px;padding:12px 16px;font-size:13px;color:#ff8899;font-family:monospace;word-break:break-all}
790.footer{text-align:center;font-size:11px;color:#555;padding:16px}
791</style>
792</head>
793<body>
794<div class="container">
795<div class="header"><h1>ACME/TLS <span>Status</span></h1><div class="nav-links"><a href="/.rss/">← Dashboard</a><a href="/api/analytics/dashboard">Analytics</a><a href="/api/acme/status">JSON</a></div></div>
796<div id="banner"></div>
797<div class="cards" id="cards"></div>
798<div class="section" id="cert-section"><h2>Certificate</h2><div id="cert-info"></div></div>
799<div class="section" id="sans-section"><h2>Subject Alternative Names</h2><div id="sans-list"></div></div>
800<div class="section" id="detail-section"><h2>Details</h2><div id="details"></div></div>
801<div id="error-section" style="display:none" class="section"><h2>Last Error</h2><div id="error-box"></div></div>
802<div class="footer" id="foot">Loading...</div>
803</div>
804<script>
805var D=__ACME_DATA__;
806function render(){
807var s=D.status||'not_configured';
808var labels={'success':'Certificate Active','failed':'Provisioning Failed','provisioning':'Provisioning in Progress...','idle':'Idle','not_configured':'Not Configured'};
809document.getElementById('banner').innerHTML='<div class="status-banner '+s+'"><span class="dot"></span>'+esc(labels[s]||s)+'</div>';
810if(s==='not_configured'){document.getElementById('cards').innerHTML='<div class="card"><div class="lbl">Status</div><div class="val purple">N/A</div><div class="sub">ACME not configured</div></div>';document.getElementById('foot').textContent='ACME/Let\'s Encrypt is not enabled';return}
811var cert=D.certificate||{};
812document.getElementById('cards').innerHTML='<div class="card"><div class="lbl">Status</div><div class="val '+(s==='success'?'green':s==='failed'?'red':s==='provisioning'?'yellow':'blue')+'">'+esc(s.charAt(0).toUpperCase()+s.slice(1))+'</div></div>'+'<div class="card"><div class="lbl">Certificate</div><div class="val '+(cert.exists?'green':'red')+'">'+(cert.exists?'Valid':'Missing')+'</div>'+(cert.age_days!=null?'<div class="sub">'+cert.age_days+' days old</div>':'')+'</div>'+'<div class="card"><div class="lbl">Renewal</div><div class="val '+((cert.days_until_renewal!=null&&cert.days_until_renewal>14)?'green':(cert.days_until_renewal!=null&&cert.days_until_renewal>0)?'yellow':'red')+'">'+(cert.days_until_renewal!=null?cert.days_until_renewal+' days':'N/A')+'</div><div class="sub">until renewal</div></div>'+'<div class="card"><div class="lbl">Attempts</div><div class="val purple">'+(D.attempt_count||0)+'</div><div class="sub">provisioning attempts</div></div>';
813var ci='';
814ci+='<div class="info-row"><span class="label">Domain</span><span class="value"><code>'+esc(D.domain||'')+'</code></span></div>';
815ci+='<div class="info-row"><span class="label">Subdomains</span><span class="value">'+(D.subdomains&&D.subdomains.length?D.subdomains.map(function(s){return '<code>'+esc(s)+'</code>'}).join(' '):'<em>none</em>')+'</span></div>';
816ci+='<div class="info-row"><span class="label">Certificate Exists</span><span class="value '+(cert.exists?'success':'error')+'">'+(cert.exists?'Yes':'No')+'</span></div>';
817if(cert.age_days!=null)ci+='<div class="info-row"><span class="label">Certificate Age</span><span class="value">'+cert.age_days+' days</span></div>';
818if(cert.days_until_renewal!=null)ci+='<div class="info-row"><span class="label">Days Until Renewal</span><span class="value '+(cert.days_until_renewal>14?'success':'warn')+'">'+cert.days_until_renewal+' days</span></div>';
819document.getElementById('cert-info').innerHTML=ci;
820var sans=cert.sans||[];
821if(sans.length>0){document.getElementById('sans-section').style.display='';document.getElementById('sans-list').innerHTML='<div class="san-list">'+sans.map(function(s){return '<span class="san-tag">'+esc(s)+'</span>'}).join('')+'</div>'}else{document.getElementById('sans-section').style.display='none'}
822var di='';
823di+='<div class="info-row"><span class="label">Last Attempt</span><span class="value">'+(D.last_attempt?fmtTime(D.last_attempt):'Never')+'</span></div>';
824di+='<div class="info-row"><span class="label">Last Success</span><span class="value '+(D.last_success?'success':'')+'">'+(D.last_success?fmtTime(D.last_success):'Never')+'</span></div>';
825di+='<div class="info-row"><span class="label">Next Renewal Check</span><span class="value">'+(D.next_renewal_check?fmtTime(D.next_renewal_check):'Not scheduled')+'</span></div>';
826di+='<div class="info-row"><span class="label">Attempt Count</span><span class="value">'+(D.attempt_count||0)+'</span></div>';
827document.getElementById('details').innerHTML=di;
828if(D.last_error){document.getElementById('error-section').style.display='';document.getElementById('error-box').innerHTML='<div class="error-box">'+esc(D.last_error)+'</div>'}else{document.getElementById('error-section').style.display='none'}
829document.getElementById('foot').textContent='Last updated: '+new Date().toLocaleTimeString()+' \u00b7 Auto-refresh in 15s'}
830function fmtTime(s){try{var d=new Date(s);return d.toLocaleString()}catch(e){return s}}
831function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
832render();setTimeout(function(){location.reload()},15000);
833</script>
834</body></html>"#;
835
836pub async fn provision_certificate(
841 domain: &str,
842 cert_dir: &Path,
843 email: &str,
844 staging: bool,
845 subdomains: &[String],
846) -> Result<(), String> {
847 log::info!(
848 "Starting Let's Encrypt provisioning for {} (staging={})",
849 domain,
850 staging
851 );
852
853 let mut client = AcmeClient::new(cert_dir, staging).await?;
854 client.register_account(email).await?;
855 client.request_certificate(domain, subdomains).await?;
856
857 log::info!("Let's Encrypt certificate provisioned for {}", domain);
858 Ok(())
859}
860
861pub async fn check_and_renew(
863 domain: &str,
864 cert_dir: &Path,
865 email: &str,
866 staging: bool,
867 renew_before_days: u32,
868 subdomains: &[String],
869) -> Result<bool, String> {
870 let cert_path = cert_dir.join(format!("{}.fullchain.pem", domain));
871 let key_path = cert_dir.join(format!("{}.privkey.pem", domain));
872
873 if !cert_path.exists() || !key_path.exists() {
874 log::info!("No certificate found for {}, provisioning...", domain);
875 provision_certificate(domain, cert_dir, email, staging, subdomains).await?;
876 return Ok(true);
877 }
878
879 if !subdomains.is_empty() {
882 let sans_path = cert_dir.join(format!("{}.sans.json", domain));
883 let mut expected: Vec<String> = vec![domain.to_string(), format!("www.{}", domain)];
884 for sub in subdomains {
885 let fqdn = if sub.contains('.') {
886 sub.clone()
887 } else {
888 format!("{}.{}", sub, domain)
889 };
890 if !expected.contains(&fqdn) {
891 expected.push(fqdn);
892 }
893 }
894 expected.sort();
895
896 let sans_mismatch = if let Ok(content) = std::fs::read_to_string(&sans_path) {
897 if let Ok(mut stored) = serde_json::from_str::<Vec<String>>(&content) {
898 stored.sort();
899 stored != expected
900 } else {
901 true }
903 } else {
904 true
907 };
908
909 if sans_mismatch {
910 log::info!(
911 "ACME: Certificate SANs changed for {}, re-provisioning with {} subdomains...",
912 domain,
913 subdomains.len()
914 );
915 provision_certificate(domain, cert_dir, email, staging, subdomains).await?;
916 return Ok(true);
917 }
918 }
919
920 let metadata = std::fs::metadata(&cert_path)
922 .map_err(|e| format!("Failed to read cert metadata: {}", e))?;
923
924 let modified = metadata
925 .modified()
926 .map_err(|e| format!("Failed to get modification time: {}", e))?;
927
928 let age = modified.elapsed().unwrap_or_default();
929 let max_age = std::time::Duration::from_secs((90 - renew_before_days) as u64 * 24 * 60 * 60);
930
931 if age > max_age {
932 log::info!(
933 "Certificate for {} is due for renewal ({} days old)",
934 domain,
935 age.as_secs() / (24 * 60 * 60)
936 );
937 provision_certificate(domain, cert_dir, email, staging, subdomains).await?;
938 return Ok(true);
939 }
940
941 let remaining_days = (max_age.as_secs().saturating_sub(age.as_secs())) / (24 * 60 * 60);
942 log::debug!(
943 "Certificate for {} is valid ({} days until renewal)",
944 domain,
945 remaining_days
946 );
947 Ok(false)
948}
949
950pub fn start_acme_background(domain: String, cert_dir: PathBuf, email: String, staging: bool, subdomains: Vec<String>) {
955 init_status(&domain, &subdomains, &cert_dir);
956
957 tokio::spawn(async move {
958 tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
960
961 update_status(AcmeState::Provisioning, None);
963 let provisioned = match check_and_renew(&domain, &cert_dir, &email, staging, 30, &subdomains).await {
964 Ok(renewed) => {
965 update_status(AcmeState::Success, None);
966 if renewed {
967 log::info!("ACME: Certificate provisioned/renewed for {} (with {} subdomains)", domain, subdomains.len());
968 } else {
969 log::info!("ACME: Certificate for {} is still valid", domain);
970 }
971 true
972 }
973 Err(e) => {
974 log::error!("ACME: Failed to provision with subdomains {:?}: {}", subdomains, e);
975 update_status(AcmeState::Failed, Some(&e));
976
977 let cert_path = cert_dir.join(format!("{}.fullchain.pem", domain));
981 if cert_path.exists() {
982 log::warn!(
983 "ACME: Keeping existing certificate for {} (will retry with all subdomains next cycle)",
984 domain
985 );
986 true } else {
988 log::info!("ACME: No certificate exists. Trying bare domain only: {}", domain);
990 update_status(AcmeState::Provisioning, None);
991 match check_and_renew(&domain, &cert_dir, &email, staging, 30, &[]).await {
992 Ok(renewed) => {
993 update_status(AcmeState::Success, None);
994 if renewed {
995 log::info!("ACME: Certificate provisioned for {} (bare domain fallback)", domain);
996 }
997 true
998 }
999 Err(e2) => {
1000 log::error!("ACME: Bare domain fallback also failed for {}: {}", domain, e2);
1001 update_status(AcmeState::Failed, Some(&e2));
1002 false
1003 }
1004 }
1005 }
1006 }
1007 };
1008
1009 if provisioned {
1012 crate::proxy::handler::reload_proxy_tls(&domain);
1013 } else {
1014 log::info!("ACME: Will retry in 60 seconds...");
1016 tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
1017 update_status(AcmeState::Provisioning, None);
1018 match check_and_renew(&domain, &cert_dir, &email, staging, 30, &subdomains).await {
1019 Ok(true) => {
1020 update_status(AcmeState::Success, None);
1021 log::info!("ACME: Certificate provisioned on retry for {}", domain);
1022 crate::proxy::handler::reload_proxy_tls(&domain);
1023 }
1024 Ok(false) => {
1025 update_status(AcmeState::Success, None);
1026 log::info!("ACME: Certificate for {} is valid on retry", domain);
1027 }
1028 Err(e) => {
1029 update_status(AcmeState::Failed, Some(&e));
1030 log::error!("ACME: Retry also failed for {}: {}", domain, e);
1031 }
1032 }
1033 }
1034
1035 set_next_check(now_unix() + 24 * 60 * 60);
1037 let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
1038 loop {
1039 interval.tick().await;
1040 update_status(AcmeState::Provisioning, None);
1041 match check_and_renew(&domain, &cert_dir, &email, staging, 30, &subdomains).await {
1042 Ok(true) => {
1043 update_status(AcmeState::Success, None);
1044 log::info!("ACME: Certificate renewed for {}", domain);
1045 crate::proxy::handler::reload_proxy_tls(&domain);
1046 }
1047 Ok(false) => {
1048 update_status(AcmeState::Success, None);
1049 }
1050 Err(e) => {
1051 update_status(AcmeState::Failed, Some(&e));
1052 log::error!("ACME: Renewal check failed for {}: {}", domain, e);
1053 }
1054 }
1055 set_next_check(now_unix() + 24 * 60 * 60);
1056 }
1057 });
1058}