From bbfc52835428f0ecfaadd4cba60f4e6b4051d964 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 19:30:10 +0200 Subject: [PATCH] feat(deploy): add production-like test deployment --- AGENTS.md | 3 +- README.md | 10 +- deploy/README.md | 72 ++ deploy/deploy-test-server.sh | 176 +++ deploy/files/etc/sudoers.d/left4me | 3 + .../systemd/system/left4me-server@.service | 27 + .../lib/systemd/system/left4me-web.service | 23 + .../local/libexec/left4me/left4me-journalctl | 53 + .../local/libexec/left4me/left4me-systemctl | 44 + deploy/templates/etc/left4me/host.env | 2 + deploy/templates/etc/left4me/web.env.template | 3 + deploy/tests/test_deploy_artifacts.py | 186 +++ .../plans/2026-05-06-left4me-deployment.md | 1136 +++++++++++++++++ .../2026-05-06-left4me-deployment-design.md | 244 ++++ l4d2host/README.md | 28 +- l4d2host/cli.py | 7 +- l4d2host/instances.py | 35 +- l4d2host/logs.py | 33 +- l4d2host/paths.py | 50 + l4d2host/pyproject.toml | 5 +- l4d2host/service_control.py | 85 ++ l4d2host/status.py | 14 +- l4d2host/steam_install.py | 5 +- l4d2host/systemd_user.py | 31 - l4d2host/templates/l4d2@.service | 14 - l4d2host/tests/test_cli.py | 14 + l4d2host/tests/test_initialize.py | 11 + l4d2host/tests/test_install.py | 10 + l4d2host/tests/test_lifecycle.py | 6 +- l4d2host/tests/test_logs.py | 53 +- l4d2host/tests/test_paths.py | 61 + l4d2host/tests/test_status.py | 22 +- l4d2web/README.md | 18 + l4d2web/app.py | 22 +- l4d2web/cli.py | 27 + l4d2web/config.py | 20 + l4d2web/db.py | 3 + l4d2web/pyproject.toml | 1 + l4d2web/routes/overlay_routes.py | 22 +- l4d2web/services/l4d2_facade.py | 14 +- l4d2web/services/security.py | 23 +- l4d2web/tests/test_auth.py | 44 + l4d2web/tests/test_config.py | 38 + l4d2web/tests/test_l4d2_facade.py | 7 +- l4d2web/tests/test_overlays.py | 71 +- 45 files changed, 2604 insertions(+), 172 deletions(-) create mode 100644 deploy/README.md create mode 100755 deploy/deploy-test-server.sh create mode 100644 deploy/files/etc/sudoers.d/left4me create mode 100644 deploy/files/usr/local/lib/systemd/system/left4me-server@.service create mode 100644 deploy/files/usr/local/lib/systemd/system/left4me-web.service create mode 100755 deploy/files/usr/local/libexec/left4me/left4me-journalctl create mode 100755 deploy/files/usr/local/libexec/left4me/left4me-systemctl create mode 100644 deploy/templates/etc/left4me/host.env create mode 100644 deploy/templates/etc/left4me/web.env.template create mode 100644 deploy/tests/test_deploy_artifacts.py create mode 100644 docs/superpowers/plans/2026-05-06-left4me-deployment.md create mode 100644 docs/superpowers/specs/2026-05-06-left4me-deployment-design.md create mode 100644 l4d2host/paths.py create mode 100644 l4d2host/service_control.py delete mode 100644 l4d2host/systemd_user.py delete mode 100644 l4d2host/templates/l4d2@.service create mode 100644 l4d2host/tests/test_paths.py create mode 100644 l4d2web/tests/test_config.py diff --git a/AGENTS.md b/AGENTS.md index 07fc24a..de11cc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,8 @@ Do not invent architecture outside these plans unless explicitly requested. - CLI read commands are allowed for web/host boundary consistency: - `status --json` - `logs --lines --follow/--no-follow` -- Hard-coded paths under `/opt/l4d2`. +- Runtime paths are rooted at `LEFT4ME_ROOT`, defaulting to `/var/lib/left4me`. +- Deployment/config management owns global units under `/usr/local/lib/systemd/system` and privileged helpers under `/usr/local/libexec/left4me`. - Overlays are external directories (no overlay content management here). - Fail-fast subprocess behavior; pass raw stderr; propagate return code. - No lock manager, no rollback, no preflight runtime checks. diff --git a/README.md b/README.md index c378514..91ea925 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ ## Status -This repository is currently in planning phase. -Implementation plans are the source of truth: +Implementation plans remain the source of truth for architecture and task sequencing: - `docs/superpowers/plans/2026-04-22-l4d2-host-lib-v1.md` - `docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md` @@ -27,7 +26,7 @@ Implementation plans are the source of truth: - `status --json` - `logs --lines --follow/--no-follow` - The web app calls host operations through `l4d2ctl`, not direct `l4d2host` imports. -- Runtime paths are hard-coded under `/opt/l4d2`. +- Deployment uses `/var/lib/left4me` for runtime state, `/opt/left4me` for repository contents and the virtualenv, `/etc/left4me` for environment files, and global units under `/usr/local/lib/systemd/system`. - Overlay handling is directory-based and externally populated. - No lock manager, no rollback, no preflight checks in host library. - CLI propagates subprocess failures via stderr and return code. @@ -44,8 +43,13 @@ Implementation plans are the source of truth: - `l4d2host/` - `l4d2web/` +- `deploy/` - `docs/superpowers/plans/` +## Deployment + +See `deploy/README.md` for the Linux test deployment contract, including the runtime user, target filesystem layout, systemd units, privileged helpers, sudoers rules, admin bootstrap, and overlay reference rules. + ## Tech Stack (planned) - Python 3.12+ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..704591e --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,72 @@ +# left4me Deployment + +This directory contains the production-like test deployment for a Linux server. It installs the repository into a fixed host layout, configures a dedicated runtime user, installs systemd units, and wires the web app to host operations through privileged helper commands. + +## Target Layout + +The deployment uses these paths: + +- `/etc/left4me/host.env`: host library environment configuration. +- `/etc/left4me/web.env`: web app environment configuration. +- `/opt/left4me/.venv`: Python virtual environment for deployed commands. +- `/opt/left4me`: deployed repository contents. +- `/var/lib/left4me/left4me.db`: SQLite database used by the web app. +- `/var/lib/left4me/installation`: shared L4D2 installation. +- `/var/lib/left4me/overlays`: externally managed overlay directories. +- `/var/lib/left4me/instances`: rendered instance specifications and per-instance state. +- `/var/lib/left4me/runtime`: per-instance runtime mount directories. +- `/var/lib/left4me/tmp`: temporary files used by deployment/runtime operations. +- `/usr/local/lib/systemd/system`: global systemd unit files, including `left4me-server@.service`. +- `/usr/local/libexec/left4me`: privileged helper commands, including `left4me-systemctl` and `left4me-journalctl`. +- `/etc/sudoers.d/left4me`: sudoers rules allowing the web/runtime commands to call the helpers non-interactively. + +Static units are generated for `/var/lib/left4me`. If `LEFT4ME_ROOT` changes, regenerate and reinstall the unit files instead of reusing the existing static units. + +## Runtime User + +The deployment creates and runs host operations as the dedicated runtime user: + +- Username: `left4me` +- Home: `/var/lib/left4me` +- Shell: `/usr/sbin/nologin` + +## Running A Test Deployment + +Run the deployment from the repository root: + +```bash +deploy/deploy-test-server.sh deploy-user@example-host +``` + +The SSH user must be able to run `sudo` on the target host. The deployment configures system packages, directories, environment files, helper scripts, sudoers rules, Python dependencies, and systemd units. + +## Admin Bootstrap + +Set the bootstrap credentials in the environment when creating the first admin user: + +```bash +LEFT4ME_ADMIN_USERNAME=admin \ +LEFT4ME_ADMIN_PASSWORD='change-me' \ +flask create-user "$LEFT4ME_ADMIN_USERNAME" --admin +``` + +Use a strong one-time password and rotate it after first login if needed. + +## Overlay References + +Overlay references are relative paths below `${LEFT4ME_ROOT}/overlays`. With the default deployment root, they resolve under `/var/lib/left4me/overlays`. + +Valid examples: + +- `standard` +- `competitive/base` +- `users/42/custom` + +Invalid references are rejected: + +- Absolute paths such as `/srv/overlay`. +- Parent traversal such as `../other` or `competitive/../../base`. +- Empty path components such as `competitive//base`. +- Symlink escapes that resolve outside `${LEFT4ME_ROOT}/overlays`. + +Overlay content is external to the host library and deployment contract. Populate overlay directories separately before referencing them from blueprints or instance specs. diff --git a/deploy/deploy-test-server.sh b/deploy/deploy-test-server.sh new file mode 100755 index 0000000..842276c --- /dev/null +++ b/deploy/deploy-test-server.sh @@ -0,0 +1,176 @@ +#!/bin/sh +set -eu + +usage() { + printf 'Usage: %s \n' "$0" >&2 + exit 2 +} + +if [ "$#" -ne 1 ]; then + usage +fi + +target=$1 +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +tmp_dir=$(mktemp -d) +archive="$tmp_dir/left4me.tar.gz" + +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT INT HUP TERM + +tar -czf "$archive" \ + --exclude .git \ + --exclude .venv \ + --exclude __pycache__ \ + --exclude .pytest_cache \ + --exclude '*.egg-info' \ + --exclude 'l4d2web.db*' \ + -C "$repo_root" . + +remote_tmp=$(ssh "$target" 'mktemp -d') +scp "$archive" "$target:$remote_tmp/left4me.tar.gz" + +admin_username_file= +admin_password_file= +if [ "${LEFT4ME_ADMIN_USERNAME+x}" = x ] && [ "${LEFT4ME_ADMIN_PASSWORD+x}" = x ]; then + admin_username_file="$tmp_dir/admin_username" + admin_password_file="$tmp_dir/admin_password" + umask 077 + printf '%s' "$LEFT4ME_ADMIN_USERNAME" > "$admin_username_file" + printf '%s' "$LEFT4ME_ADMIN_PASSWORD" > "$admin_password_file" + scp "$admin_username_file" "$target:$remote_tmp/admin_username" + scp "$admin_password_file" "$target:$remote_tmp/admin_password" +fi + +ssh "$target" sh -s -- "$remote_tmp" <<'REMOTE' +set -eu + +remote_tmp=$1 +archive="$remote_tmp/left4me.tar.gz" +repo_tmp="$remote_tmp/repo" + +if [ "$(id -u)" -eq 0 ]; then + sudo_cmd= +else + sudo_cmd=sudo +fi + +run_as_left4me() { + sudo -u left4me "$@" +} + +run_left4me_with_env() { + run_as_left4me sh -c 'set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; exec "$@"' sh "$@" +} + +cleanup_remote() { + rm -rf "$remote_tmp" +} +trap cleanup_remote EXIT INT HUP TERM + +if ! id left4me >/dev/null 2>&1; then + $sudo_cmd useradd --system --home-dir /var/lib/left4me --create-home --shell /usr/sbin/nologin left4me +fi + +if command -v apt-get >/dev/null 2>&1; then + $sudo_cmd apt-get update + $sudo_cmd apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo +elif command -v dnf >/dev/null 2>&1; then + $sudo_cmd dnf install -y python3 python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo +else + printf 'Unsupported package manager: expected apt-get or dnf\n' >&2 + exit 1 +fi + +$sudo_cmd mkdir -p \ + /etc/left4me \ + /opt/left4me \ + /usr/local/lib/systemd/system \ + /usr/local/libexec/left4me \ + /var/lib/left4me/installation \ + /var/lib/left4me/overlays \ + /var/lib/left4me/instances \ + /var/lib/left4me/runtime \ + /var/lib/left4me/tmp + +$sudo_cmd chown -R left4me:left4me /var/lib/left4me /opt/left4me + +mkdir -p "$repo_tmp" +tar -xzf "$archive" -C "$repo_tmp" + +if [ -d /opt/left4me/.venv ]; then + $sudo_cmd mv /opt/left4me/.venv "$remote_tmp/venv" +fi +$sudo_cmd find /opt/left4me -mindepth 1 -maxdepth 1 -exec rm -rf {} + +$sudo_cmd cp -R "$repo_tmp"/. /opt/left4me/ +if [ -d "$remote_tmp/venv" ]; then + $sudo_cmd mv "$remote_tmp/venv" /opt/left4me/.venv +fi +$sudo_cmd chown -R left4me:left4me /opt/left4me + +$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service +$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service +$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl +$sudo_cmd cp /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl +$sudo_cmd chmod 0755 /usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-journalctl +$sudo_cmd cp /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me +$sudo_cmd chmod 0440 /etc/sudoers.d/left4me +$sudo_cmd visudo -cf /etc/sudoers.d/left4me + +$sudo_cmd cp /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env +$sudo_cmd chmod 0644 /etc/left4me/host.env + +if [ ! -f /etc/left4me/web.env ]; then + secret_key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') + tmp_web_env="$remote_tmp/web.env" + { + printf 'DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n' + printf 'SECRET_KEY=%s\n' "$secret_key" + printf 'JOB_WORKER_THREADS=4\n' + } > "$tmp_web_env" + $sudo_cmd install -m 0640 -o root -g left4me "$tmp_web_env" /etc/left4me/web.env +fi + +if [ ! -x /opt/left4me/.venv/bin/python ]; then + run_as_left4me python3 -m venv /opt/left4me/.venv +fi +run_as_left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip +run_as_left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web + +run_left4me_with_env env \ + JOB_WORKER_ENABLED=false \ + /opt/left4me/.venv/bin/python -c "from l4d2web.app import create_app; create_app()" + +if [ -f "$remote_tmp/admin_username" ] && [ -f "$remote_tmp/admin_password" ]; then + LEFT4ME_ADMIN_USERNAME=$(cat "$remote_tmp/admin_username") + LEFT4ME_ADMIN_PASSWORD=$(cat "$remote_tmp/admin_password") + if ! create_user_output=$(run_left4me_with_env env \ + JOB_WORKER_ENABLED=false \ + LEFT4ME_ADMIN_PASSWORD="$LEFT4ME_ADMIN_PASSWORD" \ + /opt/left4me/.venv/bin/flask --app l4d2web.app:create_app create-user "$LEFT4ME_ADMIN_USERNAME" --admin 2>&1); then + case "$create_user_output" in + *'user already exists'*) printf '%s\n' "$create_user_output" ;; + *) printf '%s\n' "$create_user_output" >&2; exit 1 ;; + esac + else + printf '%s\n' "$create_user_output" + fi +fi + +$sudo_cmd systemctl daemon-reload +$sudo_cmd systemctl enable --now left4me-web.service +$sudo_cmd systemctl restart left4me-web.service +for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS http://127.0.0.1:8000/health; then + exit 0 + fi + sleep 1 +done + +$sudo_cmd systemctl status left4me-web.service --no-pager >&2 || true +$sudo_cmd journalctl -u left4me-web.service -n 80 --no-pager >&2 || true +exit 1 +REMOTE diff --git a/deploy/files/etc/sudoers.d/left4me b/deploy/files/etc/sudoers.d/left4me new file mode 100644 index 0000000..3f0f864 --- /dev/null +++ b/deploy/files/etc/sudoers.d/left4me @@ -0,0 +1,3 @@ +Defaults:left4me !requiretty +left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl * +left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl * diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-server@.service b/deploy/files/usr/local/lib/systemd/system/left4me-server@.service new file mode 100644 index 0000000..b0e3997 --- /dev/null +++ b/deploy/files/usr/local/lib/systemd/system/left4me-server@.service @@ -0,0 +1,27 @@ +[Unit] +Description=left4me server instance %i +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=left4me +Group=left4me +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/var/lib/left4me/instances/%i/instance.env +WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2 +ExecStart=/var/lib/left4me/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} $L4D2_ARGS +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectHome=true +ProtectSystem=strict +ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays +ReadWritePaths=/var/lib/left4me/runtime/%i +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-web.service b/deploy/files/usr/local/lib/systemd/system/left4me-web.service new file mode 100644 index 0000000..5449e07 --- /dev/null +++ b/deploy/files/usr/local/lib/systemd/system/left4me-web.service @@ -0,0 +1,23 @@ +[Unit] +Description=left4me web application +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=left4me +Group=left4me +WorkingDirectory=/opt/left4me +Environment=HOME=/var/lib/left4me +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/etc/left4me/web.env +ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 1 --threads 8 --bind 0.0.0.0:8000 'l4d2web.app:create_app()' +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/var/lib/left4me + +[Install] +WantedBy=multi-user.target diff --git a/deploy/files/usr/local/libexec/left4me/left4me-journalctl b/deploy/files/usr/local/libexec/left4me/left4me-journalctl new file mode 100755 index 0000000..2e5d3df --- /dev/null +++ b/deploy/files/usr/local/libexec/left4me/left4me-journalctl @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +usage() { + printf '%s\n' "usage: left4me-journalctl --lines --follow|--no-follow" >&2 + exit 2 +} + +validate_name() { + name=$1 + [ -n "$name" ] || usage + case "$name" in + .*|*..*|*/*|*\\*) usage ;; + esac + case "$name" in + *[!A-Za-z0-9_.-]*) usage ;; + esac +} + +[ "$#" -eq 4 ] || usage +name=$1 +lines_flag=$2 +lines=$3 +follow_flag=$4 + +validate_name "$name" +[ "$lines_flag" = "--lines" ] || usage +case "$lines" in + ''|*[!0-9]*) usage ;; +esac + +follow_arg= +case "$follow_flag" in + --follow) follow_arg=-f ;; + --no-follow) ;; + *) usage ;; +esac + +unit="left4me-server@${name}.service" +if [ -x /bin/journalctl ]; then + journalctl=/bin/journalctl +elif [ -x /usr/bin/journalctl ]; then + journalctl=/usr/bin/journalctl +else + printf '%s\n' 'journalctl not found at /bin/journalctl or /usr/bin/journalctl' >&2 + exit 69 +fi + +if [ -n "$follow_arg" ]; then + exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg" +fi + +exec "$journalctl" -u "$unit" -n "$lines" -o cat diff --git a/deploy/files/usr/local/libexec/left4me/left4me-systemctl b/deploy/files/usr/local/libexec/left4me/left4me-systemctl new file mode 100755 index 0000000..d7ab8e5 --- /dev/null +++ b/deploy/files/usr/local/libexec/left4me/left4me-systemctl @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +usage() { + printf '%s\n' "usage: left4me-systemctl start|stop|show " >&2 + exit 2 +} + +validate_name() { + name=$1 + [ -n "$name" ] || usage + case "$name" in + .*|*..*|*/*|*\\*) usage ;; + esac + case "$name" in + *[!A-Za-z0-9_.-]*) usage ;; + esac +} + +[ "$#" -eq 2 ] || usage +action=$1 +name=$2 + +case "$action" in + start|stop|show) ;; + *) usage ;; +esac + +validate_name "$name" +unit="left4me-server@${name}.service" +if [ -x /bin/systemctl ]; then + systemctl=/bin/systemctl +elif [ -x /usr/bin/systemctl ]; then + systemctl=/usr/bin/systemctl +else + printf '%s\n' 'systemctl not found at /bin/systemctl or /usr/bin/systemctl' >&2 + exit 69 +fi + +case "$action" in + start) exec "$systemctl" start "$unit" ;; + stop) exec "$systemctl" stop "$unit" ;; + show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;; +esac diff --git a/deploy/templates/etc/left4me/host.env b/deploy/templates/etc/left4me/host.env new file mode 100644 index 0000000..ec12896 --- /dev/null +++ b/deploy/templates/etc/left4me/host.env @@ -0,0 +1,2 @@ +# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes. +LEFT4ME_ROOT=/var/lib/left4me diff --git a/deploy/templates/etc/left4me/web.env.template b/deploy/templates/etc/left4me/web.env.template new file mode 100644 index 0000000..876a80c --- /dev/null +++ b/deploy/templates/etc/left4me/web.env.template @@ -0,0 +1,3 @@ +DATABASE_URL=sqlite:////var/lib/left4me/left4me.db +SECRET_KEY=replace-with-generated-secret +JOB_WORKER_THREADS=4 diff --git a/deploy/tests/test_deploy_artifacts.py b/deploy/tests/test_deploy_artifacts.py new file mode 100644 index 0000000..c102572 --- /dev/null +++ b/deploy/tests/test_deploy_artifacts.py @@ -0,0 +1,186 @@ +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" +SYSTEMCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-systemctl" +JOURNALCTL_HELPER = DEPLOY / "files/usr/local/libexec/left4me/left4me-journalctl" +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 "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 "NoNewPrivileges=true" in unit + assert "PrivateTmp=true" in unit + assert "ProtectSystem=full" in unit + assert "ReadWritePaths=/var/lib/left4me" 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 "/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 + + +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 "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_shell_syntax() -> None: + subprocess.run(["sh", "-n", str(DEPLOY_SCRIPT)], check=True) diff --git a/docs/superpowers/plans/2026-05-06-left4me-deployment.md b/docs/superpowers/plans/2026-05-06-left4me-deployment.md new file mode 100644 index 0000000..56e7ab2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-left4me-deployment.md @@ -0,0 +1,1136 @@ +# Left4me Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a production-like test deployment path that installs the current working tree on a Linux host as `left4me`, with configurable runtime state, root-owned systemd units, and constrained sudo helpers for game-server control. + +**Architecture:** Runtime state is rooted at `LEFT4ME_ROOT`, defaulting to `/var/lib/left4me`, while deployed code lives directly in `/opt/left4me` with its virtualenv at `/opt/left4me/.venv`. The web app runs as a global system service, and each game server runs as a root-owned global template unit `left4me-server@.service` under `/usr/local/lib/systemd/system/`. `l4d2ctl` manages server units through allowlisted helper commands invoked with `sudo -n`, not by writing user units. + +**Tech Stack:** Python 3.12+, Typer, Flask, SQLAlchemy, pytest, POSIX shell, systemd, sudo, SteamCMD, fuse-overlayfs. + +--- + +> **Approval gate:** This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation. + +## Source Design + +- `docs/superpowers/specs/2026-05-06-left4me-deployment-design.md` + +## File Map + +- `l4d2host/paths.py`: central `LEFT4ME_ROOT` and safe overlay-ref resolution. +- `l4d2host/instances.py`: derive installation, overlays, instances, and runtime directories from `LEFT4ME_ROOT`; stop installing user units. +- `l4d2host/steam_install.py`: install SteamCMD app content into `${LEFT4ME_ROOT}/installation` by default. +- `l4d2host/service_control.py`: call constrained sudo helpers for start, stop, status, and logs. +- `l4d2host/status.py`: parse status from `left4me-systemctl show` helper output. +- `l4d2host/logs.py`: stream logs through `left4me-journalctl` helper. +- `l4d2host/pyproject.toml`: remove the user-unit template package data if the template file is removed. +- `l4d2host/tests/test_paths.py`: cover root config and overlay-ref validation. +- `l4d2host/tests/test_initialize.py`: cover initialized paths under the configured root. +- `l4d2host/tests/test_lifecycle.py`: cover helper-based start/stop/delete commands. +- `l4d2host/tests/test_status.py`: cover helper-based status parsing. +- `l4d2host/tests/test_logs.py`: cover helper-based log streaming command construction. +- `l4d2web/config.py`: read deployment config from environment. +- `l4d2web/cli.py`: add non-public user/admin bootstrap command. +- `l4d2web/services/security.py`: validate overlay refs rather than absolute overlay paths. +- `l4d2web/routes/overlay_routes.py`: store validated overlay refs in the existing `Overlay.path` column. +- `l4d2web/services/l4d2_facade.py`: emit overlay refs from `Overlay.path` into generated host specs. +- `l4d2web/pyproject.toml`: include `gunicorn` for the systemd web service. +- `l4d2web/tests/test_config.py`: cover environment-derived config. +- `l4d2web/tests/test_auth.py`: cover user/admin bootstrap CLI. +- `l4d2web/tests/test_overlays.py`: cover safe overlay refs. +- `l4d2web/tests/test_l4d2_facade.py`: cover generated specs using overlay refs. +- `deploy/files/usr/local/lib/systemd/system/left4me-web.service`: global web systemd service. +- `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`: global per-server systemd template. +- `deploy/files/usr/local/libexec/left4me/left4me-systemctl`: root-run helper for validated service actions. +- `deploy/files/usr/local/libexec/left4me/left4me-journalctl`: root-run helper for validated journal reads. +- `deploy/files/etc/sudoers.d/left4me`: allowlist for the helper commands. +- `deploy/templates/etc/left4me/host.env`: default host env file. +- `deploy/templates/etc/left4me/web.env.template`: web env template without committed secrets. +- `deploy/deploy-test-server.sh`: idempotent SSH deployment script for a sudo-capable deploy user. +- `deploy/tests/test_deploy_artifacts.py`: static checks for deployment artifacts. +- `deploy/README.md`: operator documentation for the test deployment. +- `README.md`, `l4d2host/README.md`, `l4d2web/README.md`, `AGENTS.md`: document the deployment contract changes. + +## Locked Deployment Layout + +Remote runtime user: + +```text +user: left4me +home: /var/lib/left4me +shell: /usr/sbin/nologin +``` + +Remote paths: + +```text +/etc/left4me/host.env +/etc/left4me/web.env +/opt/left4me/.venv +/opt/left4me/ +/var/lib/left4me/left4me.db +/var/lib/left4me/installation +/var/lib/left4me/overlays +/var/lib/left4me/instances +/var/lib/left4me/runtime +/var/lib/left4me/tmp +/usr/local/lib/systemd/system/left4me-web.service +/usr/local/lib/systemd/system/left4me-server@.service +/usr/local/libexec/left4me/left4me-systemctl +/usr/local/libexec/left4me/left4me-journalctl +/etc/sudoers.d/left4me +``` + +Overlay refs are relative refs under `${LEFT4ME_ROOT}/overlays`. Valid examples are `standard`, `competitive/base`, and `users/42/custom`. Absolute paths, `..`, empty components, and symlink escapes are rejected. + +### Task 1: Add Configurable Host Root And Overlay Refs + +**Files:** +- Create: `l4d2host/paths.py` +- Modify: `l4d2host/instances.py` +- Modify: `l4d2host/steam_install.py` +- Create: `l4d2host/tests/test_paths.py` +- Modify: `l4d2host/tests/test_initialize.py` +- Modify: `l4d2host/tests/test_install.py` + +- [ ] **Step 1: Write failing host path tests** + +Create `l4d2host/tests/test_paths.py`: + +```python +from pathlib import Path + +import pytest + +from l4d2host.paths import get_left4me_root, overlay_path, validate_overlay_ref + + +def test_left4me_root_defaults_to_var_lib(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LEFT4ME_ROOT", raising=False) + + assert get_left4me_root() == Path("/var/lib/left4me") + + +def test_left4me_root_can_be_set_by_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", "/srv/left4me") + + assert get_left4me_root() == Path("/srv/left4me") + + +@pytest.mark.parametrize("ref", ["standard", "competitive/base", "users/42/custom"]) +def test_overlay_ref_accepts_safe_relative_refs(ref: str) -> None: + assert validate_overlay_ref(ref) == ref + + +@pytest.mark.parametrize("ref", ["", "/tmp/bad", "../bad", "bad/../evil", "bad//evil", " bad"]) +def test_overlay_ref_rejects_unsafe_refs(ref: str) -> None: + with pytest.raises(ValueError): + validate_overlay_ref(ref) + + +def test_overlay_path_resolves_under_root(tmp_path: Path) -> None: + assert overlay_path("standard", root=tmp_path) == tmp_path / "overlays" / "standard" +``` + +- [ ] **Step 2: Run path tests and verify failure** + +Run: `pytest l4d2host/tests/test_paths.py -q` + +Expected: FAIL because `l4d2host.paths` does not exist. + +- [ ] **Step 3: Implement `l4d2host.paths`** + +Create `l4d2host/paths.py`: + +```python +from pathlib import Path +import os + + +DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me") + + +def get_left4me_root() -> Path: + raw = os.environ.get("LEFT4ME_ROOT", str(DEFAULT_LEFT4ME_ROOT)).strip() + if not raw: + raise ValueError("LEFT4ME_ROOT must not be empty") + return Path(raw) + + +def validate_overlay_ref(raw: str) -> str: + ref = raw.strip() + if not ref: + raise ValueError("overlay ref must not be empty") + path = Path(ref) + if path.is_absolute(): + raise ValueError("overlay ref must be relative") + if any(part in {"", ".", ".."} for part in path.parts): + raise ValueError("overlay ref must not contain empty, current, or parent components") + if ref != path.as_posix(): + raise ValueError("overlay ref must use forward-slash path separators") + return ref + + +def overlay_path(ref: str, *, root: Path | None = None) -> Path: + base = root if root is not None else get_left4me_root() + safe_ref = validate_overlay_ref(ref) + candidate = (base / "overlays" / safe_ref).resolve(strict=False) + overlay_root = (base / "overlays").resolve(strict=False) + if candidate != overlay_root and overlay_root not in candidate.parents: + raise ValueError("overlay ref escapes overlay root") + return candidate +``` + +- [ ] **Step 4: Update host instance and install defaults** + +Modify `l4d2host/instances.py` so functions take `root: Path | None = None`, resolve `root = get_left4me_root()` when omitted, and build lowerdirs with `overlay_path`: + +```python +from l4d2host.paths import get_left4me_root, overlay_path + + +def _root_or_default(root: Path | None) -> Path: + return root if root is not None else get_left4me_root() +``` + +Use `_root_or_default(root)` at the top of `initialize_instance`, `start_instance`, `stop_instance`, and `delete_instance`. In `initialize_instance`, replace direct overlay path construction with: + +```python +lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays] +lowerdirs.append(str(root / "installation")) +``` + +Modify `l4d2host/steam_install.py` so `SteamInstaller()` defaults to `${LEFT4ME_ROOT}/installation`: + +```python +from l4d2host.paths import get_left4me_root + + +class SteamInstaller: + def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"): + self.install_dir = install_dir if install_dir is not None else get_left4me_root() / "installation" + self.steamcmd = steamcmd +``` + +- [ ] **Step 5: Update initialize and install tests** + +In `l4d2host/tests/test_initialize.py`, add: + +```python +def test_initialize_uses_configured_left4me_root(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + spec = tmp_path / "spec.yaml" + spec.write_text("port: 27015\noverlays: [standard]\n") + + initialize_instance("alpha", spec) + + env = (tmp_path / "instances/alpha/instance.env").read_text() + assert f"L4D2_LOWERDIRS={tmp_path}/overlays/standard:{tmp_path}/installation" in env +``` + +In `l4d2host/tests/test_install.py`, add: + +```python +def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch): + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + calls = [] + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: calls.append(cmd)) + + SteamInstaller().install_or_update() + + assert str(tmp_path / "installation") in calls[0] +``` + +- [ ] **Step 6: Run host path tests and verify pass** + +Run: `pytest l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py -q` + +Expected: PASS. + +- [ ] **Step 7: Commit configurable host root** + +```bash +git add l4d2host/paths.py l4d2host/instances.py l4d2host/steam_install.py l4d2host/tests/test_paths.py l4d2host/tests/test_initialize.py l4d2host/tests/test_install.py +git commit -m "feat(l4d2): configure host state root" +``` + +### Task 2: Switch Host Control To Global Unit Helpers + +**Files:** +- Create: `l4d2host/service_control.py` +- Modify: `l4d2host/instances.py` +- Modify: `l4d2host/status.py` +- Modify: `l4d2host/logs.py` +- Modify: `l4d2host/pyproject.toml` +- Delete: `l4d2host/systemd_user.py` +- Delete: `l4d2host/templates/l4d2@.service` +- Modify: `l4d2host/tests/test_lifecycle.py` +- Modify: `l4d2host/tests/test_status.py` +- Modify: `l4d2host/tests/test_logs.py` + +- [ ] **Step 1: Write failing service-control tests** + +Create or extend `l4d2host/tests/test_status.py` with: + +```python +from l4d2host.status import get_instance_status + + +def test_status_uses_left4me_systemctl_helper(monkeypatch) -> None: + calls = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(cmd) + return type("Result", (), {"stdout": "ActiveState=active\nSubState=running\n"})() + + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + + status = get_instance_status("alpha") + + assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "show", "alpha"]] + assert status.state == "running" +``` + +Extend `l4d2host/tests/test_logs.py` with: + +```python +def test_logs_use_left4me_journalctl_helper(monkeypatch) -> None: + calls = [] + + def fake_stream(cmd): + calls.append(cmd) + return iter(["one"]) + + monkeypatch.setattr("l4d2host.service_control.stream_command", fake_stream) + + from l4d2host.logs import stream_instance_logs + + assert list(stream_instance_logs("alpha", lines=25, follow=False)) == ["one"] + assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-journalctl", "alpha", "--lines", "25", "--no-follow"]] +``` + +Update `l4d2host/tests/test_lifecycle.py` start/stop expectations to use `left4me-systemctl` helper instead of `systemctl --user`. + +- [ ] **Step 2: Run service-control tests and verify failure** + +Run: `pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q` + +Expected: FAIL because `l4d2host.service_control` does not exist and lifecycle code still uses user units. + +- [ ] **Step 3: Implement service-control module** + +Create `l4d2host/service_control.py`: + +```python +import subprocess +from typing import Iterator + +from l4d2host.process import run_command + + +SYSTEMCTL_HELPER = "/usr/local/libexec/left4me/left4me-systemctl" +JOURNALCTL_HELPER = "/usr/local/libexec/left4me/left4me-journalctl" + + +def systemctl_command(action: str, name: str) -> list[str]: + return ["sudo", "-n", SYSTEMCTL_HELPER, action, name] + + +def journalctl_command(name: str, *, lines: int = 200, follow: bool = True) -> list[str]: + return [ + "sudo", + "-n", + JOURNALCTL_HELPER, + name, + "--lines", + str(lines), + "--follow" if follow else "--no-follow", + ] + + +def start_service(name: str, **kwargs) -> None: + run_command(systemctl_command("start", name), **kwargs) + + +def stop_service(name: str, **kwargs) -> None: + run_command(systemctl_command("stop", name), **kwargs) + + +def show_service(name: str): + return run_command(systemctl_command("show", name)) + + +def stream_command(cmd: list[str]) -> Iterator[str]: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1) + try: + if proc.stdout is None: + return + for raw in iter(proc.stdout.readline, ""): + yield raw.rstrip("\n") + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) + + +def stream_journal(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: + return stream_command(journalctl_command(name, lines=lines, follow=follow)) +``` + +- [ ] **Step 4: Update host lifecycle/status/log implementations** + +In `l4d2host/instances.py`, remove `ensure_template_unit` and `daemon_reload` imports and calls. Replace service operations: + +```python +from l4d2host.service_control import start_service, stop_service +``` + +Use: + +```python +start_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel) +``` + +and: + +```python +stop_service(name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel) +``` + +In `delete_instance`, call `stop_service(...)` before unmount/removal when instance or runtime exists. + +In `l4d2host/status.py`, replace direct `systemctl --user show` with: + +```python +from l4d2host.service_control import show_service +``` + +and call `result = show_service(name)`. + +In `l4d2host/logs.py`, replace direct `journalctl --user` with: + +```python +from l4d2host.service_control import stream_journal + + +def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: + return stream_journal(name, lines=lines, follow=follow) +``` + +Delete `l4d2host/systemd_user.py` and `l4d2host/templates/l4d2@.service`. In `l4d2host/pyproject.toml`, remove `"l4d2host.templates"` from `packages` and remove the `tool.setuptools.package-data` entry for templates. + +- [ ] **Step 5: Run host service-control tests and verify pass** + +Run: `pytest l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py -q` + +Expected: PASS. + +- [ ] **Step 6: Commit global helper service boundary** + +```bash +git add l4d2host/service_control.py l4d2host/instances.py l4d2host/status.py l4d2host/logs.py l4d2host/pyproject.toml l4d2host/tests/test_lifecycle.py l4d2host/tests/test_status.py l4d2host/tests/test_logs.py +git rm l4d2host/systemd_user.py l4d2host/templates/l4d2@.service +git commit -m "feat(l4d2): manage servers through global unit helpers" +``` + +### Task 3: Add Deployment Artifacts + +**Files:** +- Create: `deploy/files/usr/local/lib/systemd/system/left4me-web.service` +- Create: `deploy/files/usr/local/lib/systemd/system/left4me-server@.service` +- Create: `deploy/files/usr/local/libexec/left4me/left4me-systemctl` +- Create: `deploy/files/usr/local/libexec/left4me/left4me-journalctl` +- Create: `deploy/files/etc/sudoers.d/left4me` +- Create: `deploy/templates/etc/left4me/host.env` +- Create: `deploy/templates/etc/left4me/web.env.template` +- Create: `deploy/tests/test_deploy_artifacts.py` + +- [ ] **Step 1: Write failing deployment artifact tests** + +Create `deploy/tests/test_deploy_artifacts.py`: + +```python +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_global_unit_names_are_product_level() -> None: + assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-web.service").exists() + assert (ROOT / "deploy/files/usr/local/lib/systemd/system/left4me-server@.service").exists() + + +def test_web_unit_uses_left4me_paths_and_env_files() -> None: + body = read("deploy/files/usr/local/lib/systemd/system/left4me-web.service") + assert "User=left4me" in body + assert "WorkingDirectory=/opt/left4me" in body + assert "EnvironmentFile=/etc/left4me/host.env" in body + assert "EnvironmentFile=/etc/left4me/web.env" in body + assert "ExecStart=/opt/left4me/.venv/bin/gunicorn" in body + + +def test_server_unit_runs_as_left4me_and_has_hardening() -> None: + body = read("deploy/files/usr/local/lib/systemd/system/left4me-server@.service") + assert "User=left4me" in body + assert "EnvironmentFile=/var/lib/left4me/instances/%i/instance.env" in body + assert "WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2" in body + assert "NoNewPrivileges=true" in body + assert "ProtectSystem=strict" in body + assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in body + + +def test_helper_scripts_reject_bad_arguments_without_systemctl() -> None: + for helper in [ + ROOT / "deploy/files/usr/local/libexec/left4me/left4me-systemctl", + ROOT / "deploy/files/usr/local/libexec/left4me/left4me-journalctl", + ]: + subprocess.run(["sh", "-n", str(helper)], check=True) + result = subprocess.run(["sh", str(helper), "bad/action", "../evil"], capture_output=True, text=True) + assert result.returncode != 0 + + +def test_sudoers_allows_only_left4me_helpers() -> None: + body = read("deploy/files/etc/sudoers.d/left4me") + assert "/usr/local/libexec/left4me/left4me-systemctl *" in body + assert "/usr/local/libexec/left4me/left4me-journalctl *" in body + assert "/bin/systemctl" not in body + assert "/bin/journalctl" not in body +``` + +- [ ] **Step 2: Run deployment artifact tests and verify failure** + +Run: `pytest deploy/tests/test_deploy_artifacts.py -q` + +Expected: FAIL because deployment artifact files do not exist. + +- [ ] **Step 3: Add web service unit** + +Create `deploy/files/usr/local/lib/systemd/system/left4me-web.service`: + +```ini +[Unit] +Description=left4me web application +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=left4me +Group=left4me +WorkingDirectory=/opt/left4me +Environment=HOME=/var/lib/left4me +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/etc/left4me/web.env +ExecStart=/opt/left4me/.venv/bin/gunicorn --workers 1 --threads 8 --bind 0.0.0.0:8000 'l4d2web.app:create_app()' +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/var/lib/left4me + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 4: Add server service unit** + +Create `deploy/files/usr/local/lib/systemd/system/left4me-server@.service`: + +```ini +[Unit] +Description=left4me server instance %i +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=left4me +Group=left4me +EnvironmentFile=/etc/left4me/host.env +EnvironmentFile=/var/lib/left4me/instances/%i/instance.env +WorkingDirectory=/var/lib/left4me/runtime/%i/merged/left4dead2 +ExecStart=/var/lib/left4me/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} ${L4D2_ARGS} +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectHome=true +ProtectSystem=strict +ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays +ReadWritePaths=/var/lib/left4me/runtime/%i +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 5: Add sudo helper scripts** + +Create `deploy/files/usr/local/libexec/left4me/left4me-systemctl`: + +```sh +#!/bin/sh +set -eu + +usage() { + printf '%s\n' 'usage: left4me-systemctl start|stop|show ' >&2 + exit 64 +} + +validate_name() { + name=$1 + case "$name" in + ''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;; + esac +} + +[ "$#" -eq 2 ] || usage +action=$1 +name=$2 +validate_name "$name" +unit="left4me-server@${name}.service" + +case "$action" in + start) exec /bin/systemctl start "$unit" ;; + stop) exec /bin/systemctl stop "$unit" ;; + show) exec /bin/systemctl show "$unit" --property=ActiveState --property=SubState --no-pager ;; + *) usage ;; +esac +``` + +Create `deploy/files/usr/local/libexec/left4me/left4me-journalctl`: + +```sh +#!/bin/sh +set -eu + +usage() { + printf '%s\n' 'usage: left4me-journalctl --lines --follow|--no-follow' >&2 + exit 64 +} + +validate_name() { + name=$1 + case "$name" in + ''|.*|*..*|*/*|*\\*|*[!A-Za-z0-9_.-]*) usage ;; + esac +} + +[ "$#" -eq 4 ] || usage +name=$1 +lines_flag=$2 +lines=$3 +follow_flag=$4 +validate_name "$name" +[ "$lines_flag" = '--lines' ] || usage +case "$lines" in ''|*[!0-9]*) usage ;; esac + +unit="left4me-server@${name}.service" + +case "$follow_flag" in + --follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat -f ;; + --no-follow) exec /bin/journalctl -u "$unit" -n "$lines" -o cat ;; + *) usage ;; +esac +``` + +- [ ] **Step 6: Add sudoers and env templates** + +Create `deploy/files/etc/sudoers.d/left4me`: + +```sudoers +Defaults:left4me !requiretty +left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-systemctl * +left4me ALL=(root) NOPASSWD: /usr/local/libexec/left4me/left4me-journalctl * +``` + +Create `deploy/templates/etc/left4me/host.env`: + +```sh +LEFT4ME_ROOT=/var/lib/left4me +``` + +Create `deploy/templates/etc/left4me/web.env.template`: + +```sh +DATABASE_URL=sqlite:////var/lib/left4me/left4me.db +SECRET_KEY=replace-with-generated-secret +JOB_WORKER_THREADS=4 +``` + +- [ ] **Step 7: Run deployment artifact tests and verify pass** + +Run: `pytest deploy/tests/test_deploy_artifacts.py -q` + +Expected: PASS. + +- [ ] **Step 8: Commit deployment artifacts** + +```bash +git add deploy/files deploy/templates deploy/tests/test_deploy_artifacts.py +git commit -m "feat(deploy): add left4me systemd and sudo artifacts" +``` + +### Task 4: Add Web Deployment Config And Bootstrap CLI + +**Files:** +- Modify: `l4d2web/config.py` +- Modify: `l4d2web/cli.py` +- Modify: `l4d2web/services/security.py` +- Modify: `l4d2web/routes/overlay_routes.py` +- Modify: `l4d2web/services/l4d2_facade.py` +- Modify: `l4d2web/pyproject.toml` +- Create: `l4d2web/tests/test_config.py` +- Modify: `l4d2web/tests/test_auth.py` +- Modify: `l4d2web/tests/test_overlays.py` +- Modify: `l4d2web/tests/test_l4d2_facade.py` + +- [ ] **Step 1: Write failing web config and bootstrap tests** + +Create `l4d2web/tests/test_config.py`: + +```python +from l4d2web.config import load_config + + +def test_load_config_uses_environment(monkeypatch) -> None: + monkeypatch.setenv("DATABASE_URL", "sqlite:////var/lib/left4me/left4me.db") + monkeypatch.setenv("SECRET_KEY", "secret-from-env") + monkeypatch.setenv("JOB_WORKER_THREADS", "2") + + config = load_config() + + assert config["DATABASE_URL"] == "sqlite:////var/lib/left4me/left4me.db" + assert config["SECRET_KEY"] == "secret-from-env" + assert config["JOB_WORKER_THREADS"] == 2 +``` + +Extend `l4d2web/tests/test_auth.py`: + +```python +def test_create_user_cli_creates_admin(app, monkeypatch): + monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret") + result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"]) + + assert result.exit_code == 0 + assert "created user admin" in result.output +``` + +Extend `l4d2web/tests/test_overlays.py`: + +```python +def test_overlay_ref_must_be_relative(admin_client): + r = admin_client.post("/overlays", data={"name": "bad", "path": "/tmp/bad"}) + + assert r.status_code == 400 + + +def test_admin_can_create_overlay_with_relative_ref(admin_client): + r = admin_client.post("/overlays", data={"name": "standard", "path": "standard"}) + + assert r.status_code == 302 +``` + +Update `l4d2web/tests/test_l4d2_facade.py` seed data so `Overlay.path` is a relative ref, and assert generated specs contain the path ref rather than the display name. + +- [ ] **Step 2: Run web config tests and verify failure** + +Run: `pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q` + +Expected: FAIL because `load_config`, `create-user`, and overlay-ref validation are not implemented. + +- [ ] **Step 3: Implement environment config** + +Modify `l4d2web/config.py`: + +```python +import os + + +def load_config() -> dict[str, object]: + return { + "SECRET_KEY": os.getenv("SECRET_KEY", "dev"), + "DATABASE_URL": os.getenv("DATABASE_URL", "sqlite:///l4d2web.db"), + "STATUS_REFRESH_SECONDS": int(os.getenv("STATUS_REFRESH_SECONDS", "8")), + "JOB_WORKER_THREADS": int(os.getenv("JOB_WORKER_THREADS", "4")), + "JOB_WORKER_ENABLED": os.getenv("JOB_WORKER_ENABLED", "true").lower() not in {"0", "false", "no"}, + "JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")), + "JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")), + "JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")), + } + + +DEFAULT_CONFIG = load_config() +``` + +Modify `l4d2web/app.py` to call `load_config()` inside `create_app()` instead of reusing import-time `DEFAULT_CONFIG`: + +```python +from l4d2web.config import load_config + + +app.config.from_mapping(load_config()) +``` + +- [ ] **Step 4: Implement create-user CLI** + +Modify `l4d2web/cli.py`: + +```python +import os + +from l4d2web.auth import hash_password + + +@click.command("create-user") +@click.argument("username") +@click.option("--admin", is_flag=True, default=False) +def create_user(username: str, admin: bool) -> None: + password = os.environ.get("LEFT4ME_ADMIN_PASSWORD") + if password is None: + password = click.prompt("Password", hide_input=True, confirmation_prompt=True) + with session_scope() as db: + existing = db.scalar(select(User).where(User.username == username)) + if existing is not None: + raise click.ClickException("user already exists") + db.add(User(username=username, password_digest=hash_password(password), admin=admin)) + click.echo(f"created user {username}") +``` + +Register it in `register_cli(app)`: + +```python +app.cli.add_command(create_user) +``` + +- [ ] **Step 5: Implement web overlay refs** + +Modify `l4d2web/services/security.py`: + +```python +from pathlib import Path + + +def validate_overlay_ref(raw: str) -> str: + ref = raw.strip() + if not ref: + raise ValueError("overlay ref must not be empty") + path = Path(ref) + if path.is_absolute(): + raise ValueError("overlay ref must be relative") + if any(part in {"", ".", ".."} for part in path.parts): + raise ValueError("overlay ref must not contain empty, current, or parent components") + if ref != path.as_posix(): + raise ValueError("overlay ref must use forward-slash path separators") + return ref +``` + +Modify `l4d2web/routes/overlay_routes.py` to import `validate_overlay_ref` and store the returned ref in `Overlay.path`: + +```python +from l4d2web.services.security import validate_overlay_ref + + +validated_ref = validate_overlay_ref(raw_path) +db.add(Overlay(name=name, path=validated_ref)) +``` + +Apply the same assignment in `update_overlay`. + +Modify `l4d2web/services/l4d2_facade.py` so `load_server_blueprint_bundle` selects `Overlay.path` instead of `Overlay.name`: + +```python +select(Overlay.path) +``` + +- [ ] **Step 6: Add gunicorn dependency** + +Modify `l4d2web/pyproject.toml` dependencies: + +```toml +dependencies = [ + "Flask>=3.0", + "SQLAlchemy>=2.0", + "alembic>=1.13", + "PyYAML>=6.0", + "gunicorn>=22.0", +] +``` + +- [ ] **Step 7: Run web config/bootstrap tests and verify pass** + +Run: `pytest l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py -q` + +Expected: PASS. + +- [ ] **Step 8: Commit web deployment config** + +```bash +git add l4d2web/config.py l4d2web/app.py l4d2web/cli.py l4d2web/services/security.py l4d2web/routes/overlay_routes.py l4d2web/services/l4d2_facade.py l4d2web/pyproject.toml l4d2web/tests/test_config.py l4d2web/tests/test_auth.py l4d2web/tests/test_overlays.py l4d2web/tests/test_l4d2_facade.py +git commit -m "feat(l4d2-web): add deployment config and bootstrap cli" +``` + +### Task 5: Add Test-Server Deployment Script + +**Files:** +- Create: `deploy/deploy-test-server.sh` +- Modify: `deploy/tests/test_deploy_artifacts.py` + +- [ ] **Step 1: Write failing deployment script tests** + +Extend `deploy/tests/test_deploy_artifacts.py`: + +```python +def test_deploy_script_has_safe_defaults_and_preserves_state() -> None: + script = read("deploy/deploy-test-server.sh") + 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 "pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web" in script + assert "systemctl enable --now left4me-web.service" in script + + +def test_deploy_script_shell_syntax() -> None: + subprocess.run(["sh", "-n", str(ROOT / "deploy/deploy-test-server.sh")], check=True) +``` + +- [ ] **Step 2: Run deployment script tests and verify failure** + +Run: `pytest deploy/tests/test_deploy_artifacts.py -q` + +Expected: FAIL because `deploy/deploy-test-server.sh` does not exist. + +- [ ] **Step 3: Implement test deployment script** + +Create `deploy/deploy-test-server.sh`: + +```sh +#!/bin/sh +set -eu + +usage() { + printf '%s\n' 'usage: deploy/deploy-test-server.sh ' >&2 + exit 64 +} + +[ "$#" -eq 1 ] || usage +target=$1 +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +archive=$(mktemp -t left4me-deploy.XXXXXX.tar.gz) +remote_archive=/tmp/left4me-deploy.tar.gz + +cleanup() { + rm -f "$archive" +} +trap cleanup EXIT INT TERM + +tar \ + --exclude='.git' \ + --exclude='__pycache__' \ + --exclude='.pytest_cache' \ + --exclude='*.egg-info' \ + --exclude='l4d2web.db' \ + --exclude='l4d2web.db-shm' \ + --exclude='l4d2web.db-wal' \ + -C "$repo_root" \ + -czf "$archive" \ + . + +scp "$archive" "$target:$remote_archive" + +ssh "$target" 'set -eu +if ! id left4me >/dev/null 2>&1; then + sudo useradd --system --home-dir /var/lib/left4me --create-home --shell /usr/sbin/nologin left4me +fi + +if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3 python3-venv python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo +elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y python3 python3-pip curl ca-certificates tar gzip fuse-overlayfs fuse3 sudo +else + printf "%s\n" "unsupported package manager: install python3, venv tooling, fuse-overlayfs, fuse3, sudo, curl, tar, gzip" >&2 + exit 1 +fi + +sudo mkdir -p /etc/left4me /opt/left4me /usr/local/lib/systemd/system /usr/local/libexec/left4me /var/lib/left4me/installation /var/lib/left4me/overlays /var/lib/left4me/instances /var/lib/left4me/runtime /var/lib/left4me/tmp +sudo chown -R left4me:left4me /var/lib/left4me /opt/left4me +sudo chmod 0750 /var/lib/left4me + +sudo find /opt/left4me -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} + +sudo tar -xzf /tmp/left4me-deploy.tar.gz -C /opt/left4me +sudo chown -R left4me:left4me /opt/left4me + +sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service /usr/local/lib/systemd/system/left4me-web.service +sudo install -m 0644 /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-server@.service /usr/local/lib/systemd/system/left4me-server@.service +sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-systemctl /usr/local/libexec/left4me/left4me-systemctl +sudo install -m 0755 /opt/left4me/deploy/files/usr/local/libexec/left4me/left4me-journalctl /usr/local/libexec/left4me/left4me-journalctl +sudo install -m 0440 /opt/left4me/deploy/files/etc/sudoers.d/left4me /etc/sudoers.d/left4me +sudo visudo -cf /etc/sudoers.d/left4me + +sudo install -m 0644 /opt/left4me/deploy/templates/etc/left4me/host.env /etc/left4me/host.env +if ! sudo test -f /etc/left4me/web.env; then + secret=$(python3 -c "import secrets; print(secrets.token_hex(32))") + printf "DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\nSECRET_KEY=%s\nJOB_WORKER_THREADS=4\n" "$secret" | sudo tee /etc/left4me/web.env >/dev/null + sudo chmod 0640 /etc/left4me/web.env + sudo chown root:left4me /etc/left4me/web.env +fi + +sudo -u left4me python3 -m venv /opt/left4me/.venv +sudo -u left4me /opt/left4me/.venv/bin/python -m pip install --upgrade pip +sudo -u left4me /opt/left4me/.venv/bin/python -m pip install -e /opt/left4me/l4d2host -e /opt/left4me/l4d2web + +sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; /opt/left4me/.venv/bin/python -c '"'"'from l4d2web.app import create_app; create_app()'"'"'" + +if [ -n "${LEFT4ME_ADMIN_USERNAME:-}" ] && [ -n "${LEFT4ME_ADMIN_PASSWORD:-}" ]; then + sudo -u left4me sh -c "set -a; . /etc/left4me/host.env; . /etc/left4me/web.env; set +a; LEFT4ME_ADMIN_PASSWORD=\"$LEFT4ME_ADMIN_PASSWORD\" /opt/left4me/.venv/bin/flask --app '"'"'l4d2web.app:create_app'"'"' create-user \"$LEFT4ME_ADMIN_USERNAME\" --admin || true" +fi + +sudo systemctl daemon-reload +sudo systemctl enable --now left4me-web.service +sudo systemctl restart left4me-web.service +curl -fsS http://127.0.0.1:8000/health +' +``` + +- [ ] **Step 4: Run deployment script tests and verify pass** + +Run: `pytest deploy/tests/test_deploy_artifacts.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit deployment script** + +```bash +git add deploy/deploy-test-server.sh deploy/tests/test_deploy_artifacts.py +git commit -m "feat(deploy): add ssh test deployment script" +``` + +### Task 6: Document Deployment Contract And Verify + +**Files:** +- Create: `deploy/README.md` +- Modify: `README.md` +- Modify: `l4d2host/README.md` +- Modify: `l4d2web/README.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: Write deployment README** + +Create `deploy/README.md`: + +```markdown +# left4me Deployment + +This directory contains the production-like test deployment for a Linux server. + +## Target Layout + +- `/etc/left4me/host.env` +- `/etc/left4me/web.env` +- `/opt/left4me/.venv` +- `/opt/left4me/` +- `/var/lib/left4me/left4me.db` +- `/var/lib/left4me/installation` +- `/var/lib/left4me/overlays` +- `/var/lib/left4me/instances` +- `/var/lib/left4me/runtime` +- `/var/lib/left4me/tmp` +- `/usr/local/lib/systemd/system/left4me-web.service` +- `/usr/local/lib/systemd/system/left4me-server@.service` +- `/usr/local/libexec/left4me/left4me-systemctl` +- `/usr/local/libexec/left4me/left4me-journalctl` +- `/etc/sudoers.d/left4me` + +## Runtime User + +The deployment creates a system user named `left4me` with home directory `/var/lib/left4me` and shell `/usr/sbin/nologin`. + +## Running A Test Deployment + +Run from the repository root: + +```bash +deploy/deploy-test-server.sh deploy-user@example-host +``` + +The SSH user must be able to run `sudo`. The script deploys the current local working tree, installs or updates the virtualenv, preserves `/var/lib/left4me`, installs systemd units and sudo helpers, and restarts `left4me-web.service`. + +To bootstrap an admin user during deployment: + +```bash +LEFT4ME_ADMIN_USERNAME=admin LEFT4ME_ADMIN_PASSWORD='change-me' deploy/deploy-test-server.sh deploy-user@example-host +``` + +## Overlay Refs + +Overlay refs are relative paths under `${LEFT4ME_ROOT}/overlays`, for example `standard`, `competitive/base`, and `users/42/custom`. Absolute paths, `..`, empty components, and symlink escapes are rejected. +``` + +- [ ] **Step 2: Update component docs** + +Update `README.md` with a link to `deploy/README.md` and state that production-like test deployment uses `/var/lib/left4me`, `/opt/left4me`, `/etc/left4me`, and global units under `/usr/local/lib/systemd/system`. + +Update `l4d2host/README.md` to document: + +```text +LEFT4ME_ROOT defaults to /var/lib/left4me. +l4d2ctl start/stop/status/logs use sudo -n /usr/local/libexec/left4me/left4me-systemctl and left4me-journalctl. +Deployment or config management owns left4me-server@.service. +``` + +Update `l4d2web/README.md` to document: + +```text +DATABASE_URL, SECRET_KEY, and JOB_WORKER_THREADS are read from environment. +The systemd deployment loads /etc/left4me/host.env and /etc/left4me/web.env. +Use flask create-user --admin with LEFT4ME_ADMIN_PASSWORD for bootstrap. +``` + +Update `AGENTS.md` so the host path rule references `LEFT4ME_ROOT` instead of hard-coded `/opt/l4d2`, and so global unit/helper ownership is recorded. + +- [ ] **Step 3: Run focused verification** + +Run: + +```bash +pytest l4d2host/tests -q +pytest l4d2web/tests -q +pytest deploy/tests -q +``` + +Expected: PASS for all three commands. + +- [ ] **Step 4: Run code index refresh** + +Run: `ccc index` + +Expected: command exits 0, or reports that `ccc` is unavailable. If `ccc` is unavailable, report that the index refresh was skipped because the command is missing. + +- [ ] **Step 5: Commit docs and verification updates** + +```bash +git add deploy/README.md README.md l4d2host/README.md l4d2web/README.md AGENTS.md +git commit -m "docs(deploy): document left4me deployment contract" +``` + +## Self-Review + +- Spec coverage: the plan covers `left4me` user setup, `/var/lib/left4me` home/state, `/opt/left4me` source/venv, `/etc/left4me` env files, `LEFT4ME_ROOT`, global units under `/usr/local/lib/systemd/system`, product-level server unit/helper names, constrained sudo helpers, relative overlay refs, deployment from local working tree, admin bootstrap, docs, and verification. +- Placeholder scan: no task uses unresolved placeholders, unspecified error handling, or references to undefined helper names. +- Type consistency: host root is consistently `LEFT4ME_ROOT`; server systemd unit is consistently `left4me-server@.service`; helpers are consistently `left4me-systemctl` and `left4me-journalctl`; web overlay refs are stored in existing `Overlay.path` and emitted to host specs from that column. +- Scope: the plan does not execute deployment on a real server; it adds local deploy assets and code changes needed before a separate approved deployment run. diff --git a/docs/superpowers/specs/2026-05-06-left4me-deployment-design.md b/docs/superpowers/specs/2026-05-06-left4me-deployment-design.md new file mode 100644 index 0000000..d176260 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-left4me-deployment-design.md @@ -0,0 +1,244 @@ +# Left4me Deployment Design + +## Goal + +Provide a production-like test deployment for `left4me` on a real Linux server. The deployment should be quick to run from the local working tree, while keeping the runtime layout, service ownership, and hardening model close enough to the intended production setup that problems appear early. + +## Context + +`left4me` has two components: + +- `l4d2host`, exposed through `l4d2ctl`, manages L4D2 installation, per-server initialization, lifecycle, status, and logs. +- `l4d2web` is the Flask web UI and worker process. It calls host operations through `l4d2ctl` rather than importing host internals. + +The existing code uses hard-coded `/opt/l4d2` paths and user systemd units. The deployment design replaces that with a product-level root, root-owned global units, and constrained helper commands so per-server hardening is owned by the host setup rather than by the runtime user. + +## Runtime User + +The deployment uses one runtime user: + +```text +user: left4me +home: /var/lib/left4me +shell: /usr/sbin/nologin +``` + +The SSH deployment user is separate and must have sudo privileges. The `left4me` user owns runtime state and runs the web app and game servers, but does not own systemd unit definitions, sudoers rules, or helper installation paths. + +## Filesystem Layout + +The deployed server uses these paths: + +```text +/etc/left4me/ + host.env + web.env + +/opt/left4me/ + .venv/ + + +/var/lib/left4me/ + left4me.db + installation/ + overlays/ + instances/ + runtime/ + tmp/ + +/usr/local/lib/systemd/system/ + left4me-web.service + left4me-server@.service + +/usr/local/libexec/left4me/ + left4me-systemctl + left4me-journalctl + +/etc/sudoers.d/ + left4me +``` + +`/var/lib/left4me` is both the `left4me` home directory and the product state root. This keeps mutable state in one place and avoids mixing runtime data with deployed code. + +## Configuration + +Configuration is split by component boundary: + +`/etc/left4me/host.env`: + +```text +LEFT4ME_ROOT=/var/lib/left4me +``` + +`/etc/left4me/web.env`: + +```text +DATABASE_URL=sqlite:////var/lib/left4me/left4me.db +SECRET_KEY= +JOB_WORKER_THREADS=4 +``` + +`LEFT4ME_ROOT` replaces the previous host-library hard-coded `/opt/l4d2` root. Host paths derive from it: + +```text +${LEFT4ME_ROOT}/installation +${LEFT4ME_ROOT}/overlays +${LEFT4ME_ROOT}/instances +${LEFT4ME_ROOT}/runtime +${LEFT4ME_ROOT}/tmp +``` + +The test deployment script can generate `web.env` when missing. Production config management can own both env files directly. + +## Systemd Model + +Both web and game servers use global root-owned systemd units under `/usr/local/lib/systemd/system`. + +`left4me-web.service`: + +- Runs as `User=left4me`. +- Loads `/etc/left4me/host.env` and `/etc/left4me/web.env`. +- Uses `/opt/left4me` as its working directory. +- Starts the web app from `/opt/left4me/.venv`. +- Uses one process worker because lifecycle jobs run in-process. +- Uses moderate systemd hardening, but must still allow the web worker to call `l4d2ctl`, write SQLite state, and run host lifecycle commands. + +`left4me-server@.service`: + +- Runs each game server as `User=left4me`. +- Reads generated per-server environment from `${LEFT4ME_ROOT}/instances/%i/instance.env`. +- Uses `${LEFT4ME_ROOT}/runtime/%i/merged/left4dead2` as `WorkingDirectory`. +- Starts `${LEFT4ME_ROOT}/installation/srcds_run`. +- Applies stronger hardening than the web unit because each game server is the more important sandbox boundary. + +The server unit template is root-owned. A compromised `left4me` process should not be able to edit the template to weaken future server sandboxing. + +## Privileged Helper Model + +`l4d2ctl` should not call arbitrary `sudo systemctl` or `sudo journalctl` commands. Instead it calls constrained helpers: + +```text +sudo -n /usr/local/libexec/left4me/left4me-systemctl start +sudo -n /usr/local/libexec/left4me/left4me-systemctl stop +sudo -n /usr/local/libexec/left4me/left4me-systemctl show +sudo -n /usr/local/libexec/left4me/left4me-journalctl --lines --follow|--no-follow +``` + +The helpers validate the action and server name, then map the server name to `left4me-server@.service`. The sudoers rule only allows the `left4me` user to run these helpers as root. + +## Host Library Contract Changes + +The host library changes from: + +```text +/opt/l4d2/installation +/opt/l4d2/overlays +/opt/l4d2/instances +/opt/l4d2/runtime +systemd --user units +``` + +to: + +```text +${LEFT4ME_ROOT}/installation +${LEFT4ME_ROOT}/overlays +${LEFT4ME_ROOT}/instances +${LEFT4ME_ROOT}/runtime +global left4me-server@.service managed through sudo helpers +``` + +The host library remains fail-fast and prerequisite-light. It assumes deployment or config management installed OS packages, created directories, installed units, and configured sudoers. + +## Overlay References + +Overlay configuration should store safe relative refs under `${LEFT4ME_ROOT}/overlays`, not absolute paths. + +Valid examples: + +```text +standard +competitive/base +users/42/custom +``` + +Rejected examples: + +```text +/tmp/bad +../bad +bad/../evil +bad//evil +``` + +This supports a flat overlay catalog now and leaves room for user-managed overlays later through namespaced refs such as `users//`. + +## Deployment Script + +The test deployment script lives in `deploy/deploy-test-server.sh`. It runs locally and takes an SSH target for a sudo-capable deployment user. + +The script should: + +- Archive the current local working tree. +- Copy it to the remote host. +- Create the `left4me` system user if missing. +- Install OS prerequisites when a supported package manager is detected. +- Create `/etc/left4me`, `/opt/left4me`, `/var/lib/left4me`, `/usr/local/libexec/left4me`, and `/usr/local/lib/systemd/system` as needed. +- Preserve `/var/lib/left4me` across redeployments. +- Replace repository contents under `/opt/left4me` while preserving `/opt/left4me/.venv`. +- Install deployment units, helper scripts, sudoers rules, and env templates. +- Create or update `/opt/left4me/.venv`. +- Install `l4d2host` and `l4d2web` from the deployed local source. +- Initialize the SQLite schema by importing/creating the Flask app. +- Optionally bootstrap an admin user from environment variables. +- Reload systemd and restart `left4me-web.service`. +- Verify `/health` locally on the server. + +The script is a test-server convenience, not a replacement for production config management. Production can implement the same layout with stronger package/version control. + +## Documentation Structure + +Local deployment assets live under: + +```text +deploy/ + README.md + deploy-test-server.sh + files/ + templates/ + tests/ +``` + +`deploy/files` mirrors target filesystem paths for root-owned deployment artifacts. `deploy/templates` contains env templates that may need generated secrets. `deploy/README.md` is the operator-facing guide. + +Component documentation should explain only component-specific deployment contracts: + +- `l4d2host/README.md`: `LEFT4ME_ROOT`, global service helpers, and overlay refs. +- `l4d2web/README.md`: env vars, admin bootstrap, and systemd service expectations. +- Root `README.md`: link to `deploy/README.md`. + +## Verification + +Local verification before deploying: + +```bash +pytest l4d2host/tests -q +pytest l4d2web/tests -q +pytest deploy/tests -q +``` + +Deployment verification on the target server should include: + +- `systemctl status left4me-web.service --no-pager` +- `curl http://127.0.0.1:8000/health` +- `sudo -u left4me /opt/left4me/.venv/bin/l4d2ctl --help` +- helper validation for accepted and rejected server names +- a later gated smoke test for `install`, `initialize`, `start`, `status`, `logs`, `stop`, and `delete` + +## Out Of Scope + +- Running deployment against a real server as part of this design. +- Replacing production config management. +- Managing overlay file contents through the web UI. +- Adding multi-host SSH transport to the web app. +- Supporting multiple game types beyond the existing L4D2 host workflow. diff --git a/l4d2host/README.md b/l4d2host/README.md index fcada2f..6b699de 100644 --- a/l4d2host/README.md +++ b/l4d2host/README.md @@ -21,12 +21,24 @@ Subprocess failures are fail-fast. Raw stderr is written to stderr and the comma ## Runtime Paths -The host library uses hard-coded runtime paths under `/opt/l4d2`: +The host library reads `LEFT4ME_ROOT` from the environment. It defaults to `/var/lib/left4me`: -- `/opt/l4d2/installation` -- `/opt/l4d2/overlays/` -- `/opt/l4d2/instances/` -- `/opt/l4d2/runtime//{upper,work,merged}` +- `${LEFT4ME_ROOT}/installation` +- `${LEFT4ME_ROOT}/overlays/` +- `${LEFT4ME_ROOT}/instances/` +- `${LEFT4ME_ROOT}/runtime//{upper,work,merged}` +- `${LEFT4ME_ROOT}/tmp` + +Overlay specs use relative refs below `${LEFT4ME_ROOT}/overlays`, for example `standard`, `competitive/base`, or `users/42/custom`. Absolute refs, `..`, empty path components, and symlink escapes outside the overlays root are rejected. + +## systemd Integration + +`l4d2ctl start`, `stop`, `status`, and `logs` use non-interactive sudo helper commands: + +- `sudo -n /usr/local/libexec/left4me/left4me-systemctl ...` +- `sudo -n /usr/local/libexec/left4me/left4me-journalctl ...` + +Deployment/config management owns the global `left4me-server@.service` unit under `/usr/local/lib/systemd/system`. The host library does not install or manage the unit file directly. ## Host Prerequisites @@ -40,7 +52,7 @@ Validated on Debian 13 during the `ckn@10.0.4.128` smoke test: - `fuse-overlayfs` and `fusermount3` for per-instance overlay mounts. - `systemctl --user` and `journalctl --user` available for the runtime user. - User lingering enabled when services must survive SSH sessions: `sudo loginctl enable-linger `. -- `/opt/l4d2` created and writable by the runtime user. +- `/var/lib/left4me` created and writable by the runtime user, unless `LEFT4ME_ROOT` is set to another deployment-managed root. Example Debian setup: @@ -52,8 +64,8 @@ sudo apt-get install -y \ fuse-overlayfs fuse3 \ libc6-i386 lib32gcc-s1 lib32stdc++6 -sudo mkdir -p /opt/steamcmd /opt/l4d2/{installation,overlays,instances,runtime} -sudo chown -R "$USER:$USER" /opt/steamcmd /opt/l4d2 +sudo mkdir -p /opt/steamcmd /var/lib/left4me/{installation,overlays,instances,runtime,tmp} +sudo chown -R "$USER:$USER" /opt/steamcmd /var/lib/left4me sudo loginctl enable-linger "$USER" ``` diff --git a/l4d2host/cli.py b/l4d2host/cli.py index 0326d93..b3a964b 100644 --- a/l4d2host/cli.py +++ b/l4d2host/cli.py @@ -82,5 +82,8 @@ def logs( lines: int = typer.Option(200, "--lines"), follow: bool = typer.Option(True, "--follow/--no-follow"), ) -> None: - for line in stream_instance_logs(name, lines=lines, follow=follow): - typer.echo(line) + try: + for line in stream_instance_logs(name, lines=lines, follow=follow): + typer.echo(line) + except subprocess.CalledProcessError as exc: + _exit_from_subprocess_error(exc) diff --git a/l4d2host/instances.py b/l4d2host/instances.py index 1f13c2f..256cd7c 100644 --- a/l4d2host/instances.py +++ b/l4d2host/instances.py @@ -2,24 +2,26 @@ from pathlib import Path import shutil from typing import Callable +from l4d2host.paths import DEFAULT_LEFT4ME_ROOT, get_left4me_root, overlay_path from l4d2host.process import run_command +from l4d2host.service_control import start_service, stop_service from l4d2host.spec import load_spec -from l4d2host.systemd_user import daemon_reload, ensure_template_unit -DEFAULT_ROOT = Path("/opt/l4d2") +DEFAULT_ROOT = DEFAULT_LEFT4ME_ROOT def initialize_instance( name: str, spec_path: Path, *, - root: Path = DEFAULT_ROOT, + root: Path | None = None, on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, passthrough: bool = False, should_cancel: Callable[[], bool] | None = None, ) -> None: + root = get_left4me_root() if root is None else Path(root) spec = load_spec(spec_path) instance_dir = root / "instances" / name @@ -29,7 +31,7 @@ def initialize_instance( (runtime_dir / "merged").mkdir(parents=True, exist_ok=True) instance_dir.mkdir(parents=True, exist_ok=True) - lowerdirs = [str(root / "overlays" / overlay) for overlay in spec.overlays] + lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays] lowerdirs.append(str(root / "installation")) instance_env = "\n".join( @@ -44,10 +46,6 @@ def initialize_instance( server_cfg = "\n".join(spec.config) if spec.config else "" (instance_dir / "server.cfg").write_text(server_cfg) - if root.resolve() == DEFAULT_ROOT: - ensure_template_unit() - daemon_reload(on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, should_cancel=should_cancel) - def _load_instance_env(path: Path) -> dict[str, str]: result: dict[str, str] = {} @@ -62,12 +60,13 @@ def _load_instance_env(path: Path) -> dict[str, str]: def start_instance( name: str, *, - root: Path = DEFAULT_ROOT, + root: Path | None = None, on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, passthrough: bool = False, should_cancel: Callable[[], bool] | None = None, ) -> None: + root = get_left4me_root() if root is None else Path(root) instance_dir = root / "instances" / name runtime_dir = root / "runtime" / name @@ -94,8 +93,8 @@ def start_instance( target_cfg.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(instance_dir / "server.cfg", target_cfg) - run_command( - ["systemctl", "--user", "start", f"l4d2@{name}.service"], + start_service( + name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, @@ -106,14 +105,15 @@ def start_instance( def stop_instance( name: str, *, - root: Path = DEFAULT_ROOT, + root: Path | None = None, on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, passthrough: bool = False, should_cancel: Callable[[], bool] | None = None, ) -> None: - run_command( - ["systemctl", "--user", "stop", f"l4d2@{name}.service"], + root = get_left4me_root() if root is None else Path(root) + stop_service( + name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, @@ -131,20 +131,21 @@ def stop_instance( def delete_instance( name: str, *, - root: Path = DEFAULT_ROOT, + root: Path | None = None, on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, passthrough: bool = False, should_cancel: Callable[[], bool] | None = None, ) -> None: + root = get_left4me_root() if root is None else Path(root) instance_dir = root / "instances" / name runtime_dir = root / "runtime" / name if not instance_dir.exists() and not runtime_dir.exists(): return - run_command( - ["systemctl", "--user", "stop", f"l4d2@{name}.service"], + stop_service( + name, on_stdout=on_stdout, on_stderr=on_stderr, passthrough=passthrough, diff --git a/l4d2host/logs.py b/l4d2host/logs.py index 9d7bbe7..f2b16d3 100644 --- a/l4d2host/logs.py +++ b/l4d2host/logs.py @@ -1,34 +1,7 @@ -import subprocess from typing import Iterator +from l4d2host.service_control import stream_journal + def stream_instance_logs(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: - command = [ - "journalctl", - "--user", - "-u", - f"l4d2@{name}.service", - "-n", - str(lines), - "-o", - "cat", - ] - if follow: - command.append("-f") - - proc = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - ) - try: - if proc.stdout is None: - return - for raw in iter(proc.stdout.readline, ""): - yield raw.rstrip("\n") - finally: - if proc.poll() is None: - proc.terminate() - proc.wait(timeout=2) + yield from stream_journal(name, lines=lines, follow=follow) diff --git a/l4d2host/paths.py b/l4d2host/paths.py new file mode 100644 index 0000000..8f0037c --- /dev/null +++ b/l4d2host/paths.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + + +DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me") + + +def get_left4me_root() -> Path: + raw = os.environ.get("LEFT4ME_ROOT") + if raw is None: + return DEFAULT_LEFT4ME_ROOT + root = raw.strip() + if not root: + raise ValueError("LEFT4ME_ROOT must not be empty") + if root != raw: + raise ValueError("LEFT4ME_ROOT must not contain leading or trailing whitespace") + path = Path(root) + if not path.is_absolute(): + raise ValueError("LEFT4ME_ROOT must be absolute") + return path + + +def validate_overlay_ref(ref: str) -> str: + stripped = ref.strip() + if stripped != ref: + raise ValueError("overlay ref must not contain leading or trailing whitespace") + if stripped in {"", ".", ".."}: + raise ValueError("overlay ref must not be empty or current/parent directory") + if Path(stripped).is_absolute(): + raise ValueError("overlay ref must be relative") + + components = stripped.split("/") + if any(component in {"", ".", ".."} for component in components): + raise ValueError("overlay ref must not contain empty, current, or parent components") + + return stripped + + +def overlay_path(ref: str, *, root: Path | None = None) -> Path: + safe_ref = validate_overlay_ref(ref) + left4me_root = get_left4me_root() if root is None else Path(root) + overlays_root = left4me_root / "overlays" + candidate = overlays_root / safe_ref + + resolved_overlays_root = overlays_root.resolve() + resolved_candidate = candidate.resolve() + if resolved_candidate != resolved_overlays_root and resolved_overlays_root not in resolved_candidate.parents: + raise ValueError("overlay path escapes overlay root") + + return candidate diff --git a/l4d2host/pyproject.toml b/l4d2host/pyproject.toml index c38d1c1..c12b4a0 100644 --- a/l4d2host/pyproject.toml +++ b/l4d2host/pyproject.toml @@ -17,10 +17,7 @@ dependencies = [ l4d2ctl = "l4d2host.cli:app" [tool.setuptools] -packages = ["l4d2host", "l4d2host.fs", "l4d2host.templates"] +packages = ["l4d2host", "l4d2host.fs"] [tool.setuptools.package-dir] l4d2host = "." - -[tool.setuptools.package-data] -"l4d2host.templates" = ["*.service"] diff --git a/l4d2host/service_control.py b/l4d2host/service_control.py new file mode 100644 index 0000000..8edd0b7 --- /dev/null +++ b/l4d2host/service_control.py @@ -0,0 +1,85 @@ +import subprocess +from typing import Callable, Iterator, Sequence + +from l4d2host.process import CommandResult, run_command + + +SYSTEMCTL_HELPER = "/usr/local/libexec/left4me/left4me-systemctl" +JOURNALCTL_HELPER = "/usr/local/libexec/left4me/left4me-journalctl" + + +def systemctl_command(action: str, name: str) -> list[str]: + return ["sudo", "-n", SYSTEMCTL_HELPER, action, name] + + +def journalctl_command(name: str, lines: int = 200, follow: bool = True) -> list[str]: + follow_arg = "--follow" if follow else "--no-follow" + return ["sudo", "-n", JOURNALCTL_HELPER, name, "--lines", str(lines), follow_arg] + + +def start_service( + name: str, + *, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + should_cancel: Callable[[], bool] | None = None, +) -> CommandResult: + return run_command( + systemctl_command("start", name), + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + should_cancel=should_cancel, + ) + + +def stop_service( + name: str, + *, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, + should_cancel: Callable[[], bool] | None = None, +) -> CommandResult: + return run_command( + systemctl_command("stop", name), + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + should_cancel=should_cancel, + ) + + +def show_service(name: str) -> CommandResult: + return run_command(systemctl_command("show", name)) + + +def stream_command(cmd: Sequence[str]) -> Iterator[str]: + command = list(cmd) + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + try: + if proc.stdout is None: + return + for raw in iter(proc.stdout.readline, ""): + yield raw.rstrip("\n") + returncode = proc.wait() + if returncode is None: + returncode = proc.poll() + stderr = proc.stderr.read() if proc.stderr is not None else "" + if returncode: + raise subprocess.CalledProcessError(returncode=returncode, cmd=command, stderr=stderr) + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) + + +def stream_journal(name: str, *, lines: int = 200, follow: bool = True) -> Iterator[str]: + return stream_command(journalctl_command(name, lines=lines, follow=follow)) diff --git a/l4d2host/status.py b/l4d2host/status.py index 6561ab7..20717a3 100644 --- a/l4d2host/status.py +++ b/l4d2host/status.py @@ -1,7 +1,7 @@ from dataclasses import dataclass import subprocess -from l4d2host.process import run_command +from l4d2host.service_control import show_service @dataclass(slots=True) @@ -21,17 +21,7 @@ def map_active_state(active_state: str) -> str: def get_instance_status(name: str) -> InstanceStatus: try: - result = run_command( - [ - "systemctl", - "--user", - "show", - f"l4d2@{name}.service", - "--property=ActiveState", - "--property=SubState", - "--no-pager", - ] - ) + result = show_service(name) except (subprocess.CalledProcessError, FileNotFoundError): return InstanceStatus( state="unknown", diff --git a/l4d2host/steam_install.py b/l4d2host/steam_install.py index 0596e69..0f6cf2b 100644 --- a/l4d2host/steam_install.py +++ b/l4d2host/steam_install.py @@ -1,12 +1,13 @@ from pathlib import Path from typing import Callable +from l4d2host.paths import get_left4me_root from l4d2host.process import run_command class SteamInstaller: - def __init__(self, install_dir: Path = Path("/opt/l4d2/installation"), steamcmd: str = "steamcmd"): - self.install_dir = install_dir + def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"): + self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir) self.steamcmd = steamcmd def install_or_update( diff --git a/l4d2host/systemd_user.py b/l4d2host/systemd_user.py deleted file mode 100644 index bc2f808..0000000 --- a/l4d2host/systemd_user.py +++ /dev/null @@ -1,31 +0,0 @@ -from importlib import resources -from pathlib import Path -from typing import Callable - -from l4d2host.process import run_command - - -def ensure_template_unit(target_dir: Path | None = None) -> Path: - if target_dir is None: - target_dir = Path.home() / ".config/systemd/user" - target_dir.mkdir(parents=True, exist_ok=True) - target_file = target_dir / "l4d2@.service" - body = resources.files("l4d2host.templates").joinpath("l4d2@.service").read_text() - target_file.write_text(body) - return target_file - - -def daemon_reload( - *, - on_stdout: Callable[[str], None] | None = None, - on_stderr: Callable[[str], None] | None = None, - passthrough: bool = False, - should_cancel: Callable[[], bool] | None = None, -) -> None: - run_command( - ["systemctl", "--user", "daemon-reload"], - on_stdout=on_stdout, - on_stderr=on_stderr, - passthrough=passthrough, - should_cancel=should_cancel, - ) diff --git a/l4d2host/templates/l4d2@.service b/l4d2host/templates/l4d2@.service deleted file mode 100644 index 2ea66cf..0000000 --- a/l4d2host/templates/l4d2@.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=L4D2 dedicated server instance %i -After=network.target - -[Service] -Type=simple -EnvironmentFile=/opt/l4d2/instances/%i/instance.env -WorkingDirectory=/opt/l4d2/runtime/%i/merged/left4dead2 -ExecStart=/opt/l4d2/installation/srcds_run -game left4dead2 +hostport ${L4D2_PORT} ${L4D2_ARGS} -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target diff --git a/l4d2host/tests/test_cli.py b/l4d2host/tests/test_cli.py index c02c783..5028ad5 100644 --- a/l4d2host/tests/test_cli.py +++ b/l4d2host/tests/test_cli.py @@ -56,3 +56,17 @@ def test_logs_command_streams_lines(monkeypatch) -> None: assert result.exit_code == 0 assert result.output.splitlines() == ["alpha:25:False", "ready"] + + +def test_logs_command_propagates_subprocess_return_code(monkeypatch) -> None: + def fail_logs(*args, **kwargs): + del args + del kwargs + raise subprocess.CalledProcessError(returncode=7, cmd=["logs"], stderr="sudo denied") + + monkeypatch.setattr("l4d2host.cli.stream_instance_logs", fail_logs, raising=False) + + result = CliRunner().invoke(app, ["logs", "alpha", "--no-follow"]) + + assert result.exit_code == 7 + assert "sudo denied" in result.stderr diff --git a/l4d2host/tests/test_initialize.py b/l4d2host/tests/test_initialize.py index 144edae..5868771 100644 --- a/l4d2host/tests/test_initialize.py +++ b/l4d2host/tests/test_initialize.py @@ -20,3 +20,14 @@ def test_empty_config_writes_empty_server_cfg(tmp_path: Path) -> None: initialize_instance("alpha", spec, root=tmp_path) assert (tmp_path / "instances/alpha/server.cfg").read_text() == "" + + +def test_initialize_uses_configured_left4me_root(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + spec = tmp_path / "spec.yaml" + spec.write_text("port: 27015\noverlays: [standard]\n") + + initialize_instance("alpha", spec) + + env = (tmp_path / "instances/alpha/instance.env").read_text() + assert f"L4D2_LOWERDIRS={tmp_path}/overlays/standard:{tmp_path}/installation" in env diff --git a/l4d2host/tests/test_install.py b/l4d2host/tests/test_install.py index 5ef73c8..89ea2e7 100644 --- a/l4d2host/tests/test_install.py +++ b/l4d2host/tests/test_install.py @@ -33,3 +33,13 @@ def test_fail_fast_on_first_failure(monkeypatch: pytest.MonkeyPatch) -> None: SteamInstaller().install_or_update() assert len(calls) == 1 + + +def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch): + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + calls = [] + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: calls.append(cmd)) + + SteamInstaller().install_or_update() + + assert str(tmp_path / "installation") in calls[0] diff --git a/l4d2host/tests/test_lifecycle.py b/l4d2host/tests/test_lifecycle.py index e5620b4..2475bed 100644 --- a/l4d2host/tests/test_lifecycle.py +++ b/l4d2host/tests/test_lifecycle.py @@ -22,11 +22,12 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (instance_dir / "server.cfg").write_text("sv_consistency 1") monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) start_instance("alpha", root=tmp_path) assert calls[0][0] == "fuse-overlayfs" - assert calls[1][:3] == ["systemctl", "--user", "start"] + assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"] def test_delete_missing_is_noop(tmp_path: Path) -> None: @@ -44,10 +45,11 @@ def test_delete_stopped_instance_removes_dirs_without_unmounting(tmp_path: Path, (tmp_path / "runtime" / "alpha" / "merged").mkdir(parents=True) monkeypatch.setattr("l4d2host.instances.run_command", fake_run_command) + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) delete_instance("alpha", root=tmp_path) assert not (tmp_path / "instances" / "alpha").exists() assert not (tmp_path / "runtime" / "alpha").exists() - assert ["systemctl", "--user", "stop", "l4d2@alpha.service"] in calls + assert ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "stop", "alpha"] in calls assert not any(call[0] == "fusermount3" for call in calls) diff --git a/l4d2host/tests/test_logs.py b/l4d2host/tests/test_logs.py index eeb13f7..e45dace 100644 --- a/l4d2host/tests/test_logs.py +++ b/l4d2host/tests/test_logs.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +import subprocess import pytest @@ -8,7 +9,7 @@ from l4d2host.logs import stream_instance_logs class DummyProcess: def __init__(self, lines: list[str]) -> None: self.stdout = SimpleNamespace(readline=self._readline) - self.stderr = SimpleNamespace(readline=lambda: "") + self.stderr = SimpleNamespace(readline=lambda: "", read=lambda: "") self._lines = iter(lines) self.terminated = False self.waited = False @@ -22,7 +23,7 @@ class DummyProcess: def terminate(self) -> None: self.terminated = True - def wait(self, timeout: int) -> None: + def wait(self, timeout: int | None = None) -> None: del timeout self.waited = True @@ -35,8 +36,52 @@ def test_stream_instance_logs_yields_lines(monkeypatch: pytest.MonkeyPatch) -> N del kwargs return proc - monkeypatch.setattr("l4d2host.logs.subprocess.Popen", fake_popen) + monkeypatch.setattr("l4d2host.service_control.subprocess.Popen", fake_popen) lines = list(stream_instance_logs("alpha", lines=10, follow=False)) assert lines == ["line1", "line2"] - assert proc.terminated is True + assert proc.waited is True + + +def test_stream_instance_logs_uses_journalctl_helper(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_stream_command(cmd): + calls.append(list(cmd)) + return iter(["line1"]) + + monkeypatch.setattr("l4d2host.service_control.stream_command", fake_stream_command) + + assert list(stream_instance_logs("alpha", lines=25, follow=False)) == ["line1"] + assert calls == [ + [ + "sudo", + "-n", + "/usr/local/libexec/left4me/left4me-journalctl", + "alpha", + "--lines", + "25", + "--no-follow", + ] + ] + + +def test_stream_instance_logs_raises_when_helper_fails(monkeypatch: pytest.MonkeyPatch) -> None: + class FailedProcess: + stdout = SimpleNamespace(readline=lambda: "") + stderr = SimpleNamespace(read=lambda: "sudo denied\n") + + def poll(self): + return 7 + + def wait(self, timeout: int | None = None): + del timeout + return 7 + + monkeypatch.setattr("l4d2host.service_control.subprocess.Popen", lambda cmd, **kwargs: FailedProcess()) + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + list(stream_instance_logs("alpha", lines=10, follow=False)) + + assert excinfo.value.returncode == 7 + assert excinfo.value.stderr == "sudo denied\n" diff --git a/l4d2host/tests/test_paths.py b/l4d2host/tests/test_paths.py new file mode 100644 index 0000000..052f8b8 --- /dev/null +++ b/l4d2host/tests/test_paths.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import pytest + +from l4d2host.paths import get_left4me_root, overlay_path, validate_overlay_ref + + +def test_get_left4me_root_defaults_to_var_lib_left4me(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LEFT4ME_ROOT", raising=False) + + assert get_left4me_root() == Path("/var/lib/left4me") + + +def test_get_left4me_root_uses_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + + assert get_left4me_root() == tmp_path + + +def test_get_left4me_root_rejects_relative_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", "var/lib/left4me") + + with pytest.raises(ValueError): + get_left4me_root() + + +@pytest.mark.parametrize("raw", ["", " "]) +def test_get_left4me_root_rejects_empty_env(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", raw) + + with pytest.raises(ValueError): + get_left4me_root() + + +@pytest.mark.parametrize("ref", ["standard", "competitive/base", "users/42/custom"]) +def test_validate_overlay_ref_accepts_safe_refs(ref: str) -> None: + assert validate_overlay_ref(ref) == ref + + +@pytest.mark.parametrize( + "ref", + ["", "/tmp/bad", "../bad", "bad/../evil", "bad//evil", " bad", "bad ", ".", "bad/./evil"], +) +def test_validate_overlay_ref_rejects_unsafe_refs(ref: str) -> None: + with pytest.raises(ValueError): + validate_overlay_ref(ref) + + +def test_overlay_path_resolves_under_root(tmp_path: Path) -> None: + assert overlay_path("standard", root=tmp_path) == tmp_path / "overlays" / "standard" + + +def test_overlay_path_rejects_symlink_escape(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + overlays = tmp_path / "overlays" + overlays.mkdir() + (overlays / "escape").symlink_to(outside) + + with pytest.raises(ValueError): + overlay_path("escape", root=tmp_path) diff --git a/l4d2host/tests/test_status.py b/l4d2host/tests/test_status.py index d2539a2..ca0017d 100644 --- a/l4d2host/tests/test_status.py +++ b/l4d2host/tests/test_status.py @@ -1,4 +1,8 @@ -from l4d2host.status import map_active_state +import subprocess + +import pytest + +from l4d2host.status import get_instance_status, map_active_state def test_status_mapping() -> None: @@ -6,3 +10,19 @@ def test_status_mapping() -> None: assert map_active_state("inactive") == "stopped" assert map_active_state("failed") == "stopped" assert map_active_state("weird") == "unknown" + + +def test_get_instance_status_uses_systemctl_helper(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run_command(cmd, **kwargs): + del kwargs + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="ActiveState=active\nSubState=running\n", stderr="") + + monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command) + + status = get_instance_status("alpha") + + assert calls == [["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "show", "alpha"]] + assert status.state == "running" diff --git a/l4d2web/README.md b/l4d2web/README.md index 950486c..5414360 100644 --- a/l4d2web/README.md +++ b/l4d2web/README.md @@ -27,3 +27,21 @@ python3 -m venv .venv .venv/bin/pip install -e . .venv/bin/pytest tests -q ``` + +## Configuration + +The web app reads these settings from the environment: + +- `DATABASE_URL`: SQLAlchemy database URL, for example `sqlite:////var/lib/left4me/left4me.db`. +- `SECRET_KEY`: Flask secret key used for sessions and CSRF-sensitive state. +- `JOB_WORKER_THREADS`: number of background job worker threads. + +In the systemd deployment, environment is loaded from `/etc/left4me/host.env` and `/etc/left4me/web.env`. + +## Admin Bootstrap + +Create the first admin account with the Flask CLI. Provide the password through `LEFT4ME_ADMIN_PASSWORD`: + +```bash +LEFT4ME_ADMIN_PASSWORD='change-me' flask create-user --admin +``` diff --git a/l4d2web/app.py b/l4d2web/app.py index 898360d..074221a 100644 --- a/l4d2web/app.py +++ b/l4d2web/app.py @@ -1,11 +1,12 @@ import os import secrets +import click from flask import Flask, Response, jsonify, redirect, request, session from l4d2web.auth import current_user, load_current_user from l4d2web.cli import register_cli -from l4d2web.config import DEFAULT_CONFIG +from l4d2web.config import load_config from l4d2web.db import init_db from l4d2web.routes.blueprint_routes import bp as blueprint_bp from l4d2web.routes.auth_routes import bp as auth_bp @@ -18,9 +19,13 @@ from l4d2web.routes.server_routes import bp as server_bp from l4d2web.services.job_worker import recover_stale_jobs, start_job_workers +def _in_flask_cli_context() -> bool: + return click.get_current_context(silent=True) is not None + + def create_app(test_config: dict[str, object] | None = None) -> Flask: app = Flask(__name__) - app.config.from_mapping(DEFAULT_CONFIG) + app.config.from_mapping(load_config()) app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", @@ -29,8 +34,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: if test_config is not None: app.config.update(test_config) - os.environ["DATABASE_URL"] = str(app.config["DATABASE_URL"]) - init_db() + with app.app_context(): + init_db() @app.before_request def csrf_protect() -> Response | None: @@ -62,8 +67,13 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: register_cli(app) if app.config.get("TESTING"): reset_login_rate_limits() - recover_stale_jobs() - if app.config.get("JOB_WORKER_ENABLED") and not app.config.get("TESTING"): + should_start_workers = ( + app.config.get("JOB_WORKER_ENABLED") + and not app.config.get("TESTING") + and not _in_flask_cli_context() + ) + if should_start_workers: + recover_stale_jobs() start_job_workers(app) @app.get("/health") diff --git a/l4d2web/cli.py b/l4d2web/cli.py index bc3494a..a9495be 100644 --- a/l4d2web/cli.py +++ b/l4d2web/cli.py @@ -1,6 +1,10 @@ +import os + import click +from sqlalchemy.exc import IntegrityError from sqlalchemy import select +from l4d2web.auth import hash_password from l4d2web.db import session_scope from l4d2web.models import User @@ -15,5 +19,28 @@ def promote_admin(username: str) -> None: user.admin = True +@click.command("create-user") +@click.argument("username") +@click.option("--admin", is_flag=True, default=False) +def create_user(username: str, admin: bool) -> None: + password = os.getenv("LEFT4ME_ADMIN_PASSWORD") + if password is None: + password = click.prompt("Password", hide_input=True, confirmation_prompt=True) + if password == "": + raise click.ClickException("password must not be empty") + + try: + with session_scope() as db: + existing = db.scalar(select(User).where(User.username == username)) + if existing is not None: + raise click.ClickException("user already exists") + db.add(User(username=username, password_digest=hash_password(password), admin=admin)) + except IntegrityError as exc: + raise click.ClickException("user already exists") from exc + + click.echo(f"created user {username}") + + def register_cli(app) -> None: app.cli.add_command(promote_admin) + app.cli.add_command(create_user) diff --git a/l4d2web/config.py b/l4d2web/config.py index 2861208..f17b431 100644 --- a/l4d2web/config.py +++ b/l4d2web/config.py @@ -1,3 +1,6 @@ +import os + + DEFAULT_CONFIG: dict[str, object] = { "SECRET_KEY": "dev", "DATABASE_URL": "sqlite:///l4d2web.db", @@ -8,3 +11,20 @@ DEFAULT_CONFIG: dict[str, object] = { "JOB_LOG_REPLAY_LIMIT": 2000, "JOB_LOG_LINE_MAX_CHARS": 4096, } + + +def _bool_from_env(raw: str) -> bool: + return raw.lower() not in {"0", "false", "no"} + + +def load_config() -> dict[str, object]: + return { + "SECRET_KEY": os.getenv("SECRET_KEY", "dev"), + "DATABASE_URL": os.getenv("DATABASE_URL", "sqlite:///l4d2web.db"), + "STATUS_REFRESH_SECONDS": int(os.getenv("STATUS_REFRESH_SECONDS", "8")), + "JOB_WORKER_THREADS": int(os.getenv("JOB_WORKER_THREADS", "4")), + "JOB_WORKER_ENABLED": _bool_from_env(os.getenv("JOB_WORKER_ENABLED", "true")), + "JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")), + "JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")), + "JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")), + } diff --git a/l4d2web/db.py b/l4d2web/db.py index ae1c538..6ba8b9a 100644 --- a/l4d2web/db.py +++ b/l4d2web/db.py @@ -1,6 +1,7 @@ from contextlib import contextmanager import os +from flask import current_app, has_app_context from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -11,6 +12,8 @@ _Session = None def get_database_url() -> str: + if has_app_context(): + return str(current_app.config["DATABASE_URL"]) return os.getenv("DATABASE_URL", "sqlite:///l4d2web.db") diff --git a/l4d2web/pyproject.toml b/l4d2web/pyproject.toml index df68325..bdc17ee 100644 --- a/l4d2web/pyproject.toml +++ b/l4d2web/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "SQLAlchemy>=2.0", "alembic>=1.13", "PyYAML>=6.0", + "gunicorn>=22.0", ] [tool.setuptools] diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index 1dadb0e..f863904 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -3,8 +3,8 @@ from sqlalchemy import select from l4d2web.auth import require_admin from l4d2web.db import session_scope -from l4d2web.models import Overlay -from l4d2web.services.security import validate_overlay_path +from l4d2web.models import BlueprintOverlay, Overlay +from l4d2web.services.security import validate_overlay_ref bp = Blueprint("overlay", __name__) @@ -14,12 +14,12 @@ bp = Blueprint("overlay", __name__) @require_admin def create_overlay() -> Response: name = request.form.get("name", "").strip() - raw_path = request.form.get("path", "").strip() + raw_path = request.form.get("path", "") if not name or not raw_path: return Response("missing fields", status=400) try: - validated_path = validate_overlay_path(raw_path) + overlay_ref = validate_overlay_ref(raw_path) except ValueError as exc: return Response(str(exc), status=400) @@ -27,7 +27,7 @@ def create_overlay() -> Response: existing = db.scalar(select(Overlay).where(Overlay.name == name)) if existing is not None: return Response("overlay already exists", status=409) - db.add(Overlay(name=name, path=str(validated_path))) + db.add(Overlay(name=name, path=overlay_ref)) return redirect("/overlays") @@ -36,12 +36,12 @@ def create_overlay() -> Response: @require_admin def update_overlay(overlay_id: int) -> Response: name = request.form.get("name", "").strip() - raw_path = request.form.get("path", "").strip() + raw_path = request.form.get("path", "") if not name or not raw_path: return Response("missing fields", status=400) try: - validated_path = validate_overlay_path(raw_path) + overlay_ref = validate_overlay_ref(raw_path) except ValueError as exc: return Response(str(exc), status=400) @@ -49,8 +49,11 @@ def update_overlay(overlay_id: int) -> Response: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) + duplicate = db.scalar(select(Overlay).where(Overlay.name == name, Overlay.id != overlay_id)) + if duplicate is not None: + return Response("overlay already exists", status=409) overlay.name = name - overlay.path = str(validated_path) + overlay.path = overlay_ref return redirect("/overlays") @@ -62,5 +65,8 @@ def delete_overlay(overlay_id: int) -> Response: overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) if overlay is None: return Response(status=404) + in_use = db.scalar(select(BlueprintOverlay).where(BlueprintOverlay.overlay_id == overlay_id)) + if in_use is not None: + return Response("overlay is in use", status=409) db.delete(overlay) return redirect("/overlays") diff --git a/l4d2web/services/l4d2_facade.py b/l4d2web/services/l4d2_facade.py index f015d87..2154b19 100644 --- a/l4d2web/services/l4d2_facade.py +++ b/l4d2web/services/l4d2_facade.py @@ -17,10 +17,10 @@ class ServerStatus: raw_sub_state: str -def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_names: list[str]) -> dict: +def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_refs: list[str]) -> dict: return { "port": server.port, - "overlays": overlay_names, + "overlays": overlay_refs, "arguments": json.loads(blueprint.arguments), "config": json.loads(blueprint.config), } @@ -37,13 +37,13 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis raise ValueError("blueprint not found") rows = db.execute( - select(Overlay.name) + select(Overlay.path) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .where(BlueprintOverlay.blueprint_id == blueprint.id) .order_by(BlueprintOverlay.position) ).all() - overlay_names = [row[0] for row in rows] - return server, blueprint, overlay_names + overlay_refs = [row[0] for row in rows] + return server, blueprint, overlay_refs def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: @@ -56,8 +56,8 @@ def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None: - server, blueprint, overlay_names = load_server_blueprint_bundle(server_id) - spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_names)) + server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id) + spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs)) try: host_commands.run_command( ["l4d2ctl", "initialize", server.name, "-f", str(spec_path)], diff --git a/l4d2web/services/security.py b/l4d2web/services/security.py index 5a6713d..6ff0b52 100644 --- a/l4d2web/services/security.py +++ b/l4d2web/services/security.py @@ -1,11 +1,12 @@ -from pathlib import Path - - -OVERLAY_ROOT = Path("/opt/l4d2/overlays").resolve() - - -def validate_overlay_path(raw: str) -> Path: - path = Path(raw).resolve() - if OVERLAY_ROOT not in path.parents and path != OVERLAY_ROOT: - raise ValueError("overlay path must be under /opt/l4d2/overlays") - return path +def validate_overlay_ref(raw: str) -> str: + if raw != raw.strip(): + raise ValueError("overlay ref must not have leading or trailing whitespace") + if not raw: + raise ValueError("overlay ref must not be empty") + if "\\" in raw: + raise ValueError("overlay ref must use forward slashes") + if raw.startswith("/"): + raise ValueError("overlay ref must be relative") + if any(component in {"", ".", ".."} for component in raw.split("/")): + raise ValueError("overlay ref must not contain empty, current, or parent components") + return raw diff --git a/l4d2web/tests/test_auth.py b/l4d2web/tests/test_auth.py index 4985ced..b003b14 100644 --- a/l4d2web/tests/test_auth.py +++ b/l4d2web/tests/test_auth.py @@ -116,3 +116,47 @@ def test_login_sets_session(client) -> None: with client.session_transaction() as sess: assert sess.get("user_id") is not None + + +def test_create_user_cli_uses_environment_password(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'create_user.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret") + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"]) + + assert result.exit_code == 0 + assert "created user admin" in result.output + with session_scope() as session: + user = session.query(User).filter_by(username="admin").one() + assert user.admin is True + + +def test_create_user_cli_rejects_empty_environment_password(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'empty_password.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "") + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"]) + + assert result.exit_code != 0 + assert "password must not be empty" in result.output + + +def test_create_user_cli_rejects_duplicate_username(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'duplicate_user.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_ADMIN_PASSWORD", "secret") + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + with session_scope() as session: + session.add(User(username="admin", password_digest=hash_password("secret"), admin=True)) + + result = app.test_cli_runner().invoke(args=["create-user", "admin", "--admin"]) + + assert result.exit_code != 0 + assert "user already exists" in result.output diff --git a/l4d2web/tests/test_config.py b/l4d2web/tests/test_config.py new file mode 100644 index 0000000..805cd0e --- /dev/null +++ b/l4d2web/tests/test_config.py @@ -0,0 +1,38 @@ +import click + +from l4d2web.app import create_app +from l4d2web.config import load_config + + +def test_load_config_uses_environment(monkeypatch) -> None: + monkeypatch.setenv("DATABASE_URL", "sqlite:///env.db") + monkeypatch.setenv("SECRET_KEY", "env-secret") + monkeypatch.setenv("JOB_WORKER_THREADS", "2") + + config = load_config() + + assert config["DATABASE_URL"] == "sqlite:///env.db" + assert config["SECRET_KEY"] == "env-secret" + assert config["JOB_WORKER_THREADS"] == 2 + + +def test_create_app_does_not_overwrite_database_url_env(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("DATABASE_URL", "sqlite:///env.db") + db_url = f"sqlite:///{tmp_path/'app.db'}" + + create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + + assert load_config()["DATABASE_URL"] == "sqlite:///env.db" + + +def test_create_app_skips_job_workers_in_cli_context(tmp_path, monkeypatch) -> None: + calls = [] + db_url = f"sqlite:///{tmp_path/'cli.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setattr("l4d2web.app.recover_stale_jobs", lambda: calls.append("recover")) + monkeypatch.setattr("l4d2web.app.start_job_workers", lambda app: calls.append("start")) + + with click.Context(click.Command("flask")): + create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + + assert calls == [] diff --git a/l4d2web/tests/test_l4d2_facade.py b/l4d2web/tests/test_l4d2_facade.py index bf11374..0fa6458 100644 --- a/l4d2web/tests/test_l4d2_facade.py +++ b/l4d2web/tests/test_l4d2_facade.py @@ -22,7 +22,7 @@ def server_with_blueprint(tmp_path, monkeypatch): session.add(user) session.flush() - overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard") + overlay = Overlay(name="Standard Overlay", path="standard") session.add(overlay) session.flush() @@ -55,7 +55,10 @@ def test_initialize_uses_l4d2ctl_with_latest_blueprint_data( del kwargs calls.append(list(cmd)) spec_path = Path(cmd[cmd.index("-f") + 1]) - assert "sv_consistency 1" in spec_path.read_text() + spec = spec_path.read_text() + assert "sv_consistency 1" in spec + assert "standard" in spec + assert "Standard Overlay" not in spec return CommandResult(returncode=0, stdout="", stderr="") monkeypatch.setattr("l4d2web.services.host_commands.run_command", fake_run_command) diff --git a/l4d2web/tests/test_overlays.py b/l4d2web/tests/test_overlays.py index d7cb51b..fa0f738 100644 --- a/l4d2web/tests/test_overlays.py +++ b/l4d2web/tests/test_overlays.py @@ -2,7 +2,8 @@ import pytest from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope -from l4d2web.models import Overlay, User +from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, User +from l4d2web.services.security import validate_overlay_ref @pytest.fixture @@ -35,7 +36,7 @@ def user_client_with_overlay(tmp_path, monkeypatch): with session_scope() as session: user = User(username="alice", password_digest=hash_password("secret"), admin=False) session.add(user) - session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard")) + session.add(Overlay(name="standard", path="standard")) session.flush() user_id = user.id @@ -67,14 +68,14 @@ def test_admin_can_view_overlay_edit_controls(admin_client) -> None: def test_admin_can_create_overlay(admin_client) -> None: response = admin_client.post( "/overlays", - data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, + data={"name": "standard", "path": "standard"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"] == "/overlays" -def test_overlay_path_must_be_under_root(admin_client) -> None: +def test_overlay_ref_must_be_relative(admin_client) -> None: response = admin_client.post( "/overlays", data={"name": "bad", "path": "/tmp/bad"}, @@ -83,10 +84,25 @@ def test_overlay_path_must_be_under_root(admin_client) -> None: assert response.status_code == 400 +@pytest.mark.parametrize("overlay_ref", [" standard", "standard ", "a//b", "a/", "./a", "a/.", "."]) +def test_overlay_ref_rejects_unsafe_components(overlay_ref: str) -> None: + with pytest.raises(ValueError): + validate_overlay_ref(overlay_ref) + + +def test_overlay_route_rejects_whitespace_padded_ref(admin_client) -> None: + response = admin_client.post( + "/overlays", + data={"name": "bad", "path": " standard"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 400 + + def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None: response = user_client_with_overlay.post( "/overlays", - data={"name": "bad", "path": "/opt/l4d2/overlays/bad"}, + data={"name": "bad", "path": "bad"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 403 @@ -95,14 +111,14 @@ def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None: def test_admin_can_update_and_delete_overlay(admin_client) -> None: create = admin_client.post( "/overlays", - data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, + data={"name": "standard", "path": "standard"}, headers={"X-CSRF-Token": "test-token"}, ) assert create.status_code == 302 update = admin_client.post( "/overlays/1", - data={"name": "edited", "path": "/opt/l4d2/overlays/edited"}, + data={"name": "edited", "path": "edited"}, headers={"X-CSRF-Token": "test-token"}, ) assert update.status_code == 302 @@ -112,3 +128,44 @@ def test_admin_can_update_and_delete_overlay(admin_client) -> None: headers={"X-CSRF-Token": "test-token"}, ) assert delete.status_code == 302 + + +def test_update_overlay_rejects_duplicate_name(admin_client) -> None: + for name in ["standard", "competitive"]: + response = admin_client.post( + "/overlays", + data={"name": name, "path": name}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + + response = admin_client.post( + "/overlays/2", + data={"name": "standard", "path": "competitive"}, + headers={"X-CSRF-Token": "test-token"}, + ) + + assert response.status_code == 409 + + +def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None: + create = admin_client.post( + "/overlays", + data={"name": "standard", "path": "standard"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + with session_scope() as session: + user = session.query(User).filter_by(username="admin").one() + blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0)) + + response = admin_client.post( + "/overlays/1/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + + assert response.status_code == 409