Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/bao.rs
atlas 44caa72c19 swarmctl: interactive setup walks the first-run ceremony
swarmctl setup with no subcommand checks the setup status first, then takes the open steps one at a time: prompts for input, shows a running action as one live line, prints the store keys, root token and new password under a warning until the operator confirms, and names the deploy option for a step this host cannot do. It calls the code of bao init, bao grant-bootstrap, user add, forge make-admin, agent create, agent permissions set and agent set-state. Refuses without root or a terminal.
2026-11-08 18:11:39 +01:00

904 lines
32 KiB
Rust

//! `swarmctl bao` — initialise the swarm's secret store and bootstrap its
//! granter, so the operator never copies the root token between commands.
//!
//! Every call goes through the `bao` CLI over HTTPS, so the store need not
//! run on this host; the CLI's own `BAO_ADDR`, `BAO_CACERT` and
//! `BAO_CLIENT_*` variables say where it is. Only [`grant_bootstrap`]'s last
//! two steps (the token file and the unit restart) are local: they act on the
//! host that runs `swarm-bao-granter-role`.
//!
//! The root token is handled so that it exists in exactly three places: the
//! operator's terminal, this process's memory, and the `BAO_TOKEN`
//! environment of one `bao` child at a time. It is never in a child's
//! argument list (visible to every user through `ps`), never in this
//! process's own environment, never in a file, and never in an error
//! 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};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use clap::Args;
use serde::Deserialize;
use zeroize::{Zeroize as _, Zeroizing};
use crate::path_from;
/// The policy the 24h bootstrap token carries, and the policy the granter
/// unit creates once it has used that token. Its presence is how a store is
/// known to be bootstrapped already.
const BOOTSTRAP_POLICY: &str = "bao-bootstrap";
const GRANTER_POLICY: &str = "bao-granter";
const GRANTER_UNIT: &str = "swarm-bao-granter-role";
/// How long `--and-grant` waits for a freshly initialised store to unseal
/// itself. A `pkcs11` seal unseals within seconds; a `shamir` seal never does.
const UNSEAL_WAIT: Duration = Duration::from_mins(1);
#[derive(Args)]
pub struct ToolArgs {
/// `bao` binary to run. Defaults to `bao` on `PATH`, which on a host that
/// runs the store is the wrapper that already carries the store's
/// address, CA and client certificate.
#[arg(long, value_name = "PATH")]
bao_bin: Option<PathBuf>,
}
#[derive(Args)]
pub struct GrantArgs {
/// Where to write the 24h bootstrap token (mode 0600) for
/// `swarm-bao-granter-role` to pick up. Must be a path on the host running
/// that unit.
#[arg(long, value_name = "PATH")]
bootstrap_token_file: Option<PathBuf>,
/// The `bao-bootstrap` policy file written to the store.
#[arg(long, value_name = "PATH")]
bootstrap_policy: Option<PathBuf>,
}
#[derive(Args)]
pub struct InitArgs {
/// Right after initialising, bootstrap the granter in this same process,
/// with the root token held only in memory.
///
/// Waits for the store to unseal itself first, so the store must unseal
/// itself (a `pkcs11` seal); with a `shamir` seal, unseal by hand and run
/// `swarmctl bao grant-bootstrap`.
#[arg(long)]
and_grant: bool,
#[command(flatten)]
tool: ToolArgs,
#[command(flatten)]
grant: GrantArgs,
}
#[derive(Args)]
pub struct GrantBootstrapArgs {
#[command(flatten)]
tool: ToolArgs,
#[command(flatten)]
grant: GrantArgs,
}
/// The programs this verb runs, as paths so that tests can substitute stubs.
struct Tools {
bao: PathBuf,
systemctl: PathBuf,
unseal_wait: Duration,
}
impl ToolArgs {
fn tools(self) -> Tools {
Tools {
bao: self.bao_bin.unwrap_or_else(|| PathBuf::from("bao")),
systemctl: PathBuf::from("systemctl"),
unseal_wait: UNSEAL_WAIT,
}
}
}
struct GrantPaths {
token_file: PathBuf,
policy_file: PathBuf,
}
impl GrantArgs {
fn resolve(self) -> Result<GrantPaths> {
Ok(GrantPaths {
token_file: path_from(
self.bootstrap_token_file,
"SWARMCTL_BAO_BOOTSTRAP_TOKEN_FILE",
)?,
policy_file: path_from(self.bootstrap_policy, "SWARMCTL_BAO_BOOTSTRAP_POLICY")?,
})
}
}
/// `swarmctl bao init`.
pub fn init(args: InitArgs) -> Result<()> {
let grant = args.and_grant.then(|| args.grant.resolve()).transpose()?;
init_with(&args.tool.tools(), grant.as_ref(), &mut io::stdout().lock())
}
fn init_with(tools: &Tools, grant: Option<&GrantPaths>, out: &mut impl io::Write) -> Result<()> {
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 \
granter."
);
return Ok(());
};
// The keys are already on the operator's terminal, so a failure from here
// on must say the init itself succeeded.
let granted = wait_unsealed(tools)
.and_then(|()| grant_bootstrap_with(tools, paths, &root_token))
.context(
"the store is initialised and its keys and root token are printed above, but the \
granter bootstrap failed; once fixed, run `swarmctl bao grant-bootstrap`",
)?;
writeln!(out, "{}", granted.describe(paths)).context("writing the result to stdout")
}
/// `swarmctl bao grant-bootstrap`.
pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> {
let paths = args.grant.resolve()?;
let tools = args.tool.tools();
let root_token = read_root_token()?;
let granted = grant_bootstrap_with(&tools, &paths, &root_token)?;
println!("{}", granted.describe(&paths));
Ok(())
}
/// The verbs above without their printing and prompting, for `swarmctl setup`.
pub(crate) struct Bao {
tools: Tools,
}
impl Bao {
pub(crate) fn new() -> Self {
Self {
tools: ToolArgs { bao_bin: None }.tools(),
}
}
/// Whether the nix module gave this host the bootstrap token path and
/// policy file, which it does only where the store runs.
pub(crate) fn grant_configured() -> bool {
GrantArgs {
bootstrap_token_file: None,
bootstrap_policy: None,
}
.resolve()
.is_ok()
}
/// `swarmctl bao init`, minus delivering the secrets.
pub(crate) fn init(&self) -> Result<Created> {
create_store(&self.tools)
}
pub(crate) fn wait_unsealed(&self) -> Result<()> {
wait_unsealed(&self.tools)
}
/// `swarmctl bao grant-bootstrap` with `root` already in hand. Returns
/// the sentence the verb prints.
pub(crate) fn grant_bootstrap(&self, root: &str) -> Result<String> {
let paths = GrantArgs {
bootstrap_token_file: None,
bootstrap_policy: None,
}
.resolve()?;
Ok(grant_bootstrap_with(&self.tools, &paths, root)?.describe(&paths))
}
}
/// What `bao operator init -format=json` prints, minus the fields not shown.
///
/// Wiped on drop. The JSON parser and the child's output buffer pass through
/// the heap on the way here, so this narrows how long the secrets sit in
/// memory; it cannot make it zero.
#[derive(Deserialize)]
struct InitOutput {
#[serde(default)]
unseal_keys_b64: Vec<String>,
#[serde(default)]
recovery_keys_b64: Vec<String>,
root_token: String,
}
impl Drop for InitOutput {
fn drop(&mut self) {
self.unseal_keys_b64.zeroize();
self.recovery_keys_b64.zeroize();
self.root_token.zeroize();
}
}
/// 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.
///
/// 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 created = create_store(tools)?;
eprintln!(
"Record the keys and the root token below off this host now: swarmctl keeps none of \
them and cannot show them again."
);
if let Err(stdout_err) = write_secrets(out, &created.parsed) {
let delivered = write_secrets(fallback, &created.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(created.root_token())
}
/// A store this process just initialised, holding the only copy of its keys
/// and root token. Wiped on drop, through [`InitOutput`].
pub(crate) struct Created {
parsed: InitOutput,
}
impl Created {
pub(crate) fn root_token(&self) -> Zeroizing<String> {
Zeroizing::new(self.parsed.root_token.clone())
}
/// The keys and the root token, one labelled line each, as the operator
/// is to record them.
pub(crate) fn rendered(&self) -> Result<Zeroizing<String>> {
let mut bytes = Zeroizing::new(Vec::with_capacity(4096));
write_secrets(&mut *bytes, &self.parsed).context("rendering the keys")?;
let text = String::from_utf8(std::mem::take(&mut *bytes))
.map_err(|_| anyhow::anyhow!("the keys are not UTF-8"))?;
Ok(Zeroizing::new(text))
}
}
/// Runs `bao operator init` against a store that is not initialised yet and
/// reads what it printed. Prints nothing: delivering the secrets is the
/// caller's.
fn create_store(tools: &Tools) -> Result<Created> {
let status = run(&mut tools.bao_cmd(&["operator", "init", "-status"]))?;
match status.status.code() {
Some(0) => bail!(
"the secret store is already initialised; its keys and root token were printed \
when it was, and are not stored anywhere to print again"
),
Some(2) => {}
_ => bail!(
"could not read the store's init status: {}",
failure(&status, &[])
),
}
let created = run(&mut tools.bao_cmd(&["operator", "init", "-format=json"]))?;
let raw = Zeroizing::new(created.stdout);
if !created.status.success() {
bail!(
"`bao operator init` failed: {}",
failure_text(&created.stderr, &[])
);
}
// The parser's own error can quote the value it choked on, so none of it
// is passed on.
let parsed: InitOutput = serde_json::from_slice(&raw).map_err(|_| {
anyhow::anyhow!("`bao operator init` printed output that is not the expected JSON")
})?;
if parsed.unseal_keys_b64.is_empty() && parsed.recovery_keys_b64.is_empty() {
bail!("`bao operator init` printed no unseal or recovery keys");
}
Ok(Created { parsed })
}
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;
loop {
let status = run(&mut tools.bao_cmd(&["status"]))?;
match status.status.code() {
Some(0) => return Ok(()),
Some(2) if Instant::now() < deadline => std::thread::sleep(Duration::from_secs(1)),
Some(2) => bail!(
"the store is still sealed after {}s; with a shamir seal, unseal it with \
`bao operator unseal` and run `swarmctl bao grant-bootstrap`",
tools.unseal_wait.as_secs()
),
_ => bail!(
"could not read the store's seal status: {}",
failure(&status, &[])
),
}
}
}
#[derive(Debug)]
enum Granted {
AlreadyDone,
Done,
}
impl Granted {
fn describe(&self, paths: &GrantPaths) -> String {
match self {
Self::AlreadyDone => format!(
"the granter is already bootstrapped (policy {GRANTER_POLICY} exists); nothing done"
),
Self::Done => format!(
"granter bootstrap started: grant token written to {}, {GRANTER_UNIT} restarted",
paths.token_file.display()
),
}
}
}
/// The S3 bootstrap of `docs/1-getting-started/setup.md`: write the
/// `bao-bootstrap` policy, mint the 24h grant token, place it for the granter
/// unit and restart that unit.
fn grant_bootstrap_with(tools: &Tools, paths: &GrantPaths, root: &str) -> Result<Granted> {
let policies = run(tools.bao_cmd(&["policy", "list"]).env("BAO_TOKEN", root))?;
ensure(&policies, "bao policy list", &[root])?;
if String::from_utf8_lossy(&policies.stdout)
.lines()
.any(|line| line.trim() == GRANTER_POLICY)
{
return Ok(Granted::AlreadyDone);
}
let write = run(tools
.bao_cmd(&["policy", "write", BOOTSTRAP_POLICY])
.arg(&paths.policy_file)
.env("BAO_TOKEN", root))?;
ensure(&write, "bao policy write", &[root])?;
let minted = run(tools
.bao_cmd(&[
"token",
"create",
&format!("-policy={BOOTSTRAP_POLICY}"),
"-ttl=24h",
"-orphan",
&format!("-display-name={BOOTSTRAP_POLICY}"),
"-field=token",
])
.env("BAO_TOKEN", root))?;
ensure(&minted, "bao token create", &[root])?;
let minted = Zeroizing::new(minted.stdout);
let grant_token = Zeroizing::new(String::from_utf8_lossy(&minted).trim().to_owned());
if grant_token.is_empty() {
bail!("`bao token create` printed no token");
}
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 {}",
paths.token_file.display()
)
})?;
let restart = run(Command::new(&tools.systemctl).args(["restart", GRANTER_UNIT]))?;
ensure(&restart, "systemctl restart", &secrets)?;
Ok(Granted::Done)
}
impl Tools {
fn bao_cmd(&self, args: &[&str]) -> Command {
let mut cmd = Command::new(&self.bao);
cmd.args(args);
cmd
}
}
/// Runs `cmd` to completion with stdin closed.
///
/// `BAO_TOKEN` is dropped from the child's inherited environment unless the
/// caller sets it, so an ambient token in the operator's shell is never used
/// by accident. Spawn errors name the program and nothing else.
fn run(cmd: &mut Command) -> Result<Output> {
let program = cmd.get_program().to_string_lossy().into_owned();
if !cmd.get_envs().any(|(key, _)| key == "BAO_TOKEN") {
cmd.env_remove("BAO_TOKEN");
}
cmd.stdin(Stdio::null())
.output()
.with_context(|| format!("running {program}"))
}
/// Fails with the child's stderr, secrets replaced, when it did not succeed.
fn ensure(out: &Output, what: &str, secrets: &[&str]) -> Result<()> {
if out.status.success() {
return Ok(());
}
bail!("`{what}` failed: {}", failure(out, secrets))
}
fn failure(out: &Output, secrets: &[&str]) -> String {
format!("{} ({})", failure_text(&out.stderr, secrets), out.status)
}
/// A child's stderr as one trimmed line of text with every secret replaced.
fn failure_text(stderr: &[u8], secrets: &[&str]) -> String {
let mut text = String::from_utf8_lossy(stderr).trim().to_owned();
for secret in secrets.iter().filter(|s| !s.is_empty()) {
text = text.replace(secret, "[redacted]");
}
text
}
/// The root token: from a no-echo prompt on a terminal, otherwise the first
/// line of stdin. Never from argv or the environment.
fn read_root_token() -> Result<Zeroizing<String>> {
let stdin = io::stdin();
let token = if stdin.is_terminal() {
prompt_no_echo(&stdin, "Root token (input hidden): ")?
} else {
read_token_line(&mut stdin.lock())?
};
if token.is_empty() {
bail!("no root token was given");
}
Ok(token)
}
pub(crate) fn prompt_no_echo(stdin: &io::Stdin, prompt: &str) -> Result<Zeroizing<String>> {
use rustix::termios::{LocalModes, OptionalActions, tcgetattr, tcsetattr};
let saved = tcgetattr(stdin).context("reading terminal settings")?;
let mut quiet = saved.clone();
quiet.local_modes.remove(LocalModes::ECHO);
tcsetattr(stdin, OptionalActions::Flush, &quiet).context("turning terminal echo off")?;
eprint!("{prompt}");
io::stderr().flush().ok();
let read = read_token_line(&mut stdin.lock());
// Restored before the error, if any, is looked at.
let restored = tcsetattr(stdin, OptionalActions::Flush, &saved);
eprintln!();
restored.context("restoring terminal echo")?;
read
}
fn read_token_line(input: &mut impl io::BufRead) -> Result<Zeroizing<String>> {
let mut line = Zeroizing::new(String::new());
input
.read_line(&mut line)
.context("reading the root token")?;
Ok(Zeroizing::new(line.trim().to_owned()))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::os::unix::fs::PermissionsExt as _;
use super::*;
use crate::tests::write_executable;
const ROOT: &str = "hvs.ROOTTOKEN";
const GRANT: &str = "hvs.GRANTTOKEN";
/// Stand-ins for `bao` and `systemctl` that log every call (argument list
/// and the `BAO_TOKEN` they were given) and answer from files in `dir`.
struct Fixture {
dir: PathBuf,
tools: Tools,
paths: GrantPaths,
}
const STUB: &str = r#"#!/bin/sh
D=$(dirname "$0")
echo "ARGV $(basename "$0") $*" >> "$D/log"
echo "ENV BAO_TOKEN=${BAO_TOKEN-}" >> "$D/log"
case "$(basename "$0") $1 $2 $3" in
"bao operator init -status") exit "$(cat "$D/init_status")" ;;
"bao operator init -format=json") cat "$D/init.json"; exit 0 ;;
"bao status "*) exit "$(cat "$D/seal_status")" ;;
"bao policy list"*)
if [ -e "$D/list_fails" ]; then echo "permission denied for token $BAO_TOKEN" >&2; exit 2; fi
cat "$D/policies"; exit 0 ;;
"bao policy write"*) exit 0 ;;
"bao token create"*) echo "hvs.GRANTTOKEN"; exit 0 ;;
"systemctl restart "*)
if [ -e "$D/restart_fails" ]; then echo "unit failed, token file: $(cat "$D/grant.token")" >&2; exit 5; fi
exit 0 ;;
esac
echo "unexpected call" >&2
exit 9
"#;
impl Fixture {
fn new(tag: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("swarmctl-bao-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("temp dir");
write_executable(&dir.join("bao"), STUB);
write_executable(&dir.join("systemctl"), STUB);
let fixture = Self {
tools: Tools {
bao: dir.join("bao"),
systemctl: dir.join("systemctl"),
unseal_wait: Duration::ZERO,
},
paths: GrantPaths {
token_file: dir.join("grant.token"),
policy_file: dir.join("policy.hcl"),
},
dir,
};
fixture.set("init_status", "2");
fixture.set("seal_status", "0");
fixture.set("policies", "default\nroot\n");
fixture.set(
"init.json",
r#"{"unseal_keys_b64":["UK1","UK2"],"recovery_keys_b64":["RK1"],"root_token":"hvs.ROOTTOKEN"}"#,
);
fixture
}
fn set(&self, name: &str, body: &str) {
fs::write(self.dir.join(name), body).expect("write fixture file");
}
fn log(&self) -> String {
fs::read_to_string(self.dir.join("log")).unwrap_or_default()
}
/// The recorded argument lists, one per call, in order.
fn calls(&self) -> Vec<String> {
self.log()
.lines()
.filter_map(|l| l.strip_prefix("ARGV ").map(str::to_owned))
.collect()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.dir).ok();
}
}
fn count(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
#[test]
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, &mut Vec::new()).expect("init succeeds");
let out = String::from_utf8(out).expect("utf8");
assert_eq!(&*token, ROOT);
for line in [
"Unseal key 1: UK1",
"Unseal key 2: UK2",
"Recovery key 1: RK1",
"Initial root token: hvs.ROOTTOKEN",
] {
assert_eq!(count(&out, line), 1, "{line} in {out:?}");
}
assert_eq!(count(&out, ROOT), 1, "the token appears exactly once");
// Presence control: the log does record what init was asked.
assert_eq!(
f.calls(),
[
"bao operator init -status",
"bao operator init -format=json"
]
);
}
#[test]
fn init_hands_bao_no_token_in_argv_or_environment() {
let f = Fixture::new("init-argv");
initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect("init succeeds");
let log = f.log();
assert!(!log.contains(ROOT), "{log}");
assert!(
log.contains("ENV BAO_TOKEN=\n"),
"control: ENV lines are logged: {log}"
);
}
#[test]
fn init_refuses_an_initialised_store_without_initialising() {
let f = Fixture::new("init-refuse");
f.set("init_status", "0");
let mut out = Vec::new();
let err = initialise(&f.tools, &mut out, &mut Vec::new()).expect_err("must refuse");
assert!(
format!("{err:#}").contains("already initialised"),
"{err:#}"
);
assert!(out.is_empty(), "nothing is printed");
assert!(
!f.log().contains("-format=json"),
"init was not attempted: {}",
f.log()
);
}
#[test]
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(), &mut Vec::new()).expect_err("must stop");
assert!(format!("{err:#}").contains("init status"), "{err:#}");
assert!(!f.log().contains("-format=json"), "{}", f.log());
}
#[test]
fn a_malformed_init_reply_does_not_leak_its_contents() {
let f = Fixture::new("init-garbled");
f.set(
"init.json",
r#"{"unseal_keys_b64":"hvs.LEAKED","root_token":"x"}"#,
);
// Control: the parser's own error does quote the value.
let raw = fs::read(f.dir.join("init.json")).expect("read");
let parser = serde_json::from_slice::<InitOutput>(&raw)
.err()
.expect("rejects");
assert!(parser.to_string().contains("hvs.LEAKED"), "{parser}");
let err = initialise(&f.tools, &mut Vec::new(), &mut Vec::new()).expect_err("must fail");
assert!(!format!("{err:#}").contains("hvs.LEAKED"), "{err:#}");
}
#[test]
fn grant_runs_the_bootstrap_steps_in_order_with_no_token_in_any_argv() {
let f = Fixture::new("grant-order");
let granted = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("grant succeeds");
assert!(matches!(granted, Granted::Done));
let policy = f.paths.policy_file.display();
assert_eq!(
f.calls(),
[
"bao policy list".to_owned(),
format!("bao policy write bao-bootstrap {policy}"),
"bao token create -policy=bao-bootstrap -ttl=24h -orphan \
-display-name=bao-bootstrap -field=token"
.to_owned(),
"systemctl restart swarm-bao-granter-role".to_owned(),
]
);
let log = f.log();
for argv in f.calls() {
assert!(!argv.contains(ROOT) && !argv.contains(GRANT), "{argv}");
}
// Presence control: the root token did reach the three bao calls, by
// the environment of the child alone.
assert_eq!(count(&log, &format!("ENV BAO_TOKEN={ROOT}\n")), 3, "{log}");
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")
);
}
/// 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");
f.set("policies", "bao-granter\ndefault\nroot\n");
let granted = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect("exits ok");
assert!(matches!(granted, Granted::AlreadyDone));
assert_eq!(f.calls(), ["bao policy list"]);
assert!(!f.paths.token_file.exists());
assert!(granted.describe(&f.paths).contains("already bootstrapped"));
}
#[test]
fn a_failing_bao_call_does_not_put_the_root_token_in_the_error() {
let f = Fixture::new("grant-fail-bao");
f.set("list_fails", "");
let err = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect_err("must fail");
let text = format!("{err:#}");
// The stub echoed the token on stderr; only the redaction keeps it out.
assert!(text.contains("permission denied"), "{text}");
assert!(text.contains("[redacted]"), "{text}");
assert!(!text.contains(ROOT), "{text}");
}
#[test]
fn a_failing_restart_does_not_put_either_token_in_the_error() {
let f = Fixture::new("grant-fail-restart");
f.set("restart_fails", "");
let err = grant_bootstrap_with(&f.tools, &f.paths, ROOT).expect_err("must fail");
let text = format!("{err:#}");
// The stub printed the grant token's file contents on stderr.
assert!(text.contains("unit failed"), "{text}");
assert!(text.contains("[redacted]"), "{text}");
assert!(!text.contains(GRANT) && !text.contains(ROOT), "{text}");
}
#[test]
fn and_grant_initialises_then_bootstraps_in_one_process() {
let f = Fixture::new("and-grant");
let mut out = Vec::new();
init_with(&f.tools, Some(&f.paths), &mut out).expect("succeeds");
let out = String::from_utf8(out).expect("utf8");
assert_eq!(count(&out, ROOT), 1, "{out}");
assert!(out.contains("granter bootstrap started"), "{out}");
let calls = f.calls();
assert_eq!(
calls[..3],
[
"bao operator init -status",
"bao operator init -format=json",
"bao status"
]
);
assert_eq!(calls[3], "bao policy list");
assert_eq!(
calls.last().map(String::as_str),
Some("systemctl restart swarm-bao-granter-role")
);
assert!(calls.iter().all(|c| !c.contains(ROOT)), "{calls:?}");
}
#[test]
fn init_without_and_grant_never_touches_the_granter() {
let f = Fixture::new("init-only");
init_with(&f.tools, None, &mut Vec::new()).expect("succeeds");
assert_eq!(f.calls().len(), 2, "{:?}", f.calls());
}
#[test]
fn a_sealed_store_after_init_keeps_the_printed_token_and_leaks_nothing() {
let f = Fixture::new("and-grant-sealed");
f.set("seal_status", "2");
let mut out = Vec::new();
let err = init_with(&f.tools, Some(&f.paths), &mut out).expect_err("sealed store");
let text = format!("{err:#}");
assert!(text.contains("printed above"), "{text}");
assert!(text.contains("still sealed"), "{text}");
assert!(!text.contains(ROOT), "{text}");
assert_eq!(count(&String::from_utf8(out).expect("utf8"), ROOT), 1);
assert!(
!f.calls().iter().any(|c| c.starts_with("bao policy")),
"no grant attempted"
);
}
#[test]
fn a_seal_status_error_is_not_read_as_sealed() {
let f = Fixture::new("seal-error");
f.set("seal_status", "1");
let err = wait_unsealed(&f.tools).expect_err("must fail");
assert!(format!("{err:#}").contains("seal status"), "{err:#}");
}
#[test]
fn a_missing_bao_binary_names_the_binary() {
let f = Fixture::new("no-bao");
let tools = Tools {
bao: f.dir.join("absent"),
systemctl: f.tools.systemctl.clone(),
unseal_wait: Duration::ZERO,
};
let err = grant_bootstrap_with(&tools, &f.paths, ROOT).expect_err("must fail");
let text = format!("{err:#}");
assert!(text.contains("absent"), "{text}");
assert!(!text.contains(ROOT), "{text}");
}
#[test]
fn the_root_token_is_the_first_line_of_a_piped_stdin() {
let token = read_token_line(&mut "hvs.ROOTTOKEN\nsecond\n".as_bytes()).expect("reads");
assert_eq!(&*token, ROOT);
let token = read_token_line(&mut " hvs.ROOTTOKEN ".as_bytes()).expect("reads");
assert_eq!(&*token, ROOT);
let token = read_token_line(&mut "".as_bytes()).expect("reads");
assert!(token.is_empty());
}
}