1mod 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
40pub 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 tls: Option<Tls>,
54}
55
56impl Acme {
57 pub fn lets_encrypt(domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
59 Self::new(domains, false)
60 }
61
62 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 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 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 pub fn hsts(mut self, enabled: bool) -> Self {
117 self.hsts = enabled;
118 self
119 }
120
121 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 pub fn with_tls(mut self, tls: Tls) -> Self {
136 self.tls = Some(tls);
137 self
138 }
139
140 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 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 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
270pub use storage::CertMeta;
272
273#[cfg(test)]
274mod tests_unit;