Adds l4d2web/services/steam_workshop.py: parse_workshop_input (single ID,
URL, or multi-line batch), resolve_collection (HTTPS POST to
GetCollectionDetails), fetch_metadata_batch (HTTPS POST to
GetPublishedFileDetails with consumer_app_id == 550 enforcement that
raises WorkshopValidationError in add-mode and silently skips in
refresh-mode), download_to_cache (atomic + idempotent on mtime+size),
and refresh_all (ThreadPoolExecutor with per-item error collection).
Adds requests as an explicit dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Overlay.type and Overlay.user_id with two partial unique indexes
(externals globally unique by name; user overlays unique per user).
Adds WorkshopItem registry keyed on steam_id and a pure many-to-many
overlay_workshop_items association. Adds Job.overlay_id for build_overlay
job tracking. Switches overlays.id to AUTOINCREMENT so deleted IDs are
never reused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a typed-overlay model with workshop as the first non-external type:
deduplicated WorkshopItem registry, symlink-based overlay directories,
auto-rebuild after item changes, admin global refresh, and a unified
Create-overlay UI with web-managed paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L4D2 dedicated server expects to dlopen steamclient.so via
~/.steam/sdk32 (and sdk64). Without those symlinks, srcds_run logs
'cannot open shared object file' and SteamAPI_Init fails, which means
the server is invisible to the public Steam master server, Workshop
addon downloads break, and Steam 'Join Game' / lobby joins do not
reach the server (only direct-IP connect works).
SteamInstaller.install_or_update now ensures the symlinks exist after
SteamCMD finishes. Targets prefer SteamCMD's linux32/linux64 sibling
dirs; falls back to <install_dir>/bin/ if the siblings cannot be
located. Idempotent: re-running the install repairs or leaves the
symlinks alone.
Path.home() respects HOME, which the systemd web unit sets to
/var/lib/left4me, so the symlinks land in the left4me user's home.
Existing deploys can apply the fix by re-running 'Install' from /admin
without a full redeploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProtectSystem=full + ReadWritePaths implicitly give the unit a private
mount namespace (systemd needs to remount /usr read-only). The default
namespace propagation is slave, so mounts the worker creates inside
never reach the host. The gameserver units (started via systemctl,
each with their own namespace) then inherit a host that lacks the
overlay, and their CHDIR into /var/lib/left4me/runtime/<name>/merged
fails.
Set MountFlags=shared so mount events propagate from the worker's
namespace back to the host, then onward to gameserver units at their
unshare time.
Verified on test box: nsenter -t <gunicorn-pid> -m mount showed the
fuse-overlayfs mount inside the worker but plain mount on the host
did not, while web unit had ProtectSystem=full + ReadWritePaths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PrivateTmp=true gives the unit a private mount namespace. The worker's
fuse-overlayfs mount lives only inside that namespace, so the host
cannot see it and the gameserver unit (started via systemctl, with its
own namespace inherited from the host) also cannot see it. The
gameserver unit then fails CHDIR on
/var/lib/left4me/runtime/<name>/merged/left4dead2.
The mount must land in the host namespace so the gameserver unit
inherits it at unshare time. Remaining hardening: dedicated user,
ProtectSystem=full, ReadWritePaths, sudoers allowlist limited to two
helper scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The job worker calls fusermount3 (setuid-root) to mount per-instance
FUSE overlays and sudo to invoke the privileged systemctl wrapper.
NoNewPrivileges=true blocks both, surfacing as
"fusermount3: mount failed: Operation not permitted" the first time a
server is started. Hardening is still enforced via dedicated user,
PrivateTmp, ProtectSystem=full, ReadWritePaths, and the narrow sudoers
allowlist limited to two helper scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Per-row "Create server" link on /blueprints navigates to
/servers?blueprint_id=<id>; that page validates the param against
the user's owned blueprints, pre-selects the option, and auto-opens
the create modal.
- /servers empty-blueprint state now shows an actionable
"Create a blueprint first ->" link (styled like the primary button)
pointing at /blueprints, replacing the silent disabled "+ Create"
button + muted hint.
- Drop the "Reassign blueprint" form on the server detail page
along with the unused POST /servers/<id> form route. The JSON
PATCH /servers/<id> endpoint is retained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Native <dialog> modal infra (CSS + ~30 LOC JS, no framework) used for
create forms and delete confirmations.
- Index pages become listing-only: + Create button opens a modal; the
broken blueprint Actions column and inline overlay edit cells are gone.
- Server detail gains a blueprint reassignment form; existing Delete
button now opens a confirmation modal before tearing down the runtime.
- Blueprint detail gains a Delete button + confirmation modal (was
unreachable from the UI before).
- New overlay detail page at /overlays/<id> with edit form, "Used by"
blueprints list, and delete (admin only).
- Server create: port field is now optional; backend auto-assigns the
next free port from LEFT4ME_PORT_RANGE_START/_END (default
27015-27115). 409 on range exhaustion.
- New routes: POST /blueprints/<id>/delete (form sentinel matching
overlays pattern), POST /servers/<id> (form-friendly blueprint
reassign), GET /overlays/<id>.
- Server delete operation now redirects to /servers; overlay update
redirects to /overlays/<id>.
Server rename remains unsupported pending an id-vs-name design pass for
l4d2host (the runtime directory is name-keyed; renaming would orphan
files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When tar runs on macOS it embeds ._* resource-fork sidecars next to each
file. These ended up under l4d2web/alembic/versions/ on the target and
alembic tried to import them as migration modules, failing with
"source code string cannot contain null bytes". Set COPYFILE_DISABLE=1
and add an --exclude '._*' so the archive is portable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTTP-only test deployment binds gunicorn to 0.0.0.0:8000 with no TLS
terminator, so a hardcoded SESSION_COOKIE_SECURE=True breaks browser
login. Make it opt-out via env (default True outside TESTING) and set
SESSION_COOKIE_SECURE=false in the generated web.env so the test box
keeps working over HTTP.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- validate instance names at the host lib and web boundary against
[a-z0-9][a-z0-9_-]{0,63} to prevent path traversal via Server.name
- fail-closed on SECRET_KEY: load_config returns None when env unset,
create_app raises if missing or "dev" outside TESTING
- close login timing oracle by hashing a dummy digest when the user
is not found, equalizing response time
- set SESSION_COOKIE_SECURE outside TESTING
- delete_instance tolerates stop_service and fusermount3 failures so
partially-initialized instances clean up without contract breaks;
drops the is_mount() preflight that violated AGENTS.md
- document claim_next_job's single-process assumption
- clarify emit_step contract via docstring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>