Skip to main content

acme_proxy/
tls.rs

1//! HTTPS termination for the server's own listener.
2//!
3//! RFC 8555 §6.1 expects ACME to be spoken over HTTPS. This module is the
4//! alternative to putting a reverse proxy in front: with `server.tls.enabled`,
5//! `server.bind_address` speaks TLS **instead of** cleartext HTTP — one listener,
6//! not two. It is off by default, so an existing deployment is untouched.
7//!
8//! **Provisioning only.** Accepting connections — and handing one of these
9//! acceptors to each handshake — belongs to [`crate::listener`], which owns a
10//! socket that can be replaced and a TLS mode that can be switched off. The line
11//! between the two is the one this module already drew for itself: resolving
12//! configuration at startup on this side, the accept loop on the other.
13//!
14//! Shaped like the other subsystems ([`crate::signer`], [`crate::filter`],
15//! [`crate::challenge`]): [`from_config`] resolves everything at startup — files
16//! read or generated, certificate and key parsed, rustls configuration built — so
17//! a broken setup stops the server instead of failing every connection later.
18//! Its `Option` is the "disabled" case, which touches no disk at all.
19//!
20//! ## Certificate provisioning
21//!
22//! `cert_path`/`key_path` are loaded when **both** exist; otherwise a self-signed
23//! certificate for the host of `server.base_url` is generated and written, the
24//! way [`crate::signer::local_ca`] provisions the CA and `sqlite.db` provisions
25//! itself. The key is created `0600` (see [`crate::pemfile`]).
26//!
27//! ## Two rustls constraints
28//!
29//! 1. The crypto provider is passed **explicitly**, never installed as a process
30//!    default — the same rule as [`crate::challenge::tls_alpn_01`], and for the
31//!    same reason: `CryptoProvider::install_default` panics on a second call.
32//! 2. `with_single_cert` parses the chain with `rustls-webpki`, which refuses an
33//!    unrecognised *critical* extension. An ordinary server certificate is fine;
34//!    a `tls-alpn-01` *responder* certificate is exactly what cannot be served
35//!    this way (see `AcceptAnyServerCert` in [`crate::challenge::tls_alpn_01`]).
36
37use std::path::Path;
38use std::sync::Arc;
39use std::time::Duration;
40
41use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose};
42use time::OffsetDateTime;
43use tokio_rustls::TlsAcceptor;
44use tracing::{info, warn};
45use url::{Host, Url};
46
47use crate::config::{ServerConfig, TlsConfig};
48use crate::pemfile;
49
50/// Validity of a generated self-signed certificate, in days (~10 years).
51///
52/// Long on purpose: nothing regenerates the file once it exists, so a short-lived
53/// certificate would expire in silence years after anyone remembers where it came
54/// from. An operator who wants a real lifecycle supplies their own files.
55const SELF_SIGNED_VALIDITY_DAYS: i64 = 3653;
56
57/// Backdating applied to `not_before`, to tolerate modest clock skew between
58/// this server and a client — as in [`crate::signer::local_ca`].
59const CLOCK_SKEW_ALLOWANCE: time::Duration = time::Duration::hours(1);
60
61/// Builds the TLS acceptor for the ACME listener, or `None` when HTTPS is
62/// disabled. Called once at startup, so it may fail fast (the caller exits).
63///
64/// Takes the whole [`ServerConfig`] rather than its `tls` table alone: the two
65/// warnings below are ACME-specific and need `base_url` to spot. Everything
66/// after them is [`acceptor_from`], which the admin listener shares.
67pub fn from_config(cfg: &ServerConfig) -> anyhow::Result<Option<TlsAcceptor>> {
68    if !cfg.tls.enabled {
69        warn!(
70            event = "tls_disabled",
71            outcome = "advisory",
72            "serving ACME in cleartext: RFC 8555 §6.1 expects HTTPS, so either \
73             enable server.tls or terminate TLS in front of this server"
74        );
75        return Ok(None);
76    }
77
78    // The JWS `url` check (RFC 8555 §6.4) is a string equality against
79    // `base_url + path`, so a base URL still naming `http://` breaks every signed
80    // request the moment this listener speaks TLS. The converse — `https://` with
81    // TLS off — is the legitimate reverse-proxy setup, and says nothing.
82    if cfg.base_url.starts_with("http://") {
83        warn!(event = "tls_base_url_mismatch", outcome = "advisory", base_url = ?cfg.base_url,
84              "server.base_url names http:// while TLS is enabled: signed requests \
85               will be refused until it names https://");
86    }
87
88    Ok(Some(acceptor_from(
89        &cfg.tls,
90        &cfg.base_url,
91        &cfg.bind_address,
92        "acme",
93    )?))
94}
95
96/// Builds the TLS acceptor for the web admin listener, or `None` when it is
97/// disabled — or when the panel itself is off, which touches no disk.
98///
99/// The counterpart to [`from_config`], with the ACME-specific warnings replaced
100/// by the one that matters here. Nothing warns about cleartext: for this
101/// listener that combination is already refused outright at startup unless the
102/// bind is loopback (see [`crate::webadmin::check_config`]), and on loopback it
103/// is the documented default rather than something to complain about on every
104/// boot.
105pub fn admin_from_config(cfg: &crate::config::AdminConfig) -> anyhow::Result<Option<TlsAcceptor>> {
106    if !cfg.enabled || !cfg.tls.enabled {
107        return Ok(None);
108    }
109
110    // `AdminTlsConfig` is a distinct type from `TlsConfig` (its defaults
111    // differ), but the fields are the same four, so the shared builder takes
112    // the parts rather than either struct.
113    let tls = TlsConfig {
114        enabled: cfg.tls.enabled,
115        cert_path: cfg.tls.cert_path.clone(),
116        key_path: cfg.tls.key_path.clone(),
117        handshake_timeout_ms: cfg.tls.handshake_timeout_ms,
118    };
119    Ok(Some(acceptor_from(
120        &tls,
121        &cfg.base_url,
122        &cfg.bind_address,
123        "admin",
124    )?))
125}
126
127/// Loads or generates `cfg.cert_path`/`cfg.key_path` and builds the rustls
128/// acceptor.
129///
130/// Knows nothing about ACME. `listener` (`"acme"` / `"admin"`) only labels the
131/// log lines, so one socket's certificate churn is distinguishable from the
132/// other's; the event *names* stay the same, because "a TLS certificate was
133/// loaded" means the same thing on both — and `tls_enabled` is among the names
134/// `tests/e2e/` watches for.
135fn acceptor_from(
136    cfg: &TlsConfig,
137    host_url: &str,
138    bind_address: &str,
139    listener: &'static str,
140) -> anyhow::Result<TlsAcceptor> {
141    let cert_path = Path::new(&cfg.cert_path);
142    let key_path = Path::new(&cfg.key_path);
143
144    if cert_path.exists() && key_path.exists() {
145        pemfile::warn_if_key_is_readable("tls_key_permissive", key_path);
146        info!(event = "tls_cert_loaded", outcome = "success", listener = listener, cert_path = ?cfg.cert_path);
147    } else {
148        let (cert_pem, key_pem) = generate_self_signed(host_url)?;
149        std::fs::write(cert_path, &cert_pem)
150            .map_err(|error| anyhow::anyhow!("{}: {error}", cert_path.display()))?;
151        pemfile::write_private_key(key_path, &key_pem)?;
152        info!(event = "tls_cert_generated", outcome = "success", listener = listener, cert_path = ?cfg.cert_path, key_path = ?cfg.key_path);
153    }
154
155    // Read back what was just written rather than keeping the DER in hand: what
156    // is served is then exactly what is on disk, on the first run as on every
157    // later one.
158    let chain = pemfile::read_certificates(cert_path)?;
159    let key = pemfile::read_private_key(key_path)?;
160
161    let provider = rustls::crypto::ring::default_provider();
162    let mut config = rustls::ServerConfig::builder_with_provider(Arc::new(provider))
163        .with_safe_default_protocol_versions()
164        .map_err(|error| anyhow::anyhow!("building the TLS server configuration: {error}"))?
165        .with_no_client_auth()
166        .with_single_cert(chain, key)
167        .map_err(|error| {
168            anyhow::anyhow!(
169                "{} and {} are not a usable certificate/key pair: {error}",
170                cert_path.display(),
171                key_path.display()
172            )
173        })?;
174    // `axum` is taken with its default features, which do not include `http2`:
175    // advertising `h2` would promise a protocol this server does not speak.
176    config.alpn_protocols = vec![b"http/1.1".to_vec()];
177
178    info!(event = "tls_enabled", outcome = "success", listener = listener, bind_address = ?bind_address, cert_path = ?cfg.cert_path);
179    Ok(TlsAcceptor::from(Arc::new(config)))
180}
181
182/// Generates a self-signed certificate for the host of `base_url`, returning the
183/// certificate and key PEMs.
184///
185/// The host is the only name a client will ever ask for, which is why it is
186/// derived rather than configured: a certificate for anything else would not be
187/// accepted anyway, and an operator needing more names supplies their own files.
188fn generate_self_signed(base_url: &str) -> anyhow::Result<(String, String)> {
189    let url = Url::parse(base_url)
190        .map_err(|error| anyhow::anyhow!("server.base_url is not a URL: {error}"))?;
191    let host = match url
192        .host()
193        .ok_or_else(|| anyhow::anyhow!("server.base_url has no host: {base_url}"))?
194    {
195        Host::Domain(name) => name.to_string(),
196        // `Host`'s own `Display` brackets an IPv6 address; `CertificateParams`
197        // parses each name itself and would take `[::1]` for a DNS name.
198        Host::Ipv4(address) => address.to_string(),
199        Host::Ipv6(address) => address.to_string(),
200    };
201
202    let key_pair = KeyPair::generate()?;
203    // `new` turns anything that parses as an IP into an `IpAddress` SAN and
204    // everything else into a `DnsName` one, which is exactly the split we want.
205    let mut params = CertificateParams::new(vec![host.clone()])?;
206    params
207        .distinguished_name
208        .push(DnType::CommonName, host.clone());
209    params.is_ca = IsCa::NoCa;
210    params.key_usages = vec![
211        KeyUsagePurpose::DigitalSignature,
212        KeyUsagePurpose::KeyEncipherment,
213    ];
214    params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
215
216    let now = OffsetDateTime::now_utc();
217    params.not_before = now - CLOCK_SKEW_ALLOWANCE;
218    params.not_after = now + time::Duration::days(SELF_SIGNED_VALIDITY_DAYS);
219    // rcgen's default serial — `SHA256(subjectPublicKey)[0..20]` — is unique
220    // enough here: the key is freshly generated and signs one certificate. The
221    // local CA needs a random one because it signs many, from the same key.
222
223    let certificate = params.self_signed(&key_pair)?;
224    Ok((certificate.pem(), key_pair.serialize_pem()))
225}
226
227/// Everything one handshake needs, as a single swappable value.
228///
229/// The pair travels together because both halves come from the same `[tls]`
230/// section and both are read at the same moment — the top of a handshake. Making
231/// this the unit [`crate::listener`]'s accept loop reads is what lets a renewed
232/// certificate and a changed `handshake_timeout_ms` land without rebinding the
233/// socket: a configuration reload publishes a new `TlsSettings` and the next
234/// connection uses it, while connections already established are untouched.
235///
236/// That cell is an `Option`, and the absence is `tls.enabled = false` — which is
237/// why turning TLS on or off is not a different listener type either.
238#[derive(Clone)]
239pub struct TlsSettings {
240    pub acceptor: TlsAcceptor,
241    pub handshake_timeout: Duration,
242}
243
244impl TlsSettings {
245    #[must_use]
246    pub fn new(acceptor: TlsAcceptor, handshake_timeout: Duration) -> Self {
247        Self {
248            acceptor,
249            handshake_timeout,
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::config::TlsConfig;
258    use crate::testutil::TempDir;
259    use std::fs;
260
261    /// A scratch directory that removes itself, so a failing assertion cannot
262    /// leave key material behind.
263    /// A `ServerConfig` with TLS enabled, its material inside `dir`.
264    fn tls_config(dir: &TempDir, base_url: &str) -> ServerConfig {
265        ServerConfig {
266            bind_address: "127.0.0.1:0".to_string(),
267            base_url: base_url.to_string(),
268            tls: TlsConfig {
269                enabled: true,
270                cert_path: dir.join("server.pem").display().to_string(),
271                key_path: dir.join("server.key").display().to_string(),
272                handshake_timeout_ms: 5_000,
273            },
274            ..ServerConfig::default()
275        }
276    }
277
278    /// `TlsAcceptor` is not `Debug`, so `unwrap_err` is unavailable.
279    fn startup_error(result: anyhow::Result<Option<TlsAcceptor>>) -> String {
280        match result {
281            Err(error) => error.to_string(),
282            Ok(_) => panic!("this configuration must not build"),
283        }
284    }
285
286    /// An `AdminConfig` with the panel *and* its TLS on, material inside `dir`.
287    fn admin_config(dir: &TempDir, base_url: &str) -> crate::config::AdminConfig {
288        crate::config::AdminConfig {
289            enabled: true,
290            bind_address: "127.0.0.1:0".to_string(),
291            base_url: base_url.to_string(),
292            tls: crate::config::AdminTlsConfig {
293                enabled: true,
294                cert_path: dir.join("admin.pem").display().to_string(),
295                key_path: dir.join("admin.key").display().to_string(),
296                handshake_timeout_ms: 5_000,
297            },
298            ..crate::config::AdminConfig::default()
299        }
300    }
301
302    /// Two ways for the admin listener to want no acceptor, and neither may
303    /// touch the disk: the panel off entirely, or the panel on in cleartext.
304    #[test]
305    fn the_admin_acceptor_is_absent_when_the_panel_or_its_tls_is_off() {
306        for (panel, tls) in [(false, true), (true, false), (false, false)] {
307            let dir = TempDir::new("tls-admin");
308            let mut cfg = admin_config(&dir, "https://localhost:3001");
309            cfg.enabled = panel;
310            cfg.tls.enabled = tls;
311
312            assert!(
313                admin_from_config(&cfg).unwrap().is_none(),
314                "enabled={panel} tls={tls} must build no acceptor"
315            );
316            assert!(!Path::new(&cfg.tls.cert_path).exists());
317            assert!(!Path::new(&cfg.tls.key_path).exists());
318        }
319    }
320
321    /// The admin listener provisions and reloads exactly as the ACME one does,
322    /// and takes its name from `admin.base_url` rather than `server.base_url`.
323    #[test]
324    fn the_admin_certificate_is_generated_reloaded_and_names_its_own_host() {
325        use x509_parser::prelude::*;
326
327        let dir = TempDir::new("tls-admin");
328        let cfg = admin_config(&dir, "https://panel.example.test:3001");
329
330        assert!(admin_from_config(&cfg).unwrap().is_some());
331        let generated = fs::read(&cfg.tls.cert_path).unwrap();
332
333        #[cfg(unix)]
334        {
335            use std::os::unix::fs::PermissionsExt;
336            let mode = fs::metadata(&cfg.tls.key_path)
337                .unwrap()
338                .permissions()
339                .mode()
340                & 0o777;
341            assert_eq!(mode, 0o600, "the generated key was {mode:o}");
342        }
343
344        // Reload rather than mint a second identity.
345        assert!(admin_from_config(&cfg).unwrap().is_some());
346        assert_eq!(fs::read(&cfg.tls.cert_path).unwrap(), generated);
347
348        let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
349        let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
350        let names: Vec<_> = certificate
351            .subject_alternative_name()
352            .unwrap()
353            .unwrap()
354            .value
355            .general_names
356            .iter()
357            .map(|name| format!("{name:?}"))
358            .collect();
359        assert!(names[0].contains("panel.example.test"), "{names:?}");
360    }
361
362    /// A mismatched pair fails at startup here too, naming both files.
363    #[test]
364    fn a_mismatched_admin_pair_is_a_startup_error() {
365        let acme_dir = TempDir::new("tls-acme");
366        let admin_dir = TempDir::new("tls-admin");
367
368        // Generate two independent identities, then cross them.
369        let acme = tls_config(&acme_dir, "https://localhost:3000");
370        from_config(&acme).unwrap();
371        let cfg = admin_config(&admin_dir, "https://localhost:3001");
372        admin_from_config(&cfg).unwrap();
373
374        let crossed = crate::config::AdminConfig {
375            tls: crate::config::AdminTlsConfig {
376                key_path: acme.tls.key_path.clone(),
377                ..cfg.tls.clone()
378            },
379            ..cfg
380        };
381        let error = startup_error(admin_from_config(&crossed));
382        assert!(
383            error.contains("not a usable certificate/key pair"),
384            "got: {error}"
385        );
386    }
387
388    /// The two listeners keep separate certificates: provisioning one must not
389    /// write, read or overwrite the other's files.
390    #[test]
391    fn the_two_listeners_provision_independent_certificates() {
392        let dir = TempDir::new("tls-both");
393        let acme = ServerConfig {
394            bind_address: "127.0.0.1:0".to_string(),
395            base_url: "https://acme.example.test".to_string(),
396            tls: TlsConfig {
397                enabled: true,
398                cert_path: dir.join("server.pem").display().to_string(),
399                key_path: dir.join("server.key").display().to_string(),
400                handshake_timeout_ms: 5_000,
401            },
402            ..ServerConfig::default()
403        };
404        let admin = admin_config(&dir, "https://panel.example.test");
405
406        from_config(&acme).unwrap();
407        admin_from_config(&admin).unwrap();
408
409        assert_ne!(
410            fs::read(&acme.tls.cert_path).unwrap(),
411            fs::read(&admin.tls.cert_path).unwrap(),
412            "the two listeners answer to different names and must not share a certificate"
413        );
414    }
415
416    /// The default is off, and off must mean *nothing happens*: no file read, no
417    /// certificate generated where one was not asked for.
418    #[test]
419    fn disabled_builds_nothing_and_touches_no_disk() {
420        let dir = TempDir::new("tls");
421        let mut cfg = tls_config(&dir, "http://localhost:3000");
422        cfg.tls.enabled = false;
423
424        assert!(from_config(&cfg).unwrap().is_none());
425        assert!(!Path::new(&cfg.tls.cert_path).exists());
426        assert!(!Path::new(&cfg.tls.key_path).exists());
427    }
428
429    /// First run provisions both files; the second reuses them rather than
430    /// minting a new identity on every restart.
431    #[test]
432    fn a_missing_certificate_is_generated_then_reloaded() {
433        let dir = TempDir::new("tls");
434        let cfg = tls_config(&dir, "https://localhost:3000");
435
436        assert!(from_config(&cfg).unwrap().is_some());
437        let generated = fs::read(&cfg.tls.cert_path).unwrap();
438
439        #[cfg(unix)]
440        {
441            use std::os::unix::fs::PermissionsExt;
442            let mode = fs::metadata(&cfg.tls.key_path)
443                .unwrap()
444                .permissions()
445                .mode()
446                & 0o777;
447            assert_eq!(mode, 0o600, "the generated key was {mode:o}");
448        }
449
450        assert!(from_config(&cfg).unwrap().is_some());
451        assert_eq!(fs::read(&cfg.tls.cert_path).unwrap(), generated);
452    }
453
454    /// The generated certificate is for the host of `base_url` — the only name a
455    /// client will present in SNI.
456    #[test]
457    fn the_generated_certificate_names_the_base_url_host() {
458        use x509_parser::prelude::*;
459
460        let dir = TempDir::new("tls");
461        let cfg = tls_config(&dir, "https://acme.example.test:8443");
462        from_config(&cfg).unwrap();
463
464        let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
465        let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
466        let names: Vec<_> = certificate
467            .subject_alternative_name()
468            .unwrap()
469            .unwrap()
470            .value
471            .general_names
472            .iter()
473            .map(|name| format!("{name:?}"))
474            .collect();
475        assert_eq!(names.len(), 1, "{names:?}");
476        assert!(names[0].contains("acme.example.test"), "{names:?}");
477        // The port is not part of the name.
478        assert!(!names[0].contains("8443"), "{names:?}");
479    }
480
481    /// A base URL on an IP address must yield an `iPAddress` SAN: no client
482    /// honours an IP written into a `dNSName`.
483    #[test]
484    fn an_ip_base_url_yields_an_ip_san() {
485        use x509_parser::prelude::*;
486
487        let dir = TempDir::new("tls");
488        let cfg = tls_config(&dir, "https://127.0.0.1:3000");
489        from_config(&cfg).unwrap();
490
491        let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
492        let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
493        let general_names = certificate.subject_alternative_name().unwrap().unwrap();
494        assert!(
495            general_names
496                .value
497                .general_names
498                .iter()
499                .any(|name| matches!(name, GeneralName::IPAddress(_))),
500            "{:?}",
501            general_names.value.general_names
502        );
503    }
504
505    #[test]
506    fn a_base_url_without_a_host_is_a_startup_error() {
507        let dir = TempDir::new("tls");
508        let cfg = tls_config(&dir, "not-a-url");
509
510        let error = startup_error(from_config(&cfg));
511        assert!(error.contains("server.base_url"), "{error}");
512    }
513
514    /// A certificate and a key that do not go together are caught at startup,
515    /// not on the first handshake.
516    #[test]
517    fn a_mismatched_pair_is_a_startup_error() {
518        let dir = TempDir::new("tls");
519        let cfg = tls_config(&dir, "https://localhost:3000");
520
521        let (cert_pem, _) = generate_self_signed("https://localhost").unwrap();
522        let (_, key_pem) = generate_self_signed("https://localhost").unwrap();
523        fs::write(&cfg.tls.cert_path, cert_pem).unwrap();
524        fs::write(&cfg.tls.key_path, key_pem).unwrap();
525
526        let error = startup_error(from_config(&cfg));
527        assert!(
528            error.contains("not a usable certificate/key pair"),
529            "{error}"
530        );
531    }
532
533    #[test]
534    fn a_certificate_file_without_a_certificate_is_a_startup_error() {
535        let dir = TempDir::new("tls");
536        let cfg = tls_config(&dir, "https://localhost:3000");
537
538        let (_, key_pem) = generate_self_signed("https://localhost").unwrap();
539        fs::write(&cfg.tls.cert_path, "nothing useful here\n").unwrap();
540        fs::write(&cfg.tls.key_path, key_pem).unwrap();
541
542        let error = startup_error(from_config(&cfg));
543        assert!(error.contains("no CERTIFICATE block"), "{error}");
544    }
545}