1use 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
16const RENEWAL_THRESHOLD_DAYS: i64 = 30;
18
19#[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 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 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 pub async fn set_challenge(&self, token: String, authorization: String) {
53 self.challenges.write().await.insert(token, authorization);
54 }
55
56 pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
58 self.challenges.read().await.get(token).cloned()
59 }
60
61 pub async fn clear_challenge(&self, token: &str) {
63 self.challenges.write().await.remove(token);
64 }
65
66 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 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 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 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 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
187fn 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}