From 52ba0a996f09dece6c7c27ab51a241b1fb2134cd Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 8 Nov 2026 02:53:48 +0100 Subject: [PATCH] swarmctl bao: recreate the grant token file 0600, deliver init keys to stderr when stdout fails --- swarmctl/src/bao.rs | 127 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 109 insertions(+), 18 deletions(-) diff --git a/swarmctl/src/bao.rs b/swarmctl/src/bao.rs index 00e0f52d..314ebc2e 100644 --- a/swarmctl/src/bao.rs +++ b/swarmctl/src/bao.rs @@ -15,6 +15,7 @@ //! message. The child's environment is readable only by root through //! `/proc//environ`. +use std::fs; use std::io::{self, IsTerminal as _, Write as _}; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; @@ -124,7 +125,7 @@ pub fn init(args: InitArgs) -> Result<()> { } fn init_with(tools: &Tools, grant: Option<&GrantPaths>, out: &mut impl io::Write) -> Result<()> { - let root_token = initialise(tools, out)?; + let root_token = initialise(tools, out, &mut io::stderr().lock())?; let Some(paths) = grant else { eprintln!( "The store is initialised. Next, `swarmctl bao grant-bootstrap` bootstraps the \ @@ -177,7 +178,14 @@ impl Drop for InitOutput { /// Initialises the store, writes the shares and the root token to `out`, and /// returns the root token for the caller to use once and drop. -fn initialise(tools: &Tools, out: &mut impl io::Write) -> Result> { +/// +/// When `out` fails the store is initialised already and the secrets exist +/// nowhere else, so they are written to `fallback` once before failing. +fn initialise( + tools: &Tools, + out: &mut impl io::Write, + fallback: &mut impl io::Write, +) -> Result> { let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?; match status.status.code() { Some(0) => bail!( @@ -212,20 +220,36 @@ fn initialise(tools: &Tools, out: &mut impl io::Write) -> Result io::Result<()> { - for (i, key) in parsed.unseal_keys_b64.iter().enumerate() { - writeln!(out, "Unseal key {}: {key}", i + 1)?; - } - for (i, key) in parsed.recovery_keys_b64.iter().enumerate() { - writeln!(out, "Recovery key {}: {key}", i + 1)?; - } - writeln!(out, "Initial root token: {}", parsed.root_token)?; - out.flush() - })(); - printed.context("writing the keys and root token to stdout")?; + if let Err(stdout_err) = write_secrets(out, &parsed) { + let delivered = write_secrets(fallback, &parsed); + return Err(match delivered { + Ok(()) => anyhow::anyhow!( + "the store is now initialised, but its keys and root token could not be written \ + to stdout ({stdout_err}); they were written to stderr instead, once: record \ + them now" + ), + Err(stderr_err) => anyhow::anyhow!( + "the store is now initialised, but its keys and root token could not be \ + written to stdout ({stdout_err}) or to stderr ({stderr_err}) and are lost; \ + see OpenBao's `operator rekey` and `operator generate-root` documentation \ + for what a remaining key holder can recover" + ), + }); + } Ok(Zeroizing::new(parsed.root_token.clone())) } +fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> { + for (i, key) in parsed.unseal_keys_b64.iter().enumerate() { + writeln!(out, "Unseal key {}: {key}", i + 1)?; + } + for (i, key) in parsed.recovery_keys_b64.iter().enumerate() { + writeln!(out, "Recovery key {}: {key}", i + 1)?; + } + writeln!(out, "Initial root token: {}", parsed.root_token)?; + out.flush() +} + /// Polls until `bao status` reports an unsealed store (exit 0). fn wait_unsealed(tools: &Tools) -> Result<()> { let deadline = Instant::now() + tools.unseal_wait; @@ -306,6 +330,16 @@ fn grant_bootstrap_with(tools: &Tools, paths: &GrantPaths, root: &str) -> Result let secrets = [root, grant_token.as_str()]; let contents = Zeroizing::new(format!("{}\n", *grant_token)); + // `write_atomic` keeps an existing file's mode and owner; this one must be + // ours and 0600 whatever was there. + match fs::remove_file(&paths.token_file) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + return Err(e) + .with_context(|| format!("removing the old {}", paths.token_file.display())); + } + } crate::write_atomic(&paths.token_file, &contents).with_context(|| { format!( "placing the bootstrap token at {}", @@ -503,7 +537,7 @@ exit 9 fn init_prints_the_keys_and_the_root_token_once() { let f = Fixture::new("init-once"); let mut out = Vec::new(); - let token = initialise(&f.tools, &mut out).expect("init succeeds"); + let token = initialise(&f.tools, &mut out, &mut Vec::new()).expect("init succeeds"); let out = String::from_utf8(out).expect("utf8"); assert_eq!(&*token, ROOT); @@ -529,7 +563,7 @@ exit 9 #[test] fn init_hands_bao_no_token_in_argv_or_environment() { let f = Fixture::new("init-argv"); - initialise(&f.tools, &mut Vec::new()).expect("init succeeds"); + initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect("init succeeds"); let log = f.log(); assert!(!log.contains(ROOT), "{log}"); assert!( @@ -543,7 +577,7 @@ exit 9 let f = Fixture::new("init-refuse"); f.set("init_status", "0"); let mut out = Vec::new(); - let err = initialise(&f.tools, &mut out).expect_err("must refuse"); + let err = initialise(&f.tools, &mut out, &mut Vec::new()).expect_err("must refuse"); assert!( format!("{err:#}").contains("already initialised"), "{err:#}" @@ -560,7 +594,7 @@ exit 9 fn init_reports_an_unreadable_status_instead_of_initialising() { let f = Fixture::new("init-status-err"); f.set("init_status", "1"); - let err = initialise(&f.tools, &mut Vec::new()).expect_err("must stop"); + let err = initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect_err("must stop"); assert!(format!("{err:#}").contains("init status"), "{err:#}"); assert!(!f.log().contains("-format=json"), "{}", f.log()); } @@ -579,7 +613,7 @@ exit 9 .expect("rejects"); assert!(parser.to_string().contains("hvs.LEAKED"), "{parser}"); - let err = initialise(&f.tools, &mut Vec::new()).expect_err("must fail"); + let err = initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect_err("must fail"); assert!(!format!("{err:#}").contains("hvs.LEAKED"), "{err:#}"); } @@ -621,6 +655,63 @@ exit 9 ); } + /// A writer that refuses every write, as a closed pipe or full disk does. + struct Broken; + + impl io::Write for Broken { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::other("broken pipe")) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("broken pipe")) + } + } + + #[test] + fn a_failing_stdout_falls_back_to_stderr_and_says_the_store_is_initialised() { + let f = Fixture::new("stdout-fails"); + let mut fallback = Vec::new(); + let err = initialise(&f.tools, &mut Broken, &mut fallback).expect_err("must fail"); + let text = format!("{err:#}"); + assert!(text.contains("now initialised"), "{text}"); + assert!(text.contains("written to stderr"), "{text}"); + assert!(!text.contains(ROOT) && !text.contains("UK1"), "{text}"); + let fallback = String::from_utf8(fallback).expect("utf8"); + assert_eq!(count(&fallback, ROOT), 1, "{fallback}"); + assert_eq!(count(&fallback, "Unseal key 1: UK1"), 1, "{fallback}"); + } + + #[test] + fn keys_that_neither_stdout_nor_stderr_accept_are_reported_lost() { + let f = Fixture::new("both-fail"); + let err = initialise(&f.tools, &mut Broken, &mut Broken).expect_err("must fail"); + let text = format!("{err:#}"); + assert!(text.contains("now initialised"), "{text}"); + assert!(text.contains("are lost"), "{text}"); + assert!(text.contains("rekey"), "{text}"); + assert!(!text.contains(ROOT), "{text}"); + } + + #[test] + fn an_existing_world_readable_token_file_is_replaced_by_a_0600_one() { + let f = Fixture::new("token-mode"); + fs::write(&f.paths.token_file, "old\n").expect("seed"); + fs::set_permissions(&f.paths.token_file, fs::Permissions::from_mode(0o644)).expect("chmod"); + + grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("grant succeeds"); + + let mode = fs::metadata(&f.paths.token_file) + .expect("stat") + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600); + assert_eq!( + fs::read_to_string(&f.paths.token_file).expect("read"), + format!("{GRANT}\n") + ); + } + #[test] fn grant_on_a_bootstrapped_store_changes_nothing() { let f = Fixture::new("grant-again");