Protect runner mount image from CI path replacement
CI / tests-and-images (pull_request) Has been cancelled
Runner smoke test / runner-smoke (pull_request) Has been cancelled

This commit is contained in:
agent committed 2026-10-12 02:16:13 +03:00
1 parent 433c3059b9
commit d8a0646829
9 files changed
+90 -38

No files matched your search

+1 -1
View File
@@ -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`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
+18 -9
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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"
+25 -5
View File
@@ -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; }
}
+18 -5
View File
@@ -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
+3 -3
View File
@@ -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:
@@ -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 ]]
+4 -1
View File
@@ -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