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.
This commit is contained in:
parent
4fae6a7766
commit
44caa72c19
1 changed files with 2172 additions and 82 deletions
|
|
@ -154,6 +154,50 @@ pub fn grant_bootstrap(args: GrantBootstrapArgs) -> Result<()> {
|
|||
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
|
||||
|
|
@ -186,6 +230,56 @@ fn initialise(
|
|||
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!(
|
||||
|
|
@ -215,28 +309,7 @@ fn initialise(
|
|||
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."
|
||||
);
|
||||
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()))
|
||||
Ok(Created { parsed })
|
||||
}
|
||||
|
||||
fn write_secrets(out: &mut impl io::Write, parsed: &InitOutput) -> io::Result<()> {
|
||||
|
|
@ -401,7 +474,7 @@ fn failure_text(stderr: &[u8], secrets: &[&str]) -> String {
|
|||
fn read_root_token() -> Result<Zeroizing<String>> {
|
||||
let stdin = io::stdin();
|
||||
let token = if stdin.is_terminal() {
|
||||
prompt_no_echo(&stdin)?
|
||||
prompt_no_echo(&stdin, "Root token (input hidden): ")?
|
||||
} else {
|
||||
read_token_line(&mut stdin.lock())?
|
||||
};
|
||||
|
|
@ -411,14 +484,14 @@ fn read_root_token() -> Result<Zeroizing<String>> {
|
|||
Ok(token)
|
||||
}
|
||||
|
||||
fn prompt_no_echo(stdin: &io::Stdin) -> Result<Zeroizing<String>> {
|
||||
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!("Root token (input hidden): ");
|
||||
eprint!("{prompt}");
|
||||
io::stderr().flush().ok();
|
||||
let read = read_token_line(&mut stdin.lock());
|
||||
// Restored before the error, if any, is looked at.
|
||||
|
|
|
|||
Loading…
Reference in a new issue