Add --cert-group policy for non-root container clients #1923
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |