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::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 super::default_orca_dir;
18
19/// Pure-Rust ACME provider backed by `instant-acme`.
20#[derive(Clone)]
21pub struct AcmeProvider {
22    email: String,
23    cache_dir: PathBuf,
24    challenges: Arc<RwLock<HashMap<String, String>>>,
25}
26
27impl AcmeProvider {
28    pub fn new(
29        email: String,
30        cache_dir: PathBuf,
31        challenges: Arc<RwLock<HashMap<String, String>>>,
32    ) -> Self {
33        Self {
34            email,
35            cache_dir,
36            challenges,
37        }
38    }
39
40    /// Provision a TLS certificate for the given domain via ACME HTTP-01.
41    ///
42    /// The proxy must be serving HTTP on port 80 so Let's Encrypt can reach
43    /// `/.well-known/acme-challenge/{token}`.
44    ///
45    /// Returns `(cert_pem, key_pem)` as byte vectors.
46    pub async fn provision_cert(&self, domain: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
47        info!(domain, "Starting ACME certificate provisioning");
48
49        let account = self.load_or_create_account().await?;
50        let identifiers = vec![Identifier::Dns(domain.to_string())];
51        let mut order = account.new_order(&NewOrder::new(&identifiers)).await?;
52        debug!(domain, "ACME order created");
53
54        // Process authorizations
55        self.handle_authorizations(&mut order).await?;
56
57        // Poll until order is ready for finalization.
58        // Challenge tokens remain available until LE validates them.
59        let status = order.poll_ready(&RetryPolicy::default()).await?;
60
61        // Clean up challenge tokens now that validation is complete
62        self.challenges.write().await.clear();
63
64        if status != OrderStatus::Ready {
65            anyhow::bail!("Order not ready after challenges: {status:?}");
66        }
67        info!(domain, "ACME order ready, finalizing");
68
69        // Finalize: instant-acme generates the key + CSR internally
70        let key_pem = order.finalize().await?;
71        let cert_pem = order.poll_certificate(&RetryPolicy::default()).await?;
72
73        // Save to cache
74        self.save_cert(domain, cert_pem.as_bytes(), key_pem.as_bytes())
75            .await?;
76
77        info!(domain, "Certificate provisioned and cached");
78        Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
79    }
80
81    /// Process all authorizations for an order, handling HTTP-01 challenges.
82    async fn handle_authorizations(&self, order: &mut instant_acme::Order) -> anyhow::Result<()> {
83        let mut authorizations = order.authorizations();
84        while let Some(result) = authorizations.next().await {
85            let mut authz = result?;
86
87            if authz.status == AuthorizationStatus::Valid {
88                debug!("Authorization already valid");
89                continue;
90            }
91
92            let mut challenge = authz
93                .challenge(ChallengeType::Http01)
94                .ok_or_else(|| anyhow::anyhow!("No HTTP-01 challenge offered"))?;
95
96            let token = challenge.token.clone();
97            let key_auth = challenge.key_authorization().as_str().to_string();
98
99            debug!(token = %token, "Serving HTTP-01 challenge");
100            self.challenges
101                .write()
102                .await
103                .insert(token.clone(), key_auth);
104
105            challenge.set_ready().await?;
106
107            // Don't remove the token yet — Let's Encrypt needs to hit our
108            // /.well-known/acme-challenge/{token} endpoint. The token stays
109            // in memory until poll_ready succeeds on the order, then we
110            // clean up all challenge tokens.
111        }
112
113        Ok(())
114    }
115
116    /// Load ACME account from cache or create a new one.
117    async fn load_or_create_account(&self) -> anyhow::Result<Account> {
118        let account_path = self.account_cache_path();
119
120        if account_path.exists() {
121            debug!("Loading cached ACME account");
122            let json = tokio::fs::read_to_string(&account_path).await?;
123            let creds: AccountCredentials = serde_json::from_str(&json)?;
124            let account = Account::builder()?.from_credentials(creds).await?;
125            return Ok(account);
126        }
127
128        info!(email = %self.email, "Creating new ACME account");
129        let contact = format!("mailto:{}", self.email);
130        let (account, credentials) = Account::builder()?
131            .create(
132                &NewAccount {
133                    contact: &[&contact],
134                    terms_of_service_agreed: true,
135                    only_return_existing: false,
136                },
137                LetsEncrypt::Production.url().to_owned(),
138                None,
139            )
140            .await?;
141
142        // Cache the account credentials
143        if let Some(parent) = account_path.parent() {
144            tokio::fs::create_dir_all(parent).await?;
145        }
146        let json = serde_json::to_string_pretty(&credentials)?;
147        tokio::fs::write(&account_path, json).await?;
148        info!("ACME account cached at {}", account_path.display());
149
150        Ok(account)
151    }
152
153    /// Save provisioned cert and key to the cache directory.
154    async fn save_cert(&self, domain: &str, cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<()> {
155        tokio::fs::create_dir_all(&self.cache_dir).await?;
156        let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
157        let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
158        tokio::fs::write(&cert_path, cert_pem).await?;
159        tokio::fs::write(&key_path, key_pem).await?;
160        debug!(domain, "Saved cert to {}", cert_path.display());
161        Ok(())
162    }
163
164    /// Path to the cached ACME account credentials.
165    fn account_cache_path(&self) -> PathBuf {
166        default_orca_dir().join("acme-account.json")
167    }
168
169    /// Ensure a valid cert exists for the domain — load from cache or provision.
170    ///
171    /// Returns a `TlsAcceptor` ready for use, or an error if provisioning fails.
172    pub async fn ensure_cert(&self, domain: &str) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
173        // Check cache first
174        let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
175        let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
176
177        if cert_path.exists()
178            && key_path.exists()
179            && let Ok(days) = super::certs::check_cert_expiry(&cert_path)
180        {
181            if days >= super::RENEWAL_THRESHOLD_DAYS {
182                debug!(domain, days_remaining = days, "Using cached cert");
183                return self.build_acceptor(&cert_path, &key_path);
184            }
185            info!(domain, days_remaining = days, "Cert expiring, renewing");
186        }
187
188        // Provision new cert
189        let (cert_pem, key_pem) = self.provision_cert(domain).await?;
190        self.build_acceptor_from_pem(&cert_pem, &key_pem)
191    }
192
193    /// Build a TlsAcceptor from PEM files on disk.
194    fn build_acceptor(
195        &self,
196        cert_path: &std::path::Path,
197        key_path: &std::path::Path,
198    ) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
199        let (certs, key) = super::certs::load_pem_certs(cert_path, key_path)?;
200        let config = rustls::ServerConfig::builder()
201            .with_no_client_auth()
202            .with_single_cert(certs, key)?;
203        Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
204    }
205
206    /// Build a TlsAcceptor from in-memory PEM bytes.
207    fn build_acceptor_from_pem(
208        &self,
209        cert_pem: &[u8],
210        key_pem: &[u8],
211    ) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
212        let certs = rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
213        let key = rustls_pemfile::private_key(&mut &key_pem[..])?
214            .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
215        let config = rustls::ServerConfig::builder()
216            .with_no_client_auth()
217            .with_single_cert(certs, key)?;
218        Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
219    }
220}