125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
#!/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()
|