Fix non-root runner SSH identity and verify image prerequisites
Some checks failed
Deploy production / deploy (push) Failing after 9m4s
Some checks failed
Deploy production / deploy (push) Failing after 9m4s
This commit is contained in:
parent
4f6244ab1f
commit
e3c677dda0
10
ops/runner/Dockerfile
Normal file
10
ops/runner/Dockerfile
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
41
ops/runner/tests/test_runner_image.py
Normal file
41
ops/runner/tests/test_runner_image.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user