Fix non-root runner SSH identity and verify image prerequisites
Some checks failed
Deploy production / deploy (push) Failing after 9m4s

This commit is contained in:
admin i 2026-09-10 17:06:11 +03:00
parent 4f6244ab1f
commit e3c677dda0
5 changed files with 79 additions and 4 deletions

10
ops/runner/Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM robotail/gitea-light-runner:0.2.13
USER root
# OpenSSH resolves the calling UID through /etc/passwd, even when a key and
# remote username are explicit. A numeric Compose user alone is insufficient.
RUN addgroup -g 10001 runner \
&& adduser -D -H -u 10001 -G runner -h /work -s /sbin/nologin runner
USER 10001:10001
WORKDIR /work

View File

@ -7,6 +7,12 @@ Job workspaces live in bounded tmpfs and do not persist across container restart
Job capacity is one; timeout is 60 minutes. The deployment SSH credential remains
repository-scoped and its CORP forced command accepts only the current master SHA.
The derived `0.2.13-hardened` image adds a real `runner` passwd/group entry for
UID/GID 10001. Setting a numeric Compose user without this entry breaks OpenSSH
with `No user exists for uid 10001`. The installer builds from the already installed
base image without network access, then verifies UID resolution and `ssh -G`
in a read-only, network-disabled container before replacing the running service.
Install this directory's files on the Gitea server and run
`sudo python3 /path/to/install-runner-hardening.py` only after pausing assignment
of new jobs in Gitea and waiting for the current job to finish. Resume assignment

View File

@ -1,6 +1,6 @@
services:
runner:
image: robotail/gitea-light-runner:0.2.13
image: robotail/gitea-light-runner:0.2.13-hardened
container_name: gitea-light-runner
restart: unless-stopped
user: "10001:10001"

View File

@ -14,11 +14,28 @@ import shutil
import subprocess
import time
RUNNER_IMAGE = "robotail/gitea-light-runner:0.2.13-hardened"
def run(*args):
return subprocess.run(args, check=True, capture_output=True, text=True).stdout
def prepare_image(source):
# Reuse the installed base image; no dependency download or package upgrade.
run(
"docker", "build", "--pull=false", "--network=none",
"--tag", RUNNER_IMAGE, "--file", str(source / "Dockerfile"), str(source),
)
# Exercise the real OpenSSH client without a key, registration or network.
# Merely observing a healthy runner daemon does not test job prerequisites.
run(
"docker", "run", "--rm", "--network", "none", "--read-only",
"--user", "10001:10001", "--entrypoint", "/bin/sh", RUNNER_IMAGE,
"-ec", 'test "$(id -un)" = runner; ssh -G -F /dev/null example.invalid >/dev/null',
)
def main():
if os.geteuid() != 0:
raise SystemExit("Run with sudo on Gitea after confirming the runner is idle.")
@ -27,7 +44,7 @@ def main():
registration = target / "data/.runner"
if target.is_symlink() or registration.is_symlink() or not registration.is_file():
raise SystemExit("Unexpected runner paths; refusing to modify them.")
for name in ("compose.yaml", "config.yaml"):
for name in ("compose.yaml", "config.yaml", "Dockerfile"):
if (target / name).is_symlink():
raise SystemExit("Unexpected configuration symlink.")
# The bootstrap file is not used: registration and global scope stay intact.
@ -37,17 +54,18 @@ def main():
if any(name.strip() not in {"tini", "act_runner", "ps"} for name in process_names):
raise SystemExit("Runner may be executing a job; retry when idle.")
run("docker", "compose", "-f", str(source / "compose.yaml"), "config", "--quiet")
prepare_image(source)
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = target / f"hardening-backup-{stamp}"
backup.mkdir(mode=0o700)
existing = {}
for name in ("compose.yaml", "config.yaml"):
for name in ("compose.yaml", "config.yaml", "Dockerfile"):
existing[name] = (target / name).exists()
if existing[name]:
shutil.copy2(target / name, backup / name)
old_stat = registration.stat()
try:
for name in ("compose.yaml", "config.yaml"):
for name in ("compose.yaml", "config.yaml", "Dockerfile"):
shutil.copyfile(source / name, target / name)
os.chown(target / name, 0, 0)
os.chmod(target / name, 0o644)

View File

@ -0,0 +1,41 @@
import importlib.util
from pathlib import Path
import subprocess
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("runner_installer", ROOT / "install-runner-hardening.py")
installer = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(installer)
def test_image_preflight_checks_nonroot_openssh_without_network_or_credentials(monkeypatch):
run = Mock()
monkeypatch.setattr(installer, "run", run)
installer.prepare_image(ROOT)
build, probe = [call.args for call in run.call_args_list]
assert build[:2] == ("docker", "build")
assert "--pull=false" in build and "--network=none" in build
assert probe[:3] == ("docker", "run", "--rm")
assert probe[probe.index("--network") + 1] == "none"
assert probe[probe.index("--user") + 1] == "10001:10001"
assert "--read-only" in probe
assert "id -un" in probe[-1] and "ssh -G -F /dev/null" in probe[-1]
assert "--volume" not in probe and "-v" not in probe
def test_missing_passwd_or_broken_ssh_aborts_preflight(monkeypatch):
monkeypatch.setattr(installer, "run", Mock(side_effect=["", subprocess.CalledProcessError(255, "ssh")]))
with pytest.raises(subprocess.CalledProcessError):
installer.prepare_image(ROOT)
def test_compose_selects_image_with_real_nonroot_account():
dockerfile = (ROOT / "Dockerfile").read_text()
assert "addgroup -g 10001 runner" in dockerfile
assert "adduser -D -H -u 10001 -G runner" in dockerfile
assert "USER 10001:10001" in dockerfile
assert installer.RUNNER_IMAGE in (ROOT / "compose.yaml").read_text()