Skip to main content

salvo_acme/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![cfg_attr(test, allow(clippy::unwrap_used))]
3//! Automatic HTTPS/TLS certificate management for Salvo via the ACME protocol.
4//!
5//! This crate integrates [certon](https://crates.io/crates/certon), a
6//! production-grade ACME client, with Salvo's listener/acceptor system.
7//!
8//! ## Features
9//!
10//! - **Multiple issuers**: Let's Encrypt, ZeroSSL, or any ACME-compatible CA.
11//! - **Multiple challenge types**: HTTP-01, TLS-ALPN-01, DNS-01.
12//! - **On-demand TLS**: obtain certificates at handshake time.
13//! - **OCSP stapling**: automatic OCSP response fetching and stapling.
14//! - **Multiple key types**: ECDSA P-256/P-384/P-521, RSA, Ed25519.
15//! - **Persistent storage**: pluggable storage backend via [`Storage`].
16//! - **Background renewal**: automatic certificate renewal and OCSP refresh.
17//!
18//! ## Certificate key type
19//!
20//! Salvo ACME defaults to [`KeyType::EcdsaP256`] for newly generated
21//! certificate private keys. RSA key types remain available for compatibility,
22//! but they are explicit opt-in via [`AcmeConfigBuilder::key_type`] or
23//! [`AcmeListenerBuilder::key_type`].
24//!
25//! ## Quick Start - HTTP-01
26//!
27//! ```ignore
28//! use salvo_acme::AcmeListener;
29//! use salvo_core::prelude::*;
30//!
31//! #[handler]
32//! async fn hello() -> &'static str {
33//!     "Hello World"
34//! }
35//!
36//! #[tokio::main]
37//! async fn main() {
38//!     let mut router = Router::new().get(hello);
39//!     let listener = TcpListener::new("0.0.0.0:443")
40//!         .acme()
41//!         .cache_path("acme/letsencrypt")
42//!         .add_domain("example.com")
43//!         .http01_challenge(&mut router);
44//!     let acceptor = listener.join(TcpListener::new("0.0.0.0:80")).bind().await;
45//!     Server::new(acceptor).serve(router).await;
46//! }
47//! ```
48//!
49//! ## Quick Start - TLS-ALPN-01
50//!
51//! ```ignore
52//! use salvo_acme::AcmeListener;
53//! use salvo_core::prelude::*;
54//!
55//! #[handler]
56//! async fn hello() -> &'static str {
57//!     "Hello World"
58//! }
59//!
60//! #[tokio::main]
61//! async fn main() {
62//!     let router = Router::new().get(hello);
63//!     let acceptor = TcpListener::new("0.0.0.0:443")
64//!         .acme()
65//!         .cache_path("acme/letsencrypt")
66//!         .add_domain("example.com")
67//!         .bind().await;
68//!     Server::new(acceptor).serve(router).await;
69//! }
70//! ```
71
72use salvo_core::cfg_feature;
73
74mod config;
75mod listener;
76
77use std::collections::HashMap;
78use std::sync::Arc;
79
80pub use config::{AcmeConfig, AcmeConfigBuilder};
81pub use listener::{AcmeAcceptor, AcmeListenerBuilder};
82use salvo_core::conn::tcp::TcpListener;
83use salvo_core::http::StatusError;
84use salvo_core::{Depot, FlowCtrl, Handler, Request, Response, async_trait};
85use tokio::net::ToSocketAddrs;
86use tokio::sync::RwLock;
87
88cfg_feature! {
89    #![feature = "quinn"]
90    pub use listener::AcmeQuinnListener;
91}
92
93// ---------------------------------------------------------------------------
94// Re-exports from certon for advanced usage
95// ---------------------------------------------------------------------------
96
97/// Re-export the entire `certon` crate for advanced configuration.
98pub use certon;
99pub use certon::{
100    AcmeIssuer, AcmeIssuerBuilder, CertCache, CertIssuer, CertResolver, Certificate,
101    Config as CertonConfig, ConfigBuilder as CertonConfigBuilder, DistributedSolver, Dns01Solver,
102    DnsProvider, FileStorage, Http01Solver, IssuedCertificate, IssuerPolicy, KeyType,
103    LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING, MaintenanceConfig, Manager, OcspConfig,
104    OnDemandConfig, PreChecker, Revoker, Solver, Storage, TlsAlpn01Solver, ZEROSSL_PRODUCTION,
105    ZeroSslIssuer,
106};
107
108// ---------------------------------------------------------------------------
109// Constants
110// ---------------------------------------------------------------------------
111
112/// Well known ACME challenge path.
113pub(crate) const WELL_KNOWN_PATH: &str = "/.well-known/acme-challenge";
114
115/// Challenge type for ACME.
116#[derive(Debug, Copy, Clone, Eq, PartialEq)]
117#[non_exhaustive]
118pub enum ChallengeType {
119    /// HTTP-01 challenge.
120    ///
121    /// Reference: <https://letsencrypt.org/docs/challenge-types/#http-01-challenge>
122    Http01,
123    /// TLS-ALPN-01 challenge.
124    ///
125    /// Reference: <https://letsencrypt.org/docs/challenge-types/#tls-alpn-01>
126    TlsAlpn01,
127    /// DNS-01 challenge.
128    ///
129    /// Reference: <https://letsencrypt.org/docs/challenge-types/#dns-01-challenge>
130    Dns01,
131}
132
133// ---------------------------------------------------------------------------
134// HTTP-01 challenge handler (Salvo Handler implementation)
135// ---------------------------------------------------------------------------
136
137/// Handler for HTTP-01 ACME challenges.
138///
139/// Reads challenge tokens from a shared map that is populated by the ACME
140/// issuance flow. This handler should be registered on the router at
141/// `/.well-known/acme-challenge/{token}`.
142pub struct Http01Handler {
143    pub(crate) keys: Arc<RwLock<HashMap<String, String>>>,
144}
145impl std::fmt::Debug for Http01Handler {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("Http01Handler").finish()
148    }
149}
150
151#[async_trait]
152impl Handler for Http01Handler {
153    async fn handle(
154        &self,
155        req: &mut Request,
156        _depot: &mut Depot,
157        res: &mut Response,
158        _ctrl: &mut FlowCtrl,
159    ) {
160        if let Some(token) = req.params().get("token") {
161            // First check our local map.
162            let keys = self.keys.read().await;
163            if let Some(value) = keys.get(token) {
164                res.render(value);
165                return;
166            }
167            drop(keys);
168
169            // Fall back to certon's global active challenge map.
170            if let Some(value) = certon::solvers::get_active_challenge(token) {
171                res.render(value);
172                return;
173            }
174
175            // Log only the length, not the token itself, so log/APM/SIEM
176            // pipelines do not become a credential surface for the ACME
177            // challenge flow.
178            tracing::error!(
179                token_len = token.len(),
180                "key not found for ACME challenge token"
181            );
182            res.render(StatusError::not_found().brief("challenge token not found"));
183        } else {
184            res.render(StatusError::not_found().brief("missing token"));
185        }
186    }
187}
188
189/// Extension trait for Listener to support ACME.
190pub trait AcmeListener {
191    /// Enable ACME support for the listener.
192    fn acme(self) -> AcmeListenerBuilder<Self>
193    where
194        Self: Sized;
195}
196
197impl<T> AcmeListener for TcpListener<T>
198where
199    T: ToSocketAddrs + Send + 'static,
200{
201    fn acme(self) -> AcmeListenerBuilder<Self> {
202        AcmeListenerBuilder::new(self)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use salvo_core::http::StatusCode;
209    use salvo_core::prelude::*;
210    use salvo_core::test::{ResponseExt, TestClient};
211
212    use super::*;
213
214    #[tokio::test]
215    async fn http01_handler_serves_known_token() {
216        let keys = Arc::new(RwLock::new(HashMap::from([(
217            "known".to_owned(),
218            "key-authorization".to_owned(),
219        )])));
220        let handler = Http01Handler { keys };
221        let router = Router::with_path(format!("{WELL_KNOWN_PATH}/{{token}}")).goal(handler);
222
223        let mut response = TestClient::get("http://127.0.0.1/.well-known/acme-challenge/known")
224            .send(router)
225            .await;
226
227        assert_eq!(response.status_code, Some(StatusCode::OK));
228        assert_eq!(response.take_string().await.unwrap(), "key-authorization");
229    }
230
231    #[tokio::test]
232    async fn http01_handler_rejects_unknown_token() {
233        let handler = Http01Handler {
234            keys: Arc::new(RwLock::new(HashMap::new())),
235        };
236        let router = Router::with_path(format!("{WELL_KNOWN_PATH}/{{token}}")).goal(handler);
237
238        let response = TestClient::get("http://127.0.0.1/.well-known/acme-challenge/unknown")
239            .send(router)
240            .await;
241
242        assert_eq!(response.status_code, Some(StatusCode::NOT_FOUND));
243    }
244}