Skip to content

Add --cert-group policy for non-root container clients #1923

Add --cert-group policy for non-root container clients

Add --cert-group policy for non-root container clients #1923

Workflow file for this run

name: CI
permissions:
contents: read
on:
push:
branches:
- "**"
pull_request:
branches:
- main
env:
CARGO_TERM_COLOR: always
jobs:
changes:
name: Change Filter
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.decide.outputs.docs_only }}
steps:
- uses: actions/checkout@v6
- name: Filter Paths
id: filter
uses: dorny/paths-filter@v4
with:
filters: |
docs:
- "docs/**"
- "**/*.md"
- "mkdocs.yml"
- ".markdownlint*"
code:
- "src/**"
- "tests/**"
- "scripts/**"
- "Cargo.toml"
- "Cargo.lock"
- "pyproject.toml"
- "docker-compose.yml"
- "Dockerfile*"
- "responder.toml.example"
- ".github/workflows/**"
- name: Decide Docs-Only
id: decide
run: |
if [ "${{ steps.filter.outputs.docs }}" = "true" ] && [ "${{ steps.filter.outputs.code }}" != "true" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
else
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
check:
name: Quality Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Install Python Tools
run: python -m pip install --upgrade pip ruff mkdocs-material mkdocs-static-i18n
- name: Fetch Docs Theme
run: ./scripts/fetch-theme.sh
env:
GH_TOKEN: ${{ github.token }}
- name: Check Rust Formatting
run: cargo fmt -- --check --config group_imports=StdExternalCrate
- name: Check Rust Lints (Clippy)
run: cargo clippy --all-targets -- -D warnings
- name: Check Python Formatting
run: ruff format --check .
- name: Check Python Lints
run: ruff check .
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: latest
- name: Run Biome (Config/JSON Check)
run: biome ci --error-on-warnings .
- name: Run Markdown Lint
uses: DavidAnson/markdownlint-cli2-action@v23
with:
globs: "**/*.md"
- name: Build Docs
run: mkdocs build --strict
- name: Install cargo-audit
run: cargo install cargo-audit || true
- name: Run Security Audit
run: cargo audit
test-core:
name: Unit & CLI Smoke
needs: [check, changes]
if: needs.changes.outputs.docs_only != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- name: Free port 5432 (runner PostgreSQL)
run: sudo systemctl stop postgresql || true
- name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
# --- Unit Testing ---
- name: Pre-pull openbao image for real-daemon TLS E2E
run: docker pull openbao/openbao:latest
# Issue #593: provision a dedicated supplementary group for the
# runner so `tests/e2e_cert_group_chown.rs` actually exercises a
# non-primary gid through the kernel chown permission check. The
# test refuses to silently skip when
# BOOTROOT_E2E_REQUIRE_CERT_GROUP=1 is exported, so a runner that
# ever loses this fixture surfaces as a CI failure rather than
# green.
- name: Provision cert-group fixture for e2e_cert_group_chown
id: cert_group_fixture
run: |
set -euo pipefail
GROUP_NAME="bootroot-e2e-certgrp"
if ! getent group "$GROUP_NAME" >/dev/null; then
sudo groupadd "$GROUP_NAME"
fi
GID="$(getent group "$GROUP_NAME" | cut -d: -f3)"
sudo usermod -aG "$GROUP_NAME" "$USER"
echo "BOOTROOT_E2E_CERT_GROUP_GID=$GID" >> "$GITHUB_ENV"
echo "BOOTROOT_E2E_REQUIRE_CERT_GROUP=1" >> "$GITHUB_ENV"
echo "BOOTROOT_E2E_CERT_GROUP_NAME=$GROUP_NAME" >> "$GITHUB_ENV"
echo "Provisioned $GROUP_NAME (gid=$GID) and added $USER as supplementary member"
- name: Run Unit Tests
# `usermod -aG` updates /etc/group but the current shell process
# inherits the old supplementary group list. `sg <group>` would
# work but it makes the named group the *primary* gid of the
# spawned shell, which defeats the regression: the test wants
# the fixture gid to appear as a non-primary supplementary
# group so the kernel `chown(-1, gid)` permission check is
# actually exercised. Use `setpriv --init-groups` instead: it
# re-reads the user's supplementary groups from /etc/group
# while keeping the original primary gid intact, which is the
# exact post-login state the test assertions assume.
#
# `sudo` resets PATH via sudoers `secure_path`, so resolve the
# cargo binary before sudo and pass the absolute path through
# setpriv. CARGO_HOME / RUSTUP_HOME / HOME are also preserved
# so cargo can find the toolchain installed by rustup.
#
# `sudo` also strips the BOOTROOT_E2E_* fixture variables by
# default (env_reset). Without explicitly preserving them,
# `tests/e2e_cert_group_chown.rs` would see
# BOOTROOT_E2E_REQUIRE_CERT_GROUP unset and fall back to the
# dev-mode skip path, defeating the fixture and letting a broken
# CI environment produce a green build. Pass them through
# --preserve-env so the require-flag actually reaches the test
# process.
run: |
CARGO_BIN="$(command -v cargo)"
sudo --preserve-env=HOME,PATH,CARGO_HOME,RUSTUP_HOME,BOOTROOT_E2E_CERT_GROUP_GID,BOOTROOT_E2E_REQUIRE_CERT_GROUP,BOOTROOT_E2E_CERT_GROUP_NAME \
setpriv --reuid="$(id -u)" --regid="$(id -g)" --init-groups \
-- "$CARGO_BIN" test
# --- E2E Integration Testing (Docker) ---
- name: Set secrets directory
run: echo "BOOTROOT_SECRETS_DIR=$GITHUB_WORKSPACE/secrets" >> "$GITHUB_ENV"
- name: Monitoring Integration Test (E2E)
run: cargo test --test monitoring_integration
- name: Build all binaries
run: cargo build --bins
- name: Install Infrastructure
run: cargo run --bin bootroot -- infra install
- name: Zero-config Init (answer n, no show-secrets)
run: |
BOOTROOT_LANG=en printf "n\n" | cargo run --bin bootroot -- init \
--enable auto-generate \
--http-hmac "dev-hmac" \
--secrets-dir "$BOOTROOT_SECRETS_DIR" \
--responder-url "http://localhost:8080" \
--no-eab \
--skip responder-check 2>&1 | tee zero-config-init.log
if ! grep -q "unseal key" zero-config-init.log; then
echo "FAIL: unseal keys not shown when declining save"
exit 1
fi
echo "PASS: unseal keys displayed in cleartext after declining save"
- name: Clean and Reinstall
run: |
cargo run --bin bootroot -- clean -y
cargo run --bin bootroot -- infra install
- name: CLI Init (Smoke)
run: |
BOOTROOT_LANG=en printf "y\n" | cargo run --bin bootroot -- init \
--enable auto-generate,show-secrets \
--http-hmac "dev-hmac" \
--secrets-dir "$BOOTROOT_SECRETS_DIR" \
--responder-url "http://localhost:8080" \
--no-eab \
--skip responder-check | tee cli-init.log
ROOT_TOKEN="$(awk -F': ' '/root token:/ {print $2; exit}' cli-init.log)"
if [ -z "${ROOT_TOKEN:-}" ]; then
echo "Failed to read root token from init output"
exit 1
fi
echo "OPENBAO_ROOT_TOKEN=${ROOT_TOKEN}" >> "$GITHUB_ENV"
- name: CLI Service Add + Verify (Smoke)
run: |
mkdir -p tmp certs
cat > tmp/agent.toml <<'EOF'
email = "admin@example.com"
server = "https://localhost:9000/acme/acme/directory"
domain = "trusted.domain"
[acme]
directory_fetch_attempts = 10
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_hmac = "dev-hmac"
http_responder_timeout_secs = 5
http_responder_token_ttl_secs = 300
[trust]
EOF
cargo run --bin bootroot -- service add \
--service-name edge-proxy \
--deploy-type daemon \
--hostname edge-node-01 \
--domain trusted.domain \
--agent-config "$(pwd)/tmp/agent.toml" \
--cert-path "$(pwd)/certs/edge-proxy.crt" \
--key-path "$(pwd)/certs/edge-proxy.key" \
--instance-id 001 \
--root-token "$OPENBAO_ROOT_TOKEN"
cargo run --bin bootroot -- service add \
--service-name web-app \
--deploy-type docker \
--hostname web-01 \
--domain trusted.domain \
--agent-config "$(pwd)/tmp/agent.toml" \
--cert-path "$(pwd)/certs/web-app.crt" \
--key-path "$(pwd)/certs/web-app.key" \
--instance-id 001 \
--container-name web-app \
--root-token "$OPENBAO_ROOT_TOKEN"
cargo run --bin bootroot -- service add \
--service-name bootroot-agent \
--deploy-type daemon \
--hostname bootroot-agent \
--domain trusted.domain \
--agent-config "$(pwd)/tmp/agent.toml" \
--cert-path "$(pwd)/certs/bootroot-agent.crt" \
--key-path "$(pwd)/certs/bootroot-agent.key" \
--instance-id 001 \
--root-token "$OPENBAO_ROOT_TOKEN"
# Re-create the responder with Docker DNS aliases so step-ca
# can resolve challenge hostnames via Docker's internal DNS.
# Include the responder compose override written by init so the
# rendered config (with the correct HMAC) is preserved.
RESPONDER_OVERRIDE="$BOOTROOT_SECRETS_DIR/responder/docker-compose.responder.override.yml"
docker compose \
-f docker-compose.yml \
-f "$RESPONDER_OVERRIDE" \
-f docker-compose.test.yml \
up -d bootroot-http01
# Wait for step-ca to be healthy after the restart triggered
# by init's DB password rotation.
for i in {1..30}; do
if curl -k --fail -sS https://localhost:9000/health >/dev/null 2>&1; then
echo "step-ca is healthy"
break
fi
echo "Waiting for step-ca health (attempt ${i}/30)"
sleep 1
done
export PATH="$(pwd)/target/debug:$PATH"
cargo run --bin bootroot -- verify \
--service-name edge-proxy \
--agent-config "$(pwd)/tmp/agent.toml"
cargo run --bin bootroot -- verify \
--service-name web-app \
--agent-config "$(pwd)/tmp/agent.toml"
cargo run --bin bootroot -- verify \
--service-name bootroot-agent \
--agent-config "$(pwd)/tmp/agent.toml"
- name: Verify CA Health
run: |
for i in {1..10}; do
if curl -k --fail https://localhost:9000/health; then
exit 0
fi
echo "Waiting for CA health..."
sleep 3
done
docker logs bootroot-ca
exit 1
- name: Verify Agent Success (E2E)
run: |
if [ -s certs/bootroot-agent.crt ] && [ -s certs/bootroot-agent.key ]; then
echo "PASS: bootroot-agent certificate files created"
else
echo "FAIL: bootroot-agent certificate files not found"
ls -la certs/
exit 1
fi
- name: Cleanup
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.test.yml down
test-docker-e2e-matrix:
name: Docker E2E (${{ matrix.scenario.label }})
needs: [check, changes]
if: needs.changes.outputs.docs_only != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
scenario:
- label: local-no-hosts
script: run-local-lifecycle.sh
resolution: no-hosts
artifact: ci-local-no-hosts
oba_deployment: sidecar
oba_topology: default
- label: local-no-hosts-host-daemon
script: run-local-lifecycle.sh
resolution: no-hosts
artifact: ci-local-no-hosts-host-daemon
oba_deployment: host-daemon
oba_topology: default
- label: local-no-hosts-custom-project
script: run-local-lifecycle.sh
resolution: no-hosts
artifact: ci-local-no-hosts-custom-project
oba_deployment: sidecar
oba_topology: custom-project
- label: local-no-hosts-openbao-missing
script: run-local-lifecycle.sh
resolution: no-hosts
artifact: ci-local-no-hosts-openbao-missing
oba_deployment: sidecar
oba_topology: openbao-missing
- label: local-no-hosts-external-openbao
script: run-local-lifecycle.sh
resolution: no-hosts
artifact: ci-local-no-hosts-external-openbao
oba_deployment: sidecar
oba_topology: external-openbao
- label: local-hosts
script: run-local-lifecycle.sh
resolution: hosts
artifact: ci-local-hosts
oba_deployment: sidecar
oba_topology: default
- label: remote-no-hosts
script: run-remote-lifecycle.sh
resolution: no-hosts
artifact: ci-remote-no-hosts
oba_deployment: ""
oba_topology: ""
- label: remote-hosts
script: run-remote-lifecycle.sh
resolution: hosts
artifact: ci-remote-hosts
oba_deployment: ""
oba_topology: ""
- label: rotation
script: run-rotation-recovery.sh
resolution: ""
artifact: ci-rotation
oba_deployment: ""
oba_topology: ""
steps:
- uses: actions/checkout@v6
- name: Free port 5432 (runner PostgreSQL)
run: sudo systemctl stop postgresql || true
- name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Build bootroot binaries
run: cargo build --bin bootroot --bin bootroot-remote --bin bootroot-agent
- name: Run E2E scenario (lifecycle)
if: matrix.scenario.label != 'rotation'
run: |
ARTIFACT_DIR="$(pwd)/tmp/e2e/${{ matrix.scenario.artifact }}-${GITHUB_RUN_ID}"
# Host-daemon arm has to wait for OpenBao Agent's
# `static_secret_render_interval` polling fallback (~30s) on
# every render-dependent step, so it gets longer per-attempt
# delays and a larger budget than the sidecar arms.
if [ "${{ matrix.scenario.oba_deployment }}" = "host-daemon" ]; then
VERIFY_ATTEMPTS=20
VERIFY_DELAY_SECS=4
else
VERIFY_ATTEMPTS=5
VERIFY_DELAY_SECS=5
fi
ARTIFACT_DIR="$ARTIFACT_DIR" \
PROJECT_NAME="bootroot-e2e-ci-${{ matrix.scenario.label }}-${GITHUB_RUN_ID}" \
RESOLUTION_MODE="${{ matrix.scenario.resolution }}" \
OBA_DEPLOYMENT="${{ matrix.scenario.oba_deployment }}" \
OBA_TOPOLOGY="${{ matrix.scenario.oba_topology }}" \
SECRETS_DIR="$(pwd)/secrets" \
TIMEOUT_SECS=120 \
INFRA_UP_ATTEMPTS=12 \
INFRA_UP_DELAY_SECS=10 \
VERIFY_ATTEMPTS="$VERIFY_ATTEMPTS" \
VERIFY_DELAY_SECS="$VERIFY_DELAY_SECS" \
BOOTROOT_BIN="$(pwd)/target/debug/bootroot" \
BOOTROOT_REMOTE_BIN="$(pwd)/target/debug/bootroot-remote" \
BOOTROOT_AGENT_BIN="$(pwd)/target/debug/bootroot-agent" \
./scripts/impl/${{ matrix.scenario.script }} || {
echo "${{ matrix.scenario.label }} failed"
[ -f "$ARTIFACT_DIR/phases.log" ] && cat "$ARTIFACT_DIR/phases.log" || true
[ -f "$ARTIFACT_DIR/run.log" ] && tail -n 200 "$ARTIFACT_DIR/run.log" || true
[ -f "$ARTIFACT_DIR/init.raw.log" ] && tail -n 200 "$ARTIFACT_DIR/init.raw.log" || true
[ -f "$ARTIFACT_DIR/init.log" ] && tail -n 200 "$ARTIFACT_DIR/init.log" || true
[ -f "$ARTIFACT_DIR/compose-logs.log" ] && tail -n 200 "$ARTIFACT_DIR/compose-logs.log" || true
exit 1
}
- name: Run E2E scenario (rotation)
if: matrix.scenario.label == 'rotation'
run: |
SCENARIO_FILE="$(pwd)/tests/e2e/docker_harness/scenarios/scenario-c-multi-node-uneven.json" \
ARTIFACT_DIR="$(pwd)/tmp/e2e/${{ matrix.scenario.artifact }}-${GITHUB_RUN_ID}" \
PROJECT_NAME="bootroot-e2e-ci-rotation-${GITHUB_RUN_ID}" \
ROTATION_ITEMS="secret_id,eab,responder_hmac,trust_sync" \
TIMEOUT_SECS=90 \
BOOTROOT_BIN="$(pwd)/target/debug/bootroot" \
BOOTROOT_REMOTE_BIN="$(pwd)/target/debug/bootroot-remote" \
./scripts/impl/run-rotation-recovery.sh
- name: Upload Artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.scenario.artifact }}-${{ github.run_id }}
path: tmp/e2e/${{ matrix.scenario.artifact }}-${{ github.run_id }}
if-no-files-found: warn