Skip to content
Merged
20 changes: 20 additions & 0 deletions scripts/impl/run-extended-suite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ case_reinit_recovery() {
"$ROOT_DIR/scripts/impl/run-reinit-recovery.sh"
}

case_registrar_endurance() {
local case_dir="$ARTIFACT_DIR/registrar-endurance"
# Renewal past expiry is an extended-tier property. The launcher owns its
# 20-minute deadline, run-scoped Docker deployment, and trace artifacts.
# The short token fits the endurance instance-name budget intact. Its PID
# remains distinct for every concurrently running suite process.
BOOTROOT_PROJECT_DIR="$ROOT_DIR" \
BOOTROOT_BIN="$BOOTROOT_BIN" \
ARTIFACT_DIR="$case_dir" \
RUN_TOKEN="e-$$" \
"$ROOT_DIR/scripts/impl/run-registrar-endurance.sh"
}

case_runner_cron() {
local case_dir="$ARTIFACT_DIR/runner-cron"
ARTIFACT_DIR="$case_dir" \
Expand Down Expand Up @@ -195,6 +208,13 @@ main() {
overall_status="fail"
fi

if line="$(run_case "registrar-endurance" case_registrar_endurance)"; then
lines+=("$line")
else
lines+=("$line")
overall_status="fail"
fi

{
printf '{\n'
printf ' "scenario_file": "%s",\n' "$SCENARIO_FILE"
Expand Down
536 changes: 536 additions & 0 deletions scripts/impl/run-registrar-endurance.sh

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion scripts/preflight/ci/deploy-no-build-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ reset_existing_stack() {

ensure_install_ports_free() {
local port
for port in 8200 9000 8080 5433; do
for port in "${OPENBAO_HOST_PORT:-8200}" "${STEPCA_HOST_PORT:-9000}" \
"${HTTP01_ADMIN_HOST_PORT:-8080}" "${POSTGRES_HOST_PORT:-5433}"; do
if bash -c ": >/dev/tcp/127.0.0.1/$port" >/dev/null 2>&1; then
fail "host port 127.0.0.1:$port is already in use; stop the listener first"
fi
Expand Down
17 changes: 10 additions & 7 deletions scripts/preflight/ci/test-core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ cd "$ROOT_DIR"

COMPOSE_FILES=(-f docker-compose.yml -f docker-compose.test.yml)
BOOTROOT_SECRETS_DIR="$ROOT_DIR/secrets"
STEPCA_HOST_PORT="${STEPCA_HOST_PORT:-9000}"
HTTP01_ADMIN_HOST_PORT="${HTTP01_ADMIN_HOST_PORT:-8080}"

cleanup() {
echo "[test-core] cleanup"
Expand Down Expand Up @@ -71,7 +73,8 @@ cargo test
# belongs in the plain `cargo test` above instead. Keep the arguments
# identical to the `Monitoring Integration Test (E2E)` step in
# .github/workflows/ci.yml, and keep this ahead of the install below:
# the test needs 8200, 9000, 8080, 3000 and 5433 free on the host.
# the test needs its configured OpenBao, Step CA, responder, Grafana, and
# PostgreSQL ports free on the host.
echo "[test-core] monitoring integration test"
cargo test --test monitoring_integration -- --include-ignored

Expand All @@ -97,7 +100,7 @@ printf "n\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \
--overwrite-password \
--overwrite-ca-json \
--overwrite-state \
--responder-url "http://localhost:8080" \
--responder-url "http://localhost:${HTTP01_ADMIN_HOST_PORT}" \
--skip responder-check 2>&1 | tee zero-config-init.log

if ! grep -q "unseal key" zero-config-init.log; then
Expand Down Expand Up @@ -127,7 +130,7 @@ printf "n\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \
--overwrite-password \
--overwrite-ca-json \
--overwrite-state \
--responder-url "http://localhost:8080" \
--responder-url "http://localhost:${HTTP01_ADMIN_HOST_PORT}" \
--skip responder-check | tee cli-init.log

ROOT_TOKEN="$(awk -F': ' '/root token:/ {print $2; exit}' cli-init.log)"
Expand All @@ -143,9 +146,9 @@ mkdir -p tmp certs
# single AppRole identity, so `service add` rejects a config path
# shared across services.
for svc in edge-proxy web-app bootroot-agent; do
cat > "tmp/agent-${svc}.toml" <<'EOF'
cat > "tmp/agent-${svc}.toml" <<EOF
email = "admin@example.com"
server = "https://localhost:9000/acme/acme/directory"
server = "https://localhost:${STEPCA_HOST_PORT}/acme/acme/directory"
domain = "trusted.domain"

[acme]
Expand All @@ -154,7 +157,7 @@ directory_fetch_base_delay_secs = 1
directory_fetch_max_delay_secs = 10
poll_attempts = 15
poll_interval_secs = 2
http_responder_url = "http://localhost:8080"
http_responder_url = "http://localhost:${HTTP01_ADMIN_HOST_PORT}"
http_responder_hmac = "dev-hmac"
http_responder_timeout_secs = 5
http_responder_token_ttl_secs = 300
Expand Down Expand Up @@ -228,7 +231,7 @@ cargo run --bin bootroot -- verify \
# --- Verify CA Health ---
echo "[test-core] verifying CA health"
for i in {1..10}; do
if curl -k --fail https://localhost:9000/health; then
if curl -k --fail "https://localhost:${STEPCA_HOST_PORT}/health"; then
break
fi
echo "Waiting for CA health..."
Expand Down
16 changes: 16 additions & 0 deletions scripts/validate-e2e-run-scope.sh
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,21 @@ check_truncation_keeps_the_discriminating_tail() {
ok "a truncated identifier keeps its tail, so runs differing only there stay distinct"
}

# The registrar endurance scenario has a shorter instance-name budget than
# the lifecycle harnesses. Its suite token is deliberately short, while a
# manual run can supply a long CI token. Both must remain run-scoped: Bash's
# `${token: -N}` expands to an empty string when the token is shorter than N.
check_registrar_endurance_token_budget() {
local script="$IMPL_DIR/run-registrar-endurance.sh"
grep -Fq "if [ \"\${#RUN_TOKEN}\" -le 19 ]; then" "$script" \
|| die "registrar endurance does not preserve short run tokens"
grep -Fq "INSTANCE_TOKEN=\"\$RUN_TOKEN\"" "$script" \
|| die "registrar endurance does not retain its whole short run token"
grep -Fq "INSTANCE_TOKEN=\"\${RUN_TOKEN: -19}\"" "$script" \
|| die "registrar endurance does not retain long run-token tails"
ok "registrar endurance retains short tokens and long-token PID tails"
}

# The separation only holds because the binary ranks the exported
# project above the declared instance, and applies it to the project
# alone. That ranking lives in `src/commands/compose_project.rs`, is
Expand Down Expand Up @@ -1413,6 +1428,7 @@ check_harness_namespaces_are_declared
check_no_namespace_can_name_the_default_identity
check_project_derivation_rejects_what_compose_would
check_truncation_keeps_the_discriminating_tail
check_registrar_endurance_token_budget
check_derivation_rejects_what_it_cannot_derive
check_the_binary_ranks_the_override_above_the_flag
check_markers
Expand Down
66 changes: 61 additions & 5 deletions src/acme/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ pub(crate) enum LeafPublication {
/// written it.
#[default]
LeafOnly,
/// The leaf followed by the issuer chain the CA returned with it.
/// The leaf followed by the issuer chain and configured trust anchors.
///
/// With `[trust].ca_bundle_path` unconfigured there is no split to
/// make and this is what the leaf-only arm writes anyway, so the two
/// coincide there.
/// The anchors let callers pin an issuing root even when the ACME
/// response stops at an intermediate. With `[trust].ca_bundle_path`
/// unconfigured there is no split to make and this is what the
/// leaf-only arm writes anyway, so the two coincide there.
LeafWithChain,
}

Expand Down Expand Up @@ -718,14 +719,17 @@ async fn run_issuance(
let cert_pem = client.download_certificate(&cert_url).await?;
info!("Certificate received.");

let (leaf_pem, chain) = if settings.trust.ca_bundle_path.is_some() {
let (leaf_pem, mut chain) = if settings.trust.ca_bundle_path.is_some() {
split_leaf_and_chain(&cert_pem)?
} else {
(cert_pem.clone(), Vec::new())
};
let cert_pem = match options.leaf_publication {
LeafPublication::LeafOnly => leaf_pem,
LeafPublication::LeafWithChain => {
if let Some(bundle_path) = &settings.trust.ca_bundle_path {
append_configured_anchors(&mut chain, bundle_path);
}
let mut published = leaf_pem;
for der in &chain {
published.push_str(&encode_cert_pem(der));
Expand All @@ -741,6 +745,33 @@ async fn run_issuance(
}))
}

/// Adds configured trust anchors the issuer did not return to a chain.
///
/// The endpoint pin verifier can only select an anchor the endpoint
/// presents. ACME responses commonly stop at an intermediate, so a
/// root anchor selected from the configured bundle must travel with a
/// registrar-surface certificate too.
fn append_configured_anchors(chain: &mut Vec<Vec<u8>>, bundle_path: &Path) {
// A missing, malformed, or unreadable bundle remains on the existing
// bootstrap/repair path. `write_merged_ca_bundle` is still the sole
// authority for rejecting an unreadable replacement target, and it runs
// before this material is published.
let Ok(bundle) = std::fs::read_to_string(bundle_path) else {
return;
};
let Ok(anchors) = crate::tls::parse_pem_to_cert_list(bundle.as_bytes()) else {
return;
};
for anchor in anchors {
if !chain
.iter()
.any(|certificate| certificate.as_slice() == anchor.as_ref())
{
chain.push(anchor.to_vec());
}
}
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;
Expand Down Expand Up @@ -1302,6 +1333,31 @@ mod tests {
assert_eq!(parse_pem_der(&leaf_pem), parse_pem_der(&with_chain));
}

#[test]
fn leaf_with_chain_adds_missing_configured_anchors() {
let temp = tempdir().expect("temp dir");
let bundle_path = temp.path().join("ca-bundle.pem");
let intermediate_pem = test_cert_pem("intermediate.example");
let root_pem = test_cert_pem("root.example");
std::fs::write(&bundle_path, format!("{root_pem}{intermediate_pem}"))
.expect("write configured bundle");

let intermediate_der = parse_pem_der(&intermediate_pem);
let root_der = parse_pem_der(&root_pem);
let mut chain = vec![intermediate_der];
append_configured_anchors(&mut chain, &bundle_path);

assert_eq!(chain.len(), 2);
assert!(chain.iter().any(|certificate| certificate == &root_der));
assert_eq!(
chain
.iter()
.filter(|certificate| *certificate == &parse_pem_der(&intermediate_pem))
.count(),
1
);
}

/// `write_merged_ca_bundle` must fail closed when the existing
/// bundle cannot be read for reasons other than `NotFound`. The
/// previous `.await.ok()` collapsed every read error into an empty
Expand Down
41 changes: 41 additions & 0 deletions tests/docker_e2e_registrar_endurance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#[cfg(unix)]
mod unix_integration {
use std::path::PathBuf;
use std::process::Command;

use anyhow::{Context, Result};

/// Runs the extended-tier registrar renewal endurance scenario.
///
/// This is intentionally not a pull-request gate: it waits beyond the
/// original six-minute leaves to prove renewal and the unchanged endpoint
/// anchor pin. It does not model a compromised bootroot host, control
/// plane, or request handler.
#[test]
#[ignore = "Requires Docker, passwordless sudo, strace, and certificate expiry"]
fn docker_registrar_endurance() -> Result<()> {
let run_id = format!("endurance-{}", std::process::id());
let project_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let artifact_dir = project_dir
.join("tmp/e2e")
.join(format!("docker-registrar-endurance-{run_id}"));
std::fs::create_dir_all(&artifact_dir)
.with_context(|| format!("creating {}", artifact_dir.display()))?;
let output = Command::new("bash")
.current_dir(&project_dir)
.arg(project_dir.join("scripts/impl/run-registrar-endurance.sh"))
.env("BOOTROOT_PROJECT_DIR", &project_dir)
.env("BOOTROOT_BIN", env!("CARGO_BIN_EXE_bootroot"))
.env("ARTIFACT_DIR", &artifact_dir)
.env("RUN_TOKEN", run_id)
.output()
.with_context(|| "running registrar-endurance scenario")?;
if !output.status.success() {
anyhow::bail!(
"registrar-endurance scenario failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
}
41 changes: 30 additions & 11 deletions tests/monitoring_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod support;

#[cfg(unix)]
mod unix_integration {
use std::env;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
Expand All @@ -15,10 +16,28 @@ mod unix_integration {

use crate::support::polling::wait_for;

/// Host ports the stack this test brings up publishes on `127.0.0.1`:
/// `OpenBao` (8200), step-ca (9000), the HTTP-01 responder (8080),
/// Grafana under the `lan` profile (3000) and `PostgreSQL` (5433).
const MONITORING_PORTS: [u16; 5] = [8200, 9000, 8080, 3000, 5433];
/// Resolves a host port from the Compose override or its documented
/// default.
fn host_port(name: &str, default: u16) -> Result<u16> {
match env::var(name) {
Ok(value) if !value.is_empty() => value
.parse()
.with_context(|| format!("parsing {name} as a host port")),
Ok(_) | Err(env::VarError::NotPresent) => Ok(default),
Err(error) => Err(error).with_context(|| format!("reading {name}")),
}
}

/// Host ports the stack this test brings up publishes on `127.0.0.1`.
fn monitoring_ports() -> Result<[u16; 5]> {
Ok([
host_port("OPENBAO_HOST_PORT", 8200)?,
host_port("STEPCA_HOST_PORT", 9000)?,
host_port("HTTP01_ADMIN_HOST_PORT", 8080)?,
3000,
host_port("POSTGRES_HOST_PORT", 5433)?,
])
}

/// Grafana admin password given to `monitoring up`, injected into
/// compose, and used to authenticate against the Grafana API. One
Expand Down Expand Up @@ -505,18 +524,18 @@ mod unix_integration {
}

#[tokio::test]
#[ignore = "Brings the Docker monitoring stack up on fixed host ports; run with --include-ignored and 8200, 9000, 8080, 3000 and 5433 free"]
#[ignore = "Brings the Docker monitoring stack up; run with --include-ignored and its configured host ports free"]
async fn monitoring_stack_is_ready() -> Result<()> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let project = format!("bootroot-itest-{nonce}");
// A conflict is a failure, not a skip: the stack publishes these on
// 127.0.0.1 from the repo compose file, so there is no port left to
// fall back to, and reporting success here is how this test spent
// its life being green without running.
let bound = bound_ports(&MONITORING_PORTS);
// 127.0.0.1 from the repo compose file. Reporting success here is
// how this test spent its life being green without running.
let ports = monitoring_ports()?;
let bound = bound_ports(&ports);
if !bound.is_empty() {
let ports = bound
.iter()
Expand All @@ -525,8 +544,8 @@ mod unix_integration {
.join(", ");
anyhow::bail!(
"Required host ports are already in use: {ports}. \
Free them and re-run; this test publishes the monitoring \
stack on 127.0.0.1 at fixed ports."
Free them, or choose the documented host-port overrides, \
and re-run."
);
}
ensure_secrets_dir()?;
Expand Down
Loading