diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3f3a22c..83e3ff9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -33,6 +33,7 @@ jobs: run: | bash infra/runner/test-runner-config.sh bash infra/runner/test-ci-container-operations.sh + bash infra/runner/test-runner-log-acl.sh bash infra/runner/test-install-runner-idempotency.sh bash infra/runner/test-install-runner-guard.sh - name: Check infrastructure shell syntax @@ -45,6 +46,7 @@ jobs: 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-ci-container-operations.sh \ + infra/runner/test-runner-log-acl.sh \ infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())" - name: Run rollback helper tests diff --git a/docs/operations/ACCESS.md b/docs/operations/ACCESS.md index 05aefb4..9b3b8b9 100644 --- a/docs/operations/ACCESS.md +++ b/docs/operations/ACCESS.md @@ -22,9 +22,12 @@ checks. Never print a private key, token, or complete environment file. - SSH alias: `akyldash_vps`, user `codex`, host `206.223.249.118`, port `22`. - SSH configuration: `~/.ssh/config`. - Identity file: `~/.ssh/akyldash_codex` (path only; never copy its contents). -- `codex` can run unprivileged read-only diagnostics. It has no sudo or Docker - group membership; Docker CLI access to the daemon is denied. Do not add it to - either group. +- `codex` can run unprivileged read-only diagnostics and read + `/var/log/akyldash-runner/act_runner.log` through a file ACL. It has no sudo + or Docker group membership; Docker CLI access to the daemon is denied. Do not + add it to either group. The runner installer in PR #46 narrows this to search + permission on the dedicated log directory and read permission on that one + file, preserved by systemd startup and logrotate hooks. - Bootstrap is complete. Docker and Compose are active, but no root-owned repository checkout or restricted helper is installed yet. The owner can run `infra/bootstrap/audit-host.sh` from the reviewed root-owned checkout to diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 07d2817..6b8b7b3 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -47,6 +47,16 @@ 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 runner daemon log is `/var/log/akyldash-runner/act_runner.log`. The +installer keeps its directory root-owned, grants `codex` only search permission +on that directory, and limits file access to the runner owner plus a named +read-only ACL for `codex`; group and other users have no file access. Systemd +starts with `UMask=0077`, and its fixed `ExecStartPost` command reapplies the +file ACL. Logrotate uses `copytruncate` and reapplies the same ACL after each +rotation. This access is limited to the runner daemon log; it does not grant +access to runner registration state, job workspaces, Docker state, or Gitea +workflow logs. The daemon log records task dispatch metadata, not step output. + The registered project label is `akyldash-ci`, mapped to the pinned `docker.gitea.com/runner-images:ubuntu-24.04` image. Both `ci.yaml` and `runner-smoke.yaml` target this exact project label; neither uses the generic @@ -231,5 +241,10 @@ that `runner-smoke.yaml` succeeds. Read-only checks on 2026-10-14 confirmed the runner service and health timer are active, the runner account has no supplementary groups, and the active service uses the rootless Docker socket. The live service has `ProtectHome=read-only`; the installer still needs the PR -update applied by an owner-run command after merge. The repository runner -inventory API returns 403 to the current non-owner API account. +update applied by an owner-run command after merge. Read-only SSH now confirms +that act_runner 0.2.13 declares `[akyldash-ci]` and accepts task 301; its log +does not include workflow step output. The Actions jobs API returns an empty +list for completed run 49, so step details are not available through the +current API account. The repository runner inventory API returns 403 to the +current non-owner API account. PR #46 pins the narrow log ACL and its rotation +hooks; this installer change has not been applied to the VPS. diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 3ea5cf8..18d25e7 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -48,6 +48,14 @@ unverified. filters are unset; the live process itself runs as UID/GID `999:988`, has effective and permitted capabilities `0`, and `NoNewPrivs=1`. No supplementary groups are present. +- The owner granted `codex` ACL access to `/var/log/akyldash-runner/act_runner.log`. + Read-only SSH confirmed the directory is `akyldash-runner:akyldash-runner` + mode `0750` with `codex:r-x`, while the log is `root:root` mode `0644` with + `codex:r--` and `other::r--`. The inspected log contains runner task metadata + only, no workflow step output. The active log is therefore currently + world-readable; PR #46 changes IaC to a root-owned directory, runner-owned + private log, `codex` search/read ACLs only, and ACL reapplication after + service startup and log rotation. No VPS changes were made. - The unprivileged check did not find `/var/run/docker.sock`; the runner user is not in `sudo` or `docker` groups. Production secret file metadata remains root-only and was not independently checked. The Gitea runner inventory API @@ -127,6 +135,13 @@ unverified. isolated BuildKit VM reached through a short-lived, TLS-authenticated `buildctl` connection. No VPS changes were made; image builds remain unverified until the owner provisions that builder. +- Runner log lines for run 49 show act_runner 0.2.13 with registered label + `[akyldash-ci]` accepted task 301 at 2026-10-15 01:16:11 +05. The main CI + task failed at 01:16:24 +05, and smoke task 302 started immediately after. + These daemon logs do not record the failed step. Gitea 1.25.5's Actions jobs + endpoint returns an empty list for completed run 49; the current API account + cannot use the owner-only jobs endpoint to obtain the log download ID. The + exact failing step is undetermined. - 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 diff --git a/infra/inventory/production.yml b/infra/inventory/production.yml index 946c24f..9423419 100644 --- a/infra/inventory/production.yml +++ b/infra/inventory/production.yml @@ -115,8 +115,10 @@ target: service_status: active; owner reports registered and Idle; owner reports smoke workflow passes on akyldash-ci systemd_protect_home: read-only in the live unit as a temporary owner change; installer update is in PR systemd_verification: verify passes; security exposure 7.4 medium; live runner process UID/GID 999:988, effective/permitted capabilities 0, NoNewPrivs 1, no supplementary groups + runner_log_observed: /var/log/akyldash-runner/act_runner.log; codex ACL read works; current file root:root 0644 is also other-readable; log contains task metadata but no job step output + runner_log_acl_iac: root-owned directory with codex search-only ACL; runner-owned private log with codex read-only ACL; systemd and logrotate reapply; not installed on VPS ci_status: main workflow uses ubuntu-latest in main and has no matching runner; PR changes it to akyldash-ci - owner_action: review and merge the PR, then rerun the root installer to pin ProtectHome and verify the CI workflow's isolated image-build path + owner_action: review and merge the PR, then rerun the root installer to pin ProtectHome and runner log ACLs; inspect run 49 step logs because current non-owner API lacks access checks: - infra/bootstrap/verify.sh akyldash_vps - docker compose -f deploy/production/compose.yaml ps diff --git a/infra/runner/install-runner.sh b/infra/runner/install-runner.sh index bed2f6c..12d5e6e 100644 --- a/infra/runner/install-runner.sh +++ b/infra/runner/install-runner.sh @@ -8,6 +8,8 @@ readonly runner_xdg_config_home="$runner_home/.config" readonly runner_xdg_data_home="$runner_runtime_home/.local/share" readonly runner_store="$runner_home/docker-data" readonly runner_image="$runner_home/docker-store.img" +readonly runner_log_dir=/var/log/akyldash-runner +readonly runner_log="$runner_log_dir/act_runner.log" readonly runner_mount_unit_name="$(systemd-escape --path --suffix=mount /var/lib/akyldash-runner/docker-data)" readonly runner_mount_unit="/etc/systemd/system/$runner_mount_unit_name" readonly runner_image_bytes=16106127360 @@ -76,7 +78,7 @@ for file in /etc/subuid /etc/subgid; do awk -F: -v user="$runner_user" -v start="$subid_start" -v end="$subid_end" '$1 != user && $2 <= end && $2 + $3 - 1 >= start { conflict=1 } END { exit conflict }' "$file" || die "runner subordinate ID range conflicts with another entry in $file" done apt-get update -DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap dbus-user-session curl logrotate docker-ce-rootless-extras +DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap dbus-user-session curl logrotate acl docker-ce-rootless-extras runner_ensure_account "$runner_user" "$runner_home" || die 'runner account creation failed' runner_lock_home "$runner_home" || die 'runner home protection failed' @@ -106,8 +108,25 @@ if [[ ! -e $runner_store && ! -L $runner_store ]]; then install -d -o root -g root -m 0755 "$runner_store" fi [[ -d $runner_store && ! -L $runner_store ]] || die 'runner mount path is not a directory' -install -d -o root -g "$runner_user" -m 0750 /etc/akyldash-runner -install -d -o "$runner_user" -g "$runner_user" -m 0750 /var/log/akyldash-runner +install -d -o root -g "$runner_user" -m 0750 /etc/akyldash-runner "$runner_log_dir" +[[ -d $runner_log_dir && ! -L $runner_log_dir ]] || die 'runner log directory must be a real directory' +[[ ! -L $runner_log ]] || die 'runner log path must not be a symbolic link' +if [[ -e $runner_log ]]; then + [[ -f $runner_log ]] || die 'runner log path must be a regular file' +else + install -o "$runner_user" -g "$runner_user" -m 0600 /dev/null "$runner_log" +fi +chown "$runner_user:$runner_user" "$runner_log" +chmod 0600 "$runner_log" +setfacl -b "$runner_log_dir" +setfacl -k "$runner_log_dir" +setfacl -b "$runner_log" +setfacl -m u:codex:--x "$runner_log_dir" +setfacl -m u:codex:r--,g::---,m::r-- "$runner_log" +getfacl -cp "$runner_log_dir" | grep -Fqx 'user:codex:--x' || die 'runner log directory ACL is not restrictive' +getfacl -cp "$runner_log" | grep -Fqx 'user:codex:r--' || die 'runner log read ACL is missing' +getfacl -cp "$runner_log" | grep -Fqx 'group::---' || die 'runner log is readable by its group' +getfacl -cp "$runner_log" | grep -Fqx 'other::---' || die 'runner log is readable by other users' runner_ensure_image "$runner_image" "$runner_image_bytes" 0 711 || die 'runner image initialization failed' runner_ensure_mount "$runner_image" "$runner_store" "$runner_mount_unit_name" "$runner_mount_unit" || die 'runner mount activation failed' @@ -187,6 +206,8 @@ RestrictSUIDSGID=yes ReadWritePaths=$runner_runtime_home $runner_xdg_config_home /var/log/akyldash-runner /run/user/$runner_uid StandardOutput=append:/var/log/akyldash-runner/act_runner.log StandardError=append:/var/log/akyldash-runner/act_runner.log +UMask=0077 +ExecStartPost=/usr/bin/setfacl -m u:codex:r--,g::---,m::r-- /var/log/akyldash-runner/act_runner.log [Install] WantedBy=multi-user.target @@ -208,6 +229,9 @@ runner_install_content /etc/logrotate.d/akyldash-runner root root 0644 <<'EOF' missingok notifempty copytruncate + postrotate + /usr/bin/setfacl -m u:codex:r--,g::---,m::r-- /var/log/akyldash-runner/act_runner.log + endscript } EOF runner_install_file "$(dirname "$0")/akyldash-runner-healthcheck.service" /etc/systemd/system/akyldash-runner-healthcheck.service root root 0644 diff --git a/infra/runner/test-runner-log-acl.sh b/infra/runner/test-runner-log-acl.sh new file mode 100644 index 0000000..10df1e5 --- /dev/null +++ b/infra/runner/test-runner-log-acl.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +installer=infra/runner/install-runner.sh + +grep -Fq 'logrotate acl docker-ce-rootless-extras' "$installer" +grep -Fq 'readonly runner_log_dir=/var/log/akyldash-runner' "$installer" +grep -Fq 'readonly runner_log="$runner_log_dir/act_runner.log"' "$installer" +grep -Fq 'install -d -o root -g "$runner_user" -m 0750 /etc/akyldash-runner "$runner_log_dir"' "$installer" +grep -Fq 'install -o "$runner_user" -g "$runner_user" -m 0600 /dev/null "$runner_log"' "$installer" +grep -Fq 'chown "$runner_user:$runner_user" "$runner_log"' "$installer" +grep -Fq 'chmod 0600 "$runner_log"' "$installer" +grep -Fq 'setfacl -b "$runner_log_dir"' "$installer" +grep -Fq 'setfacl -k "$runner_log_dir"' "$installer" +grep -Fq 'setfacl -b "$runner_log"' "$installer" +grep -Fq 'setfacl -m u:codex:--x "$runner_log_dir"' "$installer" +grep -Fq 'setfacl -m u:codex:r--,g::---,m::r-- "$runner_log"' "$installer" +grep -Fq "grep -Fqx 'group::---'" "$installer" +grep -Fq "grep -Fqx 'other::---'" "$installer" +grep -Fq 'UMask=0077' "$installer" +grep -Fq 'ExecStartPost=/usr/bin/setfacl -m u:codex:r--,g::---,m::r-- /var/log/akyldash-runner/act_runner.log' "$installer" +grep -Fq ' copytruncate' "$installer" +grep -Fq ' postrotate' "$installer" +grep -Fq ' /usr/bin/setfacl -m u:codex:r--,g::---,m::r-- /var/log/akyldash-runner/act_runner.log' "$installer" +grep -Fq ' endscript' "$installer" + +if grep -Eq 'setfacl .*(-R|default:)|chmod 0644.*act_runner.log|other::r--' "$installer"; then + echo 'Runner log ACL must stay limited to codex reading the single log file' >&2 + exit 1 +fi + +echo 'runner log ACL checks passed'