Skip to main content

sova_acme/
lib.rs

1//! Let's Encrypt ACME (HTTP-01) with TLS hot-reload for Sova.
2//!
3//! ```ignore
4//! use sova::{Acme, App, Result};
5//!
6//! #[tokio::main]
7//! async fn main() -> Result<()> {
8//!     let mut app = App::new();
9//!     app.get("/", || async { "hello https" });
10//!     app.install(
11//!         Acme::lets_encrypt(["example.com"])
12//!             .email("ops@example.com")
13//!             .dir("./data/acme")
14//!             .hsts(true),
15//!     );
16//!     // HTTPS: plugin attached TLS via App::use_tls during install
17//!     app.listen(443).await
18//! }
19//! ```
20
21mod events;
22mod handle;
23mod http01;
24mod issue;
25mod service;
26mod storage;
27
28pub use events::{AcmeFailed, CertificateIssued, CertificateRenewed};
29pub use handle::{AcmeHandle, AcmeStatus};
30
31use http01::ChallengeMap;
32use service::AcmeService;
33use sova_core::{App, Plugin, PluginMeta, Result, Tls};
34use std::path::PathBuf;
35use std::sync::{Arc, Mutex};
36use std::time::Duration;
37use storage::AcmeStorage;
38use tokio::sync::Notify;
39
40/// Let's Encrypt / ACME plugin (HTTP-01 on port 80 + background renewer).
41pub struct Acme {
42    domains: Vec<String>,
43    email: Option<String>,
44    dir: PathBuf,
45    staging: bool,
46    http_port: u16,
47    https_port: u16,
48    redirect_https: bool,
49    renew_days: u64,
50    check_interval: Duration,
51    hsts: bool,
52    /// Optional override; when unset, [`Self::tls`] is built during `install`.
53    tls: Option<Tls>,
54}
55
56impl Acme {
57    /// Production Let's Encrypt directory.
58    pub fn lets_encrypt(domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
59        Self::new(domains, false)
60    }
61
62    /// Staging Let's Encrypt directory (rate-limit friendly).
63    pub fn lets_encrypt_staging(domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
64        Self::new(domains, true)
65    }
66
67    fn new(domains: impl IntoIterator<Item = impl Into<String>>, staging: bool) -> Self {
68        Self {
69            domains: domains.into_iter().map(Into::into).collect(),
70            email: None,
71            dir: PathBuf::from("data/acme"),
72            staging,
73            http_port: 80,
74            https_port: 443,
75            redirect_https: true,
76            renew_days: 30,
77            check_interval: Duration::from_secs(12 * 3600),
78            hsts: true,
79            tls: None,
80        }
81    }
82
83    pub fn email(mut self, email: impl Into<String>) -> Self {
84        self.email = Some(email.into());
85        self
86    }
87
88    /// Directory for `account.json`, `cert.pem`, `key.pem`, `meta.json`.
89    pub fn dir(mut self, path: impl Into<PathBuf>) -> Self {
90        self.dir = path.into();
91        self
92    }
93
94    pub fn staging(mut self, staging: bool) -> Self {
95        self.staging = staging;
96        self
97    }
98
99    pub fn http_port(mut self, port: u16) -> Self {
100        self.http_port = port;
101        self
102    }
103
104    /// Port used in `Location` when redirecting non-challenge HTTP traffic.
105    pub fn https_port(mut self, port: u16) -> Self {
106        self.https_port = port;
107        self
108    }
109
110    pub fn redirect_https(mut self, on: bool) -> Self {
111        self.redirect_https = on;
112        self
113    }
114
115    /// Emit `Strict-Transport-Security` on HTTPS responses (default `true`).
116    pub fn hsts(mut self, enabled: bool) -> Self {
117        self.hsts = enabled;
118        self
119    }
120
121    /// Renew when remaining lifetime ≤ this many days (default 30).
122    pub fn renew_days(mut self, days: u64) -> Self {
123        self.renew_days = days.max(1);
124        self
125    }
126
127    pub fn check_interval(mut self, interval: Duration) -> Self {
128        self.check_interval = interval;
129        self
130    }
131
132    /// Advanced: supply a pre-built [`Tls`] (same handle used for hot-reload).
133    ///
134    /// Prefer the default path — `install` builds TLS and calls [`App::use_tls`].
135    pub fn with_tls(mut self, tls: Tls) -> Self {
136        self.tls = Some(tls);
137        self
138    }
139
140    /// Load existing cert from [`Self::dir`] or write a temporary self-signed placeholder.
141    pub fn tls(&self) -> Result<Tls> {
142        if self.domains.is_empty() {
143            return Err(sova_core::Error::Internal(
144                "acme: at least one domain is required".into(),
145            ));
146        }
147        let storage = AcmeStorage::new(&self.dir);
148        storage.ensure_dir()?;
149
150        if storage.has_cert() {
151            return Tls::from_pem(storage.cert_path(), storage.key_path());
152        }
153
154        // Placeholder so HTTPS can bind before the first LE issue completes.
155        let sans: Vec<&str> = self.domains.iter().map(|s| s.as_str()).collect();
156        let cert = rcgen::generate_simple_self_signed(
157            sans.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
158        )
159        .map_err(|e| sova_core::Error::Internal(format!("acme placeholder cert: {e}")))?;
160        let cert_pem = cert.cert.pem();
161        let key_pem = cert.key_pair.serialize_pem();
162        storage.write_pem(&cert_pem, &key_pem)?;
163        Tls::from_pem(storage.cert_path(), storage.key_path())
164    }
165}
166
167impl Plugin for Acme {
168    fn id(&self) -> &'static str {
169        "acme"
170    }
171
172    fn meta(&self) -> PluginMeta {
173        PluginMeta::new("ACME")
174            .description("Let's Encrypt HTTP-01 certificates with TLS hot-reload")
175            .version(env!("CARGO_PKG_VERSION"))
176    }
177
178    fn install(mut self, app: &mut App) {
179        if self.domains.is_empty() {
180            tracing::error!("acme: no domains configured");
181            return;
182        }
183
184        let hsts = self.hsts;
185        let tls = match self.tls.take() {
186            Some(t) => t.hsts(hsts),
187            None => match self.tls() {
188                Ok(t) => t.hsts(hsts),
189                Err(e) => {
190                    tracing::error!("acme: prepare tls: {e}");
191                    return;
192                }
193            },
194        };
195
196        let storage = AcmeStorage::new(&self.dir);
197        let _ = storage.ensure_dir();
198        let meta = storage.load_meta();
199        let placeholder = meta.is_none()
200            || meta
201                .as_ref()
202                .map(|m| m.domains != self.domains || m.staging != self.staging)
203                .unwrap_or(true);
204
205        let handle = AcmeHandle {
206            status: Arc::new(Mutex::new(AcmeStatus::from_meta(
207                &self.domains,
208                self.staging,
209                meta.as_ref(),
210                placeholder || !storage.has_cert(),
211            ))),
212            force: Arc::new(Notify::new()),
213        };
214
215        let events = Some(app.events().clone());
216        app.state(handle.clone());
217        // Wire HTTPS into the next listen/bind().run() without a manual .tls(...).
218        app.use_tls(tls.clone());
219
220        let handle_cli = handle.clone();
221        app.register_cli("acme", move |_state, args| {
222            let handle = handle_cli.clone();
223            async move {
224                match args.first().map(|s| s.as_str()) {
225                    Some("status") | None => {
226                        let st = handle.status();
227                        println!(
228                            "domains={:?} staging={} placeholder={} not_after={:?} last_error={:?}",
229                            st.domains,
230                            st.staging,
231                            st.using_placeholder,
232                            st.not_after_unix,
233                            st.last_error
234                        );
235                        Ok(())
236                    }
237                    Some("renew") => {
238                        handle.force_renew();
239                        println!("acme: renew requested");
240                        Ok(())
241                    }
242                    Some(other) => {
243                        eprintln!("usage: acme [status|renew] (got {other})");
244                        Err(sova_core::Error::Internal("bad acme args".into()))
245                    }
246                }
247            }
248        });
249
250        app.service(AcmeService {
251            domains: self.domains,
252            email: self.email,
253            staging: self.staging,
254            storage,
255            challenges: ChallengeMap::new(),
256            tls,
257            handle,
258            events,
259            http_port: self.http_port,
260            https_port: self.https_port,
261            redirect_https: self.redirect_https,
262            renew_days: self.renew_days,
263            check_interval: self.check_interval,
264        });
265
266        tracing::info!("acme: installed (HTTP-01 + renewer; TLS attached to app)");
267    }
268}
269
270// re-export for docs / tests
271pub use storage::CertMeta;
272
273#[cfg(test)]
274mod tests_unit;