Skip to main content

rush_sync_server/server/
acme.rs

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
13// ACME challenge token storage (shared with web server route handlers)
14static 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
38// =============================================================================
39// ACME Status Tracking
40// =============================================================================
41
42static 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
131/// Get ACME/TLS status as JSON for the API endpoint.
132pub 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    // Read cert metadata
147    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                // 90-day cert, renew 30 days before expiry = 60-day max age
154                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    // Read SANs from .sans.json
167    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        // Canonical JWK JSON (alphabetical key order per RFC 7638)
311        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        // Build list of domains: bare domain + www + additional subdomains.
409        // Every SAN must have a valid DNS A record pointing to this server,
410        // otherwise Let's Encrypt HTTP-01 validation fails for the ENTIRE certificate.
411        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() // already fully qualified (e.g. "www.example.com")
418            } 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        // 1. Create order
432        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        // 2. Process ALL authorizations (one per domain in the order)
485        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            // Skip already-valid authorizations
505            if auth.status == "valid" {
506                log::info!("ACME authorization already valid");
507                continue;
508            }
509
510            // 3. Find HTTP-01 challenge
511            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            // 4. Set up challenge response
518            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            // 5. Tell ACME to verify
523            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            // 6. Poll authorization until valid
536            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        // 7. Generate CSR and key pair for all domains
581        // IMPORTANT: key_pem is held in memory — NOT written to disk yet!
582        // Writing the key before the cert is saved causes a cert/key mismatch
583        // if ACME fails partway through (the old cert would pair with the new key).
584        let (csr_der, key_pem) = self.generate_csr_and_key_multi(&domains)?;
585
586        // 8. Finalize order with CSR
587        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        // 9. Poll order for certificate URL
618        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        // 10. Download certificate
661        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        // 11. Save certificate AND private key together (atomic pair).
680        // The key is saved ONLY after the cert is fully downloaded — this prevents
681        // cert/key mismatch if ACME fails partway (e.g. network error during download).
682        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        // Save SAN list for change detection on future restarts.
707        // When subdomains change (e.g. new server added), check_and_renew()
708        // compares the stored list with the requested list and re-provisions.
709        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
737/// ACME Status Dashboard HTML template. The placeholder `__ACME_DATA__` is replaced
738/// with the current ACME status JSON at render time.
739pub 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/">&larr; 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
836// Public API
837
838/// Provision a Let's Encrypt certificate for a domain.
839/// The proxy must be running on port 80 to serve HTTP-01 challenges.
840pub 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
861/// Check if a certificate exists and is valid. Returns true if renewal was performed.
862pub 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    // Check if requested subdomains differ from what the cert was provisioned with.
880    // This detects when a new server/subdomain is added and triggers re-provisioning.
881    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 // corrupt file
902            }
903        } else {
904            // No .sans.json yet — cert was provisioned before SAN tracking.
905            // Re-provision to ensure all subdomains are included.
906            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    // Check certificate age (simple file modification time check)
921    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
950/// Start background ACME provisioning and renewal.
951/// Runs initial check after a short delay (to let proxy start), then every 24h.
952/// After provisioning/renewal, hot-reloads the proxy's TLS config automatically.
953/// If provisioning with subdomains fails, retries with bare domain only.
954pub 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        // Wait for proxy + HTTP redirect server to be fully ready
959        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
960
961        // Initial provisioning/renewal
962        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                // CRITICAL: Do NOT fall back to bare domain if a cert already exists!
978                // The bare domain fallback would OVERWRITE a good multi-SAN certificate
979                // with one that only has the bare domain, breaking all subdomain HTTPS.
980                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 // reload existing cert into proxy
987                } else {
988                    // No certificate at all — try bare domain as last resort to get HTTPS working
989                    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        // ALWAYS reload TLS on startup — even if the cert wasn't renewed, the proxy
1010        // may have started with a self-signed cert and needs to load the LE cert.
1011        if provisioned {
1012            crate::proxy::handler::reload_proxy_tls(&domain);
1013        } else {
1014            // Retry sooner (60s) instead of waiting 24h
1015            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        // Periodic renewal check (every 24 hours)
1036        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}