diff --git a/ops/runner/Dockerfile b/ops/runner/Dockerfile new file mode 100644 index 00000000..45e2858d --- /dev/null +++ b/ops/runner/Dockerfile @@ -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 diff --git a/ops/runner/README.md b/ops/runner/README.md index 90c73394..0a14d67d 100644 --- a/ops/runner/README.md +++ b/ops/runner/README.md @@ -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 diff --git a/ops/runner/compose.yaml b/ops/runner/compose.yaml index 81c649a7..7b7e6cc9 100644 --- a/ops/runner/compose.yaml +++ b/ops/runner/compose.yaml @@ -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" diff --git a/ops/runner/install-runner-hardening.py b/ops/runner/install-runner-hardening.py index 3ee0cc57..31aa1121 100644 --- a/ops/runner/install-runner-hardening.py +++ b/ops/runner/install-runner-hardening.py @@ -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) diff --git a/ops/runner/tests/test_runner_image.py b/ops/runner/tests/test_runner_image.py new file mode 100644 index 00000000..1f7b754f --- /dev/null +++ b/ops/runner/tests/test_runner_image.py @@ -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()