swarmctl bao: recreate the grant token file 0600, deliver init keys to stderr when stdout fails
This commit is contained in:
parent
a20596fa59
commit
52ba0a996f
1 changed files with 109 additions and 18 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue