Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- Fixed `bootroot init` treating a closed stdin as an answer. Every
`init` prompt read the terminating EOF as an empty line, so a run
whose piped answer sequence ran out answered the rest of its prompts
itself: the EAB credential prompt re-prompted forever (over five
gigabytes of output in one observed run, ending only when the process
was killed), and the remaining prompts took an empty string —
silently skipping EAB registration, declining to save freshly
generated unseal keys, or accepting a default nobody chose. A prompt
with no input left now fails the run with "no input available (stdin
reached EOF / not a terminal)". Pressing Enter is unchanged: a blank
line still means the empty answer, the offered default, or "no".
Where the unseal-key save prompt is the one that runs out, the keys
are still echoed in cleartext before the run fails, so a partial init
cannot leave them recorded nowhere. The confirmations in
`bootroot clean` and `bootroot reinit` now report the same error
instead of quietly declining; both still decline to act.
- Fixed the published manual having no styling at all. The theme was
installed into a dot-prefixed directory under `docs/`, and MkDocs
excludes every dot-prefixed path inside `docs_dir` from the build, so
Expand Down
11 changes: 11 additions & 0 deletions docs/en/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,17 @@ prompts on the run still need their own flags (`--no-eab` or
`--no-save-unseal-keys`, and `--root-token`/`--unseal-key` where the
OpenBao state requires them).

A prompt that is reached with nothing left to read — stdin closed, or a
piped answer sequence that ran out — aborts the run with `no input
available (stdin reached EOF / not a terminal)` instead of being
answered. Pressing Enter is unaffected: a blank line is still the empty
answer, the offered default, or `n`. So a script driving `init` over a
pipe must supply one answer per prompt the run reaches, or set the flag
that suppresses it. The one exception is the "Save unseal keys to file
for automatic unseal?" prompt: running out of input there still echoes
the freshly generated keys in cleartext before the run fails, so a
partial init cannot leave them recorded nowhere.

