swarmctl: add bao init and bao grant-bootstrap
init prints the store's keys and root token once and keeps none; with --and-grant it goes on to the granter bootstrap in the same process. grant-bootstrap does that step alone, reading the root token from a hidden prompt or stdin. The token reaches bao only as BAO_TOKEN in the child's environment.
This commit is contained in:
parent
e9321297c0
commit
a20596fa59
1 changed files with 909 additions and 26 deletions
740
swarmctl/src/bao.rs
Normal file
740
swarmctl/src/bao.rs
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
//! `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::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)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn initialise(tools: &Tools, out: &mut impl io::Write) -> Result<Zeroizing<String>> {
|
||||
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");
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"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")?;
|
||||
Ok(Zeroizing::new(parsed.root_token.clone()))
|
||||
}
|
||||
|
||||
/// 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));
|
||||
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)?
|
||||
} else {
|
||||
read_token_line(&mut stdin.lock())?
|
||||
};
|
||||
if token.is_empty() {
|
||||
bail!("no root token was given");
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn prompt_no_echo(stdin: &io::Stdin) -> 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!("Root token (input hidden): ");
|
||||
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).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()).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).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()).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()).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")
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue