Skip to main content

orca_proxy/acme/
provider.rs

1//! ACME certificate provisioning via `instant-acme`.
2//!
3//! Handles account creation/caching, HTTP-01 challenges, CSR generation,
4//! and certificate download — all in pure Rust, no certbot needed.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use instant_acme::{
11    Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt,
12    NewAccount, NewOrder, OrderStatus, RetryPolicy,
13};
14use tokio::sync::RwLock;
15use tracing::{debug, info};
16
17use orca_core::fsutil;
18
19use super::default_account_path;
20
21/// Pure-Rust ACME provider backed by `instant-acme`.
22#[derive(Clone)]
23pub struct AcmeProvider {
24    email: String,
25    cache_dir: PathBuf,
26    challenges: Arc<RwLock<HashMap<String, String>>>,
27}
28
29impl AcmeProvider {
30    pub fn new(
31        email: String,
32        cache_dir: PathBuf,
33        challenges: Arc<RwLock<HashMap<String, String>>>,
34    ) -> Self {
35        Self {
36            email,
37            cache_dir,
38            challenges,
39        }
40    }
41
42    /// Provision a TLS certificate for the given domain via ACME HTTP-01.
43    ///
44    /// The proxy must be serving HTTP on port 80 so Let's Encrypt can reach
45    /// `/.well-known/acme-challenge/{token}`.
46    ///
47    /// Returns `(cert_pem, key_pem)` as byte vectors.
48    pub async fn provision_cert(&self, domain: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
49        info!(domain, "Starting ACME certificate provisioning");
50
51        let account = self.load_or_create_account().await?;
52        let identifiers = vec![Identifier::Dns(domain.to_string())];
53        let mut order = account.new_order(&NewOrder::new(&identifiers)).await?;
54        debug!(domain, "ACME order created");
55
56        // Process authorizations
57        self.handle_authorizations(&mut order).await?;
58
59        // Poll until order is ready for finalization.
60        // Challenge tokens remain available until LE validates them.
61        let status = order.poll_ready(&RetryPolicy::default()).await?;
62
63        // Clean up challenge tokens now that validation is complete
64        self.challenges.write().await.clear();
65
66        if status != OrderStatus::Ready {
67            anyhow::bail!("Order not ready after challenges: {status:?}");
68        }
69        info!(domain, "ACME order ready, finalizing");
70
71        // Finalize: instant-acme generates the key + CSR internally
72        let key_pem = order.finalize().await?;
73        let cert_pem = order.poll_certificate(&RetryPolicy::default()).await?;
74
75        // Save to cache
76        self.save_cert(domain, cert_pem.as_bytes(), key_pem.as_bytes())
77            .await?;
78
79        info!(domain, "Certificate provisioned and cached");
80        Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
81    }
82
83    /// Process all authorizations for an order, handling HTTP-01 challenges.
84    async fn handle_authorizations(&self, order: &mut instant_acme::Order) -> anyhow::Result<()> {
85        let mut authorizations = order.authorizations();
86        while let Some(result) = authorizations.next().await {
87            let mut authz = result?;
88
89            if authz.status == AuthorizationStatus::Valid {
90                debug!("Authorization already valid");
91                continue;
92            }
93
94            let mut challenge = authz
95                .challenge(ChallengeType::Http01)
96                .ok_or_else(|| anyhow::anyhow!("No HTTP-01 challenge offered"))?;
97
98            let token = challenge.token.clone();
99            let key_auth = challenge.key_authorization().as_str().to_string();
100
101            debug!(token = %token, "Serving HTTP-01 challenge");
102            self.challenges
103                .write()
104                .await
105                .insert(token.clone(), key_auth);
106
107            challenge.set_ready().await?;
108
109            // Don't remove the token yet — Let's Encrypt needs to hit our
110            // /.well-known/acme-challenge/{token} endpoint. The token stays
111            // in memory until poll_ready succeeds on the order, then we
112            // clean up all challenge tokens.
113        }
114
115        Ok(())
116    }
117
118    /// Load ACME account from cache or create a new one.
119    async fn load_or_create_account(&self) -> anyhow::Result<Account> {
120        let account_path = self.account_cache_path();
121
122        if account_path.exists() {
123            debug!("Loading cached ACME account");
124            let json = tokio::fs::read_to_string(&account_path).await?;
125            let creds: AccountCredentials = serde_json::from_str(&json)?;
126            let account = Account::builder()?.from_credentials(creds).await?;
127            return Ok(account);
128        }
129
130        info!(email = %self.email, "Creating new ACME account");
131        let contact = format!("mailto:{}", self.email);
132        // ORCA_ACME_DIRECTORY overrides the ACME directory URL — point it at
133        // Let's Encrypt staging (https://acme-staging-v02.api.letsencrypt.org/directory)
134        // for migration rehearsals so failed cutover attempts never burn
135        // production rate limits. Only consulted at account creation; cached
136        // credentials pin the directory they were created against, so switch
137        // directories by removing ~/.orca/acme-account.json.
138        let directory = std::env::var("ORCA_ACME_DIRECTORY")
139            .unwrap_or_else(|_| LetsEncrypt::Production.url().to_owned());
140        info!(directory = %directory, "Using ACME directory");
141        let (account, credentials) = Account::builder()?
142            .create(
143                &NewAccount {
144                    contact: &[&contact],
145                    terms_of_service_agreed: true,
146                    only_return_existing: false,
147                },
148                directory,
149                None,
150            )
151            .await?;
152
153        // Cache the account credentials. They are the account's private key:
154        // whoever holds them can issue and revoke certificates for our domains.
155        let json = serde_json::to_string_pretty(&credentials)?;
156        let path = account_path.clone();
157        tokio::task::spawn_blocking(move || fsutil::write_private(&path, json.as_bytes()))
158            .await??;
159        info!("ACME account cached at {}", account_path.display());
160
161        Ok(account)
162    }
163
164    /// Save provisioned cert and key to the cache directory.
165    ///
166    /// Both files are written owner-only and atomically (#186): the key is a
167    /// private key, and a torn write of either would fail the next handshake.
168    async fn save_cert(&self, domain: &str, cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<()> {
169        let dir = self.cache_dir.clone();
170        let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
171        let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
172        let (cert, key) = (cert_pem.to_vec(), key_pem.to_vec());
173        let saved_cert = cert_path.clone();
174        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
175            fsutil::create_private_dir(&dir)?;
176            fsutil::write_private(&key_path, &key)?;
177            fsutil::write_private(&cert_path, &cert)
178        })
179        .await??;
180        debug!(domain, "Saved cert to {}", saved_cert.display());
181        Ok(())
182    }
183
184    /// Path to the cached ACME account credentials.
185    fn account_cache_path(&self) -> PathBuf {
186        default_account_path()
187    }
188
189    /// Ensure a valid cert exists for the domain — load from cache or provision.
190    ///
191    /// Returns a `TlsAcceptor` ready for use, or an error if provisioning fails.
192    pub async fn ensure_cert(&self, domain: &str) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
193        // Check cache first
194        let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
195        let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
196
197        if cert_path.exists()
198            && key_path.exists()
199            && let Ok(days) = super::certs::check_cert_expiry(&cert_path)
200        {
201            if days >= super::RENEWAL_THRESHOLD_DAYS {
202                debug!(domain, days_remaining = days, "Using cached cert");
203                return self.build_acceptor(&cert_path, &key_path);
204            }
205            info!(domain, days_remaining = days, "Cert expiring, renewing");
206        }
207
208        // Provision new cert
209        let (cert_pem, key_pem) = self.provision_cert(domain).await?;
210        self.build_acceptor_from_pem(&cert_pem, &key_pem)
211    }
212
213    /// Build a TlsAcceptor from PEM files on disk.
214    fn build_acceptor(
215        &self,
216        cert_path: &std::path::Path,
217        key_path: &std::path::Path,
218    ) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
219        let (certs, key) = super::certs::load_pem_certs(cert_path, key_path)?;
220        let config = rustls::ServerConfig::builder()
221            .with_no_client_auth()
222            .with_single_cert(certs, key)?;
223        Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
224    }
225
226    /// Build a TlsAcceptor from in-memory PEM bytes.
227    fn build_acceptor_from_pem(
228        &self,
229        cert_pem: &[u8],
230        key_pem: &[u8],
231    ) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
232        let certs = rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
233        let key = rustls_pemfile::private_key(&mut &key_pem[..])?
234            .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
235        let config = rustls::ServerConfig::builder()
236            .with_no_client_auth()
237            .with_single_cert(certs, key)?;
238        Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
239    }
240}
241
242/// Restrict key material written by earlier versions, which used default
243/// permissions (#186): the cache directory to 0700, every `*.key.pem` in it
244/// and the ACME account file to 0600. Certificates are public and left alone.
245///
246/// Missing paths are skipped. Returns how many paths were tightened.
247pub(crate) fn secure_existing_key_material(
248    cache_dir: &Path,
249    account: &Path,
250) -> std::io::Result<usize> {
251    let mut tightened = 0;
252    if account.exists() && fsutil::restrict(account, 0o600)? {
253        tightened += 1;
254    }
255    if !cache_dir.is_dir() {
256        return Ok(tightened);
257    }
258    if fsutil::restrict(cache_dir, 0o700)? {
259        tightened += 1;
260    }
261    for entry in std::fs::read_dir(cache_dir)? {
262        let path = entry?.path();
263        let is_key = path
264            .file_name()
265            .and_then(|n| n.to_str())
266            .is_some_and(|n| n.ends_with(".key.pem"));
267        if is_key && path.is_file() && fsutil::restrict(&path, 0o600)? {
268            tightened += 1;
269        }
270    }
271    Ok(tightened)
272}
273
274#[cfg(test)]
275#[path = "provider_tests.rs"]
276mod tests;