Skip to main content

orca_proxy/acme/
mod.rs

1//! ACME certificate management with automatic Let's Encrypt provisioning.
2//!
3//! Uses `instant-acme` for native Rust ACME (RFC 8555) support — no certbot
4//! dependency. Certificates are cached at `~/.orca/certs/`.
5
6pub(crate) mod certs;
7mod provider;
8mod resolver;
9
10pub use provider::AcmeProvider;
11pub use resolver::DynCertResolver;
12
13use std::collections::{HashMap, HashSet};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use tokio::sync::RwLock;
18use tokio_rustls::TlsAcceptor;
19use tracing::{info, warn};
20
21/// Days before expiry to trigger renewal.
22const RENEWAL_THRESHOLD_DAYS: i64 = 30;
23
24/// Manages ACME HTTP-01 challenges and certificate loading.
25#[derive(Clone)]
26pub struct AcmeManager {
27    pub acme_email: String,
28    pub cache_dir: PathBuf,
29    challenges: Arc<RwLock<HashMap<String, String>>>,
30    domains: Arc<RwLock<HashSet<String>>>,
31    /// Semaphore ensuring only one ACME order is in-flight at a time.
32    provision_lock: Arc<tokio::sync::Semaphore>,
33}
34
35impl AcmeManager {
36    pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
37        Self {
38            acme_email: email.into(),
39            cache_dir: cache_dir.into(),
40            challenges: Arc::new(RwLock::new(HashMap::new())),
41            domains: Arc::new(RwLock::new(HashSet::new())),
42            provision_lock: Arc::new(tokio::sync::Semaphore::new(1)),
43        }
44    }
45
46    /// Create a manager with default cache directory (`~/.orca/certs/`).
47    pub fn with_default_cache(email: impl Into<String>) -> Self {
48        let cache_dir = default_orca_dir().join("certs");
49        Self::new(email, cache_dir)
50    }
51
52    /// Register a domain for certificate provisioning.
53    pub async fn add_domain(&self, domain: impl Into<String>) {
54        let domain = domain.into();
55        info!(domain = %domain, "Registered domain for ACME");
56        self.domains.write().await.insert(domain);
57    }
58
59    /// Store a challenge token and its authorization response.
60    pub async fn set_challenge(&self, token: String, authorization: String) {
61        self.challenges.write().await.insert(token, authorization);
62    }
63
64    /// Get the authorization response for an HTTP-01 challenge token.
65    pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
66        self.challenges.read().await.get(token).cloned()
67    }
68
69    /// Remove a completed challenge.
70    pub async fn clear_challenge(&self, token: &str) {
71        self.challenges.write().await.remove(token);
72    }
73
74    /// Load certificates from cache. Returns `None` if missing or unparseable.
75    pub fn load_cached_certs(
76        &self,
77        domain: &str,
78    ) -> Option<(
79        Vec<rustls::pki_types::CertificateDer<'static>>,
80        rustls::pki_types::PrivateKeyDer<'static>,
81    )> {
82        let cert_path = self.cert_path(domain);
83        let key_path = self.key_path(domain);
84        if !cert_path.exists() || !key_path.exists() {
85            return None;
86        }
87        match certs::load_pem_certs(&cert_path, &key_path) {
88            Ok(pair) => Some(pair),
89            Err(e) => {
90                warn!(domain, error = %e, "Failed to load cached certs");
91                None
92            }
93        }
94    }
95
96    /// Returns `true` if certs are missing or expiring within 30 days.
97    pub fn needs_renewal(&self, domain: &str) -> bool {
98        let cert_path = self.cert_path(domain);
99        if !cert_path.exists() {
100            return true;
101        }
102        match certs::check_cert_expiry(&cert_path) {
103            Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
104            Ok(days) => {
105                info!(domain, days_remaining = days, "Certificate expiring soon");
106                true
107            }
108            Err(e) => {
109                warn!(domain, error = %e, "Cannot check cert expiry");
110                true
111            }
112        }
113    }
114
115    /// Build a `TlsAcceptor` from cached certs for the given domain.
116    pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
117        let Some((certs, key)) = self.load_cached_certs(domain) else {
118            return Ok(None);
119        };
120        if self.needs_renewal(domain) {
121            warn!(domain, "Cert expiring soon — will auto-renew");
122        }
123        let config = rustls::ServerConfig::builder()
124            .with_no_client_auth()
125            .with_single_cert(certs, key)?;
126        Ok(Some(TlsAcceptor::from(Arc::new(config))))
127    }
128
129    /// Provision a cert for a domain and add it to the dynamic resolver.
130    ///
131    /// If a valid cached cert exists, it's loaded instead of re-provisioning.
132    /// This is the hot-provisioning entry point called during `orca deploy`.
133    pub async fn ensure_cert_for_resolver(
134        &self,
135        domain: &str,
136        resolver: &DynCertResolver,
137    ) -> anyhow::Result<()> {
138        if resolver.has_cert(domain) && !self.needs_renewal(domain) {
139            return Ok(());
140        }
141
142        // Acquire the provision lock to serialize ACME orders.
143        // Concurrent orders to the same ACME provider can fail.
144        let _permit = self
145            .provision_lock
146            .acquire()
147            .await
148            .map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
149
150        // Re-check after acquiring lock (another task may have provisioned it)
151        if resolver.has_cert(domain) && !self.needs_renewal(domain) {
152            return Ok(());
153        }
154
155        let provider = self.provider();
156        let cert_path = self.cert_path(domain);
157        let key_path = self.key_path(domain);
158
159        // Try cache first
160        let (cert_pem, key_pem) =
161            if cert_path.exists() && key_path.exists() && !self.needs_renewal(domain) {
162                info!(domain, "Loading cached cert for hot provisioning");
163                (std::fs::read(&cert_path)?, std::fs::read(&key_path)?)
164            } else {
165                info!(domain, "Hot-provisioning TLS certificate");
166                provider.provision_cert(domain).await?
167            };
168
169        let certified_key = Self::build_certified_key(&cert_pem, &key_pem)?;
170        resolver.add_cert(domain, Arc::new(certified_key));
171        info!(domain, "Certificate ready (hot-provisioned)");
172        Ok(())
173    }
174
175    /// Build a `CertifiedKey` from PEM bytes.
176    fn build_certified_key(
177        cert_pem: &[u8],
178        key_pem: &[u8],
179    ) -> anyhow::Result<rustls::sign::CertifiedKey> {
180        let certs: Vec<_> =
181            rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
182        let key = rustls_pemfile::private_key(&mut &key_pem[..])?
183            .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
184        let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
185        Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
186    }
187
188    pub fn cert_path(&self, domain: &str) -> PathBuf {
189        self.cache_dir.join(format!("{domain}.cert.pem"))
190    }
191
192    pub fn key_path(&self, domain: &str) -> PathBuf {
193        self.cache_dir.join(format!("{domain}.key.pem"))
194    }
195
196    pub async fn domains(&self) -> Vec<String> {
197        self.domains.read().await.iter().cloned().collect()
198    }
199
200    /// Build an `AcmeProvider` from this manager for cert provisioning.
201    pub fn provider(&self) -> AcmeProvider {
202        AcmeProvider::new(
203            self.acme_email.clone(),
204            self.cache_dir.clone(),
205            self.challenges.clone(),
206        )
207    }
208}
209
210pub(crate) fn default_orca_dir() -> PathBuf {
211    dirs::home_dir()
212        .unwrap_or_else(|| PathBuf::from("."))
213        .join(".orca")
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[tokio::test]
221    async fn test_challenge_lifecycle() {
222        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
223        assert!(mgr.get_challenge_response("tok1").await.is_none());
224        mgr.set_challenge("tok1".into(), "auth1".into()).await;
225        assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
226        mgr.clear_challenge("tok1").await;
227        assert!(mgr.get_challenge_response("tok1").await.is_none());
228    }
229
230    #[tokio::test]
231    async fn test_domain_registration() {
232        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
233        mgr.add_domain("example.com").await;
234        assert!(mgr.domains().await.contains(&"example.com".to_string()));
235    }
236
237    #[test]
238    fn test_cert_paths() {
239        let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
240        assert_eq!(
241            mgr.cert_path("example.com"),
242            PathBuf::from("/tmp/certs/example.com.cert.pem")
243        );
244        assert_eq!(
245            mgr.key_path("example.com"),
246            PathBuf::from("/tmp/certs/example.com.key.pem")
247        );
248    }
249
250    #[test]
251    fn test_missing_certs_needs_renewal() {
252        let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
253        assert!(mgr.needs_renewal("example.com"));
254    }
255}