Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl bao: recreate the grant token file 0600, deliver init keys to stderr when stdout fails

This commit is contained in:
atlas 2026-11-08 02:53:48 +01:00 • committed by mara
commit 52ba0a996f

View file

@ -15,6 +15,7 @@
//! message. The child's environment is readable only by root through
//! `/proc/<pid>/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<Zeroizing<String>> {
///
/// 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<Zeroizing<String>> {
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<Zeroizing<Strin
"Record the keys and the root token below off this host now: swarmctl keeps none of \
them and cannot show them again."
);
let printed = (|| -> 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<usize> {
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");