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;
8
9pub use provider::AcmeProvider;
10
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use tokio::sync::RwLock;
16use tokio_rustls::TlsAcceptor;
17use tracing::{info, warn};
18
19/// Days before expiry to trigger renewal.
20const RENEWAL_THRESHOLD_DAYS: i64 = 30;
21
22/// Manages ACME HTTP-01 challenges and certificate loading.
23#[derive(Clone)]
24pub struct AcmeManager {
25    pub acme_email: String,
26    pub cache_dir: PathBuf,
27    challenges: Arc<RwLock<HashMap<String, String>>>,
28    domains: Arc<RwLock<HashSet<String>>>,
29}
30
31impl AcmeManager {
32    pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
33        Self {
34            acme_email: email.into(),
35            cache_dir: cache_dir.into(),
36            challenges: Arc::new(RwLock::new(HashMap::new())),
37            domains: Arc::new(RwLock::new(HashSet::new())),
38        }
39    }
40
41    /// Create a manager with default cache directory (`~/.orca/certs/`).
42    pub fn with_default_cache(email: impl Into<String>) -> Self {
43        let cache_dir = default_orca_dir().join("certs");
44        Self::new(email, cache_dir)
45    }
46
47    /// Register a domain for certificate provisioning.
48    pub async fn add_domain(&self, domain: impl Into<String>) {
49        let domain = domain.into();
50        info!(domain = %domain, "Registered domain for ACME");
51        self.domains.write().await.insert(domain);
52    }
53
54    /// Store a challenge token and its authorization response.
55    pub async fn set_challenge(&self, token: String, authorization: String) {
56        self.challenges.write().await.insert(token, authorization);
57    }
58
59    /// Get the authorization response for an HTTP-01 challenge token.
60    pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
61        self.challenges.read().await.get(token).cloned()
62    }
63
64    /// Remove a completed challenge.
65    pub async fn clear_challenge(&self, token: &str) {
66        self.challenges.write().await.remove(token);
67    }
68
69    /// Load certificates from cache. Returns `None` if missing or unparseable.
70    pub fn load_cached_certs(
71        &self,
72        domain: &str,
73    ) -> Option<(
74        Vec<rustls::pki_types::CertificateDer<'static>>,
75        rustls::pki_types::PrivateKeyDer<'static>,
76    )> {
77        let cert_path = self.cert_path(domain);
78        let key_path = self.key_path(domain);
79        if !cert_path.exists() || !key_path.exists() {
80            return None;
81        }
82        match certs::load_pem_certs(&cert_path, &key_path) {
83            Ok(pair) => Some(pair),
84            Err(e) => {
85                warn!(domain, error = %e, "Failed to load cached certs");
86                None
87            }
88        }
89    }
90
91    /// Returns `true` if certs are missing or expiring within 30 days.
92    pub fn needs_renewal(&self, domain: &str) -> bool {
93        let cert_path = self.cert_path(domain);
94        if !cert_path.exists() {
95            return true;
96        }
97        match certs::check_cert_expiry(&cert_path) {
98            Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
99            Ok(days) => {
100                info!(domain, days_remaining = days, "Certificate expiring soon");
101                true
102            }
103            Err(e) => {
104                warn!(domain, error = %e, "Cannot check cert expiry");
105                true
106            }
107        }
108    }
109
110    /// Build a `TlsAcceptor` from cached certs for the given domain.
111    pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
112        let Some((certs, key)) = self.load_cached_certs(domain) else {
113            return Ok(None);
114        };
115        if self.needs_renewal(domain) {
116            warn!(domain, "Cert expiring soon — will auto-renew");
117        }
118        let config = rustls::ServerConfig::builder()
119            .with_no_client_auth()
120            .with_single_cert(certs, key)?;
121        Ok(Some(TlsAcceptor::from(Arc::new(config))))
122    }
123
124    pub fn cert_path(&self, domain: &str) -> PathBuf {
125        self.cache_dir.join(format!("{domain}.cert.pem"))
126    }
127
128    pub fn key_path(&self, domain: &str) -> PathBuf {
129        self.cache_dir.join(format!("{domain}.key.pem"))
130    }
131
132    pub async fn domains(&self) -> Vec<String> {
133        self.domains.read().await.iter().cloned().collect()
134    }
135
136    /// Build an `AcmeProvider` from this manager for cert provisioning.
137    pub fn provider(&self) -> AcmeProvider {
138        AcmeProvider::new(
139            self.acme_email.clone(),
140            self.cache_dir.clone(),
141            self.challenges.clone(),
142        )
143    }
144}
145
146pub(crate) fn default_orca_dir() -> PathBuf {
147    dirs::home_dir()
148        .unwrap_or_else(|| PathBuf::from("."))
149        .join(".orca")
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[tokio::test]
157    async fn test_challenge_lifecycle() {
158        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
159        assert!(mgr.get_challenge_response("tok1").await.is_none());
160        mgr.set_challenge("tok1".into(), "auth1".into()).await;
161        assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
162        mgr.clear_challenge("tok1").await;
163        assert!(mgr.get_challenge_response("tok1").await.is_none());
164    }
165
166    #[tokio::test]
167    async fn test_domain_registration() {
168        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
169        mgr.add_domain("example.com").await;
170        assert!(mgr.domains().await.contains(&"example.com".to_string()));
171    }
172
173    #[test]
174    fn test_cert_paths() {
175        let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
176        assert_eq!(
177            mgr.cert_path("example.com"),
178            PathBuf::from("/tmp/certs/example.com.cert.pem")
179        );
180        assert_eq!(
181            mgr.key_path("example.com"),
182            PathBuf::from("/tmp/certs/example.com.key.pem")
183        );
184    }
185
186    #[test]
187    fn test_missing_certs_needs_renewal() {
188        let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
189        assert!(mgr.needs_renewal("example.com"));
190    }
191}