Skip to main content

edgeguard/
selfsigned.rs

1//! Self-signed certificate generation, so TLS works on the first run with no prerequisites.
2//!
3//! The gap this closes: enabling `[tls]` used to require the operator to *already have* a
4//! certificate — from a public CA, an internal CA, or a hand-run `openssl req -x509 …`. For the
5//! audience this proxy exists for (an app that reached production without a front door), "go
6//! generate a keypair first" is where adoption stops. `[tls] self_signed = true` makes the
7//! binary produce its own certificate on first boot and serve HTTPS immediately.
8//!
9//! **What a self-signed certificate is and is not.** It encrypts the connection, so the six
10//! hardening headers, `Secure` cookies and HSTS become meaningful and traffic is no longer
11//! readable on the wire. It does *not* prove identity: no CA vouches for it, so a browser shows
12//! an interstitial and a strict client rejects it outright. That makes it right for localhost,
13//! a private network, a sidecar hop, or a staging box behind a VPN — and wrong for the public
14//! internet, where `[tls.acme]` should issue a real certificate instead. [`crate::doctor`] says
15//! so out loud when it sees this enabled.
16//!
17//! **Dependency note.** This uses `rcgen`, which the 0.3.0 notes describe as "dropped". That is
18//! true of *direct* use — `instant-acme` 0.8 builds its own key and CSR in `finalize()`, so the
19//! ACME path no longer constructs a certificate here. But `instant-acme` depends on `rcgen`
20//! itself, so the crate was still compiled on every build; re-declaring it is a direct edge to a
21//! node already in the graph. It adds no crate to the build and no transitive dependency.
22
23use std::io::Write;
24use std::net::IpAddr;
25use std::path::Path;
26
27use anyhow::{Context, Result};
28use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, SanType};
29use tracing::{info, warn};
30
31/// Hostnames used when the operator names none. `localhost` plus both loopback literals covers
32/// the case this feature exists for — running the proxy on the machine you are testing from.
33pub const DEFAULT_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
34
35/// A generated certificate and its private key, both PEM-encoded.
36pub struct SelfSigned {
37    pub cert_pem: String,
38    pub key_pem: String,
39}
40
41/// Build a self-signed certificate covering `hosts`, valid for `days`.
42///
43/// Every host becomes a subject-alternative name — a DNS SAN, or an IP SAN when it parses as an
44/// address, because clients match IP literals against IP SANs and ignore DNS entries for them.
45/// The CN is set to the first host for the benefit of old tooling that still reads it, but SANs
46/// are what every current client actually checks.
47pub fn generate(hosts: &[String], days: u32) -> Result<SelfSigned> {
48    anyhow::ensure!(!hosts.is_empty(), "no hosts to put in the certificate");
49    anyhow::ensure!(days > 0, "certificate validity must be at least one day");
50
51    let mut params = CertificateParams::default();
52    params.subject_alt_names =
53        hosts
54            .iter()
55            .map(|h| match h.parse::<IpAddr>() {
56                Ok(ip) => Ok(SanType::IpAddress(ip)),
57                // `Ia5String` rejects non-ASCII, which is exactly the validation we want here: a
58                // hostname that cannot be encoded is a config error, not something to paper over.
59                Err(_) => h.clone().try_into().map(SanType::DnsName).with_context(|| {
60                    format!("{h:?} is neither an IP address nor an ASCII hostname")
61                }),
62            })
63            .collect::<Result<Vec<_>>>()?;
64
65    let mut dn = DistinguishedName::new();
66    dn.push(DnType::CommonName, hosts[0].clone());
67    dn.push(DnType::OrganizationName, "EdgeGuard self-signed");
68    params.distinguished_name = dn;
69
70    // Backdate by an hour so a client whose clock runs slightly behind the generating host does
71    // not reject a certificate created seconds ago as "not yet valid".
72    let now = time::OffsetDateTime::now_utc();
73    params.not_before = now - time::Duration::hours(1);
74    // `OffsetDateTime + Duration` panics past the representable range, and `days` comes straight
75    // from `--days` / config — so `--days 4294967295` would abort the process instead of telling
76    // the operator their number is wrong. User input must not reach a panic path.
77    params.not_after = now
78        .checked_add(time::Duration::days(i64::from(days)))
79        .context("certificate validity exceeds the supported date range")?;
80
81    let key = KeyPair::generate().context("generating the certificate key pair")?;
82    let cert = params
83        .self_signed(&key)
84        .context("self-signing the certificate")?;
85
86    Ok(SelfSigned {
87        cert_pem: cert.pem(),
88        key_pem: key.serialize_pem(),
89    })
90}
91
92/// Is anything present at `path` — including a symlink whose target is missing?
93///
94/// `Path::exists()` follows symlinks and reports `false` for a dangling one, which makes a
95/// "don't clobber what's already there" check quietly wrong: a `cert.pem` symlinked to a target
96/// that is temporarily absent reads as "no file", and the rename in [`publish`] then
97/// replaces the *symlink itself* with a regular file. `symlink_metadata` does not follow, so the
98/// link is seen. Anything other than "not found" — including a permissions error we cannot see
99/// through — counts as present, because the safe answer to "is something there?" is yes.
100pub fn path_present(path: &str) -> bool {
101    !matches!(
102        std::fs::symlink_metadata(path),
103        Err(e) if e.kind() == std::io::ErrorKind::NotFound
104    )
105}
106
107/// Generate a certificate for `hosts` and write it to `cert_path`/`key_path`.
108///
109/// Creates parent directories as needed. The key is written `0600` on Unix — a private key that
110/// lands world-readable is a worse outcome than the missing certificate we set out to fix.
111pub fn write_to(
112    hosts: &[String],
113    days: u32,
114    cert_path: &str,
115    key_path: &str,
116) -> Result<SelfSigned> {
117    // The same path for both would write the certificate and then overwrite it with the key,
118    // reporting success and leaving a pair TLS can never load. Catch it before touching disk.
119    anyhow::ensure!(
120        cert_path != key_path,
121        "tls.cert_path and tls.key_path must be different files (both are {cert_path:?}); \
122         the key would overwrite the certificate"
123    );
124
125    let generated = generate(hosts, days)?;
126
127    for path in [cert_path, key_path] {
128        if let Some(parent) = Path::new(path).parent() {
129            if !parent.as_os_str().is_empty() {
130                std::fs::create_dir_all(parent)
131                    .with_context(|| format!("creating directory {}", parent.display()))?;
132            }
133        }
134    }
135
136    // Stage BOTH files first, then rename both into place — in that order, and it matters.
137    // Staging one and publishing it before the other is even written means an ordinary I/O
138    // error on the second (a full disk, a revoked permission) returns an error having already
139    // replaced the first: a new certificate live against the old key, with no crash required.
140    // Writing both to temporary files first reduces the exposure to the gap between two
141    // renames, which is a syscall apart and needs an actual crash to land in.
142    let cert_tmp = stage(cert_path, &generated.cert_pem, false)
143        .with_context(|| format!("staging certificate for {cert_path}"))?;
144    let key_tmp = match stage(key_path, &generated.key_pem, true) {
145        Ok(tmp) => tmp,
146        Err(e) => {
147            // Nothing live has changed yet, so drop the staged certificate and leave the
148            // existing pair exactly as it was.
149            let _ = std::fs::remove_file(&cert_tmp);
150            return Err(e).with_context(|| format!("staging private key for {key_path}"));
151        }
152    };
153
154    // Publishing is two renames, and the second can fail on its own — a rename is not only
155    // interrupted by a crash (a directory in the way, a read-only mount, EXDEV). So take a
156    // backup of the live certificate first and put it back if the key never lands: after any
157    // failure here the operator's existing pair is exactly as it was, rather than a new
158    // certificate paired with the old key.
159    let backup = backup_path(cert_path);
160    let had_cert = match std::fs::rename(cert_path, &backup) {
161        Ok(()) => true,
162        // Nothing to preserve on a first generation.
163        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
164        Err(e) => {
165            let _ = std::fs::remove_file(&cert_tmp);
166            let _ = std::fs::remove_file(&key_tmp);
167            return Err(e).with_context(|| format!("setting aside the existing {cert_path}"));
168        }
169    };
170
171    let published = publish(&cert_tmp, cert_path).and_then(|()| publish(&key_tmp, key_path));
172
173    match published {
174        Ok(()) => {
175            if had_cert {
176                let _ = std::fs::remove_file(&backup);
177            }
178        }
179        Err(e) => {
180            // Undo the certificate, so the pair on disk stays internally consistent.
181            let _ = std::fs::remove_file(cert_path);
182            if had_cert {
183                let _ = std::fs::rename(&backup, cert_path);
184            }
185            let _ = std::fs::remove_file(&cert_tmp);
186            let _ = std::fs::remove_file(&key_tmp);
187            return Err(e).with_context(|| {
188                format!("publishing the certificate pair ({cert_path} and {key_path})")
189            });
190        }
191    }
192
193    info!(
194        cert = %cert_path,
195        key = %key_path,
196        hosts = %hosts.join(", "),
197        days,
198        "generated a self-signed certificate (clients must trust it explicitly; \
199         use [tls.acme] for a publicly trusted one)"
200    );
201    Ok(generated)
202}
203
204/// Write `contents` to a temporary file beside `path` and return that temporary path.
205///
206/// Staging separately from publishing is what lets [`write_to`] get both files onto disk before
207/// either live path changes. When `private`, the temp file is created `0600` — and because it is
208/// always a *new* file that mode actually applies: `OpenOptionsExt::mode` only sets permissions
209/// at creation, so writing straight into an existing `0644` key (which [`ensure`] does when
210/// regenerating half a pair) would have left the new key material world-readable. The mode is
211/// also set on the open handle, so it holds regardless of umask.
212fn stage(path: &str, contents: &str, private: bool) -> Result<String> {
213    let tmp = format!("{path}.tmp.{}", std::process::id());
214    // A leftover temp file from a killed run must not block every future attempt.
215    if Path::new(&tmp).exists() {
216        let _ = std::fs::remove_file(&tmp);
217    }
218
219    let mut opts = std::fs::OpenOptions::new();
220    // `create_new` so we never inherit the contents or the permissions of a stale temp file.
221    opts.write(true).create_new(true);
222    #[cfg(unix)]
223    if private {
224        use std::os::unix::fs::OpenOptionsExt;
225        opts.mode(0o600);
226    }
227
228    let result = (|| -> Result<()> {
229        let mut file = opts.open(&tmp)?;
230        #[cfg(unix)]
231        if private {
232            use std::os::unix::fs::PermissionsExt;
233            file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
234        }
235        file.write_all(contents.as_bytes())?;
236        // Durable before it is published, so a crash cannot leave an empty file at a live path.
237        file.sync_all()?;
238        Ok(())
239    })();
240
241    match result {
242        Ok(()) => Ok(tmp),
243        Err(e) => {
244            let _ = std::fs::remove_file(&tmp);
245            Err(e)
246        }
247    }
248}
249
250/// Where the previous certificate is parked while the new pair is published. Process-scoped so
251/// two runs cannot fight over the same name, and beside the original so the rename stays within
252/// one filesystem.
253fn backup_path(path: &str) -> String {
254    format!("{path}.bak.{}", std::process::id())
255}
256
257/// Move a file staged by [`stage`] onto its live path. `rename` is atomic, so a reader sees
258/// either the complete previous file or the complete new one, never a partial write, and the
259/// mode set at staging carries across.
260fn publish(tmp: &str, path: &str) -> Result<()> {
261    std::fs::rename(tmp, path)?;
262    Ok(())
263}
264
265/// Ensure a certificate exists at `cert_path`/`key_path`, generating a self-signed one if not.
266///
267/// Generation is skipped when a certificate is already on disk, so a restart reuses the existing
268/// keypair rather than handing every client a new identity to be surprised by. Returns `true` if
269/// a certificate was generated on this call.
270///
271/// **Single-process by design.** The check and the write are not guarded by a cross-process
272/// lock, so replicas booting simultaneously against the same writable path can each decide to
273/// generate. Each file lands atomically (see [`stage`] and [`publish`]), so nobody reads a half-written
274/// one, but the replicas can end up serving different certificates, or one can fail to start on
275/// a cert/key pair from two different runs.
276///
277/// Whether a lock would help depends on the storage, so be precise about it. On **shared**
278/// storage a lock spanning the existence check and both writes would work: the first replica
279/// generates, the rest find a complete pair and reuse it, and they end up with one identity. On
280/// **per-replica** storage it cannot — there is nothing to coordinate through, and each replica
281/// necessarily holds a different self-signed identity. Since the second case has no fix at this
282/// layer and the first is better solved by not generating at boot at all, the documented answer
283/// for either is to generate once with `edgeguard cert` and mount the result read-only.
284/// First-boot generation targets the single-instance case it was added for.
285pub fn ensure(hosts: &[String], days: u32, cert_path: &str, key_path: &str) -> Result<bool> {
286    anyhow::ensure!(
287        !cert_path.is_empty() && !key_path.is_empty(),
288        "tls.self_signed needs tls.cert_path and tls.key_path set — they are where the \
289         generated certificate is written"
290    );
291
292    let have_cert = path_present(cert_path);
293    let have_key = path_present(key_path);
294    if have_cert && have_key {
295        info!(cert = %cert_path, "self-signed: reusing the existing certificate");
296        return Ok(false);
297    }
298    // One half present is a broken pair: a cert whose key is gone can't be served, and serving
299    // the old key with a new cert would fail the rustls consistency check at load. Say which
300    // file is missing and regenerate both, rather than failing with a key-mismatch error later.
301    if have_cert != have_key {
302        warn!(
303            missing = if have_cert { key_path } else { cert_path },
304            "self-signed: only half of the certificate pair is present; regenerating both"
305        );
306    }
307
308    write_to(hosts, days, cert_path, key_path)?;
309    Ok(true)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn hosts(v: &[&str]) -> Vec<String> {
317        v.iter().map(|s| s.to_string()).collect()
318    }
319
320    #[test]
321    fn generates_a_loadable_certificate_and_key() {
322        let dir = std::env::temp_dir().join(format!("eg-selfsigned-{}", std::process::id()));
323        let cert = dir.join("cert.pem");
324        let key = dir.join("key.pem");
325        let cert_s = cert.to_str().unwrap();
326        let key_s = key.to_str().unwrap();
327
328        let generated = write_to(&hosts(&DEFAULT_HOSTS), 30, cert_s, key_s);
329        // `SelfSigned` intentionally has no `Debug` — it holds a private key, and a derived
330        // `Debug` is precisely how one reaches a log line — so report the error, not the value.
331        assert!(
332            generated.is_ok(),
333            "generation failed: {:?}",
334            generated.err()
335        );
336
337        // The real assertion: rustls accepts the pair. A certificate this crate cannot serve is
338        // not a certificate, however well-formed the PEM looks.
339        crate::tls::init_crypto();
340        assert!(
341            crate::tls::load_server_config(cert_s, key_s).is_ok(),
342            "rustls rejected the generated certificate/key pair"
343        );
344
345        let _ = std::fs::remove_dir_all(&dir);
346    }
347
348    #[test]
349    fn key_file_is_not_world_readable() {
350        #[cfg(unix)]
351        {
352            use std::os::unix::fs::PermissionsExt;
353            let dir = std::env::temp_dir().join(format!("eg-perm-{}", std::process::id()));
354            let cert = dir.join("cert.pem");
355            let key = dir.join("key.pem");
356            write_to(
357                &hosts(&["localhost"]),
358                1,
359                cert.to_str().unwrap(),
360                key.to_str().unwrap(),
361            )
362            .unwrap();
363            let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
364            assert_eq!(mode, 0o600, "private key mode was {mode:o}, expected 600");
365            let _ = std::fs::remove_dir_all(&dir);
366        }
367    }
368
369    #[test]
370    fn ensure_is_idempotent() {
371        let dir = std::env::temp_dir().join(format!("eg-ensure-{}", std::process::id()));
372        let cert = dir.join("cert.pem");
373        let key = dir.join("key.pem");
374        let (c, k) = (cert.to_str().unwrap(), key.to_str().unwrap());
375
376        assert!(
377            ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
378            "first call should generate"
379        );
380        let first = std::fs::read_to_string(&cert).unwrap();
381        assert!(
382            !ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
383            "second call should reuse"
384        );
385        assert_eq!(
386            first,
387            std::fs::read_to_string(&cert).unwrap(),
388            "cert was regenerated"
389        );
390
391        // A half-present pair regenerates rather than failing later at load time.
392        std::fs::remove_file(&key).unwrap();
393        assert!(
394            ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
395            "half a pair should regenerate"
396        );
397        assert_ne!(first, std::fs::read_to_string(&cert).unwrap());
398
399        let _ = std::fs::remove_dir_all(&dir);
400    }
401
402    #[test]
403    fn rejects_empty_hosts_and_zero_validity() {
404        assert!(generate(&[], 30).is_err());
405        assert!(generate(&hosts(&["localhost"]), 0).is_err());
406    }
407
408    #[test]
409    fn an_unrepresentable_validity_errors_rather_than_panicking() {
410        // `--days 4294967295` pushes not_after past what OffsetDateTime can represent. Adding
411        // with `+` panics there; user input must produce an error instead.
412        assert!(generate(&hosts(&["localhost"]), u32::MAX).is_err());
413        assert!(generate(&hosts(&["localhost"]), 365).is_ok());
414    }
415
416    #[test]
417    fn rejects_the_same_path_for_certificate_and_key() {
418        let dir = std::env::temp_dir().join(format!("eg-samepath-{}", std::process::id()));
419        std::fs::create_dir_all(&dir).unwrap();
420        let both = dir.join("pair.pem");
421        let both = both.to_str().unwrap();
422        // Otherwise the key overwrites the certificate and the command reports success.
423        assert!(write_to(&hosts(&["localhost"]), 1, both, both).is_err());
424        assert!(
425            !Path::new(both).exists(),
426            "nothing should have been written"
427        );
428        let _ = std::fs::remove_dir_all(&dir);
429    }
430
431    #[cfg(unix)]
432    #[test]
433    fn regenerating_over_an_existing_world_readable_key_tightens_it() {
434        use std::os::unix::fs::PermissionsExt;
435        let dir = std::env::temp_dir().join(format!("eg-remode-{}", std::process::id()));
436        std::fs::create_dir_all(&dir).unwrap();
437        let cert = dir.join("cert.pem");
438        let key = dir.join("key.pem");
439        let (c, k) = (cert.to_str().unwrap(), key.to_str().unwrap());
440
441        // A key left behind at 0644 — by an older build, a config-management run, or a restore.
442        std::fs::write(&key, "stale").unwrap();
443        std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
444
445        // Half a pair, so `ensure` regenerates and writes over that existing file. `mode()` on
446        // OpenOptions only applies when it CREATES the file, so writing in place would have left
447        // the new key material readable by every other user on the box.
448        assert!(ensure(&hosts(&["localhost"]), 1, c, k).unwrap());
449        let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
450        assert_eq!(
451            mode, 0o600,
452            "regenerated key mode was {mode:o}, expected 600"
453        );
454        assert_ne!(std::fs::read_to_string(&key).unwrap(), "stale");
455
456        let _ = std::fs::remove_dir_all(&dir);
457    }
458
459    #[cfg(unix)]
460    #[test]
461    fn a_dangling_symlink_counts_as_present() {
462        let dir = std::env::temp_dir().join(format!("eg-symlink-{}", std::process::id()));
463        std::fs::create_dir_all(&dir).unwrap();
464        let link = dir.join("cert.pem");
465        std::os::unix::fs::symlink(dir.join("nowhere.pem"), &link).unwrap();
466
467        // `Path::exists()` follows the link, finds nothing, and says "absent" — which would let
468        // a no-force `edgeguard cert` replace the operator's symlink with a regular file.
469        assert!(
470            !Path::new(&link).exists(),
471            "precondition: exists() is fooled by this"
472        );
473        assert!(
474            path_present(link.to_str().unwrap()),
475            "the link itself is there"
476        );
477
478        let _ = std::fs::remove_dir_all(&dir);
479    }
480
481    #[test]
482    fn a_failed_key_write_leaves_the_existing_pair_untouched() {
483        // A staging failure on the key must not publish the new certificate: an ordinary I/O
484        // error (full disk, revoked permission) would otherwise leave a new cert live against
485        // the old key, with no crash involved. A directory in place of the key file makes the
486        // open fail the same way.
487        let dir = std::env::temp_dir().join(format!("eg-keyfail-{}", std::process::id()));
488        std::fs::create_dir_all(&dir).unwrap();
489        let cert = dir.join("cert.pem");
490        std::fs::write(&cert, "OLD CERT").unwrap();
491        let key = dir.join("key.pem");
492        std::fs::create_dir_all(&key).unwrap(); // a directory: staging beside it still fails to rename
493
494        let before = std::fs::read_to_string(&cert).unwrap();
495        let r = write_to(
496            &hosts(&["localhost"]),
497            1,
498            cert.to_str().unwrap(),
499            key.to_str().unwrap(),
500        );
501        assert!(r.is_err(), "writing over a directory should fail");
502        assert_eq!(
503            std::fs::read_to_string(&cert).unwrap(),
504            before,
505            "the live certificate was replaced despite the key write failing"
506        );
507
508        let strays: Vec<_> = std::fs::read_dir(&dir)
509            .unwrap()
510            .filter_map(|e| e.ok())
511            .map(|e| e.file_name().to_string_lossy().into_owned())
512            .filter(|n| n.contains(".tmp."))
513            .collect();
514        assert!(strays.is_empty(), "staging files left behind: {strays:?}");
515
516        let _ = std::fs::remove_dir_all(&dir);
517    }
518
519    #[test]
520    fn leaves_no_temporary_files_behind() {
521        let dir = std::env::temp_dir().join(format!("eg-tmp-{}", std::process::id()));
522        let cert = dir.join("cert.pem");
523        let key = dir.join("key.pem");
524        write_to(
525            &hosts(&["localhost"]),
526            1,
527            cert.to_str().unwrap(),
528            key.to_str().unwrap(),
529        )
530        .unwrap();
531        let strays: Vec<_> = std::fs::read_dir(&dir)
532            .unwrap()
533            .filter_map(|e| e.ok())
534            .map(|e| e.file_name().to_string_lossy().into_owned())
535            .filter(|n| n.contains(".tmp."))
536            .collect();
537        assert!(strays.is_empty(), "staging files left behind: {strays:?}");
538        let _ = std::fs::remove_dir_all(&dir);
539    }
540
541    #[test]
542    fn ensure_requires_paths() {
543        assert!(ensure(&hosts(&["localhost"]), 1, "", "").is_err());
544    }
545
546    #[test]
547    fn ip_hosts_become_ip_sans_not_dns_names() {
548        let generated = generate(&hosts(&["localhost", "127.0.0.1"]), 1).unwrap();
549        assert!(generated
550            .cert_pem
551            .starts_with("-----BEGIN CERTIFICATE-----"));
552        // Parse back through rustls-pemfile to confirm it is a single well-formed leaf.
553        let mut reader = std::io::BufReader::new(generated.cert_pem.as_bytes());
554        let certs: Vec<_> = rustls_pemfile::certs(&mut reader)
555            .collect::<Result<Vec<_>, _>>()
556            .unwrap();
557        assert_eq!(certs.len(), 1);
558    }
559}