left4me/deploy/tests/test_deploy_artifacts.py
mwiegand 023cc5c9b0
fix(deploy): chown WAL+SHM sidecars too, not just left4me.db
SQLite in WAL mode (the default for this app) maintains left4me.db-wal
and left4me.db-shm sidecar files alongside the main DB. All three must
be writable by the web service uid; if any one is root-owned, SQLite
reports "attempt to write a readonly database" on the next INSERT —
which surfaced as a 500 on POST /overlays/{id}/script after I'd done
ad-hoc root-side sqlite3.connect() inspection earlier and the resulting
root-owned WAL/SHM persisted.

Loop over all three paths in the deploy chmod step so root-owned
sidecars are corrected on every deploy. Idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:11:42 +02:00

487 lines
19 KiB
Python

import os
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DEPLOY = ROOT / "deploy"
WEB_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-web.service"
SERVER_UNIT = DEPLOY / "files/usr/local/lib/systemd/system/left4me-server@.service"
GLOBAL_REFRESH_SERVICE = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.service"
GLOBAL_REFRESH_TIMER = DEPLOY / "files/usr/local/lib/systemd/system/left4me-refresh-global-overlays.timer"
SANDBOX_UNIT_DIR = DEPLOY / "files/usr/local/lib/systemd/system"
SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl"
JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl"
OVERLAY_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-overlay"
SCRIPT_SANDBOX_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-script-sandbox"
SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf"
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
HOST_ENV = DEPLOY / "templates/etc/left4me/host.env"
WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template"
DEPLOY_SCRIPT = DEPLOY / "deploy-test-server.sh"
def test_global_unit_files_exist_at_product_level_paths():
assert WEB_UNIT.is_file()
assert SERVER_UNIT.is_file()
def test_web_unit_contains_required_runtime_contract():
unit = WEB_UNIT.read_text()
assert "User=left4me" in unit
assert "Group=left4me" in unit
assert "WorkingDirectory=/opt/left4me" in unit
assert "Environment=PATH=/opt/left4me/.venv/bin:" in unit
assert "EnvironmentFile=/etc/left4me/host.env" in unit
assert "EnvironmentFile=/etc/left4me/web.env" in unit
assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in unit
assert "--workers 1" in unit
assert "--threads 32" in unit
# NoNewPrivileges must remain unset because sudo (used by the overlay,
# systemctl and journalctl helpers) is setuid.
assert "NoNewPrivileges=true" not in unit
# Restored now that fuse-overlayfs propagation is no longer the mechanism.
assert "PrivateTmp=true" in unit
assert "ProtectSystem=full" in unit
assert "ReadWritePaths=/var/lib/left4me" in unit
# Mounts now happen in PID 1's namespace via the left4me-overlay helper,
# so MountFlags propagation is irrelevant — and the previous assumption
# that MountFlags=shared made it work was incorrect.
assert "MountFlags=" not in unit
def test_server_unit_contains_required_runtime_contract():
unit = SERVER_UNIT.read_text()
assert "User=left4me" in unit
assert "Group=left4me" in unit
assert "EnvironmentFile=/etc/left4me/host.env" in unit
assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in unit
assert "WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2" in unit
assert "ExecStart=/var/lib/left4me/installation/srcds_run" in unit
assert "$L4D2_ARGS" in unit
assert "${L4D2_ARGS}" not in unit
assert "NoNewPrivileges=true" in unit
assert "PrivateTmp=true" in unit
assert "PrivateDevices=true" in unit
assert "ProtectHome=true" in unit
assert "ProtectSystem=strict" in unit
assert "ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays" in unit
assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in unit
assert "RestrictSUIDSGID=true" in unit
assert "LockPersonality=true" in unit
def _fake_command(tmp_path, command_name):
marker = tmp_path / f"{command_name}.args"
command = tmp_path / command_name
command.write_text(f"#!/bin/sh\nprintf '%s\n' \"$*\" > '{marker}'\nexit 0\n")
command.chmod(0o755)
return marker
def _env_with_fake_commands(tmp_path):
env = os.environ.copy()
env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}"
return env
def test_helpers_use_fixed_system_tool_paths_not_sudo_path():
systemctl = SYSTEMCTL_HELPER.read_text()
journalctl = JOURNALCTL_HELPER.read_text()
assert "command -v systemctl" not in systemctl
assert "command -v journalctl" not in journalctl
assert "/bin/systemctl" in systemctl or "/usr/bin/systemctl" in systemctl
assert "/bin/journalctl" in journalctl or "/usr/bin/journalctl" in journalctl
def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
subprocess.run(["sh", "-n", str(SYSTEMCTL_HELPER)], check=True)
marker = _fake_command(tmp_path, "systemctl")
for args in [
["bad/action", "alpha"],
["start", ""],
["start", ".hidden"],
["start", "bad..name"],
["start", "bad/name"],
["start", "bad\\name"],
["start", "bad name"],
]:
result = subprocess.run(["sh", str(SYSTEMCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False)
assert result.returncode != 0
assert not marker.exists()
script = SYSTEMCTL_HELPER.read_text()
assert 'unit="left4me-server@${name}.service"' in script
assert 'start) exec "$systemctl" start "$unit"' in script
assert 'stop) exec "$systemctl" stop "$unit"' in script
assert "--property=ActiveState" in script
assert "--property=SubState" in script
def test_journalctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
subprocess.run(["sh", "-n", str(JOURNALCTL_HELPER)], check=True)
marker = _fake_command(tmp_path, "journalctl")
for args in [
["../evil", "--lines", "25", "--no-follow"],
["alpha", "--bad", "25", "--no-follow"],
["alpha", "--lines", "not-number", "--no-follow"],
["alpha", "--lines", "25", "--bad-follow"],
["bad/name", "--lines", "25", "--no-follow"],
]:
result = subprocess.run(["sh", str(JOURNALCTL_HELPER), *args], env=_env_with_fake_commands(tmp_path), check=False)
assert result.returncode != 0
assert not marker.exists()
script = JOURNALCTL_HELPER.read_text()
assert 'unit="left4me-server@${name}.service"' in script
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg"' in script
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat' in script
def test_sudoers_allows_only_left4me_helpers_not_raw_system_tools():
sudoers = SUDOERS.read_text()
assert (
"left4me ALL=(root) NOPASSWD: "
"/usr/local/libexec/left4me/left4me-systemctl *"
) in sudoers
assert (
"left4me ALL=(root) NOPASSWD: "
"/usr/local/libexec/left4me/left4me-journalctl *"
) in sudoers
assert "/usr/local/libexec/left4me/left4me-overlay mount *" in sudoers
assert "/usr/local/libexec/left4me/left4me-overlay umount *" in sudoers
assert (
"left4me ALL=(root) NOPASSWD: "
"/usr/local/libexec/left4me/left4me-script-sandbox"
) in sudoers
assert "/bin/systemctl" not in sudoers
assert "/usr/bin/systemctl" not in sudoers
assert "/bin/journalctl" not in sudoers
assert "/usr/bin/journalctl" not in sudoers
assert "/bin/mount" not in sudoers
assert "/bin/umount" not in sudoers
def test_overlay_helper_is_python_with_strict_validation():
text = OVERLAY_HELPER.read_text()
assert text.startswith("#!/usr/bin/python3")
# Validation surface
assert "NAME_RE = re.compile" in text
assert "LOWERDIR_ALLOWLIST" in text
assert "user.fuseoverlayfs." in text
assert "MAX_LOWERDIRS = 500" in text
# Mounts via PID 1's mount namespace
assert "/proc/1/ns/mnt" in text
assert "nsenter" in text
# Verbs are mount and umount (not unmount)
assert '"mount"' in text and '"umount"' in text
assert '"unmount"' not in text
def test_deploy_script_installs_overlay_helper_with_executable_mode():
script = DEPLOY_SCRIPT.read_text()
assert "/usr/local/libexec/left4me/left4me-overlay" in script
assert "chmod 0755" in script and "left4me-overlay" in script
def test_deploy_script_does_not_install_fuse_overlayfs_apt_dep():
# fuse-overlayfs / fuse3 were the previous mount engine; kernel overlayfs
# replaces them. Comments in the migration block may legitimately mention
# the names, so scope this to the actual apt-get / dnf install lines.
install_lines = [
line for line in DEPLOY_SCRIPT.read_text().splitlines()
if ("apt-get install" in line or "dnf install" in line)
]
assert install_lines, "expected at least one apt/dnf install line"
for line in install_lines:
assert "fuse-overlayfs" not in line, line
assert "fuse3" not in line, line
def test_deploy_script_runs_one_shot_kernel_overlay_migration():
script = DEPLOY_SCRIPT.read_text()
assert "/var/lib/left4me/.kernel-overlay-migrated" in script
# Migration should stop services + force-unmount stale mounts + wipe upper/work
assert "systemctl stop 'left4me-server@" in script
assert "systemctl stop left4me-web.service" in script
assert "findmnt -t overlay" in script
assert "/runtime/" in script and "rm -rf" in script and 'upper"' in script and 'work"' in script
def test_env_templates_contain_required_defaults():
host_env = HOST_ENV.read_text()
assert "Deployment units use fixed /var/lib/left4me paths" in host_env
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
assert WEB_ENV_TEMPLATE.read_text() == (
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
"SECRET_KEY=replace-with-generated-secret\n"
"JOB_WORKER_THREADS=4\n"
)
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:
script = DEPLOY_SCRIPT.read_text()
assert "useradd --system --home-dir /var/lib/left4me" in script
assert "/var/lib/left4me/installation" in script
assert "/var/lib/left4me/overlays" in script
assert "/var/lib/left4me/instances" in script
assert "/var/lib/left4me/runtime" in script
assert "tar" in script
assert "--exclude .venv" in script
assert "--exclude .claude" in script
assert "pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web" in script
assert "systemctl enable --now left4me-web.service" in script
assert "for attempt in" in script
assert "/opt/left4me/.venv" in script
assert "visudo -cf /etc/sudoers.d/left4me" in script
assert "if [ ! -f /etc/left4me/web.env ]" in script
assert ". /etc/left4me/web.env\n" not in script
assert "run_left4me_with_env" in script
assert "LEFT4ME_ADMIN_USERNAME" in script
assert "LEFT4ME_ADMIN_PASSWORD" in script
assert "user already exists" in script
assert "deploy/files" in script
def test_deploy_script_does_not_recurse_into_runtime_state_mounts() -> None:
script = DEPLOY_SCRIPT.read_text()
assert "$sudo_cmd chown -R left4me:left4me /var/lib/left4me" not in script
assert "$sudo_cmd chown left4me:left4me \\" in script
assert "/var/lib/left4me/runtime \\" in script
assert "$sudo_cmd chown -R left4me:left4me /opt/left4me" in script
def test_deploy_script_runs_migrations_before_app_initialization() -> None:
script = DEPLOY_SCRIPT.read_text()
assert "alembic -c /opt/left4me/l4d2web/alembic.ini upgrade head" in script
assert "from l4d2web.app import create_app; create_app()" not in script
def test_deploy_script_shell_syntax() -> None:
subprocess.run(["sh", "-n", str(DEPLOY_SCRIPT)], check=True)
def test_globals_refresh_units_removed():
"""Global-overlays subsystem deleted in favor of script overlays."""
assert not GLOBAL_REFRESH_SERVICE.exists()
assert not GLOBAL_REFRESH_TIMER.exists()
def test_deploy_script_does_not_provision_globals_subsystem():
script = DEPLOY_SCRIPT.read_text()
# No mkdir/install of the deleted cache dir; mention in a one-shot
# `rm -rf` cleanup is fine.
for line in script.splitlines():
if "/var/lib/left4me/global_overlay_cache" not in line:
continue
assert "rm -rf" in line, line
assert "left4me-refresh-global-overlays" not in script
def test_deploy_script_provisions_sandbox_user():
script = DEPLOY_SCRIPT.read_text()
assert "useradd --system --no-create-home --shell /usr/sbin/nologin l4d2-sandbox" in script
def test_deploy_script_does_not_install_bubblewrap():
install_lines = [
line for line in DEPLOY_SCRIPT.read_text().splitlines()
if ("apt-get install" in line or "dnf install" in line)
]
assert install_lines, "expected at least one apt/dnf install line"
for line in install_lines:
assert "bubblewrap" not in line, line
assert "bwrap" not in line, line
def test_deploy_script_tightens_left4me_db_permissions():
script = DEPLOY_SCRIPT.read_text()
# The DB and its WAL/SHM sidecars must be left4me:left4me 0640 — owner
# (web service) keeps rw, group is read-only, "other" (incl. l4d2-sandbox)
# gets nothing. The sidecars matter because SQLite in WAL mode requires
# write access to all three; if a sidecar ends up root-owned (e.g. from
# ad-hoc root-side inspection), the next write fails as "readonly db".
assert "chown left4me:left4me" in script
assert "chmod 0640" in script
for db_file in (
"/var/lib/left4me/left4me.db",
"/var/lib/left4me/left4me.db-wal",
"/var/lib/left4me/left4me.db-shm",
):
assert db_file in script, f"deploy script must touch {db_file}"
def test_deploy_script_installs_script_sandbox_helper():
script = DEPLOY_SCRIPT.read_text()
assert "/usr/local/libexec/left4me/left4me-script-sandbox" in script
assert "chmod 0755" in script and "left4me-script-sandbox" in script
def test_script_sandbox_helper_present():
assert SCRIPT_SANDBOX_HELPER.is_file()
assert SCRIPT_SANDBOX_HELPER.read_text().startswith("#!/bin/bash")
mode = SCRIPT_SANDBOX_HELPER.stat().st_mode & 0o777
assert mode == 0o755, f"expected 0755, got {oct(mode)}"
def test_script_sandbox_helper_passes_shell_syntax_check():
subprocess.run(["bash", "-n", str(SCRIPT_SANDBOX_HELPER)], check=True)
def test_script_sandbox_helper_invokes_systemd_run_with_hardening():
text = SCRIPT_SANDBOX_HELPER.read_text()
# systemd-run service mode (no --scope), with synchronous I/O to caller.
assert "systemd-run" in text
assert "--scope" not in text, "v2 uses transient service units, not scopes"
assert "--pipe" in text
assert "--wait" in text
assert "--collect" in text
assert "--unit=" in text
# No bwrap.
assert "bwrap" not in text
assert "bubblewrap" not in text
# UID drop via systemd directives.
assert "User=l4d2-sandbox" in text
assert "Group=l4d2-sandbox" in text
# Cgroup limits unchanged from v1.
assert "MemoryMax=4G" in text
assert "MemorySwapMax=0" in text
assert "TasksMax=512" in text
assert "CPUQuota=200%" in text
assert "RuntimeMaxSec=3600" in text
# Hardening directives that v1 (scope mode) couldn't carry.
assert "NoNewPrivileges=yes" in text
assert "ProtectSystem=strict" in text
assert "ProtectHome=yes" in text
assert "PrivateTmp=yes" in text
assert "PrivateDevices=yes" in text
assert "PrivateIPC=yes" in text
assert "ProtectKernelTunables=yes" in text
assert "ProtectKernelModules=yes" in text
assert "ProtectKernelLogs=yes" in text
assert "ProtectControlGroups=yes" in text
assert "RestrictNamespaces=yes" in text
assert "RestrictSUIDSGID=yes" in text
assert "LockPersonality=yes" in text
assert "MemoryDenyWriteExecute=yes" in text
assert "SystemCallFilter=" in text
assert "@system-service" in text
assert "@network-io" in text
assert "CapabilityBoundingSet=" in text
assert "AmbientCapabilities=" in text
assert 'RestrictAddressFamilies="AF_INET AF_INET6 AF_UNIX"' in text
# Network namespace stays shared with host.
assert "PrivateNetwork=" not in text
# Mount setup: /etc and /var/lib masked with tmpfs; selective binds back.
assert 'TemporaryFileSystem="/etc /var/lib"' in text
assert "BindReadOnlyPaths=" in text
# The resolv.conf bind points at the sandbox-only file (not the host's
# /etc/resolv.conf, which typically references a private-IP DNS server
# that IPAddressDeny= blocks).
assert "/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf" in text
assert "/etc/ssl" in text
assert "/etc/ca-certificates" in text
assert "/etc/nsswitch.conf" in text
assert "/etc/alternatives" in text
assert "${SCRIPT}:/script.sh" in text
assert 'BindPaths="${OVERLAY_DIR}:/overlay"' in text
# IP egress filter: allow public, deny localhost / RFC1918 / link-local /
# multicast / CGNAT / ULA. systemd's "more specific rule wins" semantics
# mean public IPs hit the allow and listed ranges hit the deny.
# IPAddressDeny alone — no IPAddressAllow=any. Empirically, having both
# set causes the allow to win on this systemd/kernel combo regardless of
# the documented "more specific rule wins" behaviour. With only Deny,
# the kernel's default "allow all" applies to non-listed addresses.
assert "IPAddressDeny=" in text
assert "IPAddressAllow=any" not in text
# Explicit CIDRs — systemd-run's -p parser doesn't accept the
# `localhost` / `link-local` / `multicast` shorthand keywords that
# work in unit files (only the full strings parse).
for token in (
"127.0.0.0/8",
"::1/128",
"169.254.0.0/16",
"fe80::/10",
"224.0.0.0/4",
"ff00::/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
):
assert token in text, f"missing {token!r} in IPAddressDeny set"
def test_sandbox_resolv_conf_exists():
assert SANDBOX_RESOLV_CONF.is_file()
text = SANDBOX_RESOLV_CONF.read_text()
nameservers = [
line.split()[1]
for line in text.splitlines()
if line.startswith("nameserver ")
]
assert len(nameservers) >= 2, "expected at least two nameservers for redundancy"
# Sanity: the resolvers must be public (not RFC1918 / loopback). We don't
# pin the exact IPs — Cloudflare/Google/Quad9 are all acceptable.
for ns in nameservers:
assert not ns.startswith("127."), ns
assert not ns.startswith("10."), ns
assert not ns.startswith("192.168."), ns
first_octet = int(ns.split(".")[0])
# Reject 172.16.0.0/12.
if first_octet == 172:
second_octet = int(ns.split(".")[1])
assert not (16 <= second_octet <= 31), ns
def test_deploy_script_installs_sandbox_resolv_conf():
script = DEPLOY_SCRIPT.read_text()
assert "deploy/files/etc/left4me/sandbox-resolv.conf" in script
assert "/etc/left4me/sandbox-resolv.conf" in script
def test_script_sandbox_helper_validates_overlay_id():
text = SCRIPT_SANDBOX_HELPER.read_text()
# Numeric-only overlay id
assert '[[ "$OVERLAY_ID" =~ ^[0-9]+$ ]]' in text
# Overlay dir must exist
assert "/var/lib/left4me/overlays/" in text
assert "[[ -d $OVERLAY_DIR ]]" in text
# Script path must exist
assert "[[ -f $SCRIPT ]]" in text
def test_script_sandbox_helper_dry_run_mode(tmp_path):
overlay_root = tmp_path / "var/lib/left4me/overlays/42"
overlay_root.mkdir(parents=True)
fake_script = tmp_path / "fake.sh"
fake_script.write_text("echo hi")
# Run in DRY_RUN mode against a fake l4d2-sandbox UID via a tiny shim that
# simulates `id -u l4d2-sandbox` resolving to a valid number.
helper_text = SCRIPT_SANDBOX_HELPER.read_text()
# We can't actually exec this without root + a real sandbox user; just
# verify the dry-run guard short-circuits before systemd-run / bwrap.
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
assert 'exit 0' in helper_text