If a previous `init` failed mid-flight and rolled back, OpenBao may
remain initialised in its volume while bootroot has no usable root
token. `init` detects this state on startup and emits an actionable
Expand Down
7 changes: 5 additions & 2 deletions docs/en/e2e-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,14 @@ bootroot infra install --compose-file "$COMPOSE_FILE"
# DB credentials are read from .env created by infra install.
# POSTGRES_HOST and POSTGRES_PORT are set by the script so that
# build_admin_dsn_from_env() connects via the host-mapped port.
BOOTROOT_LANG=en printf "y\n" | bootroot init \
# The piped sequence must answer every prompt the run reaches: init
# fails on EOF rather than answering an unanswered prompt itself.
printf "y\ny\ny\n" | BOOTROOT_LANG=en bootroot init \
--compose-file "$COMPOSE_FILE" \
--secrets-dir "$SECRETS_DIR" \
--summary-json "$INIT_SUMMARY_JSON" \
--enable auto-generate,show-secrets,db-provision \
--no-eab \
--db-user "step" \
--db-name "stepca" \
--responder-url "$RESPONDER_URL"
Expand Down Expand Up @@ -301,7 +304,7 @@ Actual commands (script excerpt):
```bash
# control node: infra-install / init / service-add
bootroot infra install --compose-file "$COMPOSE_FILE"
BOOTROOT_LANG=en printf "y\ny\nn\n" | bootroot init \
printf "y\ny\nn\n" | BOOTROOT_LANG=en bootroot init \
--compose-file "$COMPOSE_FILE" --summary-json "$INIT_SUMMARY_JSON" \
--enable auto-generate,show-secrets --eab-kid "$INIT_EAB_KID" \
--eab-hmac "$INIT_EAB_HMAC"
Expand Down
12 changes: 12 additions & 0 deletions docs/ko/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,18 @@ OpenBao 초기화/언실/정책/AppRole 구성, step-ca 초기화, 시크릿 등
`--no-save-unseal-keys`, OpenBao 상태에 따라
`--root-token`/`--unseal-key`).

읽을 입력이 남아 있지 않은 상태에서 프롬프트에 도달하면(stdin이
닫혔거나 파이프로 넘긴 답변이 모자란 경우) 그 프롬프트는 답변으로
처리되지 않고 `no input available (stdin reached EOF / not a
terminal)` 오류로 실행이 중단됩니다. Enter 입력은 그대로입니다. 빈
줄은 여전히 빈 답변이거나 제시된 기본값이거나 `n`입니다. 따라서
파이프로 `init`을 구동하는 스크립트는 실행이 도달하는 프롬프트마다
답변을 하나씩 공급하거나 해당 프롬프트를 억제하는 플래그를 지정해야
합니다. 예외는 "Save unseal keys to file for automatic unseal?"
프롬프트 하나로, 이 지점에서 입력이 떨어지면 실행이 실패하기 전에
새로 생성된 unseal key를 평문으로 출력합니다. 부분 초기화가 키를
어디에도 남기지 않는 상황을 막기 위해서입니다.

이전 `init`이 중간에 실패하고 롤백되었다면 OpenBao는 볼륨에 초기화된
상태로 남아 있는 반면 bootroot에는 사용 가능한 root token이 없을 수
있습니다. `init`은 시작 시 이 상태를 감지하고 불투명한
Expand Down
7 changes: 5 additions & 2 deletions docs/ko/e2e-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,14 @@ bootroot infra install --compose-file "$COMPOSE_FILE"
# DB 자격 증명은 infra install이 생성한 .env에서 자동으로 읽힙니다.
# POSTGRES_HOST와 POSTGRES_PORT는 스크립트에서 설정하여
# host-mapped 포트를 통해 연결합니다.
BOOTROOT_LANG=en printf "y\n" | bootroot init \
# 파이프로 넘기는 답변은 해당 실행이 도달하는 모든 프롬프트를 채워야
# 합니다. init은 EOF를 답으로 읽지 않고 실행을 실패시킵니다.
printf "y\ny\ny\n" | BOOTROOT_LANG=en bootroot init \
--compose-file "$COMPOSE_FILE" \
--secrets-dir "$SECRETS_DIR" \
--summary-json "$INIT_SUMMARY_JSON" \
--enable auto-generate,show-secrets,db-provision \
--no-eab \
--db-user "step" \
--db-name "stepca" \
--responder-url "$RESPONDER_URL"
Expand Down Expand Up @@ -292,7 +295,7 @@ sudo -n cp "$tmp_file" /etc/hosts
```bash
# control node: infra-install / init / service-add
bootroot infra install --compose-file "$COMPOSE_FILE"
BOOTROOT_LANG=en printf "y\ny\nn\n" | bootroot init \
printf "y\ny\nn\n" | BOOTROOT_LANG=en bootroot init \
--compose-file "$COMPOSE_FILE" --summary-json "$INIT_SUMMARY_JSON" \
--enable auto-generate,show-secrets --eab-kid "$INIT_EAB_KID" \
--eab-hmac "$INIT_EAB_HMAC"
Expand Down
11 changes: 9 additions & 2 deletions scripts/preflight/ci/test-core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ echo "[test-core] installing infrastructure"
cargo run --bin bootroot -- infra install

# --- Zero-config Init (answer n, no show-secrets) ---
# Two answers, one per prompt this run reaches: decline EAB registration,
# then decline saving the unseal keys. The second `n` is what makes the
# assertion below exercise the declined path deliberately — `init` fails
# on EOF rather than reading an unanswered prompt as "no".
echo "[test-core] zero-config init (answer n, no show-secrets)"
BOOTROOT_LANG=en printf "n\n" | cargo run --bin bootroot -- init \
printf "n\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \
--enable auto-generate \
--http-hmac "dev-hmac" \
--secrets-dir "$BOOTROOT_SECRETS_DIR" \
Expand All @@ -46,8 +50,11 @@ cargo run --bin bootroot -- clean -y
cargo run --bin bootroot -- infra install

