1use 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
31pub const DEFAULT_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
34
35pub struct SelfSigned {
37 pub cert_pem: String,
38 pub key_pem: String,
39}
40
41pub 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 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 let now = time::OffsetDateTime::now_utc();
73 params.not_before = now - time::Duration::hours(1);
74 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
92pub 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
107pub fn write_to(
112 hosts: &[String],
113 days: u32,
114 cert_path: &str,
115 key_path: &str,
116) -> Result<SelfSigned> {
117 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 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 let _ = std::fs::remove_file(&cert_tmp);
150 return Err(e).with_context(|| format!("staging private key for {key_path}"));
151 }
152 };
153
154 let backup = backup_path(cert_path);
160 let had_cert = match std::fs::rename(cert_path, &backup) {
161 Ok(()) => true,
162 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 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
204fn stage(path: &str, contents: &str, private: bool) -> Result<String> {
213 let tmp = format!("{path}.tmp.{}", std::process::id());
214 if Path::new(&tmp).exists() {
216 let _ = std::fs::remove_file(&tmp);
217 }
218
219 let mut opts = std::fs::OpenOptions::new();
220 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 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
250fn backup_path(path: &str) -> String {
254 format!("{path}.bak.{}", std::process::id())
255}
256
257fn publish(tmp: &str, path: &str) -> Result<()> {
261 std::fs::rename(tmp, path)?;
262 Ok(())
263}
264
265pub 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 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 assert!(
332 generated.is_ok(),
333 "generation failed: {:?}",
334 generated.err()
335 );
336
337 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 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 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 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 std::fs::write(&key, "stale").unwrap();
443 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
444
445 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 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 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(); 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 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}