Skip to main content

orca_proxy/
acme.rs

1//! ACME HTTP-01 challenge serving and certificate management.
2//!
3//! `AcmeManager` serves `/.well-known/acme-challenge/` responses, loads cached
4//! TLS certs from disk, and delegates provisioning to `certbot` via subprocess.
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use rustls::ServerConfig;
11use rustls::pki_types::{CertificateDer, PrivateKeyDer};
12use tokio::sync::RwLock;
13use tokio_rustls::TlsAcceptor;
14use tracing::{info, warn};
15
16/// Days before expiry to trigger renewal.
17const RENEWAL_THRESHOLD_DAYS: i64 = 30;
18
19/// Manages ACME HTTP-01 challenges and certificate loading.
20#[derive(Clone)]
21pub struct AcmeManager {
22    pub acme_email: String,
23    pub cache_dir: PathBuf,
24    challenges: Arc<RwLock<HashMap<String, String>>>,
25    domains: Arc<RwLock<HashSet<String>>>,
26}
27
28impl AcmeManager {
29    pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
30        Self {
31            acme_email: email.into(),
32            cache_dir: cache_dir.into(),
33            challenges: Arc::new(RwLock::new(HashMap::new())),
34            domains: Arc::new(RwLock::new(HashSet::new())),
35        }
36    }
37
38    /// Create a manager with default cache directory (`~/.orca/certs/`).
39    pub fn with_default_cache(email: impl Into<String>) -> Self {
40        let cache_dir = default_orca_dir().join("certs");
41        Self::new(email, cache_dir)
42    }
43
44    /// Register a domain for certificate provisioning.
45    pub async fn add_domain(&self, domain: impl Into<String>) {
46        let domain = domain.into();
47        info!(domain = %domain, "Registered domain for ACME");
48        self.domains.write().await.insert(domain);
49    }
50
51    /// Store a challenge token and its authorization response.
52    pub async fn set_challenge(&self, token: String, authorization: String) {
53        self.challenges.write().await.insert(token, authorization);
54    }
55
56    /// Get the authorization response for an HTTP-01 challenge token.
57    pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
58        self.challenges.read().await.get(token).cloned()
59    }
60
61    /// Remove a completed challenge.
62    pub async fn clear_challenge(&self, token: &str) {
63        self.challenges.write().await.remove(token);
64    }
65
66    /// Load certificates from cache. Returns `None` if missing or unparseable.
67    pub fn load_cached_certs(
68        &self,
69        domain: &str,
70    ) -> Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
71        let cert_path = self.cert_path(domain);
72        let key_path = self.key_path(domain);
73        if !cert_path.exists() || !key_path.exists() {
74            return None;
75        }
76        match load_pem_certs(&cert_path, &key_path) {
77            Ok(pair) => Some(pair),
78            Err(e) => {
79                warn!(domain, error = %e, "Failed to load cached certs");
80                None
81            }
82        }
83    }
84
85    /// Returns `true` if certs are missing or expiring within 30 days.
86    pub fn needs_renewal(&self, domain: &str) -> bool {
87        let cert_path = self.cert_path(domain);
88        if !cert_path.exists() {
89            return true;
90        }
91        match check_cert_expiry(&cert_path) {
92            Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
93            Ok(days) => {
94                info!(domain, days_remaining = days, "Certificate expiring soon");
95                true
96            }
97            Err(e) => {
98                warn!(domain, error = %e, "Cannot check cert expiry");
99                true
100            }
101        }
102    }
103
104    /// Build a `TlsAcceptor` from cached certs for the given domain.
105    pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
106        let Some((certs, key)) = self.load_cached_certs(domain) else {
107            warn!(
108                domain,
109                "No cached certs — run `orca certs provision {domain}`"
110            );
111            return Ok(None);
112        };
113        if self.needs_renewal(domain) {
114            warn!(
115                domain,
116                "Cert expiring soon — run `orca certs provision {domain}`"
117            );
118        }
119        let config = ServerConfig::builder()
120            .with_no_client_auth()
121            .with_single_cert(certs, key)?;
122        Ok(Some(TlsAcceptor::from(Arc::new(config))))
123    }
124
125    /// Provision a certificate via certbot subprocess.
126    ///
127    /// The proxy must be serving HTTP on port 80 for challenge validation.
128    pub async fn provision_with_certbot(&self, domain: &str) -> anyhow::Result<()> {
129        info!(domain, "Starting certbot provisioning");
130        tokio::fs::create_dir_all("/tmp/orca-acme").await?;
131
132        let output = tokio::process::Command::new("certbot")
133            .args([
134                "certonly",
135                "--webroot",
136                "-w",
137                "/tmp/orca-acme",
138                "--domain",
139                domain,
140                "--email",
141                &self.acme_email,
142                "--agree-tos",
143                "--non-interactive",
144            ])
145            .output()
146            .await?;
147
148        if !output.status.success() {
149            let stderr = String::from_utf8_lossy(&output.stderr);
150            anyhow::bail!("certbot failed for {domain}: {stderr}");
151        }
152
153        // Copy certs from certbot output to our cache
154        let le_dir = PathBuf::from(format!("/etc/letsencrypt/live/{domain}"));
155        tokio::fs::create_dir_all(&self.cache_dir).await?;
156        tokio::fs::copy(le_dir.join("fullchain.pem"), self.cert_path(domain)).await?;
157        tokio::fs::copy(le_dir.join("privkey.pem"), self.key_path(domain)).await?;
158        info!(domain, cache_dir = ?self.cache_dir, "Certs provisioned and cached");
159        Ok(())
160    }
161
162    pub fn cert_path(&self, domain: &str) -> PathBuf {
163        self.cache_dir.join(format!("{domain}.cert.pem"))
164    }
165
166    pub fn key_path(&self, domain: &str) -> PathBuf {
167        self.cache_dir.join(format!("{domain}.key.pem"))
168    }
169
170    pub async fn domains(&self) -> Vec<String> {
171        self.domains.read().await.iter().cloned().collect()
172    }
173}
174
175fn load_pem_certs(
176    cert_path: &Path,
177    key_path: &Path,
178) -> anyhow::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
179    let cert_data = std::fs::read(cert_path)?;
180    let key_data = std::fs::read(key_path)?;
181    let certs = rustls_pemfile::certs(&mut cert_data.as_slice()).collect::<Result<Vec<_>, _>>()?;
182    let key = rustls_pemfile::private_key(&mut key_data.as_slice())?
183        .ok_or_else(|| anyhow::anyhow!("no private key in {}", key_path.display()))?;
184    Ok((certs, key))
185}
186
187/// Estimate days until cert expires using file mtime + 90-day LE default.
188fn check_cert_expiry(cert_path: &Path) -> anyhow::Result<i64> {
189    let metadata = std::fs::metadata(cert_path)?;
190    let modified = metadata.modified()?;
191    let age = modified.elapsed().unwrap_or_default();
192    let ninety_days = std::time::Duration::from_secs(90 * 24 * 60 * 60);
193    if age > ninety_days {
194        Ok(0)
195    } else {
196        let remaining = ninety_days.saturating_sub(age);
197        Ok((remaining.as_secs() / (24 * 60 * 60)) as i64)
198    }
199}
200
201fn default_orca_dir() -> PathBuf {
202    dirs::home_dir()
203        .unwrap_or_else(|| PathBuf::from("."))
204        .join(".orca")
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[tokio::test]
212    async fn test_challenge_lifecycle() {
213        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
214        assert!(mgr.get_challenge_response("tok1").await.is_none());
215        mgr.set_challenge("tok1".into(), "auth1".into()).await;
216        assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
217        mgr.clear_challenge("tok1").await;
218        assert!(mgr.get_challenge_response("tok1").await.is_none());
219    }
220
221    #[tokio::test]
222    async fn test_domain_registration() {
223        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
224        mgr.add_domain("example.com").await;
225        assert!(mgr.domains().await.contains(&"example.com".to_string()));
226    }
227
228    #[test]
229    fn test_cert_paths() {
230        let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
231        assert_eq!(
232            mgr.cert_path("example.com"),
233            PathBuf::from("/tmp/certs/example.com.cert.pem")
234        );
235        assert_eq!(
236            mgr.key_path("example.com"),
237            PathBuf::from("/tmp/certs/example.com.key.pem")
238        );
239    }
240
241    #[test]
242    fn test_missing_certs_needs_renewal() {
243        let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
244        assert!(mgr.needs_renewal("example.com"));
245    }
246}