# --- CLI Init ---
# `clean -y` above removed password.txt, ca.json and state.json, so no
# overwrite confirmation fires here: the two prompts this run reaches
# are EAB registration and saving the unseal keys, both declined.
echo "[test-core] CLI init (smoke)"
BOOTROOT_LANG=en printf "y\ny\ny\nn\n" | cargo run --bin bootroot -- init \
printf "n\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \
--enable auto-generate,show-secrets \
--http-hmac "dev-hmac" \
--secrets-dir "$BOOTROOT_SECRETS_DIR" \
Expand Down
7 changes: 6 additions & 1 deletion scripts/preflight/extra/cli-scenarios.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,12 @@ run_init_scenario() {

wait_for_postgres_admin
log "Running bootroot init"
BOOTROOT_LANG=en printf "y\ny\ny\nn\n" | cargo run --bin bootroot -- init \
# One answer per prompt this run reaches. `secrets/` and `state.json`
# were removed above, so no overwrite confirmation fires: `y` confirms
# the `db-provision` feature, then EAB registration and saving the
# unseal keys are both declined. An answer short of the last prompt
# aborts the run — init fails on EOF rather than answering itself.
printf "y\nn\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \
--enable auto-generate,show-secrets,db-provision \
--summary-json "$INIT_SUMMARY_JSON" \
--http-hmac "$responder_hmac" \
Expand Down
61 changes: 56 additions & 5 deletions src/commands/init/steps/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,21 @@ async fn maybe_save_unseal_keys(
SaveUnsealKeysDecision::Save => true,
SaveUnsealKeysDecision::DoNotSave => false,
SaveUnsealKeysDecision::Prompt => {
prompt_yes_no(messages.prompt_save_unseal_keys(), messages)?
match prompt_yes_no(messages.prompt_save_unseal_keys(), messages) {
Ok(answer) => answer,
Err(err) => {
// The prompt could not be answered — stdin is at EOF, or
// the read failed. This runs outside the rollback
// envelope with OpenBao already initialised and unsealed,
// and the keys exist nowhere else yet, so echo them
// before failing: the run still exits nonzero, but the
// operator's last capture channel stays open instead of
// recreating the partial-init trap the comment in
// `run_init` describes.
echo_unseal_keys_cleartext(keys, messages);
return Err(err);
}
}
}
};
if save {
Expand All @@ -1090,14 +1104,31 @@ async fn maybe_save_unseal_keys(
// `--no-save-unseal-keys` the keys are already captured in the
// 0600 summary JSON (clap enforces `requires = "summary_json"`),
// so echoing them here would leak into CI logs — skip it.
eprintln!("{}", messages.openbao_unseal_keys_not_saved_warning());
for (idx, key) in keys.iter().enumerate() {
println!("{}", messages.summary_unseal_key(idx + 1, key));
}
echo_unseal_keys_cleartext(keys, messages);
}
Ok(())
}

/// Displays the unseal keys in cleartext, one line per key, so the
/// operator can copy them for manual safekeeping.
///
/// Shared by the declined branch and the unanswerable-prompt branch of
/// `maybe_save_unseal_keys` so both emit exactly the same thing.
fn echo_unseal_keys_cleartext(keys: &[String], messages: &Messages) {
eprintln!("{}", messages.openbao_unseal_keys_not_saved_warning());
for line in unseal_key_echo_lines(keys, messages) {
println!("{line}");
}
}

/// Formats one cleartext line per unseal key, in key order.
fn unseal_key_echo_lines(keys: &[String], messages: &Messages) -> Vec<String> {
keys.iter()
.enumerate()
.map(|(idx, key)| messages.summary_unseal_key(idx + 1, key))
.collect()
}

/// Rotates the temporary `POSTGRES_PASSWORD` from `.env` and returns
/// the new DSN on success, or `None` if rotation was skipped.
#[allow(clippy::too_many_lines)]
Expand Down Expand Up @@ -1868,6 +1899,26 @@ mod tests {
);
}

/// The cleartext echo is one shared path, so the declined branch and
/// the branch that fails on an unanswerable prompt hand the operator
/// the same thing: one line per key, in key order.
#[test]
fn unseal_key_echo_lines_emits_one_line_per_key() {
let messages = test_messages();
let keys = vec!["key-1".to_string(), "key-2".to_string()];
let lines = unseal_key_echo_lines(&keys, &messages);
assert_eq!(lines.len(), keys.len());
for (idx, key) in keys.iter().enumerate() {
let line = lines.get(idx).expect("one line per key");
assert!(line.contains(key), "line {idx} must carry its key: {line}");
assert!(
line.contains(&(idx + 1).to_string()),
"line {idx} must be numbered from 1: {line}"
);
}
assert!(unseal_key_echo_lines(&[], &messages).is_empty());
}

/// `write_root_token_file` persists the token with mode `0600`.
/// Reinit's `--root-token-output` reaches the operator via this
/// helper; tightening the permission contract here guards against
Expand Down
Loading
Loading