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.
904 lines
32 KiB
Rust
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());
|
|
}
|
|
}
|