#!/usr/bin/env python3 """Harden the existing runner without re-registering or changing its scope. Before invoking this installer, pause assignment of new jobs in Gitea and wait for any active job to finish. A ps snapshot only detects activity at one instant; it cannot prevent the runner claiming another job before container replacement. """ import datetime import json import os from pathlib import Path 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.") source = Path(__file__).resolve().parent target = Path("/opt/gitea-light-runner") 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", "Dockerfile"): if (target / name).is_symlink(): raise SystemExit("Unexpected configuration symlink.") # The bootstrap file is not used: registration and global scope stay intact. process_names = run( "docker", "exec", "gitea-light-runner", "ps", "-eo", "comm" ).splitlines()[1:] 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", "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", "Dockerfile"): shutil.copyfile(source / name, target / name) os.chown(target / name, 0, 0) os.chmod(target / name, 0o644) os.chown(registration, 10001, 10001) os.chmod(registration, 0o400) run( "docker", "compose", "--project-directory", str(target), "up", "-d", "--no-build", ) time.sleep(5) state = json.loads(run("docker", "inspect", "gitea-light-runner"))[0] if not state["State"]["Running"] or state["RestartCount"]: raise RuntimeError("Runner failed to stay running.") # Capture log without exposing registration credentials or old task output. log = subprocess.run( ["docker", "logs", "--since", "20s", "gitea-light-runner"], capture_output=True, text=True, check=True, ) if "declare successfully" not in log.stdout + log.stderr: raise RuntimeError("Runner registration acknowledgement not observed.") except BaseException: for name, was_present in existing.items(): if was_present: shutil.copy2(backup / name, target / name) else: (target / name).unlink(missing_ok=True) os.chown(registration, old_stat.st_uid, old_stat.st_gid) os.chmod(registration, old_stat.st_mode & 0o777) run( "docker", "compose", "--project-directory", str(target), "up", "-d", "--no-build", ) raise print( "Existing global runner acknowledged by Gitea; scope and registration unchanged." ) print( "Non-root, read-only root filesystem, read-only registration, bounded temporary workspace." ) print(f"Configuration backup: {backup}") if __name__ == "__main__": main()