From b68421bb5ba41dee3d04e2b01cecf249a8862dcd Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Oct 2026 01:16:11 +0300 Subject: [PATCH 1/5] Make runner installer safe to resume --- .gitea/workflows/ci.yaml | 6 +- docs/operations/CI_RUNNER.md | 41 ++-- docs/operations/CURRENT_STATE.md | 7 +- infra/runner/install-runner-lib.sh | 115 +++++++++++ infra/runner/install-runner.sh | 136 +++++++------ .../runner/test-install-runner-idempotency.sh | 182 ++++++++++++++++++ infra/runner/test-runner-config.sh | 13 +- 7 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 infra/runner/install-runner-lib.sh create mode 100644 infra/runner/test-install-runner-idempotency.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5352f72..9f85dd4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -32,6 +32,7 @@ jobs: - name: Check runner config and isolation guards run: | 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 infrastructure shell syntax run: | @@ -40,8 +41,9 @@ jobs: 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/runner/install-runner.sh infra/runner/act-runner-healthcheck.sh \ - infra/runner/test-runner-config.sh infra/runner/test-install-runner-guard.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 - name: Run rollback helper tests run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v - name: Validate production Compose and Caddyfile diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 410335c..89242a0 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -49,16 +49,24 @@ for runner images/workspaces; and configures systemd services. It starts only the separate rootless Docker daemon; the runner and health timer stay disabled until repository registration. It does not change SSH/UFW, production Docker configuration, application data, or production -secrets. It refuses to overwrite existing runner paths and requires at least -25 GiB free on `/` before reserving storage. +secrets. It can be rerun after partial setup: it validates and reuses an +existing runner account, subordinate ID mappings, image, and mount unit. An +existing image must be exactly 15 GiB and contain ext4; a mismatch stops the +installer without resizing or formatting it. The runner account must retain +a non-root system UID below `UID_MIN`, its expected home, primary group, and +sole supplementary group. The mount +is started only when it is not already active, and an active mount must be the +expected ext4 loop device backed by the runner image. Conflicting files or +state stop the installer for manual inspection. A new image requires at least +25 GiB free on `/`. If the reviewed checkout is not present at the inventoried path, first verify -that the target is absent and clone the reviewed PR branch there. This path is -currently absent per the read-only audit; do not overwrite it if that changes. +that the target is absent and clone the reviewed `main` there. Do not overwrite +an existing checkout. ```bash sudo test ! -e /opt/akyldash/current -sudo git clone --branch feature/infrastructure-deployment-phase-2 \ +sudo git clone --branch main \ --single-branch https://git.vakanaut.ru/admin/akyldash.git \ /opt/akyldash/current ``` @@ -114,16 +122,22 @@ that token; do not put it in command-line arguments or environment variables. Update `runner_version` and its expected official SHA-256 together in `infra/runner/install-runner.sh`, review upstream release compatibility with -the installed Gitea, and open a PR. From the reviewed checkout, re-run the -installer only after resolving its existing-path guards. Do not replace the -binary while a job is active. Stop the runner, install/verify the new binary, -then start it and confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`. +the installed Gitea, and open a PR. Do not replace the binary while a job is +active. Stop the runner, install/verify the new binary, then start it and +confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`. ## Recovery and rollback The runner is disposable: production data and secrets are not on its filesystem. For a runner process failure, inspect `systemctl status` and `journalctl -u akyldash-act-runner.service`; restart the fixed runner service. +If installation stops after creating or enabling the mount unit, inspect the +image and mount with `stat`, `blkid`, `findmnt`, and `losetup`, then rerun the +reviewed installer only when the image is 15 GiB ext4 and the active mount is +backed by it. The installer recognizes that state and does not format or +restart the mounted filesystem. If size, filesystem, unit contents, or backing +device differ, stop and resolve the state manually; never format the existing +image as a retry. For lost/corrupt registration state, stop the service and obtain a fresh repository-level token from Settings → Actions → Runners. Preserve the 15 GiB data image and state for diagnosis; re-register only after the repository @@ -133,6 +147,9 @@ automatic recovery step. ## Status -The configuration and scripts are prepared in Git. They have not been -installed on the VPS, a repository-level token is not available to this agent, -and no runner Online state or workflow execution is claimed yet. +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. diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index a3cbb8d..0516dad 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -86,8 +86,11 @@ UFW rule details, and protected file metadata remain unverified. 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. This is repository code only: no runner package, account, store, - service, token, or registration is installed on the VPS. + 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. - 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 diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh new file mode 100644 index 0000000..42b058f --- /dev/null +++ b/infra/runner/install-runner-lib.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +runner_die() { printf 'ERROR: %s\n' "$*" >&2; return 1; } + +runner_ensure_account() { + local user=$1 home=$2 entry uid passwd_home shell groups uid_min + if ! entry=$(getent passwd "$user"); then + [[ ! -e $home && ! -L $home ]] || { runner_die "runner home already exists without its account: $home"; return 1; } + useradd --system --create-home --home-dir "$home" --shell /usr/sbin/nologin "$user" || return 1 + return + fi + IFS=: read -r _ _ uid _ _ passwd_home shell <<<"$entry" + uid_min=$(awk '$1 == "UID_MIN" && $2 ~ /^[0-9]+$/ {print $2; exit}' /etc/login.defs) + [[ $uid =~ ^[0-9]+$ && $uid -gt 0 && $uid_min =~ ^[0-9]+$ && $uid -lt $uid_min && $passwd_home == "$home" && $shell == /usr/sbin/nologin ]] || { runner_die "existing $user account has an unexpected UID, home, or shell"; return 1; } + [[ $(id -gn "$user") == "$user" ]] || { runner_die "existing $user account has an unexpected primary group"; return 1; } + groups=$(id -nG "$user") + [[ $groups == "$user" ]] || { runner_die "existing $user account has unexpected supplementary groups: $groups"; return 1; } + [[ -d $home && ! -L $home && $(stat -c %U "$home") == "$user" ]] || { runner_die "existing $user home is missing or has unexpected ownership"; return 1; } +} + +runner_ensure_image() { + local image=$1 size=$2 bytes=$3 actual filesystem + if [[ -e $image || -L $image ]]; then + [[ -f $image && ! -L $image ]] || { runner_die "existing runner image is not a regular file: $image"; return 1; } + actual=$(stat -c %s "$image") || return + [[ $actual == "$bytes" ]] || { runner_die "existing runner image has size $actual bytes; expected $bytes; refusing to resize or format"; return 1; } + filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) + [[ $filesystem == ext4 ]] || { runner_die "existing runner image does not contain ext4; refusing to format"; return 1; } + return + fi + fallocate -l "$size" "$image" || return 1 + mkfs.ext4 -F -q "$image" || return 1 + actual=$(stat -c %s "$image") || return + filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) + [[ $actual == "$bytes" && $filesystem == ext4 ]] || { runner_die 'new runner image failed size or ext4 verification'; return 1; } +} + +runner_install_file() { + local source=$1 destination=$2 owner=$3 group=$4 mode=${5#0} + if [[ -e $destination || -L $destination ]]; then + [[ -f $destination && ! -L $destination ]] && cmp -s "$source" "$destination" || { runner_die "existing runner file differs from reviewed configuration: $destination"; return 1; } + [[ $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { runner_die "existing runner file has unexpected ownership or permissions: $destination"; return 1; } + return + fi + install -o "$owner" -g "$group" -m "0$mode" "$source" "$destination" +} + +runner_install_content() { + local destination=$1 owner=$2 group=$3 mode=$4 temporary + temporary=$(mktemp) + cat >"$temporary" + if ! runner_install_file "$temporary" "$destination" "$owner" "$group" "$mode"; then + rm -f "$temporary" + return 1 + fi + rm -f "$temporary" +} + +runner_validate_mount() { + local image=$1 store=$2 target filesystem source backing mount_info + mount_info=$(findmnt --noheadings --output TARGET,FSTYPE,SOURCE --mountpoint "$store") || { runner_die 'runner store is not mounted'; return 1; } + read -r target filesystem source <<<"$mount_info" || { runner_die 'runner store mount information is empty'; return 1; } + [[ $target == "$store" && $filesystem == ext4 && $source == /dev/loop* ]] || { runner_die 'runner store is mounted from an unexpected target, filesystem, or device'; return 1; } + backing=$(losetup --noheadings --output BACK-FILE "$source") || { runner_die 'cannot identify the runner loop backing file'; return 1; } + [[ $(realpath -e "$backing") == "$(realpath -e "$image")" ]] || { runner_die 'runner store is backed by a different image'; return 1; } +} + +runner_ensure_mount() { + local image=$1 store=$2 unit_name=$3 unit_file=$4 unit_tmp mounted=0 + [[ -d $store && ! -L $store ]] || { runner_die "runner mount point is missing or is not a directory: $store"; return 1; } + if mountpoint -q "$store"; then + runner_validate_mount "$image" "$store" || return 1 + mounted=1 + fi + unit_tmp=$(mktemp) + if ! cat >"$unit_tmp" </dev/null; then + runner_ensure_account "$runner_user" "$runner_home" || die 'runner account validation failed' +else + [[ ! -e $runner_home && ! -L $runner_home ]] || die 'runner home exists without its account; inspect it before continuing' +fi +if [[ -e $runner_image || -L $runner_image ]]; then + runner_ensure_image "$runner_image" 15G "$runner_image_bytes" || die 'runner image validation failed' +else + [[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' +fi -for path in "$runner_home" "$runner_image" "$runner_store" /etc/akyldash-runner; do - [[ ! -e $path ]] || die "$path already exists; inspect it and follow the documented recovery procedure" +for file in /etc/subuid /etc/subgid; do + awk -F: '!/^[[:space:]]*#/ && !/^[[:space:]]*$/ && (NF != 3 || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9]+$/ || $3 < 1) { bad=1 } END { exit bad }' "$file" || die "$file contains an invalid subordinate ID entry" +done +subuid_pair=$(awk -F: -v user="$runner_user" '$1 == user { count++; pair=$2 ":" $3 } END { if (count > 1) exit 2; if (count == 1) print pair }' /etc/subuid) || die 'runner has multiple subuid allocations; inspect them before continuing' +subgid_pair=$(awk -F: -v user="$runner_user" '$1 == user { count++; pair=$2 ":" $3 } END { if (count > 1) exit 2; if (count == 1) print pair }' /etc/subgid) || die 'runner has multiple subgid allocations; inspect them before continuing' +for pair in "$subuid_pair" "$subgid_pair"; do + [[ -z $pair || $pair =~ ^[0-9]+:65536$ ]] || die 'existing runner subordinate ID allocation is not a single 65536-ID range' +done +if [[ -n $subuid_pair && -n $subgid_pair && $subuid_pair != "$subgid_pair" ]]; then + die 'runner subuid and subgid allocations differ; inspect them before continuing' +fi +existing_pair=${subuid_pair:-$subgid_pair} +if [[ -n $existing_pair ]]; then + subid_start=${existing_pair%%:*} + [[ $subid_start =~ ^[1-9][0-9]*$ && $subid_start -ge 100000 && $subid_start -le 4294901760 ]] || die 'existing runner subordinate ID range is outside the supported range' + subid_end=$((subid_start + 65535)) + subid_range="$subid_start-$subid_end" +else + subid_range=$(awk -f "$(dirname "$0")/find-free-subid-range.awk" /etc/subuid /etc/subgid) || die 'could not find a free 65536-ID range shared by /etc/subuid and /etc/subgid' + subid_start=${subid_range%-*} + subid_end=${subid_range#*-} +fi +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 -! id "$runner_user" >/dev/null 2>&1 || die "user $runner_user already exists; inspect it before continuing" -[[ ! -e /usr/local/bin/act_runner ]] || die '/usr/local/bin/act_runner already exists' -[[ ! -e /etc/systemd/system/akyldash-act-runner.service ]] || die 'runner service already exists' -[[ ! -e /etc/systemd/system/akyldash-runner-healthcheck.service ]] || die 'runner health monitor already exists' -[[ ! -e /etc/logrotate.d/akyldash-runner ]] || die 'runner logrotate policy already exists' -[[ ! -e $runner_mount_unit ]] || die 'runner data mount unit already exists' -[[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' - apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap dbus-user-session curl logrotate docker-ce-rootless-extras -useradd --system --create-home --home-dir "$runner_home" --shell /usr/sbin/nologin "$runner_user" -usermod --add-subuids "$subid_range" --add-subgids "$subid_range" "$runner_user" +runner_ensure_account "$runner_user" "$runner_home" || die 'runner account creation failed' +subid_args=() +[[ -n $subuid_pair ]] || subid_args+=(--add-subuids "$subid_range") +[[ -n $subgid_pair ]] || subid_args+=(--add-subgids "$subid_range") +if ((${#subid_args[@]})); then + usermod "${subid_args[@]}" "$runner_user" +fi runner_uid=$(id -u "$runner_user") -install -d -o "$runner_user" -g "$runner_user" -m 0700 "$runner_home" "$runner_store" "$runner_home/.config/docker" "$runner_home/.config/systemd/user/docker.service.d" +install -d -o "$runner_user" -g "$runner_user" -m 0700 "$runner_home" "$runner_home/.config/docker" "$runner_home/.config/systemd/user/docker.service.d" +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 -fallocate -l 15G "$runner_image" -mkfs.ext4 -F -q "$runner_image" -cat >"$runner_mount_unit" <"$runner_home/.config/docker/daemon.json" <"$runner_home/.config/systemd/user/docker.service.d/limits.conf" <<'EOF' +runner_install_content "$runner_home/.config/systemd/user/docker.service.d/limits.conf" "$runner_user" "$runner_user" 0644 <<'EOF' [Service] Delegate=yes CPUQuota=200% @@ -100,9 +124,7 @@ TasksMax=1024 Restart=always RestartSec=5 EOF -chown -R "$runner_user:$runner_user" "$runner_home/.config" - -install -o root -g root -m 0644 /dev/stdin /etc/systemd/system/akyldash-act-runner.service <"/etc/systemd/system/user-$runner_uid.slice.d/limits.conf" </etc/logrotate.d/akyldash-runner <<'EOF' +runner_install_content /etc/logrotate.d/akyldash-runner root root 0644 <<'EOF' /var/log/akyldash-runner/act_runner.log { daily rotate 7 @@ -158,14 +180,20 @@ cat >/etc/logrotate.d/akyldash-runner <<'EOF' copytruncate } EOF -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-healthcheck.service" /etc/systemd/system/akyldash-runner-healthcheck.service -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-healthcheck.timer" /etc/systemd/system/akyldash-runner-healthcheck.timer -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-restart.service" /etc/systemd/system/akyldash-runner-restart.service +runner_install_file "$(dirname "$0")/akyldash-runner-healthcheck.service" /etc/systemd/system/akyldash-runner-healthcheck.service root root 0644 +runner_install_file "$(dirname "$0")/akyldash-runner-healthcheck.timer" /etc/systemd/system/akyldash-runner-healthcheck.timer root root 0644 +runner_install_file "$(dirname "$0")/akyldash-runner-restart.service" /etc/systemd/system/akyldash-runner-restart.service root root 0644 loginctl enable-linger "$runner_user" systemctl daemon-reload systemctl start "user@$runner_uid.service" -runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" dockerd-rootless-setuptool.sh install --force +rootless_docker_unit="$runner_home/.config/systemd/user/docker.service" +if [[ -e $rootless_docker_unit || -L $rootless_docker_unit ]]; then + [[ -f $rootless_docker_unit && ! -L $rootless_docker_unit && $(stat -c %U "$rootless_docker_unit") == "$runner_user" ]] || die 'existing rootless Docker unit has an unexpected type or owner' + grep -Fq 'dockerd-rootless.sh' "$rootless_docker_unit" || die 'existing rootless Docker unit differs from the expected service' +else + runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" dockerd-rootless-setuptool.sh install +fi runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" systemctl --user daemon-reload runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" systemctl --user enable --now docker.service diff --git a/infra/runner/test-install-runner-idempotency.sh b/infra/runner/test-install-runner-idempotency.sh new file mode 100644 index 0000000..51700f5 --- /dev/null +++ b/infra/runner/test-install-runner-idempotency.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +runner_dir=$(cd "$(dirname "$0")" && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/store" "$tmp/partial-store" +log=$tmp/commands.log +export RUNNER_TEST_LOG=$log + +cat >"$tmp/bin/fallocate" <<'EOF' +#!/usr/bin/env bash +printf 'fallocate\n' >>"$RUNNER_TEST_LOG" +truncate -s 1048576 "$3" +EOF +cat >"$tmp/bin/mkfs.ext4" <<'EOF' +#!/usr/bin/env bash +printf 'mkfs\n' >>"$RUNNER_TEST_LOG" +printf 'ext4\n' >"$3.fs" +EOF +cat >"$tmp/bin/blkid" <<'EOF' +#!/usr/bin/env bash +cat "${@: -1}.fs" +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$RUNNER_TEST_LOG" +if [[ $1 == start ]]; then + [[ ${RUNNER_TEST_FAIL_START:-0} == 0 ]] || exit 1 + : >"$RUNNER_TEST_MOUNTED" +fi +EOF +cat >"$tmp/bin/mountpoint" <<'EOF' +#!/usr/bin/env bash +[[ -e $RUNNER_TEST_MOUNTED ]] +EOF +cat >"$tmp/bin/findmnt" <<'EOF' +#!/usr/bin/env bash +printf '%s ext4 /dev/loop0\n' "$RUNNER_TEST_STORE" +EOF +cat >"$tmp/bin/losetup" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$RUNNER_TEST_BACKING" +EOF +chmod +x "$tmp/bin/"* +PATH="$tmp/bin:$PATH" +export PATH +source "$runner_dir/install-runner-lib.sh" +getent() { + [[ $1 == passwd ]] || return 2 + if [[ -n ${RUNNER_TEST_PASSWD:-} ]]; then + printf '%s\n' "$RUNNER_TEST_PASSWD" + elif [[ -r ${RUNNER_TEST_ACCOUNT_STATE:-} ]]; then + cat "$RUNNER_TEST_ACCOUNT_STATE" + else + return 2 + fi +} +id() { + case $1 in + -gn) printf '%s\n' "${RUNNER_TEST_PRIMARY_GROUP:-akyldash-runner}" ;; + -nG) [[ ${TEST_EXTRA_GROUP:-0} == 1 ]] && printf 'akyldash-runner docker\n' || printf 'akyldash-runner\n' ;; + *) command id "$@" ;; + esac +} +stat() { + if [[ $1 == -c && $2 == %U && $3 == ${RUNNER_TEST_HOME:-} ]]; then + printf '%s\n' "${RUNNER_TEST_HOME_OWNER:-akyldash-runner}" + else + command stat "$@" + fi +} +useradd() { + printf 'useradd\n' >>"$RUNNER_TEST_LOG" + local user=${@: -1} home= + while (($#)); do + if [[ $1 == --home-dir ]]; then home=$2; shift 2; else shift; fi + done + mkdir -p "$home" + printf '%s:x:990:990::%s:/usr/sbin/nologin\n' "$user" "$home" >"$RUNNER_TEST_ACCOUNT_STATE" +} + +export RUNNER_TEST_HOME="$tmp/account-home" +export RUNNER_TEST_ACCOUNT_STATE="$tmp/account.passwd" +mkdir -p "$RUNNER_TEST_HOME" +export RUNNER_TEST_PASSWD="akyldash-runner:x:990:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +TEST_EXTRA_GROUP=1 +if runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with extra group membership was accepted' >&2 + exit 1 +fi +unset TEST_EXTRA_GROUP +if RUNNER_TEST_PASSWD="akyldash-runner:x:990:990::${tmp}/wrong-home:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with a different home was accepted' >&2 + exit 1 +fi +if RUNNER_TEST_PASSWD="akyldash-runner:x:0:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with UID 0 was accepted' >&2 + exit 1 +fi +if RUNNER_TEST_PASSWD="akyldash-runner:x:1000:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with a regular-user UID was accepted' >&2 + exit 1 +fi +RUNNER_TEST_PRIMARY_GROUP=unexpected +if runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with an unexpected primary group was accepted' >&2 + exit 1 +fi +unset RUNNER_TEST_PRIMARY_GROUP +unset RUNNER_TEST_PASSWD +export RUNNER_TEST_HOME="$tmp/new-account-home" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +[[ $(grep -c '^useradd$' "$log") == 1 ]] + +image=$tmp/docker-store.img +runner_ensure_image "$image" 1M 1048576 +runner_ensure_image "$image" 1M 1048576 +[[ $(grep -c '^fallocate$' "$log") == 1 ]] +[[ $(grep -c '^mkfs$' "$log") == 1 ]] + +printf 'ext4\n' >"$image.fs" +truncate -s 1048575 "$image" +if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then + echo 'Mismatched image size was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +truncate -s 1048576 "$image" +printf 'ext3\n' >"$image.fs" +if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then + echo 'Existing non-ext4 image was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +printf 'ext4\n' >"$image.fs" + +unit_name='var-lib-akyldash\x2drunner-docker\x2ddata.mount' +unit_file=$tmp/systemd/$unit_name +mkdir -p "$(dirname "$unit_file")" +export RUNNER_TEST_STORE="$tmp/store" RUNNER_TEST_BACKING="$image" +export RUNNER_TEST_MOUNTED="$tmp/mounted" +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" +[[ $(grep -c '^start ' "$log") == 1 ]] +grep -Fq "enable $unit_name" "$log" +grep -Fq "start $unit_name" "$log" + +# A second run with the image mounted must reuse it and avoid another start. +runner_ensure_image "$image" 1M 1048576 +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^start ' "$log") == 1 ]] +chmod 0664 "$unit_file" +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" 2>/dev/null; then + echo 'Writable systemd mount unit was accepted' >&2 + exit 1 +fi +chmod 0644 "$unit_file" +export RUNNER_TEST_BACKING="$tmp/unrelated.img" +touch "$RUNNER_TEST_BACKING" +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" 2>/dev/null; then + echo 'Active mount backed by a different image was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^start ' "$log") == 1 ]] +export RUNNER_TEST_BACKING="$image" + +# Simulate interruption after unit installation/enable, before mount activation. +export RUNNER_TEST_STORE="$tmp/partial-store" RUNNER_TEST_MOUNTED="$tmp/partial-mounted" +export RUNNER_TEST_FAIL_START=1 +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" 2>/dev/null; then + echo 'Injected mount interruption was not observed' >&2 + exit 1 +fi +export RUNNER_TEST_FAIL_START=0 +runner_ensure_image "$image" 1M 1048576 +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^start ' "$log") == 3 ]] +echo 'runner installer idempotency checks passed' diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index 24f2cb8..f7d9f51 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -17,10 +17,19 @@ grep -Eq '^ valid_volumes: \[\]$' "$config" grep -Fq -- '--cpus=2 --memory=3g --pids-limit=512' "$config" assert_absent 'var/run/docker.sock|metrics:|health_check:|production.env|review_secret' "$config" grep -Fq 'docker-ce-rootless-extras' "$installer" -grep -Fq 'fallocate -l 15G' "$installer" +grep -Fq 'runner_ensure_image "$runner_image" 15G "$runner_image_bytes"' "$installer" +grep -Fq 'fallocate -l "$size" "$image"' infra/runner/install-runner-lib.sh +grep -Fq 'mkfs.ext4 -F -q "$image"' infra/runner/install-runner-lib.sh +grep -Fq 'systemctl enable "$unit_name"' infra/runner/install-runner-lib.sh +grep -Fq 'systemctl start "$unit_name"' infra/runner/install-runner-lib.sh +assert_absent 'basename.*runner_mount_unit|enable --now.*runner_mount_unit' "$installer" +grep -Fq 'groups=$(id -nG "$user")' infra/runner/install-runner-lib.sh +grep -Fq 'id -gn "$user"' infra/runner/install-runner-lib.sh +assert_absent 'dockerd-rootless-setuptool.sh install --force' "$installer" grep -Fq 'runner_sha256=3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2' "$installer" grep -Fq 'find-free-subid-range.awk' "$installer" -grep -Fq 'usermod --add-subuids "$subid_range" --add-subgids "$subid_range"' "$installer" +grep -Fq 'usermod "${subid_args[@]}" "$runner_user"' "$installer" +grep -Fq 'subid_args+=(--add-subuids "$subid_range")' "$installer" assert_absent 'usermod.*(sudo|docker)|/var/run/docker.sock|/etc/sudoers' "$installer" grep -Fq 'runs-on: akyldash-ci' .gitea/workflows/runner-smoke.yaml grep -Fq 'test ! -S /var/run/docker.sock' .gitea/workflows/runner-smoke.yaml -- 2.54.0 From db6ded4075b657d57d8bff7806ebd087b1fd6bc6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Oct 2026 01:23:03 +0300 Subject: [PATCH 2/5] Harden runner installer path and mount checks --- CHANGELOG.md | 1 + docs/operations/CI_RUNNER.md | 8 +++-- docs/operations/CURRENT_STATE.md | 3 +- infra/runner/install-runner-lib.sh | 34 +++++++++++++++++-- infra/runner/install-runner.sh | 7 +++- .../runner/test-install-runner-idempotency.sh | 22 +++++++++++- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d40a38c..68dd73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Не выпущено +- Runner installer отклоняет symlink-компоненты в путях конфигурации и повторно использует хранилище только при активных mount-опциях `nosuid,nodev`. - Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper. - Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно. - Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе. diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 89242a0..f178a1d 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -56,9 +56,11 @@ installer without resizing or formatting it. The runner account must retain a non-root system UID below `UID_MIN`, its expected home, primary group, and sole supplementary group. The mount is started only when it is not already active, and an active mount must be the -expected ext4 loop device backed by the runner image. Conflicting files or -state stop the installer for manual inspection. A new image requires at least -25 GiB free on `/`. +expected ext4 loop device backed by the runner image with both `nosuid` and +`nodev` active. Before root creates runner configuration directories, it checks +every path component for symlinks and validates the existing runner-owned +directories' ownership and mode. Conflicting files or state stop the installer +for manual inspection. A new image requires at least 25 GiB free on `/`. If the reviewed checkout is not present at the inventoried path, first verify that the target is absent and clone the reviewed `main` there. Do not overwrite diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 0516dad..e13dcf1 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -90,7 +90,8 @@ UFW rule details, and protected file metadata remain unverified. 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. + VPS changes. The reviewed installer now rejects symlinks in runner config + directory paths and requires the active store mount to include `nosuid,nodev`. - 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 diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh index 42b058f..ad6ef9b 100644 --- a/infra/runner/install-runner-lib.sh +++ b/infra/runner/install-runner-lib.sh @@ -56,11 +56,39 @@ runner_install_content() { rm -f "$temporary" } +runner_ensure_private_directory() { + local directory=$1 owner=$2 group=$3 current= component index + local -a components + [[ $directory == /* ]] || { runner_die "runner directory path is not absolute: $directory"; return 1; } + IFS=/ read -r -a components <<<"${directory#/}" + for index in "${!components[@]}"; do + component=${components[index]} + [[ -n $component && $component != . && $component != .. ]] || { runner_die "runner directory path has an unsafe component: $directory"; return 1; } + current+="/$component" + [[ ! -L $current ]] || { runner_die "runner directory path contains a symbolic link: $current"; return 1; } + if [[ ! -e $current ]]; then + ((index == ${#components[@]} - 1)) || { runner_die "runner directory parent is missing: $current"; return 1; } + break + fi + [[ -d $current ]] || { runner_die "runner directory path component is not a directory: $current"; return 1; } + if [[ $current == /var/lib/akyldash-runner || $current == /var/lib/akyldash-runner/* ]]; then + [[ $(stat -c '%U:%G:%a' "$current") == "$owner:$group:700" ]] || { runner_die "runner directory ancestor has unexpected ownership or permissions: $current"; return 1; } + fi + done + if [[ -e $directory ]]; then + [[ $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory has unexpected ownership or permissions: $directory"; return 1; } + return + fi + install -d -o "$owner" -g "$group" -m 0700 "$directory" || return 1 + [[ -d $directory && ! -L $directory && $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory failed validation: $directory"; return 1; } +} + runner_validate_mount() { - local image=$1 store=$2 target filesystem source backing mount_info - mount_info=$(findmnt --noheadings --output TARGET,FSTYPE,SOURCE --mountpoint "$store") || { runner_die 'runner store is not mounted'; return 1; } - read -r target filesystem source <<<"$mount_info" || { runner_die 'runner store mount information is empty'; return 1; } + local image=$1 store=$2 target filesystem source options backing mount_info + mount_info=$(findmnt --noheadings --output TARGET,FSTYPE,SOURCE,OPTIONS --mountpoint "$store") || { runner_die 'runner store is not mounted'; return 1; } + read -r target filesystem source options <<<"$mount_info" || { runner_die 'runner store mount information is empty'; return 1; } [[ $target == "$store" && $filesystem == ext4 && $source == /dev/loop* ]] || { runner_die 'runner store is mounted from an unexpected target, filesystem, or device'; return 1; } + case ",$options," in *,nosuid,*nodev,*|*,nodev,*nosuid,*) ;; *) runner_die 'runner store mount is missing nosuid or nodev'; return 1 ;; esac backing=$(losetup --noheadings --output BACK-FILE "$source") || { runner_die 'cannot identify the runner loop backing file'; return 1; } [[ $(realpath -e "$backing") == "$(realpath -e "$image")" ]] || { runner_die 'runner store is backed by a different image'; return 1; } } diff --git a/infra/runner/install-runner.sh b/infra/runner/install-runner.sh index 3697640..3c0282e 100644 --- a/infra/runner/install-runner.sh +++ b/infra/runner/install-runner.sh @@ -80,7 +80,12 @@ if ((${#subid_args[@]})); then fi runner_uid=$(id -u "$runner_user") -install -d -o "$runner_user" -g "$runner_user" -m 0700 "$runner_home" "$runner_home/.config/docker" "$runner_home/.config/systemd/user/docker.service.d" +runner_ensure_private_directory "$runner_home" "$runner_user" "$runner_user" || die 'runner home failed directory validation' +runner_ensure_private_directory "$runner_home/.config" "$runner_user" "$runner_user" || die 'runner config directory failed validation' +runner_ensure_private_directory "$runner_home/.config/docker" "$runner_user" "$runner_user" || die 'runner Docker config directory failed validation' +runner_ensure_private_directory "$runner_home/.config/systemd" "$runner_user" "$runner_user" || die 'runner systemd config directory failed validation' +runner_ensure_private_directory "$runner_home/.config/systemd/user" "$runner_user" "$runner_user" || die 'runner user systemd directory failed validation' +runner_ensure_private_directory "$runner_home/.config/systemd/user/docker.service.d" "$runner_user" "$runner_user" || die 'runner Docker override directory failed validation' if [[ ! -e $runner_store && ! -L $runner_store ]]; then install -d -o root -g root -m 0755 "$runner_store" fi diff --git a/infra/runner/test-install-runner-idempotency.sh b/infra/runner/test-install-runner-idempotency.sh index 51700f5..2059cf2 100644 --- a/infra/runner/test-install-runner-idempotency.sh +++ b/infra/runner/test-install-runner-idempotency.sh @@ -36,7 +36,7 @@ cat >"$tmp/bin/mountpoint" <<'EOF' EOF cat >"$tmp/bin/findmnt" <<'EOF' #!/usr/bin/env bash -printf '%s ext4 /dev/loop0\n' "$RUNNER_TEST_STORE" +printf '%s ext4 /dev/loop0 %s\n' "$RUNNER_TEST_STORE" "${RUNNER_TEST_OPTIONS:-rw,nosuid,nodev,relatime}" EOF cat >"$tmp/bin/losetup" <<'EOF' #!/usr/bin/env bash @@ -115,6 +115,20 @@ runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" [[ $(grep -c '^useradd$' "$log") == 1 ]] +private_root=$tmp/private +mkdir -m 0700 "$private_root" +runner_owner=$(command id -un) +runner_group=$(command id -gn) +runner_ensure_private_directory "$private_root/config" "$runner_owner" "$runner_group" +runner_ensure_private_directory "$private_root/config" "$runner_owner" "$runner_group" +mkdir -m 0700 "$tmp/outside" +ln -s "$tmp/outside" "$private_root/linked" +if runner_ensure_private_directory "$private_root/linked/docker" "$runner_owner" "$runner_group" 2>/dev/null; then + echo 'Runner config symlink was accepted' >&2 + exit 1 +fi +[[ ! -e $tmp/outside/docker ]] + image=$tmp/docker-store.img runner_ensure_image "$image" 1M 1048576 runner_ensure_image "$image" 1M 1048576 @@ -179,4 +193,10 @@ runner_ensure_image "$image" 1M 1048576 runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" [[ $(grep -c '^mkfs$' "$log") == 1 ]] [[ $(grep -c '^start ' "$log") == 3 ]] +export RUNNER_TEST_STORE="$tmp/store" RUNNER_TEST_MOUNTED="$tmp/mounted" RUNNER_TEST_OPTIONS=rw,relatime +if runner_validate_mount "$image" "$RUNNER_TEST_STORE" 2>/dev/null; then + echo 'Runner mount missing nosuid/nodev was accepted' >&2 + exit 1 +fi +unset RUNNER_TEST_OPTIONS echo 'runner installer idempotency checks passed' -- 2.54.0 From 4110aa54c91a61353a5f17119dff548c3fec6ba9 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Oct 2026 01:41:20 +0300 Subject: [PATCH 3/5] Make runner setup race-safe and resumable --- CHANGELOG.md | 2 +- docs/operations/CI_RUNNER.md | 12 +++++--- docs/operations/CURRENT_STATE.md | 6 ++-- infra/runner/install-runner-lib.sh | 26 +++++++++++++---- infra/runner/install-runner.sh | 4 +-- .../runner/test-install-runner-idempotency.sh | 28 +++++++++++++++++++ infra/runner/test-runner-config.sh | 2 ++ 7 files changed, 66 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68dd73f..662f80e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Не выпущено -- Runner installer отклоняет symlink-компоненты в путях конфигурации и повторно использует хранилище только при активных mount-опциях `nosuid,nodev`. +- Runner installer безопасно повторяет настройку каталогов от имени runner, включая восстановление промежуточных каталогов `0755`, и принимает хранилище только с активными mount-опциями `nosuid,nodev`. - Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper. - Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно. - Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе. diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index f178a1d..19bd1ea 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -57,10 +57,14 @@ a non-root system UID below `UID_MIN`, its expected home, primary group, and sole supplementary group. The mount is started only when it is not already active, and an active mount must be the expected ext4 loop device backed by the runner image with both `nosuid` and -`nodev` active. Before root creates runner configuration directories, it checks -every path component for symlinks and validates the existing runner-owned -directories' ownership and mode. Conflicting files or state stop the installer -for manual inspection. A new image requires at least 25 GiB free on `/`. +`nodev` active. Runner-home directories and user configuration files are +created or normalized as `akyldash-runner`, never by a privileged file-write +operation; this avoids a symlink swap turning setup into a root write outside +the home. The installer rejects static symlink components and validates +ownership and mode after setup. This also normalizes the `0755` intermediate +directories left by the first installer version. Conflicting files or state +stop the installer for manual inspection. A new image requires at least 25 GiB +free on `/`. If the reviewed checkout is not present at the inventoried path, first verify that the target is absent and clone the reviewed `main` there. Do not overwrite diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index e13dcf1..4dfe553 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -90,8 +90,10 @@ UFW rule details, and protected file metadata remain unverified. 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 now rejects symlinks in runner config - directory paths and requires the active store mount to include `nosuid,nodev`. + VPS changes. The reviewed installer now performs runner-home directory and + config writes as `akyldash-runner` (including recovery from legacy `0755` + intermediate directories), and requires active store mounts to include + `nosuid,nodev`. - 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 diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh index ad6ef9b..8dc516e 100644 --- a/infra/runner/install-runner-lib.sh +++ b/infra/runner/install-runner-lib.sh @@ -72,15 +72,31 @@ runner_ensure_private_directory() { fi [[ -d $current ]] || { runner_die "runner directory path component is not a directory: $current"; return 1; } if [[ $current == /var/lib/akyldash-runner || $current == /var/lib/akyldash-runner/* ]]; then - [[ $(stat -c '%U:%G:%a' "$current") == "$owner:$group:700" ]] || { runner_die "runner directory ancestor has unexpected ownership or permissions: $current"; return 1; } + [[ $(stat -c '%U:%G' "$current") == "$owner:$group" ]] || { runner_die "runner directory ancestor has unexpected ownership: $current"; return 1; } fi done - if [[ -e $directory ]]; then - [[ $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory has unexpected ownership or permissions: $directory"; return 1; } + # These paths are runner-writable; make changes as that user so a path swap + # cannot turn a root operation into a write outside its home. + runuser -u "$owner" -- install -d -m 0700 "$directory" || return 1 + [[ -d $directory && ! -L $directory && $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory failed validation: $directory"; return 1; } +} + +runner_install_user_content() { + local destination=$1 owner=$2 group=$3 mode=${4#0} temporary + temporary=$(mktemp) + cat >"$temporary" + if [[ -e $destination || -L $destination ]]; then + [[ -f $destination && ! -L $destination ]] && cmp -s "$temporary" "$destination" || { rm -f "$temporary"; runner_die "existing runner file differs from reviewed configuration: $destination"; return 1; } + [[ $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { rm -f "$temporary"; runner_die "existing runner file has unexpected ownership or permissions: $destination"; return 1; } + rm -f "$temporary" return fi - install -d -o "$owner" -g "$group" -m 0700 "$directory" || return 1 - [[ -d $directory && ! -L $directory && $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory failed validation: $directory"; return 1; } + chown "$owner:$group" "$temporary" && chmod "0$mode" "$temporary" || { rm -f "$temporary"; return 1; } + local result=0 + runuser -u "$owner" -- install -m "0$mode" "$temporary" "$destination" || result=$? + rm -f "$temporary" + ((result == 0)) || return 1 + [[ -f $destination && ! -L $destination && $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { runner_die "runner file failed validation: $destination"; return 1; } } runner_validate_mount() { diff --git a/infra/runner/install-runner.sh b/infra/runner/install-runner.sh index 3c0282e..488d56d 100644 --- a/infra/runner/install-runner.sh +++ b/infra/runner/install-runner.sh @@ -113,14 +113,14 @@ else trap - EXIT fi -runner_install_content "$runner_home/.config/docker/daemon.json" "$runner_user" "$runner_user" 0600 </dev/null +{"data-root":"/unexpected"} +EOF +then + echo 'Conflicting runner user config was accepted' >&2 + exit 1 +fi + image=$tmp/docker-store.img runner_ensure_image "$image" 1M 1048576 runner_ensure_image "$image" 1M 1048576 diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index f7d9f51..b18a339 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -22,6 +22,8 @@ grep -Fq 'fallocate -l "$size" "$image"' infra/runner/install-runner-lib.sh grep -Fq 'mkfs.ext4 -F -q "$image"' infra/runner/install-runner-lib.sh grep -Fq 'systemctl enable "$unit_name"' infra/runner/install-runner-lib.sh grep -Fq 'systemctl start "$unit_name"' infra/runner/install-runner-lib.sh +grep -Fq 'runuser -u "$owner" -- install -d -m 0700 "$directory"' infra/runner/install-runner-lib.sh +grep -Fq 'runner_install_user_content "$runner_home/.config/docker/daemon.json"' "$installer" assert_absent 'basename.*runner_mount_unit|enable --now.*runner_mount_unit' "$installer" grep -Fq 'groups=$(id -nG "$user")' infra/runner/install-runner-lib.sh grep -Fq 'id -gn "$user"' infra/runner/install-runner-lib.sh -- 2.54.0 From 433c3059b940d7f0847a33dc73ddf9e01bd72a12 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Oct 2026 02:01:19 +0300 Subject: [PATCH 4/5] Create runner image through a pinned file descriptor --- .gitea/workflows/ci.yaml | 1 + CHANGELOG.md | 2 +- docs/operations/CI_RUNNER.md | 5 +- docs/operations/CURRENT_STATE.md | 3 +- infra/runner/install-runner-lib.sh | 16 +-- infra/runner/install-runner.sh | 4 +- infra/runner/secure-runner-image.py | 104 ++++++++++++++++++ .../runner/test-install-runner-idempotency.sh | 52 +++++---- infra/runner/test-runner-config.sh | 7 +- 9 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 infra/runner/secure-runner-image.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9f85dd4..c188117 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -44,6 +44,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-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 run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v - name: Validate production Compose and Caddyfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 662f80e..dd5d014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Не выпущено -- Runner installer безопасно повторяет настройку каталогов от имени runner, включая восстановление промежуточных каталогов `0755`, и принимает хранилище только с активными mount-опциями `nosuid,nodev`. +- Runner installer безопасно создаёт образ хранилища по открытому файловому дескриптору, повторяет настройку каталогов от имени runner, восстанавливает промежуточные каталоги `0755` и проверяет mount-опции `nosuid,nodev`. - Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper. - Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно. - Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе. diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 19bd1ea..1611670 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -64,7 +64,10 @@ the home. The installer rejects static symlink components and validates ownership and mode after setup. This also normalizes the `0755` intermediate directories left by the first installer version. Conflicting files or state stop the installer for manual inspection. A new image requires at least 25 GiB -free on `/`. +free on `/`. Image creation opens each parent component without following +symlinks, creates the image exclusively relative to the opened directory, and +passes the open file descriptor to `mkfs.ext4`; validation checks the same inode +and rejects path replacement. If the reviewed checkout is not present at the inventoried path, first verify that the target is absent and clone the reviewed `main` there. Do not overwrite diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 4dfe553..0a2a80f 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -92,7 +92,8 @@ UFW rule details, and protected file metadata remain unverified. repository registration, and Online state are unverified; this PR makes no VPS changes. The reviewed installer now performs runner-home directory and config writes as `akyldash-runner` (including recovery from legacy `0755` - intermediate directories), and requires active store mounts to include + intermediate directories). Image initialization uses a safely opened file + descriptor and rejects path replacement; active store mounts must include `nosuid,nodev`. - Gitea repository runner inventory could not be read by the current API principal (403). The owner has not supplied runner state, and no runner diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh index 8dc516e..0fdd559 100644 --- a/infra/runner/install-runner-lib.sh +++ b/infra/runner/install-runner-lib.sh @@ -19,20 +19,8 @@ runner_ensure_account() { } runner_ensure_image() { - local image=$1 size=$2 bytes=$3 actual filesystem - if [[ -e $image || -L $image ]]; then - [[ -f $image && ! -L $image ]] || { runner_die "existing runner image is not a regular file: $image"; return 1; } - actual=$(stat -c %s "$image") || return - [[ $actual == "$bytes" ]] || { runner_die "existing runner image has size $actual bytes; expected $bytes; refusing to resize or format"; return 1; } - filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) - [[ $filesystem == ext4 ]] || { runner_die "existing runner image does not contain ext4; refusing to format"; return 1; } - return - fi - fallocate -l "$size" "$image" || return 1 - mkfs.ext4 -F -q "$image" || return 1 - actual=$(stat -c %s "$image") || return - filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) - [[ $actual == "$bytes" && $filesystem == ext4 ]] || { runner_die 'new runner image failed size or ext4 verification'; return 1; } + local image=$1 bytes=$2 parent_uid=$3 + python3 "$(dirname "${BASH_SOURCE[0]}")/secure-runner-image.py" "$image" "$bytes" "$parent_uid" || { runner_die "runner image validation or initialization failed: $image"; return 1; } } runner_install_file() { diff --git a/infra/runner/install-runner.sh b/infra/runner/install-runner.sh index 488d56d..d09a744 100644 --- a/infra/runner/install-runner.sh +++ b/infra/runner/install-runner.sh @@ -38,7 +38,7 @@ else [[ ! -e $runner_home && ! -L $runner_home ]] || die 'runner home exists without its account; inspect it before continuing' fi if [[ -e $runner_image || -L $runner_image ]]; then - runner_ensure_image "$runner_image" 15G "$runner_image_bytes" || die 'runner image validation failed' + runner_ensure_image "$runner_image" "$runner_image_bytes" "$(id -u "$runner_user")" || die 'runner image validation failed' else [[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' fi @@ -93,7 +93,7 @@ fi 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 -runner_ensure_image "$runner_image" 15G "$runner_image_bytes" || die 'runner image initialization failed' +runner_ensure_image "$runner_image" "$runner_image_bytes" "$runner_uid" || die 'runner image initialization failed' runner_ensure_mount "$runner_image" "$runner_store" "$runner_mount_unit_name" "$runner_mount_unit" || die 'runner mount activation failed' chown "$runner_user:$runner_user" "$runner_store" chmod 0700 "$runner_store" diff --git a/infra/runner/secure-runner-image.py b/infra/runner/secure-runner-image.py new file mode 100644 index 0000000..7c6e72d --- /dev/null +++ b/infra/runner/secure-runner-image.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Create or validate the runner image without following replaceable paths.""" + +import os +import stat +import subprocess +import sys + + +def open_directory(path: str) -> int: + current = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + for component in path.strip("/").split("/"): + if not component: + continue + next_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=current, + ) + os.close(current) + current = next_fd + return current + except BaseException: + os.close(current) + raise + + +def filesystem_type(fd: int) -> str: + result = subprocess.run( + ["blkid", "-p", "-s", "TYPE", "-o", "value", f"/proc/self/fd/{fd}"], + check=False, + capture_output=True, + text=True, + pass_fds=(fd,), + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def main() -> int: + image, bytes_text, parent_uid_text = sys.argv[1:] + image = os.path.abspath(image) + parent_path, name = os.path.split(image) + if not name or name in {".", ".."}: + raise ValueError("invalid image filename") + + parent_fd = open_directory(parent_path) + image_fd = -1 + try: + parent_stat = os.fstat(parent_fd) + if parent_stat.st_uid != int(parent_uid_text) or stat.S_IMODE(parent_stat.st_mode) != 0o700: + raise RuntimeError("image parent must be the runner-owned mode 0700 directory") + + created = False + try: + image_fd = os.open( + name, + os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=parent_fd, + ) + except FileNotFoundError: + image_fd = os.open( + name, + os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + dir_fd=parent_fd, + ) + created = True + + image_stat = os.fstat(image_fd) + if not stat.S_ISREG(image_stat.st_mode) or image_stat.st_uid != os.geteuid() or image_stat.st_nlink != 1: + raise RuntimeError("image must be a singly-linked regular file owned by the installer") + if not created and image_stat.st_mode & 0o022: + raise RuntimeError("existing image is writable by group or other users") + + if created: + os.posix_fallocate(image_fd, 0, int(bytes_text)) + subprocess.run( + ["mkfs.ext4", "-F", "-q", f"/proc/self/fd/{image_fd}"], + check=True, + pass_fds=(image_fd,), + ) + image_stat = os.fstat(image_fd) + + if image_stat.st_size != int(bytes_text): + raise RuntimeError(f"image size is {image_stat.st_size} bytes, expected {bytes_text}; refusing to resize or format") + if filesystem_type(image_fd) != "ext4": + raise RuntimeError("image does not contain ext4; refusing to format") + path_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if not stat.S_ISREG(path_stat.st_mode) or (path_stat.st_dev, path_stat.st_ino) != (image_stat.st_dev, image_stat.st_ino): + raise RuntimeError("image path changed during validation") + return 0 + finally: + if image_fd >= 0: + os.close(image_fd) + os.close(parent_fd) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/infra/runner/test-install-runner-idempotency.sh b/infra/runner/test-install-runner-idempotency.sh index 362897c..9498973 100644 --- a/infra/runner/test-install-runner-idempotency.sh +++ b/infra/runner/test-install-runner-idempotency.sh @@ -8,19 +8,17 @@ mkdir -p "$tmp/bin" "$tmp/store" "$tmp/partial-store" log=$tmp/commands.log export RUNNER_TEST_LOG=$log -cat >"$tmp/bin/fallocate" <<'EOF' -#!/usr/bin/env bash -printf 'fallocate\n' >>"$RUNNER_TEST_LOG" -truncate -s 1048576 "$3" -EOF cat >"$tmp/bin/mkfs.ext4" <<'EOF' #!/usr/bin/env bash printf 'mkfs\n' >>"$RUNNER_TEST_LOG" -printf 'ext4\n' >"$3.fs" +if [[ -n ${RUNNER_TEST_REPLACE_IMAGE:-} ]]; then + rm -f "$RUNNER_TEST_IMAGE" + ln -s "$RUNNER_TEST_REPLACE_IMAGE" "$RUNNER_TEST_IMAGE" +fi EOF cat >"$tmp/bin/blkid" <<'EOF' #!/usr/bin/env bash -cat "${@: -1}.fs" +printf '%s\n' "${RUNNER_TEST_FS:-ext4}" EOF cat >"$tmp/bin/systemctl" <<'EOF' #!/usr/bin/env bash @@ -158,26 +156,42 @@ then fi image=$tmp/docker-store.img -runner_ensure_image "$image" 1M 1048576 -runner_ensure_image "$image" 1M 1048576 -[[ $(grep -c '^fallocate$' "$log") == 1 ]] +runner_ensure_image "$image" 1048576 "$(command id -u)" +runner_ensure_image "$image" 1048576 "$(command id -u)" [[ $(grep -c '^mkfs$' "$log") == 1 ]] -printf 'ext4\n' >"$image.fs" truncate -s 1048575 "$image" -if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then +if runner_ensure_image "$image" 1048576 "$(command id -u)" 2>/dev/null; then echo 'Mismatched image size was accepted' >&2 exit 1 fi [[ $(grep -c '^mkfs$' "$log") == 1 ]] truncate -s 1048576 "$image" -printf 'ext3\n' >"$image.fs" -if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then +if RUNNER_TEST_FS=ext3 runner_ensure_image "$image" 1048576 "$(command id -u)" 2>/dev/null; then echo 'Existing non-ext4 image was accepted' >&2 exit 1 fi [[ $(grep -c '^mkfs$' "$log") == 1 ]] -printf 'ext4\n' >"$image.fs" +external=$tmp/external-file +printf 'preserve this file\n' >"$external" +race_image=$tmp/race.img +ln -s "$external" "$race_image" +if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 2>/dev/null; then + echo 'Symlink runner image was accepted' >&2 + exit 1 +fi +[[ $(cat "$external") == 'preserve this file' ]] +rm -f "$race_image" +race_image=$tmp/race-during-format.img +export RUNNER_TEST_IMAGE="$race_image" RUNNER_TEST_REPLACE_IMAGE="$external" +if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 2>/dev/null; then + echo 'Image path replacement during formatting was accepted' >&2 + exit 1 +fi +[[ $(cat "$external") == 'preserve this file' ]] +rm -f "$race_image" +unset RUNNER_TEST_IMAGE RUNNER_TEST_REPLACE_IMAGE +[[ $(grep -c '^mkfs$' "$log") == 2 ]] unit_name='var-lib-akyldash\x2drunner-docker\x2ddata.mount' unit_file=$tmp/systemd/$unit_name @@ -190,9 +204,9 @@ grep -Fq "enable $unit_name" "$log" grep -Fq "start $unit_name" "$log" # A second run with the image mounted must reuse it and avoid another start. -runner_ensure_image "$image" 1M 1048576 +runner_ensure_image "$image" 1048576 "$(command id -u)" runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" -[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^mkfs$' "$log") == 2 ]] [[ $(grep -c '^start ' "$log") == 1 ]] chmod 0664 "$unit_file" if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" 2>/dev/null; then @@ -217,9 +231,9 @@ if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/ exit 1 fi export RUNNER_TEST_FAIL_START=0 -runner_ensure_image "$image" 1M 1048576 +runner_ensure_image "$image" 1048576 "$(command id -u)" runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" -[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^mkfs$' "$log") == 2 ]] [[ $(grep -c '^start ' "$log") == 3 ]] export RUNNER_TEST_STORE="$tmp/store" RUNNER_TEST_MOUNTED="$tmp/mounted" RUNNER_TEST_OPTIONS=rw,relatime if runner_validate_mount "$image" "$RUNNER_TEST_STORE" 2>/dev/null; then diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index b18a339..5c61899 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -17,9 +17,10 @@ grep -Eq '^ valid_volumes: \[\]$' "$config" grep -Fq -- '--cpus=2 --memory=3g --pids-limit=512' "$config" assert_absent 'var/run/docker.sock|metrics:|health_check:|production.env|review_secret' "$config" grep -Fq 'docker-ce-rootless-extras' "$installer" -grep -Fq 'runner_ensure_image "$runner_image" 15G "$runner_image_bytes"' "$installer" -grep -Fq 'fallocate -l "$size" "$image"' infra/runner/install-runner-lib.sh -grep -Fq 'mkfs.ext4 -F -q "$image"' infra/runner/install-runner-lib.sh +grep -Fq 'runner_ensure_image "$runner_image" "$runner_image_bytes" "$runner_uid"' "$installer" +grep -Fq 'os.O_NOFOLLOW | os.O_CLOEXEC' infra/runner/secure-runner-image.py +grep -Fq 'os.posix_fallocate(image_fd, 0, int(bytes_text))' infra/runner/secure-runner-image.py +grep -Fq 'pass_fds=(image_fd,)' infra/runner/secure-runner-image.py grep -Fq 'systemctl enable "$unit_name"' infra/runner/install-runner-lib.sh grep -Fq 'systemctl start "$unit_name"' infra/runner/install-runner-lib.sh grep -Fq 'runuser -u "$owner" -- install -d -m 0700 "$directory"' infra/runner/install-runner-lib.sh -- 2.54.0 From d8a0646829a8d1f6d2c1b4e86eaa9d2151a05ebc Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Oct 2026 02:16:13 +0300 Subject: [PATCH 5/5] Protect runner mount image from CI path replacement --- CHANGELOG.md | 2 +- docs/operations/CI_RUNNER.md | 27 +++++++++++------ docs/operations/CURRENT_STATE.md | 10 +++---- infra/runner/config.yaml | 2 +- infra/runner/install-runner-lib.sh | 30 +++++++++++++++---- infra/runner/install-runner.sh | 23 ++++++++++---- infra/runner/secure-runner-image.py | 6 ++-- .../runner/test-install-runner-idempotency.sh | 23 +++++++++----- infra/runner/test-runner-config.sh | 5 +++- 9 files changed, 90 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5d014..2dd63b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Не выпущено -- Runner installer безопасно создаёт образ хранилища по открытому файловому дескриптору, повторяет настройку каталогов от имени runner, восстанавливает промежуточные каталоги `0755` и проверяет mount-опции `nosuid,nodev`. +- 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. - Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно. - Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе. diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 1611670..7f0e311 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -16,7 +16,11 @@ before placing it on the host. CI jobs run in `docker.gitea.com/runner-images:ubuntu-24.04` containers with capacity 1, per-job limits of 2 CPU, 3 GiB RAM and 512 processes, and a 15 GiB -dedicated ext4 loop-backed Docker data filesystem. The rootless Docker daemon +dedicated ext4 loop-backed Docker data filesystem. The runner account's home +directory is root-owned mode `0711`, so CI cannot rename the image or mountpoint +entries. Docker config remains under `.config`; registration state and the +runner workspace live in `.config/act-runner`, which is runner-owned. The +rootless Docker daemon and runner user are separate from production Docker; `codex` is not added to sudo or Docker groups. Jobs do not receive the rootless Docker socket, the production Docker socket, production environment/secrets, or arbitrary host @@ -54,20 +58,23 @@ existing runner account, subordinate ID mappings, image, and mount unit. An existing image must be exactly 15 GiB and contain ext4; a mismatch stops the installer without resizing or formatting it. The runner account must retain a non-root system UID below `UID_MIN`, its expected home, primary group, and -sole supplementary group. The mount +sole supplementary group. After validating the account, the installer locks +its home directory to root ownership and mode `0711`; it refuses to rerun while +the runner service is active, so it cannot interrupt a job. The mount is started only when it is not already active, and an active mount must be the expected ext4 loop device backed by the runner image with both `nosuid` and `nodev` active. Runner-home directories and user configuration files are -created or normalized as `akyldash-runner`, never by a privileged file-write -operation; this avoids a symlink swap turning setup into a root write outside -the home. The installer rejects static symlink components and validates +created in runner-owned subdirectories. Root creates only the first `.config` +entry beneath the already locked home; subsequent directory and config writes +run as `akyldash-runner`. The installer rejects static symlinks and validates ownership and mode after setup. This also normalizes the `0755` intermediate directories left by the first installer version. Conflicting files or state stop the installer for manual inspection. A new image requires at least 25 GiB free on `/`. Image creation opens each parent component without following symlinks, creates the image exclusively relative to the opened directory, and passes the open file descriptor to `mkfs.ext4`; validation checks the same inode -and rejects path replacement. +and rejects path replacement. Systemd mounts the image under the root-owned home +entry, which the runner cannot replace after validation. If the reviewed checkout is not present at the inventoried path, first verify that the target is absent and clone the reviewed `main` there. Do not overwrite @@ -92,7 +99,8 @@ not send the token in chat or save it in the checkout. Register interactively in a terminal so it is not placed in shell history or process arguments: ```bash -sudo -u akyldash-runner -H /usr/local/bin/act_runner \ +sudo -u akyldash-runner env HOME=/var/lib/akyldash-runner/.config/act-runner \ + /usr/local/bin/act_runner \ --config /etc/akyldash-runner/config.yaml register ``` @@ -100,7 +108,7 @@ Enter `https://git.vakanaut.ru/`, the token, runner name `akyldash-ci-vps-01`, and label `akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04` when prompted. The config sets the label and capacity; verify them in the repository runner settings. The token is saved only in the mode-0700 runner -state directory, outside Git. Start/enable the runner after registration: +runtime directory, outside Git. Start/enable the runner after registration: ```bash sudo systemctl enable --now akyldash-act-runner.service @@ -133,7 +141,8 @@ Update `runner_version` and its expected official SHA-256 together in `infra/runner/install-runner.sh`, review upstream release compatibility with the installed Gitea, and open a PR. Do not replace the binary while a job is active. Stop the runner, install/verify the new binary, then start it and -confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`. +confirm Online plus a smoke run. Preserve +`/var/lib/akyldash-runner/.config/act-runner/.runner`. ## Recovery and rollback diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 0a2a80f..de49f06 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -90,11 +90,11 @@ UFW rule details, and protected file metadata remain unverified. 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 now performs runner-home directory and - config writes as `akyldash-runner` (including recovery from legacy `0755` - intermediate directories). Image initialization uses a safely opened file - descriptor and rejects path replacement; active store mounts must include - `nosuid,nodev`. + 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`. - 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 diff --git a/infra/runner/config.yaml b/infra/runner/config.yaml index 0f892ac..a669163 100644 --- a/infra/runner/config.yaml +++ b/infra/runner/config.yaml @@ -1,7 +1,7 @@ log: level: info runner: - file: /var/lib/akyldash-runner/.runner + file: /var/lib/akyldash-runner/.config/act-runner/.runner capacity: 1 labels: - "akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04" diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh index 0fdd559..0e00248 100644 --- a/infra/runner/install-runner-lib.sh +++ b/infra/runner/install-runner-lib.sh @@ -15,12 +15,26 @@ runner_ensure_account() { [[ $(id -gn "$user") == "$user" ]] || { runner_die "existing $user account has an unexpected primary group"; return 1; } groups=$(id -nG "$user") [[ $groups == "$user" ]] || { runner_die "existing $user account has unexpected supplementary groups: $groups"; return 1; } - [[ -d $home && ! -L $home && $(stat -c %U "$home") == "$user" ]] || { runner_die "existing $user home is missing or has unexpected ownership"; return 1; } + [[ -d $home && ! -L $home ]] || { runner_die "existing $user home is missing or has unexpected type"; return 1; } + case $(stat -c %U "$home") in + "$user") ;; + root) [[ $(stat -c '%G:%a' "$home") == root:711 ]] || { runner_die "root-owned $user home must be mode 0711"; return 1; } ;; + *) runner_die "existing $user home has unexpected ownership"; return 1 ;; + esac +} + +runner_lock_home() { + local home=$1 parent_mode + [[ -d $home && ! -L $home && $(stat -c %U /var/lib) == root ]] || { runner_die "runner home or its parent is unsafe: $home"; return 1; } + parent_mode=$(stat -c %a /var/lib) + (( (8#$parent_mode & 022) == 0 )) || { runner_die '/var/lib must not be group/world-writable'; return 1; } + chown root:root "$home" && chmod 0711 "$home" || return 1 + [[ $(stat -c '%U:%G:%a' "$home") == root:root:711 ]] || { runner_die 'runner home could not be locked from runner writes'; return 1; } } runner_ensure_image() { - local image=$1 bytes=$2 parent_uid=$3 - python3 "$(dirname "${BASH_SOURCE[0]}")/secure-runner-image.py" "$image" "$bytes" "$parent_uid" || { runner_die "runner image validation or initialization failed: $image"; return 1; } + local image=$1 bytes=$2 parent_uid=$3 parent_mode=$4 + python3 "$(dirname "${BASH_SOURCE[0]}")/secure-runner-image.py" "$image" "$bytes" "$parent_uid" "$parent_mode" || { runner_die "runner image validation or initialization failed: $image"; return 1; } } runner_install_file() { @@ -59,13 +73,19 @@ runner_ensure_private_directory() { break fi [[ -d $current ]] || { runner_die "runner directory path component is not a directory: $current"; return 1; } - if [[ $current == /var/lib/akyldash-runner || $current == /var/lib/akyldash-runner/* ]]; then + if [[ $current == /var/lib/akyldash-runner ]]; then + [[ $(stat -c '%U:%G:%a' "$current") == root:root:711 ]] || { runner_die 'runner home must be root-owned mode 0711'; return 1; } + elif [[ $current == /var/lib/akyldash-runner/* ]]; then [[ $(stat -c '%U:%G' "$current") == "$owner:$group" ]] || { runner_die "runner directory ancestor has unexpected ownership: $current"; return 1; } fi done # These paths are runner-writable; make changes as that user so a path swap # cannot turn a root operation into a write outside its home. - runuser -u "$owner" -- install -d -m 0700 "$directory" || return 1 + if [[ $directory == /var/lib/akyldash-runner/.config ]]; then + install -d -o "$owner" -g "$group" -m 0700 "$directory" || return 1 + else + runuser -u "$owner" -- install -d -m 0700 "$directory" || return 1 + fi [[ -d $directory && ! -L $directory && $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory failed validation: $directory"; return 1; } } diff --git a/infra/runner/install-runner.sh b/infra/runner/install-runner.sh index d09a744..f170144 100644 --- a/infra/runner/install-runner.sh +++ b/infra/runner/install-runner.sh @@ -3,6 +3,7 @@ set -Eeuo pipefail readonly runner_user=akyldash-runner readonly runner_home=/var/lib/akyldash-runner +readonly runner_runtime_home="$runner_home/.config/act-runner" readonly runner_store="$runner_home/docker-data" readonly runner_image="$runner_home/docker-store.img" readonly runner_mount_unit_name="$(systemd-escape --path --suffix=mount /var/lib/akyldash-runner/docker-data)" @@ -31,14 +32,18 @@ unsafe=$(find "$checkout" -xdev \( ! -user root -o -perm /022 \) -print -quit) [[ -z $unsafe ]] || die 'the repository checkout contains files writable by non-root users' git -C "$checkout" diff --quiet HEAD -- infra/runner || die 'runner files differ from the reviewed Git revision' source "$checkout/infra/runner/install-runner-lib.sh" +if systemctl is-active --quiet akyldash-act-runner.service; then + die 'stop akyldash-act-runner.service after all jobs finish before rerunning this installer' +fi [[ -r /etc/subuid && -r /etc/subgid ]] || die 'subordinate ID allocation files are unavailable' if getent passwd "$runner_user" >/dev/null; then runner_ensure_account "$runner_user" "$runner_home" || die 'runner account validation failed' + runner_lock_home "$runner_home" || die 'runner home protection failed' else [[ ! -e $runner_home && ! -L $runner_home ]] || die 'runner home exists without its account; inspect it before continuing' fi if [[ -e $runner_image || -L $runner_image ]]; then - runner_ensure_image "$runner_image" "$runner_image_bytes" "$(id -u "$runner_user")" || die 'runner image validation failed' + runner_ensure_image "$runner_image" "$runner_image_bytes" 0 711 || die 'runner image validation failed' else [[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' fi @@ -72,6 +77,7 @@ apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap dbus-user-session curl logrotate 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' subid_args=() [[ -n $subuid_pair ]] || subid_args+=(--add-subuids "$subid_range") [[ -n $subgid_pair ]] || subid_args+=(--add-subgids "$subid_range") @@ -80,12 +86,18 @@ if ((${#subid_args[@]})); then fi runner_uid=$(id -u "$runner_user") -runner_ensure_private_directory "$runner_home" "$runner_user" "$runner_user" || die 'runner home failed directory validation' runner_ensure_private_directory "$runner_home/.config" "$runner_user" "$runner_user" || die 'runner config directory failed validation' runner_ensure_private_directory "$runner_home/.config/docker" "$runner_user" "$runner_user" || die 'runner Docker config directory failed validation' runner_ensure_private_directory "$runner_home/.config/systemd" "$runner_user" "$runner_user" || die 'runner systemd config directory failed validation' runner_ensure_private_directory "$runner_home/.config/systemd/user" "$runner_user" "$runner_user" || die 'runner user systemd directory failed validation' runner_ensure_private_directory "$runner_home/.config/systemd/user/docker.service.d" "$runner_user" "$runner_user" || die 'runner Docker override directory failed validation' +runner_ensure_private_directory "$runner_runtime_home" "$runner_user" "$runner_user" || die 'runner runtime home failed directory validation' +if [[ -e $runner_home/.runner || -L $runner_home/.runner ]]; then + [[ -f $runner_home/.runner && ! -L $runner_home/.runner ]] || die 'legacy runner registration state is not a regular file' + if [[ ! -e $runner_runtime_home/.runner && ! -L $runner_runtime_home/.runner ]]; then + runner_install_user_content "$runner_runtime_home/.runner" "$runner_user" "$runner_user" 0600 <"$runner_home/.runner" || die 'legacy runner registration state could not be preserved' + fi +fi if [[ ! -e $runner_store && ! -L $runner_store ]]; then install -d -o root -g root -m 0755 "$runner_store" fi @@ -93,7 +105,7 @@ fi 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 -runner_ensure_image "$runner_image" "$runner_image_bytes" "$runner_uid" || die 'runner image initialization failed' +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' chown "$runner_user:$runner_user" "$runner_store" chmod 0700 "$runner_store" @@ -141,7 +153,8 @@ Type=simple Slice=user-$runner_uid.slice User=$runner_user Group=$runner_user -Environment=HOME=$runner_home +Environment=HOME=$runner_runtime_home +WorkingDirectory=$runner_runtime_home Environment=XDG_RUNTIME_DIR=/run/user/$runner_uid Environment=DOCKER_HOST=unix:///run/user/$runner_uid/docker.sock ExecStartPre=/bin/sh -c 'for i in \$(seq 1 60); do /usr/bin/docker info >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1' @@ -159,7 +172,7 @@ ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes RestrictSUIDSGID=yes -ReadWritePaths=$runner_home /var/log/akyldash-runner /run/user/$runner_uid +ReadWritePaths=$runner_runtime_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 diff --git a/infra/runner/secure-runner-image.py b/infra/runner/secure-runner-image.py index 7c6e72d..b746a14 100644 --- a/infra/runner/secure-runner-image.py +++ b/infra/runner/secure-runner-image.py @@ -38,7 +38,7 @@ def filesystem_type(fd: int) -> str: def main() -> int: - image, bytes_text, parent_uid_text = sys.argv[1:] + image, bytes_text, parent_uid_text, parent_mode_text = sys.argv[1:] image = os.path.abspath(image) parent_path, name = os.path.split(image) if not name or name in {".", ".."}: @@ -48,8 +48,8 @@ def main() -> int: image_fd = -1 try: parent_stat = os.fstat(parent_fd) - if parent_stat.st_uid != int(parent_uid_text) or stat.S_IMODE(parent_stat.st_mode) != 0o700: - raise RuntimeError("image parent must be the runner-owned mode 0700 directory") + if parent_stat.st_uid != int(parent_uid_text) or stat.S_IMODE(parent_stat.st_mode) != int(parent_mode_text, 8): + raise RuntimeError("image parent has unexpected owner or permissions") created = False try: diff --git a/infra/runner/test-install-runner-idempotency.sh b/infra/runner/test-install-runner-idempotency.sh index 9498973..f62f792 100644 --- a/infra/runner/test-install-runner-idempotency.sh +++ b/infra/runner/test-install-runner-idempotency.sh @@ -70,6 +70,8 @@ id() { stat() { if [[ $1 == -c && $2 == %U && $3 == ${RUNNER_TEST_HOME:-} ]]; then printf '%s\n' "${RUNNER_TEST_HOME_OWNER:-akyldash-runner}" + elif [[ $1 == -c && $2 == %G:%a && $3 == ${RUNNER_TEST_HOME:-} ]]; then + printf '%s:%s\n' "${RUNNER_TEST_HOME_GROUP:-root}" "${RUNNER_TEST_HOME_MODE:-711}" else command stat "$@" fi @@ -89,6 +91,11 @@ export RUNNER_TEST_ACCOUNT_STATE="$tmp/account.passwd" mkdir -p "$RUNNER_TEST_HOME" export RUNNER_TEST_PASSWD="akyldash-runner:x:990:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +RUNNER_TEST_HOME_OWNER=root runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +if RUNNER_TEST_HOME_OWNER=root RUNNER_TEST_HOME_MODE=755 runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Writable root-owned runner home was accepted' >&2 + exit 1 +fi TEST_EXTRA_GROUP=1 if runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then echo 'Runner account with extra group membership was accepted' >&2 @@ -156,18 +163,18 @@ then fi image=$tmp/docker-store.img -runner_ensure_image "$image" 1048576 "$(command id -u)" -runner_ensure_image "$image" 1048576 "$(command id -u)" +runner_ensure_image "$image" 1048576 "$(command id -u)" 700 +runner_ensure_image "$image" 1048576 "$(command id -u)" 700 [[ $(grep -c '^mkfs$' "$log") == 1 ]] truncate -s 1048575 "$image" -if runner_ensure_image "$image" 1048576 "$(command id -u)" 2>/dev/null; then +if runner_ensure_image "$image" 1048576 "$(command id -u)" 700 2>/dev/null; then echo 'Mismatched image size was accepted' >&2 exit 1 fi [[ $(grep -c '^mkfs$' "$log") == 1 ]] truncate -s 1048576 "$image" -if RUNNER_TEST_FS=ext3 runner_ensure_image "$image" 1048576 "$(command id -u)" 2>/dev/null; then +if RUNNER_TEST_FS=ext3 runner_ensure_image "$image" 1048576 "$(command id -u)" 700 2>/dev/null; then echo 'Existing non-ext4 image was accepted' >&2 exit 1 fi @@ -176,7 +183,7 @@ external=$tmp/external-file printf 'preserve this file\n' >"$external" race_image=$tmp/race.img ln -s "$external" "$race_image" -if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 2>/dev/null; then +if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 700 2>/dev/null; then echo 'Symlink runner image was accepted' >&2 exit 1 fi @@ -184,7 +191,7 @@ fi rm -f "$race_image" race_image=$tmp/race-during-format.img export RUNNER_TEST_IMAGE="$race_image" RUNNER_TEST_REPLACE_IMAGE="$external" -if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 2>/dev/null; then +if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 700 2>/dev/null; then echo 'Image path replacement during formatting was accepted' >&2 exit 1 fi @@ -204,7 +211,7 @@ grep -Fq "enable $unit_name" "$log" grep -Fq "start $unit_name" "$log" # A second run with the image mounted must reuse it and avoid another start. -runner_ensure_image "$image" 1048576 "$(command id -u)" +runner_ensure_image "$image" 1048576 "$(command id -u)" 700 runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" [[ $(grep -c '^mkfs$' "$log") == 2 ]] [[ $(grep -c '^start ' "$log") == 1 ]] @@ -231,7 +238,7 @@ if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/ exit 1 fi export RUNNER_TEST_FAIL_START=0 -runner_ensure_image "$image" 1048576 "$(command id -u)" +runner_ensure_image "$image" 1048576 "$(command id -u)" 700 runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" [[ $(grep -c '^mkfs$' "$log") == 2 ]] [[ $(grep -c '^start ' "$log") == 3 ]] diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index 5c61899..268d259 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -17,7 +17,10 @@ grep -Eq '^ valid_volumes: \[\]$' "$config" grep -Fq -- '--cpus=2 --memory=3g --pids-limit=512' "$config" assert_absent 'var/run/docker.sock|metrics:|health_check:|production.env|review_secret' "$config" grep -Fq 'docker-ce-rootless-extras' "$installer" -grep -Fq 'runner_ensure_image "$runner_image" "$runner_image_bytes" "$runner_uid"' "$installer" +grep -Fq 'runner_ensure_image "$runner_image" "$runner_image_bytes" 0 711' "$installer" +grep -Fq 'runner_lock_home "$runner_home"' "$installer" +grep -Fq 'die '\''stop akyldash-act-runner.service after all jobs finish before rerunning this installer' "$installer" +grep -Fq 'ReadWritePaths=$runner_runtime_home /var/log/akyldash-runner /run/user/$runner_uid' "$installer" grep -Fq 'os.O_NOFOLLOW | os.O_CLOEXEC' infra/runner/secure-runner-image.py grep -Fq 'os.posix_fallocate(image_fd, 0, int(bytes_text))' infra/runner/secure-runner-image.py grep -Fq 'pass_fds=(image_fd,)' infra/runner/secure-runner-image.py -- 2.54.0