diff --git a/.gitea/actions/caddy-validate/Dockerfile b/.gitea/actions/caddy-validate/Dockerfile new file mode 100644 index 0000000..8026e7e --- /dev/null +++ b/.gitea/actions/caddy-validate/Dockerfile @@ -0,0 +1 @@ +FROM caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028 diff --git a/.gitea/actions/caddy-validate/action.yml b/.gitea/actions/caddy-validate/action.yml new file mode 100644 index 0000000..7ce9ca7 --- /dev/null +++ b/.gitea/actions/caddy-validate/action.yml @@ -0,0 +1,16 @@ +name: Validate Caddyfile +description: Run the pinned Caddy validator against the checked-out Caddyfile +inputs: + workspace: + description: Repository workspace path supplied by the workflow + required: true +runs: + using: docker + image: Dockerfile + entrypoint: /usr/bin/caddy + args: + - validate + - --config + - ${{ inputs.workspace }}/deploy/production/Caddyfile + - --adapter + - caddyfile diff --git a/.gitea/actions/kaniko-build/Dockerfile b/.gitea/actions/kaniko-build/Dockerfile new file mode 100644 index 0000000..5e732d4 --- /dev/null +++ b/.gitea/actions/kaniko-build/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412 diff --git a/.gitea/actions/kaniko-build/action.yml b/.gitea/actions/kaniko-build/action.yml new file mode 100644 index 0000000..2c1877d --- /dev/null +++ b/.gitea/actions/kaniko-build/action.yml @@ -0,0 +1,22 @@ +name: Build image with Kaniko in a rootless container +description: Validate a Dockerfile by building it without publishing the image or cache +inputs: + workspace: + description: Repository workspace path supplied by the workflow + required: true + dockerfile: + description: Dockerfile path relative to the checked-out repository + required: true + destination: + description: Image reference required by Kaniko; the image is not pushed + required: true +runs: + using: docker + image: Dockerfile + entrypoint: /kaniko/executor + args: + - --context=dir://${{ inputs.workspace }} + - --dockerfile=${{ inputs.workspace }}/${{ inputs.dockerfile }} + - --destination=${{ inputs.destination }} + - --no-push + - --no-push-cache diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c188117..4382f2f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -7,7 +7,7 @@ on: jobs: tests-and-images: - runs-on: ubuntu-latest + runs-on: akyldash-ci steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -19,6 +19,8 @@ jobs: run: python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v - name: Run infrastructure backup tests run: python3 -m unittest discover -s infra/backup -p 'test_*.py' -v + - name: Check root guard UID decisions + run: bash infra/bootstrap/test-root-guard.sh - name: Check recovery host guard run: bash infra/bootstrap/test-host-guard.sh - name: Check verifier helper argument guard @@ -34,16 +36,20 @@ jobs: bash infra/runner/test-runner-config.sh bash infra/runner/test-install-runner-idempotency.sh bash infra/runner/test-install-runner-guard.sh + - name: Check CI container operation isolation + run: bash infra/runner/test-ci-container-builds.sh - name: Check infrastructure shell syntax run: | bash -n infra/bootstrap/bootstrap-ubuntu-24.04.sh infra/bootstrap/host-guard.sh \ infra/bootstrap/verify.sh infra/bootstrap/verify-helper.sh infra/bootstrap/audit-host.sh \ infra/deploy/install-helper.sh infra/deploy/compose-helper.sh infra/deploy/test-compose-helper.sh \ infra/deploy/prepare-production-config.sh infra/deploy/test-production-config-guard.sh \ - infra/bootstrap/test-audit-host.sh infra/bootstrap/test-verify-helper.sh \ + infra/bootstrap/test-audit-host.sh infra/bootstrap/test-root-guard.sh \ + infra/bootstrap/test-verify-helper.sh infra/test/run-as-unprivileged.sh \ infra/runner/install-runner.sh infra/runner/install-runner-lib.sh \ infra/runner/act-runner-healthcheck.sh infra/runner/test-runner-config.sh \ - infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh + infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh \ + infra/runner/test-ci-container-builds.sh python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())" - name: Run rollback helper tests run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v @@ -52,8 +58,19 @@ jobs: secret_file=$(mktemp) trap 'rm -f "$secret_file"' EXIT REVIEW_SECRET_FILE="$secret_file" docker compose --env-file deploy/production/.env.example -f deploy/production/compose.yaml config --quiet - docker run --rm --entrypoint caddy -v "$PWD/deploy/production/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028 validate --config /etc/caddy/Caddyfile --adapter caddyfile + - name: Validate Caddyfile in isolated container action + uses: ./.gitea/actions/caddy-validate + with: + workspace: ${{ github.workspace }} - name: Build production images - run: | - docker build -f deploy/production/Dockerfile.backend -t akyldash-backend:ci . - docker build -f deploy/production/Dockerfile.opensearch -t akyldash-opensearch:ci . + uses: ./.gitea/actions/kaniko-build + with: + workspace: ${{ github.workspace }} + dockerfile: deploy/production/Dockerfile.backend + destination: akyldash-backend:ci + - name: Build OpenSearch production image + uses: ./.gitea/actions/kaniko-build + with: + workspace: ${{ github.workspace }} + dockerfile: deploy/production/Dockerfile.opensearch + destination: akyldash-opensearch:ci diff --git a/AGENTS.md b/AGENTS.md index 6d5c131..a1e3fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,9 @@ Python, SQLite и OpenSearch. Production пока не развернут. [деплой](docs/operations/DEPLOYMENT.md), [backup](docs/operations/BACKUP.md), [rollback](docs/operations/ROLLBACK.md), [восстановление](docs/operations/RECOVERY.md). - Работай в feature/fix/refactor-ветке. `main` защищена; изменения вноси через - PR, не сливай его автоматически. Сохраняй незакоммиченные изменения. + PR. DEV не одобряет и не сливает собственный PR. Условный merge выполняет + только REVIEWER по правилам раздела «Автономный workflow PR» ниже. Сохраняй + незакоммиченные изменения. - SSH alias — `akyldash_vps`; используй только read-only диагностику. Не давай `codex` общий sudo или доступ к Docker socket. Root-действия требует владелец. Для Gitea используй `gitea-api`, не читай файлы с учётными данными. @@ -34,3 +36,60 @@ Python, SQLite и OpenSearch. Production пока не развернут. для нового хоста. - После каждого изменения инфраструктуры обновляй `docs/operations/CURRENT_STATE.md` по фактическим проверкам. + +## Автономный workflow PR + +- Маршруты ниже указаны из контекста оркестратора/root: оркестратор — thread + `01a144a5-8b95-77c3-b311-9a9afd062ca8`, host `local`; DEV — thread + `01a138f1-3b34-7c91-a9ed-4b43c3c85f1f`, host + `remote-ssh-codex-managed:712b8d4e-24e1-4dd5-92af-85d1fbd26449`, Gitea + `codex` (ID 6); REVIEWER — thread `01a144f9-5b51-7923-8398-61d4a6a98306`, + host `remote-ssh-codex-managed:b61555d7-8d1a-4cfa-988b-c8da4791d426`, Gitea + `agent` (ID 7). Если этот же чат отображает REVIEWER как `local` внутри его + собственного app-server, это self-label и не является маршрутом из контекста + оркестратора. +- DEV принимает только явно зарегистрированную оркестратором человеческую + задачу с полями `task`, `repo`, `branch`, `pr`, `head_sha`, `phase`. Репозиторий + должен быть `admin/akyldash`; работай только в назначенной ветке и PR. Веди + одну задачу/PR за раз и не подхватывай старые PR самостоятельно. +- Допустимые фазы: `initial_review` — реализация и устранение review findings; + `final_review_and_merge` — только после первоначального успешного review, + changelog-коммита и отдельного назначения оркестратора. Не переходи к следующей + фазе без назначения. Если задача приостановлена, останови любые действия; + после resume перепроверь назначение, PR, head/base, checks и review с нуля. +- Повторная доставка одного назначения идемпотентна по ключу + `task`+`pr`+`head_sha`+`stage`: не дублируй commit, push, review-запрос или + merge; сообщи оркестратору о уже выполненном результате. Новый head или stage + образует новое назначение и требует повторной проверки. +- Межчатовое чтение недоступно: оркестратор через central relay сам читает + результаты и выдаёт назначения. Завершай каждый этап отчётом с полями `task`, + `repo`, `branch`, `pr`, `head_sha`, `stage`, `checks`, `findings`, + `next_action`. Не настраивай отдельные webhook, daemon или транспорт. +- DEV исследует, изменяет, проверяет, коммитит и отправляет изменения; REVIEWER + не меняет код и независимо проверяет полный diff от base после каждого push, + включая предыдущие findings. DEV не одобряет и не сливает свой PR. +- Только после первоначального успешного формального review DEV добавляет + значимые изменения в существующий changelog, коммитит и отправляет их в тот же + PR. Затем REVIEWER полностью проверяет итоговый diff. Для docs-only задачи не + придумывай изменения кода; правила версии применяй только если они явно + относятся к этой документационной правке. +- Слияние может выполнить только REVIEWER для зарегистрированной человеческой + задачи, в фазе `final_review_and_merge`, после явного назначения оркестратора. + Непосредственно перед ним REVIEWER подтверждает `admin/akyldash`, PR, base + `main`, ожидаемую ветку, автора PR `codex` (ID 6), неизменность base и head, + причём head должен совпадать с зарегистрированным `head_sha`, включая + `head_commit_id` для merge-запроса. Требуются все успешные required CI/test + checks и gates, отсутствие pending/failed/unknown checks, открытых + findings и активных `REQUEST_CHANGES`, плюс актуальный формальный `APPROVED` + от REVIEWER для точного текущего commit с параметрами `commit_id == head_sha`, + `stale == false`, `dismissed == false`. При изменении head или base review и + проверки выполняются заново. Комментарий с approval не является формальным + review. + Используй только обычный server-side merge с `head_commit_id`; не используй + `force_merge` и не обходи branch protection. Если любой существенный gate, + статус или защита неизвестны либо не проверяются — не сливай PR и сообщи + оркестратору, что именно осталось неизвестным. +- Все Gitea API-вызовы выполняй только через `gitea-api` с личным окружением; + Git используй с обычным credential helper. Не читай credentials из файлов + репозитория. Запускай Git- и API-команды отдельно, без объединения с другими + командами. diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f1394..6bac0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Не выпущено +- CI actions теперь используют workspace, переданный Gitea, для Caddyfile и Kaniko build context; CI run 69 и smoke run 70 прошли на self-hosted runner. - Runner installer сохраняет `HOME` и XDG-пути в user-unit `docker.service`, перезапускает активный daemon и проверяет окружение его процесса. - Runner installer защищает image и mountpoint root-owned home-каталогом, оставляет регистрацию и workspace в runner-owned каталоге, безопасно форматирует образ по открытому FD и проверяет `nosuid,nodev`. - Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper. diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 38890e4..b6bd997 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -42,10 +42,29 @@ systemd timer checks the runner service and the rootless Docker API each minute; on failure it restarts the fixed runner service. No UFW changes or public metrics ports are needed. Runner cache is disabled. -The label `akyldash-ci` is used by `runner-smoke.yaml` on pull requests, pushes -to `main`, and manual dispatch. Existing CI image-build work remains on its current -`ubuntu-latest` target until it has been tested separately against the -isolated runner. +The CI workflow and `runner-smoke.yaml` target the repository runner label +`akyldash-ci`. CI keeps the backend, Telegram, backup/restore, deployment, +bootstrap, runner, Compose, and Caddy checks. Caddy validation runs in a pinned +container action with explicit `/usr/bin/caddy` entrypoint. The workflow passes +Gitea's `${{ github.workspace }}` value into each container action; Caddy and +Kaniko use that supplied path for repository files instead of assuming a +container workspace location. Both production +Dockerfiles are built in a separate pinned Kaniko container action with explicit +`/kaniko/executor` entrypoint using the runner's rootless container engine; the +job container receives no Docker socket. Kaniko uses `--no-push` and +`--no-push-cache`, so CI validates the full backend and OpenSearch builds but +does not publish images or export build output. The two builds run sequentially +within one job. CI run 69 completed both builds on the runner. Actual per-action +resource-limit enforcement and peak memory or disk usage were not independently +measured or verified. No registry credentials, +production secrets, elevated container options, or arbitrary host mounts are configured. + +The Kaniko action uses the `linux/amd64` v1.28.5-debug manifest +`sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412`, +listed in the [official GitHub Packages entry](https://github.com/osscontainertools/kaniko/pkgs/container/kaniko/1271366984?tag=v1.28.5-debug). +The pinned Caddy image digest remains the same as the former `docker run` +validation. act_runner starts these container actions; they do not expose its +rootless Docker socket to workflow steps. Production deployment is not automated. ## Installation and registration @@ -136,9 +155,10 @@ that token; do not put it in command-line arguments or environment variables. present in the job container. 3. Confirm the run reports the expected runner, succeeds, and the runner service and health timer remain active. -4. Only after the repository owner reviews this boundary should CI jobs be moved - to `akyldash-ci`. Do not add a production deploy workflow or secrets to this - runner until a separate restricted deploy mechanism is reviewed. +4. Inspect the `CI` workflow run and confirm Caddy plus both Kaniko actions pass + on `akyldash-ci`. The Kaniko jobs are intentionally build-only: publishing + images requires a separately reviewed registry workflow and credentials. + Do not add production deploy workflows or secrets to this runner. ## Upgrade @@ -170,9 +190,26 @@ automatic recovery step. ## Status -The root installer and registration are incomplete. The owner reports that the -15 GiB ext4 loop-backed mount is active on `/dev/loop0` after manually running -`systemctl daemon-reload`, `enable`, and `start` when the first installer run -stopped at mount activation. This PR has not changed the VPS and does not claim -that remaining runner components are installed, a repository token is -available, the runner is Online, or a workflow has run. +The owner reports that the repository-scoped runner is registered, appears +Idle in Gitea, and its rootless Docker daemon works; the report date was not +recorded. Gitea status checked 2026-10-18 showed smoke run 62 successful and CI +run 61 pending on head `69c62466`. That workflow requested `ubuntu-latest`, +while the smoke workflow used `akyldash-ci`; the label difference is a +plausible explanation for the pending status, but the runner inventory API +returned 403 and the scheduler's reason was not independently confirmed. + +On head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run +64 succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action +with `exec: "validate": executable file not found`; Kaniko was skipped and smoke +run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the Caddy action +looked for `/github/workspace/deploy/production/Caddyfile`, which was not +mounted at that fixed path; Kaniko was skipped and smoke run 68 succeeded. On +head `5d72bb09`, CI run 69 and smoke run 70 succeeded. CI validated the Caddyfile +and built the backend and OpenSearch images using `${{ github.workspace }}`. +Actual per-action resource-limit enforcement and peak usage were not +independently measured or verified. Image publishing, host-wide resource +metrics, and production deployment remain unverified. Root-guard tests +use `setpriv` for negative cases when jobs start as UID 0 and separately test +the host-audit guard with simulated UID 0 and UID 65534; the real host audit, +production config setup, and runner installer are never executed as root by +those tests. No VPS changes are part of this PR. diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index e65ef8e..a15d875 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -1,6 +1,6 @@ # Infrastructure current state -Last audit: 2026-10-12. Source: read-only SSH session as `codex`, owner-reported +Last host audit: 2026-10-12. Gitea Actions status checked 2026-10-18. Source: read-only SSH session as `codex`, owner-reported UFW state, local repository inspection, and Gitea API. Root-only Docker state, UFW rule details, and protected file metadata remain unverified. @@ -49,9 +49,13 @@ UFW rule details, and protected file metadata remain unverified. PRs enabled. `main` is protected and requires one approval; the current API principal cannot push or merge directly to `main`. - The repository runner-list API returns `403` because this API principal is - not the repository owner. The Actions task API currently reports no workflow - runs; this does not establish runner availability. Runner inventory and - package publishing permissions remain unverified. + not the repository owner. Gitea status checked 2026-10-18 showed smoke run 62 + successful and CI run 61 pending on PR head `69c62466`. That workflow used + `ubuntu-latest`, while the smoke workflow used `akyldash-ci`; this label + difference is a plausible explanation, not a confirmed scheduler diagnosis. + The owner reports that the runner is registered and Idle and rootless Docker + works (report date not recorded). Runner inventory and package publishing + permissions remain unverified. - No public application listener is observed on TCP `80`/`443`. Docker container/volume state still requires the root audit. Repository Compose describes Caddy `2.11.6` pinned by digest, Python Search API `0.11.3`, SQLite @@ -80,31 +84,27 @@ UFW rule details, and protected file metadata remain unverified. `status` helper, guarded rollback helper, and installer with exact sudoers command rules. None is installed on the VPS. CI contains no production deployment job; starting/stopping services remains owner-run. -- A repository-scoped official `act_runner` `0.2.13` installation is prepared - in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB ext4 - loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU, +- A repository-scoped official `act_runner` `0.2.13` installation is + configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB + ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU, 3 GiB RAM, 512 processes), account slice limits (2.5 CPU, 4 GiB RAM, 2048 - tasks), rotated file logs, and a systemd health timer. Jobs use the - `akyldash-ci` label and do not receive the rootless or production Docker - socket. The owner reports a partial runner installation: the 15 GiB ext4 - loop-backed store is active on `/dev/loop0` after manually enabling and - starting its mount unit when the installer stopped. Remaining components, - repository registration, and Online state are unverified; this PR makes no - VPS changes. The reviewed installer locks the runner home to root ownership - (`0711`) so CI cannot replace the image or mountpoint, while writable config - and runtime state remain in runner-owned subdirectories. It reuses legacy - `0755` config directories, creates the image through a pinned file descriptor, - rejects path replacement, and requires mount options `nosuid,nodev`. - The reported installation blocker is that rootless setup receives this - protected directory as `HOME`; the installer assigns writable runner-owned - HOME/XDG paths, persists them in the `docker.service` user-unit drop-in, and - restarts an active daemon and checks the running process environment. Local - runner guards pass; - the change has not been run on the VPS. -- Gitea repository runner inventory could not be read by the current API - principal (403). The owner has not supplied runner state, and no runner - Online status or runner-hosted workflow is claimed. The manual - `runner-smoke.yaml` workflow is prepared but has not run. + tasks), rotated file logs, and a systemd health timer. Jobs target the + `akyldash-ci` label and do not receive either Docker socket. The owner reports + that runner registration and rootless Docker work (report date not recorded). + Gitea status checked 2026-10-18 showed smoke run 62 successful and CI run 61 + pending on PR head `69c62466`. The workflow/label difference may explain that + pending status, but the scheduler reason was not independently verified. On + head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run 64 + succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action + with `exec: "validate": executable file not found`; Kaniko was skipped and + smoke run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the + Caddy action's fixed `/github/workspace/...` path did not exist; Kaniko was + skipped and smoke run 68 succeeded. On head `5d72bb09`, CI run 69 and smoke + run 70 succeeded. CI validated the Caddyfile and built the backend and + OpenSearch images using `${{ github.workspace }}`. Actual per-action + resource-limit enforcement and peak usage were not independently measured or + verified. Image publishing, host-wide resource metrics, and production + deployment remain unverified. No VPS changes are made. - An isolated local Compose smoke test built backend/OpenSearch images, reached healthy status for OpenSearch, backend, and Caddy, and returned HTTP 200 for `/review` through Caddy. Its containers, networks, and volumes were @@ -122,8 +122,10 @@ UFW rule details, and protected file metadata remain unverified. and restore of real production data remain unverified because production is not deployed. - Cloudflare edge response was checked; origin DNS target, origin certificate, - Full (strict), runner registration/Online state, registry publishing, and production - deployment have not been verified. + Full (strict), registry publishing, host-wide runner metrics, and production + deployment have not been verified. The + owner-reported Idle state has not been independently checked through runner + inventory because the API returned 403. - This audit made no host changes. No package or service was installed by this audit; the owner performed the initial bootstrap beforehand. @@ -141,12 +143,23 @@ UFW rule details, and protected file metadata remain unverified. 3. Run `sudo bash /opt/akyldash/current/infra/deploy/install-helper.sh` after reviewing the checkout; it grants only fixed verification, Compose `config`/`status`, and guarded rollback commands. -4. After PR review and root audit, run - `sudo bash /opt/akyldash/current/infra/runner/install-runner.sh`. In the - repository Settings → Actions → Runners create a repository-level runner; - enter the token only at the interactive registration prompt as documented - in [CI_RUNNER.md](CI_RUNNER.md). Then enable the runner and health timer, - confirm **Online**, and dispatch the smoke workflow. Production deployment - remains manual and has no CI job. +4. After merging the reviewed PR, confirm a subsequent CI and smoke run on + `akyldash-ci`. Image publishing and production deployment remain manual and + are not part of CI. 5. Test backup/restore and rollback only with synthetic data in an isolated environment before any production deployment. + +## CI workflow change in this PR + +The branch targets `akyldash-ci` and uses digest-pinned isolated actions for +Caddy validation and the two production Dockerfile builds. Kaniko image +publishing and cache publishing are disabled. This repository change does not +modify the VPS or grant workflow containers a Docker socket. CI run 65 failed +in the Caddy action because the runner tried to execute `validate` directly; +Kaniko was skipped. CI run 67 then found that the action's fixed +`/github/workspace` path did not contain the repository; Kaniko was skipped. +The branch now passes Gitea's `${{ github.workspace }}` value into all three +container action invocations. CI run 69 validated Caddy and completed both +image builds; smoke run 70 passed. Actual per-action resource-limit enforcement +and peak usage were not independently measured or verified. Host-wide runner +metrics, image publishing, and production deployment remain unverified. diff --git a/infra/bootstrap/test-audit-host.sh b/infra/bootstrap/test-audit-host.sh index 63a4adf..f053383 100644 --- a/infra/bootstrap/test-audit-host.sh +++ b/infra/bootstrap/test-audit-host.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +script_dir=$(dirname "${BASH_SOURCE[0]}") +source "$script_dir/../test/run-as-unprivileged.sh" + output=$(mktemp) trap 'rm -f "$output"' EXIT -if bash "$(dirname "$0")/audit-host.sh" >"$output" 2>&1; then - echo 'Host audit unexpectedly succeeded without root.' >&2 +if run_as_unprivileged bash "$script_dir/audit-host.sh" >"$output" 2>&1; then + echo 'Host audit unexpectedly succeeded as an unprivileged UID.' >&2 exit 1 fi grep -qx 'Run this read-only audit as root.' "$output" diff --git a/infra/bootstrap/test-root-guard.sh b/infra/bootstrap/test-root-guard.sh new file mode 100644 index 0000000..fcc2e2f --- /dev/null +++ b/infra/bootstrap/test-root-guard.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(dirname "${BASH_SOURCE[0]}") +guard=$(mktemp) +output=$(mktemp) +trap 'rm -f "$guard" "$output"' EXIT + +awk ' + /^if \[\[ \$EUID -ne 0 \]\]; then$/ { capture = 1 } + capture { print } + capture && /^fi$/ { found = 1; exit } + END { if (!found) exit 1 } +' "$script_dir/audit-host.sh" | sed 's/\$EUID/\$TEST_EUID/g' >"$guard" || { + echo 'Could not extract the host audit root guard.' >&2 + exit 1 +} +if ! grep -Fq 'if [[ $TEST_EUID -ne 0 ]]; then' "$guard"; then + echo 'Extracted guard does not use the simulated effective UID.' >&2 + exit 1 +fi +if TEST_EUID=0 bash "$guard" >"$output" 2>&1; then + [[ ! -s $output ]] || { cat "$output" >&2; exit 1; } +else + echo 'Host audit root guard rejected simulated UID 0.' >&2 + exit 1 +fi +if TEST_EUID=65534 bash "$guard" >"$output" 2>&1; then + echo 'Host audit root guard accepted simulated non-root UID.' >&2 + exit 1 +fi +grep -qx 'Run this read-only audit as root.' "$output" +echo 'PASS host audit root guard accepts UID 0 and rejects non-root UIDs' diff --git a/infra/deploy/test-production-config-guard.sh b/infra/deploy/test-production-config-guard.sh index cf7ed3e..1ab96bb 100644 --- a/infra/deploy/test-production-config-guard.sh +++ b/infra/deploy/test-production-config-guard.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +script_dir=$(dirname "${BASH_SOURCE[0]}") +source "$script_dir/../test/run-as-unprivileged.sh" + output=$(mktemp) trap 'rm -f "$output"' EXIT -if bash "$(dirname "$0")/prepare-production-config.sh" >"$output" 2>&1; then - echo 'Production config setup unexpectedly succeeded without root.' >&2 +if run_as_unprivileged bash "$script_dir/prepare-production-config.sh" >"$output" 2>&1; then + echo 'Production config setup unexpectedly succeeded as an unprivileged UID.' >&2 exit 1 fi grep -qx 'Run this configuration step as root.' "$output" diff --git a/infra/runner/test-ci-container-builds.sh b/infra/runner/test-ci-container-builds.sh new file mode 100644 index 0000000..4556c90 --- /dev/null +++ b/infra/runner/test-ci-container-builds.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +workflow=.gitea/workflows/ci.yaml +kaniko_action=.gitea/actions/kaniko-build/action.yml +caddy_action=.gitea/actions/caddy-validate/action.yml + +grep -Fq 'runs-on: akyldash-ci' "$workflow" +grep -Fq 'uses: ./.gitea/actions/caddy-validate' "$workflow" +[[ $(grep -Fc 'uses: ./.gitea/actions/kaniko-build' "$workflow") -eq 2 ]] +check_workspace_wiring() { + local workflow_file=$1 caddy_file=${2:-$caddy_action} kaniko_file=${3:-$kaniko_action} + [[ $(grep -Fc 'workspace: ${{ github.workspace }}' "$workflow_file") -eq 3 ]] && + grep -Fxq ' - ${{ inputs.workspace }}/deploy/production/Caddyfile' "$caddy_file" && + grep -Fxq ' - --context=dir://${{ inputs.workspace }}' "$kaniko_file" && + grep -Fxq ' - --dockerfile=${{ inputs.workspace }}/${{ inputs.dockerfile }}' "$kaniko_file" && + ! grep -Fq '/github/workspace' "$caddy_file" "$kaniko_file" +} +if ! check_workspace_wiring "$workflow"; then + echo 'Container actions must receive github.workspace and use it for repository paths.' >&2 + exit 1 +fi +grep -Fq 'dockerfile: deploy/production/Dockerfile.backend' "$workflow" +grep -Fq 'dockerfile: deploy/production/Dockerfile.opensearch' "$workflow" +grep -Fq 'docker compose --env-file deploy/production/.env.example -f deploy/production/compose.yaml config --quiet' "$workflow" +grep -Fxq ' entrypoint: /usr/bin/caddy' "$caddy_action" +grep -Fxq ' - validate' "$caddy_action" +grep -Fxq ' - --config' "$caddy_action" +grep -Fxq ' - ${{ inputs.workspace }}/deploy/production/Caddyfile' "$caddy_action" +grep -Fxq ' - --adapter' "$caddy_action" +grep -Fxq ' - caddyfile' "$caddy_action" +if grep -Eq 'docker (build|run)([[:space:]]|$)' "$workflow"; then + echo 'workflow must not invoke docker build/run in a job container' >&2 + exit 1 +fi +check_kaniko_action() { + grep -Fxq ' entrypoint: /kaniko/executor' "$1" && + grep -Fxq ' - --context=dir://${{ inputs.workspace }}' "$1" && + grep -Fxq ' - --dockerfile=${{ inputs.workspace }}/${{ inputs.dockerfile }}' "$1" && + grep -Fxq ' - --destination=${{ inputs.destination }}' "$1" && + grep -Fxq -- ' - --no-push' "$1" && + grep -Fxq -- ' - --no-push-cache' "$1" +} +if ! check_kaniko_action "$kaniko_action"; then + echo 'Kaniko action must define its entrypoint, build arguments, and no-push flags.' >&2 + exit 1 +fi +without_entrypoint=$(mktemp) +without_no_push=$(mktemp) +without_workspace=$(mktemp) +wrong_workspace=$(mktemp) +trap 'rm -f "$without_entrypoint" "$without_no_push" "$without_workspace" "$wrong_workspace"' EXIT +sed '/^ entrypoint: \/kaniko\/executor$/d' "$kaniko_action" >"$without_entrypoint" +if check_kaniko_action "$without_entrypoint"; then + echo 'CI isolation test accepted a Kaniko action without its executor entrypoint.' >&2 + exit 1 +fi +sed '/^ - --no-push$/d' "$kaniko_action" >"$without_no_push" +if check_kaniko_action "$without_no_push"; then + echo 'CI isolation test accepted --no-push-cache without standalone --no-push.' >&2 + exit 1 +fi +sed '/workspace: \${{ github.workspace }}/d' "$workflow" >"$without_workspace" +if check_workspace_wiring "$without_workspace"; then + echo 'CI isolation test accepted container actions without workflow workspace inputs.' >&2 + exit 1 +fi +sed 's#${{ inputs.workspace }}#/github/workspace#g' "$kaniko_action" >"$wrong_workspace" +if check_workspace_wiring "$workflow" "$caddy_action" "$wrong_workspace"; then + echo 'CI isolation test accepted a fixed Kaniko workspace root.' >&2 + exit 1 +fi +grep -Fq 'FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412' .gitea/actions/kaniko-build/Dockerfile +grep -Fq 'FROM caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028' .gitea/actions/caddy-validate/Dockerfile +if grep -REn 'docker.sock|privileged:|container.options:|^[[:space:]]*volumes:' "$workflow" .gitea/actions/kaniko-build .gitea/actions/caddy-validate; then + echo 'workflow and container actions must not request sockets, privileges, or additional host mounts' >&2 + exit 1 +fi + +echo 'PASS CI container actions use the supplied workspace and remain isolated without socket or publish access' diff --git a/infra/runner/test-install-runner-guard.sh b/infra/runner/test-install-runner-guard.sh index 4e52819..aa268d4 100644 --- a/infra/runner/test-install-runner-guard.sh +++ b/infra/runner/test-install-runner-guard.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash set -Eeuo pipefail +script_dir=$(dirname "${BASH_SOURCE[0]}") +source "$script_dir/../test/run-as-unprivileged.sh" + output=$(mktemp) trap 'rm -f "$output"' EXIT -if bash "$(dirname "$0")/install-runner.sh" >"$output" 2>&1; then - echo 'Runner installer unexpectedly succeeded without root.' >&2 +if run_as_unprivileged bash "$script_dir/install-runner.sh" >"$output" 2>&1; then + echo 'Runner installer unexpectedly succeeded as an unprivileged UID.' >&2 exit 1 fi grep -qx 'ERROR: run this script as root' "$output" diff --git a/infra/test/run-as-unprivileged.sh b/infra/test/run-as-unprivileged.sh new file mode 100644 index 0000000..47c5618 --- /dev/null +++ b/infra/test/run-as-unprivileged.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +run_as_unprivileged() { + if [[ $EUID -eq 0 ]]; then + if ! command -v setpriv >/dev/null 2>&1; then + echo 'setpriv is required to run guard tests as an unprivileged UID.' >&2 + return 127 + fi + setpriv --reuid=65534 --regid=65534 --clear-groups -- "$@" + else + "$@" + fi +}