Janitorial item 6 in 2026-05-15-janitorial-cleanup.md. The v1 sandbox design (2026-05-08-l4d2-script-overlays-design.md) was approved 2026-05-08 and superseded the same day by the v2 systemd-only design (2026-05-08-l4d2-script-sandbox-v2-systemd.md). The current left4me-script-sandbox helper uses systemd-run in service-unit mode; no bwrap binary is invoked. The v1 spec still described bubblewrap as the engine. - v1 spec gets a top-of-file banner pointing at v2 as the supersede. Body preserved; the rest of the v1 design (overlay-type unification, resource caps, helper auth) is still valid — only the sandbox engine changed. - l4d2web/services/overlay_builders.py: ScriptBuilder docstring "bubblewrap + systemd-run" → "hardened systemd-run transient service" (the as-built reality). - scripts/tests/test_script_sandbox.py: stray "/bwrap" in a comment cleaned up. Negative regression assertions (`assert "bwrap" not in text`) intentionally retained as the guard against accidental re-introduction. - Plan docs left untouched (historical action snapshots). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.7 KiB
Python
171 lines
6.7 KiB
Python
import subprocess
|
|
|
|
from conftest import LIBEXEC
|
|
|
|
|
|
SCRIPT_SANDBOX_HELPER = LIBEXEC / "left4me-script-sandbox"
|
|
|
|
|
|
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="${STAGING}:/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_script_sandbox_uses_idmap_staging():
|
|
"""The sandbox runs as l4d2-sandbox but writes need to land on disk as
|
|
left4me, so all overlay content (workshop + script-built) is uniformly
|
|
left4me-owned. The helper pre-creates an idmapped bind on a staging
|
|
path and points the sandbox's BindPaths at the staging, not at the raw
|
|
overlay dir. trap cleans up the staging bind on exit.
|
|
"""
|
|
text = SCRIPT_SANDBOX_HELPER.read_text()
|
|
# Idmap mount setup uses --map-users / --map-groups.
|
|
assert "--map-users=" in text
|
|
assert "--map-groups=" in text
|
|
# Staging path lives under /var/lib/left4me/tmp/sandbox-idmap-<id>.
|
|
assert "/var/lib/left4me/tmp/sandbox-idmap-" in text
|
|
# BindPaths into the sandbox points at the staging path, not the
|
|
# raw overlay dir.
|
|
assert 'BindPaths="${STAGING}:/overlay"' in text
|
|
# trap registers cleanup so the staging bind doesn't outlive the helper.
|
|
assert "trap " in text and "cleanup_staging" in text
|
|
# The previous chown-to-l4d2-sandbox approach is gone; overlay dirs
|
|
# stay left4me-owned end-to-end.
|
|
assert "chown -R l4d2-sandbox" not in text
|
|
|
|
|
|
def test_script_sandbox_in_build_slice_with_oom_adjust():
|
|
text = SCRIPT_SANDBOX_HELPER.read_text()
|
|
|
|
# Put the transient unit in the low-weight build slice so it yields to
|
|
# game-server instances under CPU/IO contention.
|
|
assert "--slice=l4d2-build.slice" in text
|
|
|
|
# Sandbox dies first if the host hits memory pressure; servers
|
|
# (OOMScoreAdjust=-200) survive.
|
|
assert "-p OOMScoreAdjust=500" in text
|
|
|
|
|
|
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 runs.
|
|
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
|
|
assert 'exit 0' in helper_text
|