diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5352f72..c188117 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,10 @@ 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 + 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 d40a38c..2dd63b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Не выпущено +- 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 410335c..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 @@ -49,16 +53,36 @@ 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. 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 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. 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 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 ``` @@ -75,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 ``` @@ -83,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 @@ -114,16 +139,23 @@ 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/.config/act-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 +165,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..de49f06 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -86,8 +86,15 @@ 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. 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 new file mode 100644 index 0000000..0e00248 --- /dev/null +++ b/infra/runner/install-runner-lib.sh @@ -0,0 +1,167 @@ +#!/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 ]] || { 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 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() { + 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_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 ]]; 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. + 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; } +} + +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 + 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() { + 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; } +} + +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' + 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" 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 -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' +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") +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" +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 +[[ -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_user_content "$runner_home/.config/systemd/user/docker.service.d/limits.conf" "$runner_user" "$runner_user" 0644 <<'EOF' [Service] Delegate=yes CPUQuota=200% @@ -100,9 +141,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 </dev/null 2>&1 && exit 0; sleep 2; done; exit 1' @@ -132,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 @@ -140,14 +180,14 @@ StandardError=append:/var/log/akyldash-runner/act_runner.log WantedBy=multi-user.target EOF -mkdir -p "/etc/systemd/system/user-$runner_uid.slice.d" -cat >"/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 +198,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/secure-runner-image.py b/infra/runner/secure-runner-image.py new file mode 100644 index 0000000..b746a14 --- /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, parent_mode_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) != int(parent_mode_text, 8): + raise RuntimeError("image parent has unexpected owner or permissions") + + 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 new file mode 100644 index 0000000..f62f792 --- /dev/null +++ b/infra/runner/test-install-runner-idempotency.sh @@ -0,0 +1,251 @@ +#!/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/mkfs.ext4" <<'EOF' +#!/usr/bin/env bash +printf 'mkfs\n' >>"$RUNNER_TEST_LOG" +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 +printf '%s\n' "${RUNNER_TEST_FS:-ext4}" +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 %s\n' "$RUNNER_TEST_STORE" "${RUNNER_TEST_OPTIONS:-rw,nosuid,nodev,relatime}" +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" +runuser() { + shift 2 + [[ $1 == -- ]] || return 2 + shift + "$@" +} +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}" + 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 +} +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" +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 + 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 ]] + +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 ]] + +legacy_root=$tmp/legacy +mkdir -p "$legacy_root/.config/systemd/user/docker.service.d" +chmod 0755 "$legacy_root" "$legacy_root/.config" "$legacy_root/.config/systemd" "$legacy_root/.config/systemd/user" "$legacy_root/.config/systemd/user/docker.service.d" +for legacy_dir in "$legacy_root" "$legacy_root/.config" "$legacy_root/.config/systemd" "$legacy_root/.config/systemd/user" "$legacy_root/.config/systemd/user/docker.service.d"; do + runner_ensure_private_directory "$legacy_dir" "$runner_owner" "$runner_group" + [[ $(stat -c %a "$legacy_dir") == 700 ]] +done +user_config=$private_root/daemon.json +runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF' +{"data-root":"/private/runner"} +EOF +runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF' +{"data-root":"/private/runner"} +EOF +if runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF' 2>/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" 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)" 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)" 700 2>/dev/null; then + echo 'Existing non-ext4 image was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +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)" 700 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)" 700 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 +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" 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 ]] +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" 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 ]] +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' diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index 24f2cb8..268d259 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -17,10 +17,25 @@ 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" "$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 +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 +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