1pub(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
19const RENEWAL_THRESHOLD_DAYS: i64 = 30;
21
22#[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 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 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 pub async fn set_challenge(&self, token: String, authorization: String) {
56 self.challenges.write().await.insert(token, authorization);
57 }
58
59 pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
61 self.challenges.read().await.get(token).cloned()
62 }
63
64 pub async fn clear_challenge(&self, token: &str) {
66 self.challenges.write().await.remove(token);
67 }
68
69 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 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 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 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}