Compare commits

..

35 commits

Author SHA1 Message Date
mwiegand
674c4df360
deploy: add STEAM_WEB_API_KEY to web.env template
For the live-state panel's Steam profile enrichment (persona names +
avatars). Optional: empty value disables enrichment and the panel falls
back to in-game names + placeholder avatars.

The actual web.env is materialized by the ckn-bw bundle's Mako; the
template here documents the operator-facing shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:25:03 +02:00
mwiegand
37a9ad68a2
fix(live-state): cast poll_seconds to int for HTMX hx-trigger
HTMX's hx-trigger="every Ns" syntax does not accept fractional seconds —
a config override like 7.5 would render every 7.5s and silently break
auto-refresh. Floor to int with a 1s minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:23:15 +02:00
mwiegand
9aaa26d9a9
feat(servers): add live-state panel with current and recent players
HTMX-refreshed /servers/<id>/live-state fragment renders snapshot
summary, current players with avatars/ping, and recent-player history;
server_detail.html bootstraps it via hx-trigger="load".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:20:01 +02:00
mwiegand
b00a3cceea
test(live-state): assert stale server's map is not rendered in the badge
Closes the negative-assertion gap from the Task 10 review: without this
check, a regression that drops the freshness guard would still pass the
positive 2/4 + c1m2_streets assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:17:02 +02:00
mwiegand
072d9f78e7
feat(servers): show live counts + map badge in server list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:14:57 +02:00
mwiegand
0dc61d5de4
feat(live-state): start daemon poller, prune history, close stuck sessions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:10:55 +02:00
mwiegand
be476112ee
feat(live-state): enrich roster with cached Steam profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:02:58 +02:00
mwiegand
33899f8c17
feat(live-state): reconcile player sessions on each poll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:58:30 +02:00
mwiegand
c9cd2557fd
style(live-state): drop unused imports staged for later tasks
threading, time, Callable were imported in anticipation of Task 9's
daemon-thread startup. Task 9 will re-add them when actually needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:55:36 +02:00
mwiegand
f48d624dcc
feat(live-state): poller writes RLE snapshots to server_live_state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:53:58 +02:00
mwiegand
f88d07a473
feat(steam): add GetPlayerSummaries client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:48:02 +02:00
mwiegand
465a103c3a
feat(servers): generate rcon_password on server create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:43:56 +02:00
mwiegand
2a440dae45
feat(facade): append rcon_password as final server.cfg line
Source cvar semantics are last-wins; appending the rcon_password after
all overlay exec lines and blueprint config ensures no overlay or user
config line can silently override it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:40:56 +02:00
mwiegand
83d2a9932c
refactor(rcon): harden _parse_duration; surface fixture handler errors
- _parse_duration wraps int() in try/except so malformed connected
  durations raise RconError (not ValueError leaking past the poller's
  except RconError).
- fake_rcon_server captures handler exceptions and re-raises at context
  exit, so a buggy test handler surfaces as a real failure instead of
  silently degrading into a client-side timeout.
- Two new parser tests: HH:MM:SS duration parsing and malformed input
  coverage.
- Fix Steam ID formula typo in the spec doc (Z*2 + Y, not Y*2 + Z; Y is
  the low bit). Code was already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:39:32 +02:00
mwiegand
b95a82b8a4
feat(rcon): add Source RCON client + status parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:31:32 +02:00
mwiegand
e25e7098f6
refactor(live-state): drop redundant ix_sps_server_recent index
The two indexes ix_sps_server_open and ix_sps_server_recent were
byte-identical because SQLAlchemy's Index(name, *cols) form drops the
DESC ordering the spec intended. Rather than reach for text("left_at
DESC"), drop the second index entirely — SQLite scans the ASC index
backwards at no measurable cost. Spec and plan updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:27:01 +02:00
mwiegand
0f825686c6
feat(live-state): add schema for snapshots, sessions, steam profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:18:24 +02:00
mwiegand
a5f7b736a2
docs/plan: server live-state display implementation plan
Thirteen TDD-structured tasks covering schema migration, RCON client,
spec injection, password generation, Steam Web API client, live-state
poller (RLE snapshots + session reconciliation + profile enrichment +
retention + thread startup), server list badge, server detail
fragment, deploy env, and end-to-end smoke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:10:33 +02:00
mwiegand
202026e11a
docs/spec: add server live-state display design
RCON-based polling with run-length-encoded snapshots, session intervals
with min/max ping, Steam profile cache, and a server-detail roster of
current + recent players hot-linked from Steam CDN avatars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:03:26 +02:00
mwiegand
e52219b1e9
deploy: weaken refresh-timer dep on web.service from Requires to Wants 2026-05-11 23:22:42 +02:00
mwiegand
429fee3868
docs/plan: trim retry-backoff tuple to match attempts-1 2026-05-11 23:21:10 +02:00
mwiegand
8cc7f84801
deploy: schedule daily workshop refresh via systemd timer
Adds left4me-workshop-refresh.service (oneshot, triggers flask
workshop-refresh) and left4me-workshop-refresh.timer (04:00 daily,
Persistent=true, 15 min jitter). Wires both into the deploy script's
cp and enable blocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:20:13 +02:00
mwiegand
f614ac05f0
tests/cli: cover running+cancelling idempotency, tighten app-context scope 2026-05-11 23:18:54 +02:00
mwiegand
0ab54b4a7d
cli: add workshop-refresh subcommand for scheduled global refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:15:05 +02:00
mwiegand
653e3212b9
tests: harden refresh-hidden-during-build with positive assertion 2026-05-11 23:13:39 +02:00
mwiegand
25b38e633d
overlay_detail: add 'Refresh from Steam' button for workshop overlays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:10:27 +02:00
mwiegand
e1b189ad3c
workshop_routes: narrow refresh's steam exception handler
Catch only requests.RequestException in refresh_overlay so that
server-side data errors (e.g., ValueError) bubble up as 500 rather
than being disguised as a 502 "steam api error". Update the 502 test to
use a real requests exception, add a sibling test that verifies
non-requests exceptions propagate, and explicitly assert that refresh
enqueues a build_overlay job even when Steam returns no entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:08:41 +02:00
mwiegand
f5094c2d9d
workshop_routes: add per-overlay refresh endpoint
POST /overlays/{id}/refresh lets the overlay owner (or any admin)
re-fetch fresh Steam metadata for all items and enqueue a rebuild.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:03:48 +02:00
mwiegand
81c6863cca
overlay_builders: restore symlink overwrite guards + nits
Commit 16adc5c silently dropped two defensive guards from the
symlink-creation loop in WorkshopBuilder.build. Restore them:
- refuse to overwrite a non-symlink file that collides with a workshop
  name (logs a message, skips creation)
- refuse to overwrite a foreign symlink (target outside the cache root)

Also: change `skipped` from list to set (O(1) membership test, no
duplicates possible), and add a brief comment above WorkshopMetadata
construction explaining which fields download_to_cache actually uses.

Two regression tests added to pin the guard behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:01:38 +02:00
mwiegand
16adc5c1fe
overlay_builders: download missing/stale workshop items inline
Replace the old skip-uncached-with-warning logic in WorkshopBuilder.build
with an inline download phase that calls _download_with_retry for each item
whose cache file is absent or stale (mtime/size mismatch). Stamps
last_downloaded_at / last_error after each download, and skips items with
no file_url. Update test fixture to utime cache files so mtime matches
time_updated, delete the now-superseded skip-warning test, and add six
new builder-level behavior tests covering the new download path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:56:09 +02:00
mwiegand
6fc7f87943
overlay_builders: address code-review nits on retry helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:48:28 +02:00
mwiegand
13bd2e48f6
overlay_builders: add _download_with_retry + _sleep_with_cancel helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:43:22 +02:00
mwiegand
532b4c4469
docs: implementation plan for workshop auto-download
7 tasks: retry helper, builder download phase, per-overlay refresh
route, template button, CLI subcommand, systemd timer, smoke-test.
2026-05-11 22:34:31 +02:00
mwiegand
fef8cc4ea6
docs: design for workshop auto-download
Closes the gap where added workshop items never reach disk until an
admin presses the global refresh button. Downloads piggyback on the
per-overlay build_overlay job; daily updates come from a systemd
timer + CLI subcommand that enqueues the existing refresh job.
2026-05-11 22:28:20 +02:00
mwiegand
c5758487a9
Revert "AGENTS: harden the spec+plan commit rule"
This reverts commit 9c01d4b702.
2026-05-11 22:26:47 +02:00
38 changed files with 7534 additions and 67 deletions

View file

@ -27,12 +27,8 @@ Do not invent architecture outside these plans unless explicitly requested.
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`. - Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
- Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned). - Implementation plans live in `docs/superpowers/plans/` as `YYYY-MM-DD-<topic>.md` (suffix the topic with `-v1`/`-v2`/etc. if a plan is versioned).
- **Every spec and every plan must be committed to this repo.** No exceptions. As soon as the user approves a spec or a plan, the next action is `git add` + `git commit` of that file under `docs/superpowers/`. - Commit both to git as soon as the user approves them.
- The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable *only* while plan mode is open. The moment plan mode exits with an approved design, copy the content into `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and commit it — do not leave the design only in the scratch file. - Do not leave specs or plans outside this repo. The `~/.claude/plans/<slug>.md` plan-mode scratch file is acceptable while plan mode is open; the persisted artifact must end up under `docs/superpowers/` and be committed.
- Before claiming a feature is "shipped", verify both files exist in-tree:
- `git ls-files docs/superpowers/specs/ | grep <topic>` returns the spec.
- `git ls-files docs/superpowers/plans/ | grep <topic>` returns the plan.
- Push to the remote when the user asks, not automatically.
### Naming and boundaries ### Naming and boundaries

View file

@ -141,6 +141,8 @@ $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-game.sl
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/l4d2-build.slice /usr/local/lib/systemd/system/l4d2-build.slice
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-nft-mark.service /usr/local/lib/systemd/system/left4me-nft-mark.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service $sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-cake.service /usr/local/lib/systemd/system/left4me-cake.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service /usr/local/lib/systemd/system/left4me-workshop-refresh.service
$sudo_cmd cp /opt/left4me/deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.timer /usr/local/lib/systemd/system/left4me-workshop-refresh.timer
# CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that # CPU isolation via cgroup-v2 AllowedCPUs= drop-ins. Pin everything that
# isn't a live game server to core 0; give game servers cores 1..N-1. # isn't a live game server to core 0; give game servers cores 1..N-1.
@ -337,6 +339,7 @@ $sudo_cmd systemctl enable --now left4me-nft-mark.service
$sudo_cmd systemctl enable --now left4me-cake.service $sudo_cmd systemctl enable --now left4me-cake.service
$sudo_cmd systemctl enable --now left4me-web.service $sudo_cmd systemctl enable --now left4me-web.service
$sudo_cmd systemctl restart left4me-web.service $sudo_cmd systemctl restart left4me-web.service
$sudo_cmd systemctl enable --now left4me-workshop-refresh.timer
for attempt in 1 2 3 4 5 6 7 8 9 10; do 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 if curl -fsS http://127.0.0.1:8000/health; then
exit 0 exit 0

View file

@ -0,0 +1,15 @@
[Unit]
Description=left4me daily workshop refresh (enqueue job)
After=network-online.target left4me-web.service
Wants=left4me-web.service
[Service]
Type=oneshot
User=left4me
Group=left4me
WorkingDirectory=/opt/left4me
Environment=HOME=/var/lib/left4me
Environment=PATH=/opt/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/etc/left4me/host.env
EnvironmentFile=/etc/left4me/web.env
ExecStart=/opt/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh

View file

@ -0,0 +1,11 @@
[Unit]
Description=left4me daily workshop refresh
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
RandomizedDelaySec=15min
Unit=left4me-workshop-refresh.service
[Install]
WantedBy=timers.target

View file

@ -1,3 +1,10 @@
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=replace-with-generated-secret SECRET_KEY=replace-with-generated-secret
JOB_WORKER_THREADS=4 JOB_WORKER_THREADS=4
# Steam Web API key for ISteamUser/GetPlayerSummaries — used by the
# live-state poller to resolve player Steam IDs to persona names + avatars
# in the server detail panel. Free at https://steamcommunity.com/dev/apikey.
# Optional: if empty, the live-state panel still shows counts/map and the
# in-game name from RCON, just with placeholder avatars.
STEAM_WEB_API_KEY=

View file

@ -428,11 +428,13 @@ def test_env_templates_contain_required_defaults():
host_env = HOST_ENV.read_text() host_env = HOST_ENV.read_text()
assert "Deployment units use fixed /var/lib/left4me paths" in host_env assert "Deployment units use fixed /var/lib/left4me paths" in host_env
assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n") assert host_env.endswith("LEFT4ME_ROOT=/var/lib/left4me\n")
assert WEB_ENV_TEMPLATE.read_text() == ( web_env = WEB_ENV_TEMPLATE.read_text()
assert web_env.startswith(
"DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n" "DATABASE_URL=sqlite:////var/lib/left4me/left4me.db\n"
"SECRET_KEY=replace-with-generated-secret\n" "SECRET_KEY=replace-with-generated-secret\n"
"JOB_WORKER_THREADS=4\n" "JOB_WORKER_THREADS=4\n"
) )
assert web_env.rstrip().endswith("STEAM_WEB_API_KEY=")
def test_deploy_script_has_safe_defaults_and_preserves_state() -> None: def test_deploy_script_has_safe_defaults_and_preserves_state() -> None:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,326 @@
# Workshop Auto-Download — Design
## Problem
When a user adds workshop items to an overlay (`POST /overlays/{id}/items`), the route saves `WorkshopItem` metadata and enqueues a `build_overlay` job. The build symlinks already-cached `.vpk` files and emits `skipped: not yet downloaded` to stderr for everything else. The only thing that actually pulls bytes from Steam is the admin-only `refresh_workshop_items` job, which is a global mutex blocking all server starts, all builds, and installs.
In practice, this means freshly-added items never appear in the overlay until an admin presses a button. That isn't workable.
## Goals
1. Newly added items get downloaded without admin action.
2. Items that authors update on Steam get re-downloaded automatically on a daily cadence.
3. Overlay owners can manually re-check / re-pull their own overlay's items.
## Non-Goals
See "Out of Scope" at the end. In particular: the `refresh_workshop_items` global mutex stays; there is no cache GC; no per-item retry inside `download_to_cache`; no update-aware server-restart prompt.
## Architecture
Three changes layered onto the existing scheduler. None introduce a new job type or new scheduler rule.
```
┌─────────────────────────────────────────────────────────────────────┐
│ User adds items │
│ POST /overlays/{id}/items │
│ ↳ fetch metadata batch (mode=add) │
│ ↳ upsert WorkshopItem rows │
│ ↳ enqueue_build_overlay ◀── already happens today │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ build_overlay job (per-overlay; not a global mutex) │
│ WorkshopBuilder.build(): │
│ 1. query overlay's items │
│ 2. for each item where cache miss / stale: ◀── NEW │
│ download_to_cache(meta) with retry+backoff │
│ stamp WorkshopItem.last_downloaded_at │
│ 3. apply symlinks (existing logic) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Owner re-checks one overlay │
│ POST /overlays/{id}/refresh ◀── NEW │
│ ↳ fetch metadata batch for this overlay only (mode=refresh) │
│ ↳ update WorkshopItem rows │
│ ↳ enqueue_build_overlay (does the download) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Daily global update │
│ systemd timer → l4d2web workshop-refresh CLI ◀── NEW │
│ ↳ inserts Job(operation='refresh_workshop_items') │
│ ↳ worker picks it up; existing global-mutex rule still applies │
│ ↳ existing _run_refresh_workshop_items code unchanged │
└─────────────────────────────────────────────────────────────────────┘
```
Key invariant: **on-add downloads run inside the per-overlay `build_overlay` job, so they do not block server starts globally.** Only the daily global refresh keeps the existing global-mutex semantics.
## Component 1 — Auto-download inside `WorkshopBuilder.build`
The builder gets a new download phase between "query items" and "apply symlinks". Today's behavior (skip-uncached with stderr warning) is replaced.
### Decision logic
For each item bound to the overlay:
1. **Skip with warning** if `file_url == ""` (Steam returned `result != 1` last time we asked — delisted, private, or hidden). Emit one stderr line `workshop item {steam_id} skipped: no file_url (steam result: {last_error})`. Do **not** fail the build — these items quietly fall out of the symlink set because they never produce a cache file. An owner can investigate via the overlay detail page where `last_error` is shown.
2. Otherwise, **download** when any of:
- `last_downloaded_at IS NULL`, or
- cache file `{steam_id}.vpk` missing, or
- cache file `(mtime, size)` doesn't match `(time_updated, file_size)` from the row.
3. Otherwise, leave the item alone (its cache file is current).
`steam_workshop.download_to_cache` already does the `(mtime, size)` check internally and short-circuits when the cache is current, so the builder can call it unconditionally for items in the "maybe download" set and trust the helper for idempotence.
### Stamping
- On success per item: `WorkshopItem.last_downloaded_at = now()`, `last_error = ""`.
- On failure per item (after retry exhaustion): `last_error` records the final exception string; the builder raises → `last_build_status='failed'`.
### What the builder does NOT do
It does not fetch fresh Steam metadata. Metadata is the responsibility of the add route, the per-overlay refresh route, and the daily refresh job. The builder is a pure function of DB state — this keeps it cheap and predictable, and lets builds run without any outbound metadata call.
### Concurrency
Items are downloaded sequentially within one builder run. Different overlays' builds run in parallel under existing scheduler rules; when two overlays share an item and race, the existing `download_to_cache` idempotence handles it — the loser sees a fresh file and skips. `last_downloaded_at` writes from two concurrent builds collapse to one timestamp; no real race.
### Cancellation
The builder threads `should_cancel` into `download_to_cache` (the helper already accepts it). Cancelled mid-download deletes the `.partial` file; the symlink phase doesn't run. Cancellation during the inter-attempt sleep wakes up within ~250 ms (see retry section).
### Logging
Each item's download start / finish / error emits one line. Counts are reported in the existing summary line:
```
workshop overlay 'mycollection': downloaded=3 cached=12 skipped=1 created=14 removed=1 unchanged=11 errors=0
```
`skipped` now means "Steam can't serve this item (no file_url)" instead of the old "uncached" meaning. Uncached items get downloaded.
## Component 2 — Retry & backoff
Wraps each `download_to_cache(meta, ...)` call inside the builder.
```
attempts = 3
delays = [1s, 2s, 4s] # exponential; slept between attempts
for n in 1..attempts:
try:
download_to_cache(meta, cache_root, should_cancel=should_cancel)
break
except InterruptedError: # cancellation
raise # propagate immediately
except (requests.RequestException, OSError) as exc:
if n == attempts: raise # final attempt: bubble up → job fails
on_stderr(f"workshop {meta.steam_id} attempt {n}/{attempts} failed: {exc}")
sleep_with_cancel(delays[n-1], should_cancel)
```
### Notes
- `sleep_with_cancel` is a small helper that polls `should_cancel` every ~250 ms during the sleep so a cancel does not wait out the full backoff window.
- The retry loop lives in the builder (`overlay_builders.py`), not in `steam_workshop.download_to_cache`. The downloader stays a single-shot primitive; retry policy is a caller concern. Keeps the helper testable without time-mocking.
- HTTP 4xx responses raised by `raise_for_status()` are `requests.HTTPError` (a `RequestException`), so they are retried too. That is intentional — 404 / 410 will fail three times quickly and surface; the cost of three failed attempts is negligible compared to the cost of users having to guess why a single transient blip killed the job.
- On final failure the job fails with the per-item error string and overlay `last_build_status='failed'`, matching the existing "never silently mount a partial overlay" rule.
## Component 3 — Per-overlay refresh
New route `POST /overlays/{id}/refresh`. Mirrors the add route's metadata-fetch path but scoped to the items already in this overlay.
### Route sketch
```python
@bp.post("/overlays/<int:overlay_id>/refresh")
@require_login
def refresh_overlay(overlay_id: int) -> Response:
user = current_user()
with session_scope() as db:
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
if err is not None: return err
steam_ids = db.scalars(
select(WorkshopItem.steam_id)
.join(OverlayWorkshopItem, OverlayWorkshopItem.workshop_item_id == WorkshopItem.id)
.where(OverlayWorkshopItem.overlay_id == overlay_id)
).all()
if not steam_ids:
return Response("overlay has no items", status=400)
try:
metas = steam_workshop.fetch_metadata_batch(steam_ids, mode="refresh")
except Exception as exc:
return Response(f"steam api error: {exc}", status=502)
with session_scope() as db:
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
if err is not None: return err
metas_by_id = {m.steam_id: m for m in metas}
for steam_id in steam_ids:
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == steam_id))
meta = metas_by_id.get(steam_id)
if wi is None: continue
if meta is None:
wi.last_error = "steam returned no entry for this item"
continue
wi.title = meta.title
wi.filename = meta.filename
wi.file_url = meta.file_url
wi.file_size = meta.file_size
wi.time_updated = meta.time_updated
wi.preview_url = meta.preview_url
wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}"
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
```
### Behavior notes
- Permission: same `_check_workshop_overlay_access` used by add/remove — owner or admin.
- `mode="refresh"` (not `"add"`): non-L4D2 items silently drop from the batch instead of raising. An item whose `consumer_app_id` somehow changed after add will not break refresh.
- The metadata write does **not** stamp `last_downloaded_at`. That field stays bound to actual file presence — the builder's download phase stamps it after the bytes land. A refresh that finds `time_updated` advanced therefore leaves `last_downloaded_at` pointing at the prior version; the `(mtime, size)` check in `download_to_cache` sees the mismatch and the builder re-downloads. Correct by construction.
- One Steam metadata POST per click, owner-gated. No new rate-limit concern.
### UI
A "Refresh" button next to "Add items" on the overlay detail page (workshop type only). Submits the POST; redirects to the job page like everything else.
## Component 4 — Periodic global refresh (CLI + systemd timer)
The existing `_run_refresh_workshop_items` job is complete and correct — it fetches all metadata, downloads what advanced, re-enqueues `build_overlay` for affected overlays. We only need a way to enqueue it on a schedule.
### CLI subcommand
In `l4d2web/cli.py`:
```python
@cli.command("workshop-refresh")
def workshop_refresh() -> None:
"""Enqueue a global workshop refresh job. Idempotent: if one is already
queued or running, prints its id and exits 0."""
with session_scope() as db:
existing = db.scalar(
select(Job).where(
Job.operation == "refresh_workshop_items",
Job.state.in_(("queued", "running", "cancelling")),
).order_by(Job.id.desc()).limit(1)
)
if existing is not None:
click.echo(f"refresh_workshop_items job {existing.id} already {existing.state}")
return
job = Job(
user_id=None,
server_id=None,
operation="refresh_workshop_items",
state="queued",
)
db.add(job)
db.flush()
click.echo(f"enqueued refresh_workshop_items job {job.id}")
```
### Schema follow-up
`Job.user_id = None` for system-enqueued refreshes. The implementation plan must verify whether the column is currently nullable; if it is `NOT NULL`, the plan either (a) relaxes it to nullable (preferred — "system" is a real category) or (b) records the lowest-id admin user as the actor. The design assumes (a).
### systemd units in `deploy/`
```ini
# left4me-workshop-refresh.service
[Unit]
Description=Left4me — enqueue daily workshop refresh
After=network-online.target left4me-web.service
Requires=left4me-web.service
[Service]
Type=oneshot
User=left4me
ExecStart=/opt/left4me/bin/l4d2web workshop-refresh
```
```ini
# left4me-workshop-refresh.timer
[Unit]
Description=Left4me — daily workshop refresh
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
```
### Operator notes
- The timer enqueues; the worker decides when to actually run. The existing scheduler will defer the refresh if a server start, install, or build is in progress. Worst case the refresh starts after the conflicting job finishes — the intended behavior.
- `Persistent=true` handles "host was down at 04:00" — the unit runs on next boot. The CLI's idempotence check prevents pile-up if it fires twice.
- Deployment wires this into the existing `deploy/` install flow (in scope for the implementation plan).
## Testing
Layered against the existing test files. No new test infrastructure.
### `tests/test_overlay_builders.py` — bulk of new coverage
- `test_workshop_build_downloads_uncached_items` — item with `last_downloaded_at=None` and no cache file → patched `download_to_cache` is called → file appears → symlink created → `last_downloaded_at` stamped.
- `test_workshop_build_skips_already_cached_items` — item with cache file matching `(time_updated, size)``download_to_cache` returns immediately (its existing idempotence) → no network → symlink created.
- `test_workshop_build_redownloads_stale_cache` — cache file exists but `(mtime, size)` mismatches the DB row → re-download happens.
- `test_workshop_build_retry_succeeds` — patched downloader fails twice then succeeds → builder finishes ok, retry messages on stderr, `last_downloaded_at` stamped. Backoff sleep monkey-patched to zero for speed.
- `test_workshop_build_retry_exhausted_fails_job` — downloader fails all three attempts → builder raises → `last_build_status='failed'`, `last_error` populated on the WorkshopItem.
- `test_workshop_build_cancellation_during_download``should_cancel` flips true mid-download → builder returns early, `.partial` cleaned up by `download_to_cache`, symlink phase did not run.
- `test_workshop_build_cancellation_during_backoff` — cancel flips true while sleeping between retries → wakes up within ~250 ms of the cancel.
- `test_workshop_build_skips_items_with_no_file_url` — item with `file_url=""` and `last_error="steam result 9"` → builder writes one stderr line, does NOT call `download_to_cache`, build succeeds with `last_build_status='ok'`, item is absent from the symlink set.
### `tests/test_workshop_routes.py` — new per-overlay refresh route
- `test_overlay_refresh_owner_allowed` — owner POST → `fetch_metadata_batch` called with exactly that overlay's steam_ids → WorkshopItem rows updated → `build_overlay` enqueued → 302 to /jobs/{id}.
- `test_overlay_refresh_other_user_forbidden` — non-owner non-admin → 403.
- `test_overlay_refresh_admin_can_refresh_any` — admin POST on someone else's overlay → 200/302.
- `test_overlay_refresh_steam_api_error_502``fetch_metadata_batch` raises → response is 502, no job enqueued.
- `test_overlay_refresh_empty_overlay_400` — overlay has no items → 400, no Steam call.
- `test_overlay_refresh_drops_missing_items_gracefully` — Steam returns nothing for one ID → that row gets `last_error="steam returned no entry…"`, build still enqueued.
### `tests/test_cli.py` — new CLI subcommand
- `test_workshop_refresh_enqueues_job` — CLI invocation inserts a queued `Job(operation='refresh_workshop_items')` and prints its id.
- `test_workshop_refresh_idempotent_when_queued` — pre-existing queued/running refresh job → second invocation prints the existing id and does not insert a duplicate.
### `tests/test_job_worker.py`
No new tests. Scheduler rules and `_run_refresh_workshop_items` are unchanged. Existing coverage holds.
### Out of test scope
The systemd timer. Validating it requires a host; smoke it on the dev host post-deploy.
## Out of Scope
- **Replacing the global mutex on `refresh_workshop_items`.** Daily refresh still blocks server starts/builds during its run. Scheduled at 04:00 with `Persistent=true`; revisit only if it observably hurts.
- **Per-item retry policy in `download_to_cache`.** Retry stays in the builder.
- **Cache GC.** Cache still grows monotonically — same as the v1 spec.
- **Steam API rate-limit handling for the metadata endpoint.** No backoff for metadata calls. Retries apply only to per-item file downloads.
- **Update-aware server restart UX.** When the daily refresh re-downloads an item mounted by a running server, the running server keeps its old mount. Notifying the user / offering a "restart to pick up updates" prompt stays in the backlog.
- **Per-overlay refresh on non-workshop overlay types.** Only workshop overlays get the Refresh button.
## Affected Files
Implementation will touch roughly:
- `l4d2web/services/overlay_builders.py` — WorkshopBuilder download phase, retry helper.
- `l4d2web/routes/workshop_routes.py` — new `/overlays/{id}/refresh` route.
- `l4d2web/templates/...` — Refresh button on overlay detail page.
- `l4d2web/cli.py` — new `workshop-refresh` subcommand.
- `l4d2web/models.py` and `alembic/versions/...` — possibly relax `Job.user_id` to nullable (TBD per schema check).
- `deploy/` — systemd `.service` + `.timer` units, wired into the install flow.
- `l4d2web/tests/test_overlay_builders.py`, `test_workshop_routes.py`, `test_cli.py` — new test cases per the testing section.
The implementation plan will turn these into ordered steps with explicit checkpoints.

View file

@ -0,0 +1,396 @@
# Server live-state display (counts, map, roster, avatars, history)
## Context
The l4d2web UI currently shows systemd lifecycle state per game server (running/stopped/unknown) but nothing about what's happening *inside* the game: player count, current map, whether the server is hibernating, who is connected. To know any of that, users have to context-switch (open the game, query externally).
The goal is a **read-side live-state display**: counts + map + hibernating on the server list, plus a server-detail panel showing the current player roster (avatars + names) and a "recent players" section for who's been on lately. Backed by a persistent history table so we get count-over-time graphs and player-presence history (foundation for future ban UX) for free.
**Source: RCON exclusively.** A2S_INFO (UDP, anonymous) was investigated and discarded — it can't deliver Steam IDs, hibernating flag, or interactive commands, so anything beyond raw counts re-routes through RCON anyway. Both transports were verified working against prod `left4.me`. Going RCON-only means one transport, one set of tests, no throwaway scaffolding.
**Avatars: Steam Web API.** RCON gives Steam IDs; `ISteamUser/GetPlayerSummaries` resolves them to persona names + avatar URLs hot-linked from Steam's CDN. API key already obtained.
**Commands are deferred** to a separate plan. This plan is read-only.
---
## Architecture
```
┌─────────────────────────────┐
│ left4me-web (Flask) │
┌──────────────┐ RCON │ ┌───────────────────────┐ │
│ srcds 27016 │◄──────┼──┤ live-state poller │ │
└──────────────┘ TCP │ │ (daemon thread) │ │
│ └───────┬───────────────┘ │
┌──────────────┐ RCON │ │ writes │
│ srcds 27021 │◄──────┤ ▼ │
└──────────────┘ │ ┌───────────────────────┐ │
│ │ server_live_state │ │
Steam Web API │ │ server_player_session │ │
┌────────────┐ │ │ steam_user_profile │ │
│ Steam CDN │◄─┼──┤ │ │
│ avatars... │ │ └───────┬───────────────┘ │
└────────────┘ │ │ reads │
▲ │ ▼ │
│ │ ┌───────────────────────┐ │
└────────┼──┤ /servers, /servers/N │ │
<img src=...> │ │ (HTMX 5s refresh) │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
Single daemon thread (modeled on the existing `start_state_poller` in `l4d2web/services/job_worker.py:617-647`), inside the Flask process, polls every `LIVE_STATE_POLL_SECONDS` (default 5). Per poll, per running server with a configured RCON password:
1. TCP connect to `127.0.0.1:<port>`, auth, send `status`, parse response.
2. Compare server-level state (players/map/hibernating/etc.) to the latest `server_live_state` row for this server. If unchanged, bump `last_seen_at`. If changed, insert a new row.
3. Reconcile open sessions (`server_player_session` rows where `left_at IS NULL`) with the current `status` roster: open new sessions for new players (backfilling `joined_at` from RCON's `connected` field), close sessions for players no longer present, update `min_ping`/`max_ping` for continuing sessions.
4. Collect Steam IDs that are missing from `steam_user_profile` or have `fetched_at` older than 24h; batch them into a single `GetPlayerSummaries` call; upsert results.
5. Trim `server_live_state` and closed sessions older than retention.
---
## Schema (one new alembic migration)
### New column: `servers.rcon_password`
```python
rcon_password: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
```
Empty string = "no password configured yet" (poller skips). Migration backfills every existing row with `secrets.token_urlsafe(32)` (~43 chars, URL-safe character set so the literal `"..."` cfg-quoting needs no escaping).
### `server_live_state` — run-length-encoded snapshots
```sql
CREATE TABLE server_live_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
started_at DATETIME NOT NULL, -- when this exact state first appeared
last_seen_at DATETIME NOT NULL, -- most recent poll where it still held
players INTEGER NOT NULL,
max_players INTEGER NOT NULL,
bots INTEGER NOT NULL,
map VARCHAR(64) NOT NULL,
hibernating BOOLEAN NOT NULL
);
CREATE INDEX ix_sls_server_started ON server_live_state(server_id, started_at DESC);
```
- "State" = the tuple `(players, max_players, bots, map, hibernating)`. Ping/loss are deliberately not stored at server-level, so they don't churn rows.
- Idle hibernating server collapses from one-row-per-poll to one-row-per-state-change (≈17,280× compression for a 24h-idle server).
- Latest snapshot for a server: `ORDER BY started_at DESC LIMIT 1`. UI staleness check: `last_seen_at > now - LIVE_STATE_STALE_SECONDS` (default 30).
- Retention: trim rows where `last_seen_at < now - LIVE_STATE_HISTORY_DAYS` (default 30).
- Failed polls produce no DB write; the staleness check on `last_seen_at` handles UI degradation cleanly.
### `server_player_session` — interval per connection
```sql
CREATE TABLE server_player_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
steam_id_64 VARCHAR(20) NOT NULL,
joined_at DATETIME NOT NULL,
left_at DATETIME NULL, -- NULL = currently in-game
name_at_join VARCHAR(64) NOT NULL,
min_ping INTEGER NOT NULL,
max_ping INTEGER NOT NULL
);
CREATE INDEX ix_sps_server_open ON server_player_session(server_id, left_at);
CREATE INDEX ix_sps_steam_history ON server_player_session(steam_id_64, joined_at);
```
- `joined_at` is **backfilled from RCON's `connected` duration** on first sighting (`joined_at = now - connected_seconds`). This heals brief polling gaps and survives web restarts: even if we just started polling, we know when the still-connected players actually joined.
- A player who disconnects and rejoins gets two rows, not one merged interval.
- Bots are excluded — rows with a non-`STEAM_X:Y:Z` uniqueid are skipped.
- `min_ping`/`max_ping` updated only when a new poll pushes the range, to avoid noise writes.
- On poller startup, close any sessions whose server isn't in current RCON output. Plus: close sessions after N consecutive failed polls of their server (TBD constant during implementation, e.g. 6 polls = ~30s).
- Retention: trim closed sessions where `left_at < now - SESSION_HISTORY_DAYS` (default 30). Open sessions never trimmed.
### `steam_user_profile` — cached profile data (24h TTL)
```sql
CREATE TABLE steam_user_profile (
steam_id_64 VARCHAR(20) PRIMARY KEY,
persona_name VARCHAR(64) NOT NULL,
avatar_url TEXT NOT NULL, -- avatarmedium from Steam Web API
fetched_at DATETIME NOT NULL
);
```
- Cache is global, not per-server (one profile per Steam ID).
- Refreshed when `fetched_at < now - 24h` or when entry is missing.
- Soft-fail: if the Steam API key is unset, the API is down, or a profile is private, we just leave the cache as-is and the UI falls back to `name_at_join` + placeholder avatar.
### Bind-rendered queries
**Current players on server X:**
```sql
SELECT sp.steam_id_64, sp.joined_at, sp.name_at_join,
sp.min_ping, sp.max_ping,
p.persona_name, p.avatar_url
FROM server_player_session sp
LEFT JOIN steam_user_profile p USING (steam_id_64)
WHERE sp.server_id = ? AND sp.left_at IS NULL
ORDER BY sp.joined_at;
```
**Recent players on server X (last 30 days, excluding currently in-game):**
```sql
SELECT sp.steam_id_64, MAX(sp.left_at) AS last_seen,
p.persona_name, p.avatar_url
FROM server_player_session sp
LEFT JOIN steam_user_profile p USING (steam_id_64)
WHERE sp.server_id = ?
AND sp.left_at IS NOT NULL
AND sp.left_at > datetime('now', '-30 days')
AND sp.steam_id_64 NOT IN (
SELECT steam_id_64 FROM server_player_session
WHERE server_id = ? AND left_at IS NULL
)
GROUP BY sp.steam_id_64, p.persona_name, p.avatar_url
ORDER BY last_seen DESC
LIMIT 20;
```
---
## Modules
### `l4d2web/services/rcon.py` (new)
Pure stdlib (`socket`, `struct`), no new dependency. Source RCON protocol:
```python
@dataclass(slots=True, frozen=True)
class PlayerRow:
steam_id_64: str # converted from STEAM_X:Y:Z
name: str
connected_seconds: int
ping: int
@dataclass(slots=True, frozen=True)
class StatusResponse:
map: str
players: int # humans
max_players: int
bots: int
hibernating: bool
roster: list[PlayerRow]
class RconError(Exception): ...
class RconAuthError(RconError): ...
def query_status(host: str, port: int, password: str, *, timeout: float = 2.0) -> StatusResponse: ...
```
Implementation notes:
- Auth handshake quirk verified live: server sends a `type=0` empty-body packet **before** the `type=2` auth response. Consume both. `req_id == -1` on the auth response = bad password.
- Single TCP connection per query (loopback, ~10-20ms total round-trip — pooling not worth it at this scale).
- Header regex on `map :` and `players :` lines (the `(hibernating|not hibernating)` token is in `players :`).
- Roster regex: split lines starting with `#`, skip the column-header line, robustly extract the quoted name + the `STEAM_X:Y:Z` token + `MM:SS` or `HH:MM:SS` connected duration + ping. Tolerate the two-numeric-prefix L4D2 variant (`# 2 1 "Crone" STEAM_1:0:...`).
- Steam ID conversion: `STEAM_X:Y:Z``76561197960265728 + (Z * 2) + Y` (Y is the low bit; returned as string).
### `l4d2web/services/steam_users.py` (new)
Modeled directly on `l4d2web/services/steam_workshop.py:17-43` (single `requests.Session`, 30s timeout, anonymous-pattern POST with form-encoded body — only difference is the `key=` parameter).
```python
@dataclass(slots=True, frozen=True)
class SteamProfile:
steam_id_64: str
persona_name: str
avatar_url: str # avatarmedium
def fetch_profiles_batch(steam_ids: Iterable[str], *, api_key: str) -> list[SteamProfile]: ...
```
- Endpoint: `GET https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=<key>&steamids=<csv>`.
- Up to 100 IDs per call; caller batches.
- Returns only successful resolutions (private/deleted accounts simply absent from the response — fine, they stay uncached and the UI falls back).
- Raises on transport errors; caller decides whether to surface.
### `l4d2web/services/live_state_poller.py` (new)
Modeled on `start_state_poller` / `state_poller_loop` in `l4d2web/services/job_worker.py:617-647`.
```python
def start_live_state_poller(app) -> None: ... # spawns daemon thread, skipped under TESTING
def live_state_poller_loop(app, interval: float) -> None: ...
def poll_once() -> None: # one full pass over running servers
...
```
Per-server algorithm:
1. RCON `status``StatusResponse` (or skip on auth/timeout, logged via `app.logger`).
2. **Server-level RLE upsert**: load newest `server_live_state` row for this server. If `(players, max_players, bots, map, hibernating)` matches → `UPDATE last_seen_at = now()`. Else → `INSERT` new row.
3. **Session reconciliation** in a single transaction:
- Load open sessions for this server.
- For each player in `response.roster` not in open sessions: `INSERT` new session with `joined_at = now - connected_seconds`, `name_at_join = roster.name`, `min_ping = max_ping = roster.ping`.
- For each open session whose player is in the roster: if `roster.ping < min_ping` or `> max_ping`, `UPDATE` the range. Otherwise skip the write.
- For each open session whose player is *not* in the roster: `UPDATE left_at = now()`.
4. **Profile enrichment**: collect Steam IDs from the roster where the cached profile is missing or `fetched_at < now - 24h`. Skip if `STEAM_WEB_API_KEY` unset. Batch into one Steam API call. Upsert results.
Periodic (every Nth cycle, e.g. once a minute):
- Trim `server_live_state` and closed sessions past retention.
- Close any open sessions whose `server_id` hasn't had a successful RCON response in the last `STUCK_SESSION_SECONDS` (default 60).
### Modify: `l4d2web/services/l4d2_facade.py:28-52`
`build_server_spec_payload` **appends** `f'rcon_password "{server.rcon_password}"'` as the *last* entry in the returned `config` list, only if the password is non-empty. Appending (not prepending) matters: Source's cfg semantics are last-wins, so putting our line after both the overlay `exec` lines and the user's blueprint config guarantees no overlay or blueprint can silently clobber the password and break the poller. `l4d2host/instances.py:40-58` already writes `spec.config` lines verbatim to `server.cfg`**no host-side change needed**.
### Modify: server-create route
Wherever the server-create form handler lives (`l4d2web/routes/server_routes.py` or similar — confirm during implementation): before commit, generate `rcon_password = secrets.token_urlsafe(32)`.
---
## Web UI
### Server list (template TBD: `ls l4d2web/templates/` during implementation)
Add an inline live-state cell per server row:
- Stopped server: `—`
- Stale (no row newer than `LIVE_STATE_STALE_SECONDS`): dim `?` with tooltip "no data"
- Hibernating: `0/4 · idle · c1m1_hotel`
- Active: `2/4 · c1m2_streets`
No HTMX on the list page; page reload picks up the latest snapshot.
### Server detail (`l4d2web/templates/server_detail.html`)
New section, HTMX-refreshed every `LIVE_STATE_POLL_SECONDS` (default 5):
```html
<section class="panel"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="every 5s"
hx-swap="outerHTML">
<!-- rendered from l4d2web/templates/_live_state.html -->
</section>
```
The partial renders three blocks:
1. **Summary**: `players/max_players · map · idle?` plus a small "polled Ns ago" caption.
2. **Current players** (only if non-empty): grid of cards, each `<img src="{{ profile.avatar_url or placeholder }}" /> {{ profile.persona_name or session.name_at_join }} · {{ joined_relative }} · ping {{ min }}-{{ max }}ms`.
3. **Recent players** (last 30 days, excluding current; only if non-empty): smaller cards, `{{ avatar }} {{ persona_name or name_at_join }} · last seen {{ last_seen_relative }}`.
New route: `GET /servers/<id>/live-state` returns the partial. Composition mirrors the existing build-status pattern at `l4d2web/templates/_overlay_build_status.html:1-5`.
Avatar `<img>` tags point straight at Steam CDN URLs (`avatars.cloudflare.steamstatic.com` / `avatars.akamai.steamstatic.com`). No proxying. Same approach as `WorkshopItem.preview_url`. Note: confirm the existing CSP allows these hosts; if not, extend it.
No JS framework added — HTMX only.
---
## Config keys
In `l4d2web/config.py`, plus documented defaults in `deploy/templates/etc/left4me/web.env` where applicable:
| key | default | purpose |
|---|---|---|
| `LIVE_STATE_POLL_SECONDS` | `5` | poll interval |
| `LIVE_STATE_QUERY_TIMEOUT_SECONDS` | `2.0` | per-RCON-query timeout |
| `LIVE_STATE_POLL_WORKERS` | `4` | thread-pool size for parallel per-server polls |
| `LIVE_STATE_STALE_SECONDS` | `30` | UI staleness threshold |
| `LIVE_STATE_HISTORY_DAYS` | `30` | retention for snapshots + closed sessions |
| `STUCK_SESSION_SECONDS` | `60` | close open sessions whose server has been unreachable for this long |
| `STEAM_PROFILE_TTL_SECONDS` | `86400` | profile cache TTL |
| `STEAM_WEB_API_KEY` | `""` | from `web.env`; empty disables enrichment |
---
## Tests
- `l4d2web/tests/test_rcon.py` — protocol handshake against an in-process TCP fixture: auth-success, auth-failure (`req_id == -1`), header parse (incl. `(hibernating)` and `(reserved <token>)` variants), roster parse (incl. the two-numeric-prefix L4D2 variant), Steam ID conversion.
- `l4d2web/tests/test_steam_users.py` — request shape (key in querystring, batched ids, 100-per-call ceiling), response parsing, partial response (some IDs missing).
- `l4d2web/tests/test_live_state_poller.py` — mirror `test_state_poller_*` at `l4d2web/tests/test_job_worker.py:882-952`. Cover: iterates only running servers with non-empty `rcon_password`, RLE upsert (matching state → `last_seen_at` bump only; differing state → new row), session open with backfilled `joined_at`, session close on disappearance, ping range expansion, stuck-session close after N failures, drops auth failures silently, respects retention.
- `l4d2web/tests/test_server_routes.py` (extend) — `/servers/<id>/live-state` fragment route renders summary/current/recent blocks correctly; stale rendering when latest snapshot is old; soft-fail rendering when no profile cached.
- `l4d2web/tests/test_l4d2_facade.py` (extend) — `build_server_spec_payload` appends `rcon_password "..."` as the last config line when password is set; omits the line when empty; appears after both the overlay `exec` lines and the blueprint config lines.
- Migration test — existing rows backfilled with non-empty 43-char passwords; tables created with correct indexes.
---
## Critical files
**New:**
- `l4d2web/services/rcon.py` — Source RCON client + status parser
- `l4d2web/services/steam_users.py` — Steam Web API client (mirrors `steam_workshop.py`)
- `l4d2web/services/live_state_poller.py` — background thread + poll loop + session reconciler
- `l4d2web/alembic/versions/00XX_server_live_state.py` — migration: new column, three new tables, password backfill
- `l4d2web/templates/_live_state.html` — HTMX-refreshed fragment (summary + current + recent)
- `l4d2web/tests/test_rcon.py`, `l4d2web/tests/test_steam_users.py`, `l4d2web/tests/test_live_state_poller.py`
**Modify:**
- `l4d2web/models.py` — add `ServerLiveState`, `ServerPlayerSession`, `SteamUserProfile`; add `rcon_password` to `Server` (after line 137)
- `l4d2web/services/l4d2_facade.py:28-52``build_server_spec_payload` appends `rcon_password "..."` as the last config line when set
- `l4d2web/app.py` — call `start_live_state_poller(app)` next to existing `start_state_poller`
- `l4d2web/routes/server_routes.py` (or equivalent — confirm) — generate `rcon_password` in create handler; add `GET /servers/<id>/live-state`
- `l4d2web/templates/server_detail.html` — include `_live_state.html`
- `l4d2web/templates/<server-list>.html` — confirm filename; add inline badge column
- `l4d2web/config.py` — register the eight new config keys
- `deploy/templates/etc/left4me/web.env` — add `STEAM_WEB_API_KEY=` and any tunables we expose
**Reused without changes:**
- `l4d2web/services/job_worker.py:617-647` — daemon-thread / poll-loop pattern reference
- `l4d2web/services/steam_workshop.py:17-43``requests.Session` + form-POST pattern for Steam Web API
- `l4d2host/instances.py:40-58` — already writes `spec.config` verbatim, so no host-side change for password injection
- `l4d2web/templates/_overlay_build_status.html` — HTMX polling pattern reference
---
## Verification
1. **Unit tests**:
```
pytest l4d2web/tests/test_rcon.py l4d2web/tests/test_steam_users.py l4d2web/tests/test_live_state_poller.py -v
pytest l4d2web/tests -q # full regression
```
2. **Migration check**:
```
alembic upgrade head
sqlite3 l4d2web.db "SELECT id, name, length(rcon_password) FROM servers;" # every row ~43
sqlite3 l4d2web.db ".schema server_live_state server_player_session steam_user_profile"
```
3. **End-to-end against prod** (`left4.me`):
- Deploy. Confirm `systemctl status left4me-web.service` shows no crash-loop and the journal logs `start_live_state_poller` once.
- Restart both existing game servers so they pick up the injected password.
- SQL sanity (web-host shell):
```
sqlite3 l4d2web.db "SELECT server_id, started_at, last_seen_at, players, map, hibernating
FROM server_live_state ORDER BY server_id, started_at DESC LIMIT 10;"
```
Expect a single recent row per server while idle; new rows when players come/go.
- Connect to one server from the L4D2 client; within 5s, `/servers/<id>` shows a card with your avatar + persona name + ping range. Disconnect; within 5s the card moves to "recent."
- `sqlite3 l4d2web.db "SELECT * FROM server_player_session WHERE left_at IS NULL;"` — empty when nobody's connected; one row per current player when someone is.
- `sqlite3 l4d2web.db "SELECT count(*), MIN(fetched_at), MAX(fetched_at) FROM steam_user_profile;"` — at least one row after a player has been resolved.
4. **Failure-path checks**:
- Manually corrupt `servers.rcon_password` for one server; confirm the journal logs auth failure and the row's badge goes stale within `LIVE_STATE_STALE_SECONDS`; other servers unaffected.
- Unset `STEAM_WEB_API_KEY` in `web.env`, restart web; confirm display still works (in-game names + placeholder avatars), no errors in journal.
- `nft` drop the loopback TCP on one server's port; confirm rows stop appearing, open sessions close after `STUCK_SESSION_SECONDS`, badge goes stale.
---
## Open implementation questions
- **Server-list template filename**: confirm with `ls l4d2web/templates/` once implementation starts.
- **Server-create route location**: confirm path (likely `l4d2web/routes/server_routes.py`).
- **CSP allowlist for Steam avatar CDNs**: check `l4d2web/app.py` (or wherever security headers live) — extend `img-src` to include `avatars.cloudflare.steamstatic.com`, `avatars.akamai.steamstatic.com`, `avatars.steamstatic.com` if a CSP is enforced.
- **Adaptive backoff** for hibernating servers: defer; start with fixed 5s and revisit only if load becomes a concern (which it won't at current server count).
- **Migration data step**: SQLite alembic batch operation with a Python data step that iterates rows and generates `secrets.token_urlsafe(32)` per row — confirm pattern against existing migrations under `l4d2web/alembic/versions/`.
---
## Deferred to a separate plan
- Generic RCON command execution (`changelevel`, `kick`, `say`, `sm_ban`, ...)
- Web UI buttons mapped to those commands with CSRF + admin authz
- Audit log table for issued commands
- Player-count history graphs (data already accumulating from this plan)
- Ban UX (lookup by Steam ID, search across `server_player_session`)

View file

@ -0,0 +1,109 @@
"""server_live_state schema
Revision ID: 0010_server_live_state
Revises: 0009_user_password_changed_at
Create Date: 2026-05-12
"""
from __future__ import annotations
import secrets
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0010_server_live_state"
down_revision: Union[str, Sequence[str], None] = "0009_user_password_changed_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add rcon_password to servers, default ''
with op.batch_alter_table("servers") as batch:
batch.add_column(
sa.Column(
"rcon_password",
sa.String(length=64),
nullable=False,
server_default="",
)
)
# 2. Backfill every existing row with a freshly generated password.
conn = op.get_bind()
rows = conn.execute(sa.text("SELECT id FROM servers")).fetchall()
for row in rows:
conn.execute(
sa.text("UPDATE servers SET rcon_password = :pw WHERE id = :id"),
{"pw": secrets.token_urlsafe(32), "id": row.id},
)
# 3. server_live_state — run-length-encoded snapshots
op.create_table(
"server_live_state",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(), nullable=False),
sa.Column("players", sa.Integer(), nullable=False),
sa.Column("max_players", sa.Integer(), nullable=False),
sa.Column("bots", sa.Integer(), nullable=False),
sa.Column("map", sa.String(length=64), nullable=False),
sa.Column("hibernating", sa.Boolean(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index(
"ix_sls_server_started",
"server_live_state",
["server_id", "started_at"],
)
# 4. server_player_session — connection intervals
op.create_table(
"server_player_session",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"server_id",
sa.Integer(),
sa.ForeignKey("servers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("steam_id_64", sa.String(length=20), nullable=False),
sa.Column("joined_at", sa.DateTime(), nullable=False),
sa.Column("left_at", sa.DateTime(), nullable=True),
sa.Column("name_at_join", sa.String(length=64), nullable=False),
sa.Column("min_ping", sa.Integer(), nullable=False),
sa.Column("max_ping", sa.Integer(), nullable=False),
sqlite_autoincrement=True,
)
op.create_index("ix_sps_server_open", "server_player_session", ["server_id", "left_at"])
op.create_index(
"ix_sps_steam_history", "server_player_session", ["steam_id_64", "joined_at"]
)
# 5. steam_user_profile — 24h profile cache
op.create_table(
"steam_user_profile",
sa.Column("steam_id_64", sa.String(length=20), primary_key=True),
sa.Column("persona_name", sa.String(length=64), nullable=False),
sa.Column("avatar_url", sa.Text(), nullable=False),
sa.Column("fetched_at", sa.DateTime(), nullable=False),
)
def downgrade() -> None:
op.drop_table("steam_user_profile")
op.drop_index("ix_sps_steam_history", table_name="server_player_session")
op.drop_index("ix_sps_server_open", table_name="server_player_session")
op.drop_table("server_player_session")
op.drop_index("ix_sls_server_started", table_name="server_live_state")
op.drop_table("server_live_state")
with op.batch_alter_table("servers") as batch:
batch.drop_column("rcon_password")

View file

@ -25,6 +25,7 @@ from l4d2web.services.job_worker import (
start_job_workers, start_job_workers,
start_state_poller, start_state_poller,
) )
from l4d2web.services.live_state_poller import start_live_state_poller
def _in_flask_cli_context() -> bool: def _in_flask_cli_context() -> bool:
@ -98,6 +99,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
recover_stale_jobs() recover_stale_jobs()
start_job_workers(app) start_job_workers(app)
start_state_poller(app) start_state_poller(app)
if not app.config.get("TESTING"):
start_live_state_poller(app)
@app.get("/health") @app.get("/health")
def health(): def health():

View file

@ -7,7 +7,7 @@ from sqlalchemy import select
from l4d2web.auth import hash_password, validate_new_password from l4d2web.auth import hash_password, validate_new_password
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Overlay, User from l4d2web.models import Job, Overlay, User
from l4d2web.services.overlay_creation import ( from l4d2web.services.overlay_creation import (
create_overlay_directory, create_overlay_directory,
generate_overlay_path, generate_overlay_path,
@ -90,7 +90,38 @@ def seed_script_overlays(directory: Path) -> None:
click.echo(f"created {name} (id={overlay.id})") click.echo(f"created {name} (id={overlay.id})")
@click.command("workshop-refresh")
def workshop_refresh() -> None:
"""Enqueue a global workshop refresh job. Idempotent: if a refresh is
already queued or running, prints its id and exits 0."""
with session_scope() as db:
existing = db.scalar(
select(Job)
.where(
Job.operation == "refresh_workshop_items",
Job.state.in_(("queued", "running", "cancelling")),
)
.order_by(Job.id.desc())
.limit(1)
)
if existing is not None:
click.echo(
f"refresh_workshop_items job {existing.id} already {existing.state}"
)
return
job = Job(
user_id=None,
server_id=None,
operation="refresh_workshop_items",
state="queued",
)
db.add(job)
db.flush()
click.echo(f"enqueued refresh_workshop_items job {job.id}")
def register_cli(app) -> None: def register_cli(app) -> None:
app.cli.add_command(promote_admin) app.cli.add_command(promote_admin)
app.cli.add_command(create_user) app.cli.add_command(create_user)
app.cli.add_command(seed_script_overlays) app.cli.add_command(seed_script_overlays)
app.cli.add_command(workshop_refresh)

View file

@ -13,6 +13,14 @@ DEFAULT_CONFIG: dict[str, object] = {
"JOB_LOG_LINE_MAX_CHARS": 4096, "JOB_LOG_LINE_MAX_CHARS": 4096,
"PORT_RANGE_START": 27015, "PORT_RANGE_START": 27015,
"PORT_RANGE_END": 27115, "PORT_RANGE_END": 27115,
"LIVE_STATE_POLL_SECONDS": 5,
"LIVE_STATE_QUERY_TIMEOUT_SECONDS": 2.0,
"LIVE_STATE_STALE_SECONDS": 30,
"LIVE_STATE_HISTORY_DAYS": 30,
"LIVE_STATE_RETENTION_EVERY_TICKS": 60,
"STUCK_SESSION_SECONDS": 60,
"STEAM_PROFILE_TTL_SECONDS": 86400,
"STEAM_WEB_API_KEY": "",
} }
@ -33,4 +41,12 @@ def load_config() -> dict[str, object]:
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")), "JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
"PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")), "PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")),
"PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")), "PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")),
"LIVE_STATE_POLL_SECONDS": float(os.getenv("LIVE_STATE_POLL_SECONDS", "5")),
"LIVE_STATE_QUERY_TIMEOUT_SECONDS": float(os.getenv("LIVE_STATE_QUERY_TIMEOUT_SECONDS", "2.0")),
"LIVE_STATE_STALE_SECONDS": int(os.getenv("LIVE_STATE_STALE_SECONDS", "30")),
"LIVE_STATE_HISTORY_DAYS": int(os.getenv("LIVE_STATE_HISTORY_DAYS", "30")),
"LIVE_STATE_RETENTION_EVERY_TICKS": int(os.getenv("LIVE_STATE_RETENTION_EVERY_TICKS", "60")),
"STUCK_SESSION_SECONDS": int(os.getenv("STUCK_SESSION_SECONDS", "60")),
"STEAM_PROFILE_TTL_SECONDS": int(os.getenv("STEAM_PROFILE_TTL_SECONDS", "86400")),
"STEAM_WEB_API_KEY": os.getenv("STEAM_WEB_API_KEY", ""),
} }

View file

@ -143,6 +143,9 @@ class Server(Base):
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False) last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
rcon_password: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
@ -172,3 +175,52 @@ class JobLog(Base):
stream: Mapped[str] = mapped_column(String(8), nullable=False) stream: Mapped[str] = mapped_column(String(8), nullable=False)
line: Mapped[str] = mapped_column(Text, nullable=False) line: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
class ServerLiveState(Base):
__tablename__ = "server_live_state"
__table_args__ = (
Index("ix_sls_server_started", "server_id", "started_at"),
{"sqlite_autoincrement": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
players: Mapped[int] = mapped_column(Integer, nullable=False)
max_players: Mapped[int] = mapped_column(Integer, nullable=False)
bots: Mapped[int] = mapped_column(Integer, nullable=False)
map: Mapped[str] = mapped_column(String(64), nullable=False)
hibernating: Mapped[bool] = mapped_column(Boolean, nullable=False)
class ServerPlayerSession(Base):
__tablename__ = "server_player_session"
__table_args__ = (
Index("ix_sps_server_open", "server_id", "left_at"),
Index("ix_sps_steam_history", "steam_id_64", "joined_at"),
{"sqlite_autoincrement": True},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
)
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
class SteamUserProfile(Base):
__tablename__ = "steam_user_profile"
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

View file

@ -1,6 +1,7 @@
import json import json
from datetime import UTC, datetime, timedelta
from flask import Blueprint, Response, redirect, render_template, request from flask import Blueprint, Response, current_app, redirect, render_template, request
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from l4d2web.auth import current_user, require_admin, require_login from l4d2web.auth import current_user, require_admin, require_login
@ -12,6 +13,7 @@ from l4d2web.models import (
Overlay, Overlay,
OverlayWorkshopItem, OverlayWorkshopItem,
Server, Server,
ServerLiveState,
User, User,
WorkshopItem, WorkshopItem,
) )
@ -154,6 +156,42 @@ def servers_page() -> str:
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name) select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all() ).all()
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
server_ids = [s.id for s, _bp in rows]
latest_rows: dict[int, ServerLiveState] = {}
if server_ids:
subq = (
select(
ServerLiveState.server_id,
func.max(ServerLiveState.started_at).label("mx"),
)
.where(ServerLiveState.server_id.in_(server_ids))
.group_by(ServerLiveState.server_id)
.subquery()
)
sls_rows = db.scalars(
select(ServerLiveState).join(
subq,
(ServerLiveState.server_id == subq.c.server_id)
& (ServerLiveState.started_at == subq.c.mx),
)
).all()
for r in sls_rows:
latest_rows[r.server_id] = r
live_state_by_server: dict[int, dict] = {}
for sid, row in latest_rows.items():
fresh = row.last_seen_at >= cutoff
live_state_by_server[sid] = {
"fresh": fresh,
"players": row.players,
"max_players": row.max_players,
"map": row.map,
"hibernating": row.hibernating,
}
prefill_blueprint_id: int | None = None prefill_blueprint_id: int | None = None
raw_prefill = request.args.get("blueprint_id") raw_prefill = request.args.get("blueprint_id")
if raw_prefill: if raw_prefill:
@ -169,6 +207,7 @@ def servers_page() -> str:
rows=rows, rows=rows,
blueprints=blueprints, blueprints=blueprints,
prefill_blueprint_id=prefill_blueprint_id, prefill_blueprint_id=prefill_blueprint_id,
live_state_by_server=live_state_by_server,
) )

View file

@ -1,11 +1,14 @@
from flask import Blueprint, Response, current_app, jsonify, redirect, request import secrets
from sqlalchemy import select from datetime import UTC, datetime, timedelta
from flask import Blueprint, Response, current_app, jsonify, redirect, render_template, request
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from l4d2web.auth import current_user, require_login from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import Job, Server from l4d2web.models import Job, Server, ServerLiveState, ServerPlayerSession, SteamUserProfile
bp = Blueprint("server", __name__) bp = Blueprint("server", __name__)
@ -90,6 +93,7 @@ def create_server() -> Response:
desired_state="stopped", desired_state="stopped",
actual_state="unknown", actual_state="unknown",
last_error="", last_error="",
rcon_password=secrets.token_urlsafe(32),
) )
db.add(server) db.add(server)
@ -186,3 +190,84 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response:
if operation == "delete": if operation == "delete":
return redirect("/servers") return redirect("/servers")
return redirect(f"/servers/{server_id}") return redirect(f"/servers/{server_id}")
@bp.get("/servers/<int:server_id>/live-state")
@require_login
def live_state_fragment(server_id: int) -> Response:
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(
Server.id == server_id, Server.user_id == user.id,
))
if server is None:
return Response(status=404)
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
latest = db.scalar(
select(ServerLiveState)
.where(ServerLiveState.server_id == server.id)
.order_by(ServerLiveState.started_at.desc())
.limit(1)
)
current_rows = db.execute(
select(ServerPlayerSession, SteamUserProfile)
.outerjoin(
SteamUserProfile,
SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64,
)
.where(
ServerPlayerSession.server_id == server.id,
ServerPlayerSession.left_at.is_(None),
)
.order_by(ServerPlayerSession.joined_at)
).all()
current_ids = [r[0].steam_id_64 for r in current_rows]
recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
)
recent_rows = db.execute(
select(
ServerPlayerSession.steam_id_64,
func.max(ServerPlayerSession.left_at).label("last_seen"),
ServerPlayerSession.name_at_join,
SteamUserProfile.persona_name,
SteamUserProfile.avatar_url,
)
.outerjoin(
SteamUserProfile,
SteamUserProfile.steam_id_64 == ServerPlayerSession.steam_id_64,
)
.where(
ServerPlayerSession.server_id == server.id,
ServerPlayerSession.left_at.is_not(None),
ServerPlayerSession.left_at >= recent_cutoff,
~ServerPlayerSession.steam_id_64.in_(current_ids) if current_ids else True,
)
.group_by(
ServerPlayerSession.steam_id_64,
SteamUserProfile.persona_name,
SteamUserProfile.avatar_url,
ServerPlayerSession.name_at_join,
)
.order_by(func.max(ServerPlayerSession.left_at).desc())
.limit(20)
).all()
return render_template(
"_live_state.html",
server=server,
snapshot=latest,
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
current_players=current_rows,
recent_players=recent_rows,
now=datetime.now(UTC).replace(tzinfo=None),
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
)

View file

@ -2,6 +2,7 @@
admin global refresh).""" admin global refresh)."""
from __future__ import annotations from __future__ import annotations
import requests
from flask import Blueprint, Response, redirect, request from flask import Blueprint, Response, redirect, request
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import select from sqlalchemy import select
@ -120,6 +121,60 @@ def remove_item(overlay_id: int, item_id: int) -> Response:
return redirect(f"/jobs/{job_id}") return redirect(f"/jobs/{job_id}")
@bp.post("/overlays/<int:overlay_id>/refresh")
@require_login
def refresh_overlay(overlay_id: int) -> Response:
user = current_user()
assert user is not None
with session_scope() as db:
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
if err is not None:
return err
steam_ids = list(
db.scalars(
select(WorkshopItem.steam_id)
.join(
OverlayWorkshopItem,
OverlayWorkshopItem.workshop_item_id == WorkshopItem.id,
)
.where(OverlayWorkshopItem.overlay_id == overlay_id)
).all()
)
if not steam_ids:
return Response("overlay has no items", status=400)
try:
metas = steam_workshop.fetch_metadata_batch(steam_ids, mode="refresh")
except requests.RequestException as exc:
return Response(f"steam api error: {exc}", status=502)
metas_by_id = {m.steam_id: m for m in metas}
with session_scope() as db:
overlay, err = _check_workshop_overlay_access(overlay_id, user, db)
if err is not None:
return err
for steam_id in steam_ids:
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == steam_id))
if wi is None:
continue
meta = metas_by_id.get(steam_id)
if meta is None:
wi.last_error = "steam returned no entry for this item"
continue
wi.title = meta.title
wi.filename = meta.filename
wi.file_url = meta.file_url
wi.file_size = meta.file_size
wi.time_updated = meta.time_updated
wi.preview_url = meta.preview_url
wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}"
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
@bp.post("/admin/workshop/refresh") @bp.post("/admin/workshop/refresh")
@require_admin @require_admin
def admin_refresh() -> Response: def admin_refresh() -> Response:

View file

@ -44,11 +44,16 @@ def build_server_spec_payload(
for overlay_id, _, expose in reversed(overlay_rows) for overlay_id, _, expose in reversed(overlay_rows)
if expose if expose
] ]
config_lines: list[str] = exec_lines + json.loads(blueprint.config)
# rcon_password is appended LAST so neither overlays nor user blueprint
# config can override it (Source's cvar semantics are last-wins).
if server.rcon_password:
config_lines.append(f'rcon_password "{server.rcon_password}"')
return { return {
"port": server.port, "port": server.port,
"overlays": overlays, "overlays": overlays,
"arguments": json.loads(blueprint.arguments), "arguments": json.loads(blueprint.arguments),
"config": exec_lines + json.loads(blueprint.config), "config": config_lines,
} }

View file

@ -0,0 +1,259 @@
"""Background poller that maintains live game-server state in the DB.
Modeled on l4d2web/services/job_worker.py:617-647. This module owns:
- per-server snapshot writes with run-length encoding into
`server_live_state`
- player-session lifecycle in `server_player_session` (Task 7)
- Steam profile enrichment into `steam_user_profile` (Task 8)
- retention pruning and stuck-session closure (Task 10)
This file is built up across Tasks 6-10.
"""
from __future__ import annotations
import logging
import threading
import time
from datetime import datetime, timedelta, UTC
from flask import current_app
from sqlalchemy import select
from l4d2web.db import session_scope
from l4d2web.models import (
Server,
ServerLiveState,
ServerPlayerSession,
SteamUserProfile,
)
from l4d2web.services.rcon import RconError, StatusResponse, query_status
from l4d2web.services.steam_users import fetch_profiles_batch
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def poll_once() -> None:
"""One pass over all running servers with a configured rcon_password."""
with session_scope() as db:
servers = db.scalars(
select(Server)
.where(Server.actual_state == "running")
.where(Server.rcon_password != "")
).all()
targets = [(s.id, s.port, s.rcon_password) for s in servers]
api_key = current_app.config.get("STEAM_WEB_API_KEY", "") or ""
ttl_seconds = int(current_app.config.get("STEAM_PROFILE_TTL_SECONDS", 86400))
timeout = float(current_app.config.get("LIVE_STATE_QUERY_TIMEOUT_SECONDS", 2.0))
for server_id, port, password in targets:
try:
status = query_status("127.0.0.1", port, password, timeout=timeout)
except RconError:
logger.warning("rcon query failed for server %d", server_id, exc_info=True)
_close_stuck_sessions(server_id)
continue
_record_snapshot(server_id, status)
_reconcile_sessions(server_id, status)
if api_key:
_enrich_profiles(status, api_key=api_key, ttl_seconds=ttl_seconds)
def _enrich_profiles(status: StatusResponse, *, api_key: str, ttl_seconds: int) -> None:
"""Fetch+cache Steam profile data for any roster IDs missing or stale."""
roster_ids = {p.steam_id_64 for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)}
if not roster_ids:
return
cutoff = _now() - timedelta(seconds=ttl_seconds)
with session_scope() as db:
fresh = set(db.scalars(
select(SteamUserProfile.steam_id_64).where(
SteamUserProfile.steam_id_64.in_(roster_ids),
SteamUserProfile.fetched_at >= cutoff,
)
).all())
needs_fetch = sorted(roster_ids - fresh)
if not needs_fetch:
return
try:
profiles = fetch_profiles_batch(needs_fetch, api_key=api_key)
except Exception: # network / API errors are soft-fail
logger.warning("steam profile enrichment failed", exc_info=True)
return
now = _now()
with session_scope() as db:
for p in profiles:
row = db.get(SteamUserProfile, p.steam_id_64)
if row is None:
db.add(SteamUserProfile(
steam_id_64=p.steam_id_64,
persona_name=p.persona_name,
avatar_url=p.avatar_url,
fetched_at=now,
))
else:
row.persona_name = p.persona_name
row.avatar_url = p.avatar_url
row.fetched_at = now
def prune_history() -> None:
"""Delete snapshots and closed sessions older than retention."""
days = int(current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30))
cutoff = _now() - timedelta(days=days)
with session_scope() as db:
db.query(ServerLiveState).filter(
ServerLiveState.last_seen_at < cutoff
).delete(synchronize_session=False)
db.query(ServerPlayerSession).filter(
ServerPlayerSession.left_at.is_not(None),
ServerPlayerSession.left_at < cutoff,
).delete(synchronize_session=False)
def _close_stuck_sessions(server_id: int) -> None:
"""If a server has open sessions whose joined_at is older than threshold
AND we've failed to observe them, close them as stuck."""
seconds = int(current_app.config.get("STUCK_SESSION_SECONDS", 60))
cutoff = _now() - timedelta(seconds=seconds)
with session_scope() as db:
rows = db.scalars(
select(ServerPlayerSession).where(
ServerPlayerSession.server_id == server_id,
ServerPlayerSession.left_at.is_(None),
ServerPlayerSession.joined_at < cutoff,
)
).all()
for row in rows:
row.left_at = _now()
def _record_snapshot(server_id: int, status: StatusResponse) -> None:
"""RLE write: bump last_seen_at if state matches, else insert a new row."""
now = _now()
with session_scope() as db:
latest = db.scalars(
select(ServerLiveState)
.where(ServerLiveState.server_id == server_id)
.order_by(ServerLiveState.started_at.desc())
.limit(1)
).first()
if latest is not None and _matches(latest, status):
latest.last_seen_at = now
return
db.add(
ServerLiveState(
server_id=server_id,
started_at=now,
last_seen_at=now,
players=status.players,
max_players=status.max_players,
bots=status.bots,
map=status.map,
hibernating=status.hibernating,
)
)
def _matches(row: ServerLiveState, status: StatusResponse) -> bool:
return (
row.players == status.players
and row.max_players == status.max_players
and row.bots == status.bots
and row.map == status.map
and row.hibernating == status.hibernating
)
_STEAM_ID_64_PREFIX = "7656" # all SteamID64s start with this; bots/anon do not
def _is_valid_steam_id_64(value: str) -> bool:
return value.startswith(_STEAM_ID_64_PREFIX) and value.isdigit() and len(value) == 17
def _reconcile_sessions(server_id: int, status: StatusResponse) -> None:
"""Open new sessions, update ping ranges, close departed sessions."""
now = _now()
roster = [p for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)]
seen_ids = {p.steam_id_64 for p in roster}
with session_scope() as db:
open_rows = db.scalars(
select(ServerPlayerSession).where(
ServerPlayerSession.server_id == server_id,
ServerPlayerSession.left_at.is_(None),
)
).all()
open_by_sid = {r.steam_id_64: r for r in open_rows}
# Close sessions for players no longer in the roster.
for sid, row in open_by_sid.items():
if sid not in seen_ids:
row.left_at = now
# Open / update sessions for current roster.
for p in roster:
existing = open_by_sid.get(p.steam_id_64)
if existing is None:
db.add(
ServerPlayerSession(
server_id=server_id,
steam_id_64=p.steam_id_64,
joined_at=now - timedelta(seconds=p.connected_seconds),
left_at=None,
name_at_join=p.name,
min_ping=p.ping,
max_ping=p.ping,
)
)
else:
if p.ping < existing.min_ping:
existing.min_ping = p.ping
if p.ping > existing.max_ping:
existing.max_ping = p.ping
_poller_started_lock = threading.Lock()
_poller_started = False
def start_live_state_poller(app) -> None:
"""Spawn the daemon poller thread once per process."""
global _poller_started
with _poller_started_lock:
if _poller_started:
return
_poller_started = True
interval = float(app.config.get("LIVE_STATE_POLL_SECONDS", 5))
retention_every = max(1, int(app.config.get("LIVE_STATE_RETENTION_EVERY_TICKS", 60)))
thread = threading.Thread(
target=_poller_loop,
args=(app, interval, retention_every),
name="left4me-live-state-poller",
daemon=True,
)
thread.start()
def _poller_loop(app, interval: float, retention_every: int) -> None:
tick = 0
while True:
try:
with app.app_context():
poll_once()
if tick % retention_every == 0:
prune_history()
except Exception:
logger.exception("live-state poller loop exception")
tick += 1
time.sleep(interval)

View file

@ -10,9 +10,12 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
import tempfile import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Callable, Protocol from typing import Callable, Protocol
import requests
from sqlalchemy import select from sqlalchemy import select
from l4d2host.paths import get_left4me_root from l4d2host.paths import get_left4me_root
@ -20,6 +23,7 @@ from l4d2host.paths import get_left4me_root
from l4d2web.db import session_scope from l4d2web.db import session_scope
from l4d2web.models import Overlay, OverlayWorkshopItem, WorkshopItem from l4d2web.models import Overlay, OverlayWorkshopItem, WorkshopItem
from l4d2web.services.host_commands import run_command from l4d2web.services.host_commands import run_command
from l4d2web.services.steam_workshop import WorkshopMetadata, download_to_cache
from l4d2web.services.workshop_paths import cache_path, workshop_cache_root from l4d2web.services.workshop_paths import cache_path, workshop_cache_root
@ -30,6 +34,59 @@ LogSink = Callable[[str], None]
SCRIPT_SANDBOX_HELPER = "/usr/local/libexec/left4me/left4me-script-sandbox" SCRIPT_SANDBOX_HELPER = "/usr/local/libexec/left4me/left4me-script-sandbox"
DISK_BUDGET_BYTES = 20 * 1024**3 DISK_BUDGET_BYTES = 20 * 1024**3
DOWNLOAD_RETRY_ATTEMPTS = 3
DOWNLOAD_RETRY_BACKOFF_SECONDS = (1.0, 2.0)
assert len(DOWNLOAD_RETRY_BACKOFF_SECONDS) == DOWNLOAD_RETRY_ATTEMPTS - 1
def _sleep_with_cancel(
seconds: float,
should_cancel: CancelCheck,
*,
poll_interval: float = 0.25,
) -> bool:
"""Sleep up to `seconds`, returning early (True) if `should_cancel` becomes
True. Returns False on a full uninterrupted sleep. Polls every
`poll_interval` seconds."""
deadline = time.monotonic() + seconds
while True:
if should_cancel():
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
time.sleep(min(poll_interval, remaining))
def _download_with_retry(
meta: WorkshopMetadata,
cache_root: Path,
*,
on_stderr: LogSink,
should_cancel: CancelCheck,
) -> None:
"""Wrap `download_to_cache` with bounded retries and cancel-aware backoff.
Raises the last exception after `DOWNLOAD_RETRY_ATTEMPTS` failures.
Raises `InterruptedError` if cancelled during a backoff sleep."""
last_exc: BaseException | None = None
for attempt in range(1, DOWNLOAD_RETRY_ATTEMPTS + 1):
try:
download_to_cache(meta, cache_root, should_cancel=should_cancel)
return
except InterruptedError:
raise
except (requests.RequestException, OSError) as exc:
last_exc = exc
if attempt == DOWNLOAD_RETRY_ATTEMPTS:
raise
on_stderr(
f"workshop {meta.steam_id} attempt {attempt}/"
f"{DOWNLOAD_RETRY_ATTEMPTS} failed: {exc}"
)
delay = DOWNLOAD_RETRY_BACKOFF_SECONDS[attempt - 1]
if _sleep_with_cancel(delay, should_cancel):
raise InterruptedError("download cancelled during backoff") from last_exc
def _sandbox_script_dir() -> Path: def _sandbox_script_dir() -> Path:
"""Where script tmpfiles live before being bind-mounted into the sandbox. """Where script tmpfiles live before being bind-mounted into the sandbox.
@ -70,9 +127,9 @@ def overlay_path_for_id(overlay_id: int) -> Path:
class WorkshopBuilder: class WorkshopBuilder:
"""Diff-apply symlinks under `left4dead2/addons/` against the overlay's """Diff-apply symlinks under `left4dead2/addons/` against the overlay's
current `WorkshopItem` associations. Cached items get an absolute symlink current `WorkshopItem` associations. Downloads missing or stale items
into `workshop_cache/{steam_id}.vpk`. Items missing from cache are before applying symlinks. Items with no file_url are skipped with a
skipped with a warning rather than turned into broken symlinks.""" warning."""
def build( def build(
self, self,
@ -85,8 +142,9 @@ class WorkshopBuilder:
addons_dir = _overlay_root(overlay) / "left4dead2" / "addons" addons_dir = _overlay_root(overlay) / "left4dead2" / "addons"
addons_dir.mkdir(parents=True, exist_ok=True) addons_dir.mkdir(parents=True, exist_ok=True)
# Snapshot every field the decision logic + downloader will need.
with session_scope() as db: with session_scope() as db:
items = db.scalars( rows = db.scalars(
select(WorkshopItem) select(WorkshopItem)
.join( .join(
OverlayWorkshopItem, OverlayWorkshopItem,
@ -94,37 +152,117 @@ class WorkshopBuilder:
) )
.where(OverlayWorkshopItem.overlay_id == overlay.id) .where(OverlayWorkshopItem.overlay_id == overlay.id)
).all() ).all()
# Detach items so we can use them outside the session.
items_data = [ items_data = [
(it.steam_id, it.last_downloaded_at) for it in items (
it.id,
it.steam_id,
it.title,
it.filename,
it.file_url,
it.file_size,
it.time_updated,
it.preview_url,
it.last_downloaded_at,
it.last_error,
)
for it in rows
] ]
cache_root = workshop_cache_root() cache_root = workshop_cache_root()
# desired: symlink-name -> absolute target path (only for cached items) cache_root.mkdir(parents=True, exist_ok=True)
desired: dict[str, Path] = {}
skipped: list[str] = [] downloaded = 0
for steam_id, last_downloaded_at in items_data: cached = 0
target = cache_path(steam_id) skipped: set[str] = set()
if last_downloaded_at is None or not target.exists():
skipped.append(steam_id) # Download phase.
for (
item_id, steam_id, title, filename, file_url, file_size,
time_updated, preview_url, last_downloaded_at, last_error,
) in items_data:
if should_cancel():
on_stderr("workshop build cancelled during download phase")
return
if not file_url:
on_stderr(
f"workshop item {steam_id} skipped: no file_url "
f"(steam result: {last_error or 'unknown'})"
)
skipped.add(steam_id)
continue continue
target = cache_path(steam_id)
needs_download = (
last_downloaded_at is None
or not target.exists()
or int(target.stat().st_mtime) != int(time_updated)
or int(target.stat().st_size) != int(file_size)
)
if not needs_download:
cached += 1
continue
# download_to_cache only reads steam_id, file_url, file_size, time_updated;
# consumer_app_id and result are required by the dataclass but unused here.
meta = WorkshopMetadata(
steam_id=steam_id,
title=title,
filename=filename,
file_url=file_url,
file_size=file_size,
time_updated=time_updated,
preview_url=preview_url,
consumer_app_id=550,
result=1,
)
on_stdout(f"workshop {steam_id} downloading")
try:
_download_with_retry(
meta, cache_root,
on_stderr=on_stderr, should_cancel=should_cancel,
)
except InterruptedError:
raise
except Exception as exc:
with session_scope() as db:
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.id == item_id))
if wi is not None:
wi.last_error = f"download failed: {exc}"
raise
with session_scope() as db:
wi = db.scalar(select(WorkshopItem).where(WorkshopItem.id == item_id))
if wi is not None:
wi.last_downloaded_at = datetime.now(UTC)
wi.last_error = ""
downloaded += 1
# Re-snapshot for symlink phase: only items that have a cache file now
# belong in the desired set. Items skipped above stay out.
desired: dict[str, Path] = {}
for (
_item_id, steam_id, _title, _filename, _file_url, _file_size,
_time_updated, _preview_url, _last_downloaded_at, _last_error,
) in items_data:
if steam_id in skipped:
continue
target = cache_path(steam_id)
if not target.exists():
continue # shouldn't happen post-download; safety net
desired[f"{steam_id}.vpk"] = target.resolve() desired[f"{steam_id}.vpk"] = target.resolve()
if should_cancel(): if should_cancel():
on_stderr("workshop build cancelled before applying symlinks") on_stderr("workshop build cancelled before applying symlinks")
return return
# existing: symlink-name -> link target (only for symlinks pointing at our cache) # existing: symlink-name -> link target (only symlinks pointing at our cache)
existing: dict[str, Path] = {} existing: dict[str, Path] = {}
for entry in os.scandir(addons_dir): for entry in os.scandir(addons_dir):
if not entry.is_symlink(): if not entry.is_symlink():
continue continue
try: try:
target = Path(os.readlink(entry.path)) link_target = Path(os.readlink(entry.path))
except OSError: except OSError:
continue continue
try: try:
resolved = target.resolve(strict=False) resolved = link_target.resolve(strict=False)
except OSError: except OSError:
continue continue
if not _is_under(resolved, cache_root): if not _is_under(resolved, cache_root):
@ -135,7 +273,6 @@ class WorkshopBuilder:
removed = 0 removed = 0
unchanged = 0 unchanged = 0
# Remove obsolete or stale symlinks first.
for name, current_target in existing.items(): for name, current_target in existing.items():
if should_cancel(): if should_cancel():
on_stderr("workshop build cancelled mid-removal") on_stderr("workshop build cancelled mid-removal")
@ -146,16 +283,14 @@ class WorkshopBuilder:
removed += 1 removed += 1
elif current_target != desired_target: elif current_target != desired_target:
os.unlink(addons_dir / name) os.unlink(addons_dir / name)
# will be recreated below
else: else:
unchanged += 1 unchanged += 1
# Recompute existing post-removal so the create loop knows what's left.
post_removal_existing = { post_removal_existing = {
name for name in existing if name in desired and existing[name] == desired[name] name for name in existing
if name in desired and existing[name] == desired[name]
} }
# Create new symlinks.
for name, target in desired.items(): for name, target in desired.items():
if should_cancel(): if should_cancel():
on_stderr("workshop build cancelled mid-creation") on_stderr("workshop build cancelled mid-creation")
@ -176,18 +311,13 @@ class WorkshopBuilder:
f"refusing to overwrite foreign symlink at {link_path}" f"refusing to overwrite foreign symlink at {link_path}"
) )
continue continue
os.symlink(str(target), str(link_path)) os.symlink(target, link_path)
created += 1 created += 1
on_stdout( on_stdout(
f"workshop overlay {overlay.name!r}: created={created} " f"workshop overlay {overlay.name!r}: "
f"removed={removed} unchanged={unchanged} " f"downloaded={downloaded} cached={cached} skipped={len(skipped)} "
f"skipped(uncached)={len(skipped)}" f"created={created} removed={removed} unchanged={unchanged}"
)
for steam_id in skipped:
on_stderr(
f"workshop item {steam_id} skipped: not yet downloaded "
f"(refresh required before this overlay can mount it)"
) )

179
l4d2web/services/rcon.py Normal file
View file

@ -0,0 +1,179 @@
"""Source RCON client + status parser.
Pure stdlib. One TCP connection per query fine at our scale (loopback
~10-20ms round-trip; pooling not worth the complexity).
Source RCON wire format:
size : little-endian int32 (count of the bytes that follow)
req_id: little-endian int32
ptype : little-endian int32
body : utf-8 string, null-terminated
pad : one extra null byte
Packet types:
SERVERDATA_AUTH = 3 (client -> server)
SERVERDATA_EXECCOMMAND = 2 (client -> server)
SERVERDATA_AUTH_RESPONSE= 2 (server -> client)
SERVERDATA_RESPONSE_VALUE = 0 (server -> client)
After auth, the server sends a type=0 empty packet *first* and then the
type=2 auth response. req_id == -1 on the auth response = bad password.
"""
from __future__ import annotations
import re
import socket
import struct
from dataclasses import dataclass
SERVERDATA_AUTH = 3
SERVERDATA_EXECCOMMAND = 2
SERVERDATA_AUTH_RESPONSE = 2
SERVERDATA_RESPONSE_VALUE = 0
_STEAM_ID_BASE = 76561197960265728
class RconError(Exception):
"""Network, timeout, or protocol error."""
class RconAuthError(RconError):
"""The server rejected the password."""
@dataclass(slots=True, frozen=True)
class PlayerRow:
steam_id_64: str
name: str
connected_seconds: int
ping: int
@dataclass(slots=True, frozen=True)
class StatusResponse:
map: str
players: int
max_players: int
bots: int
hibernating: bool
roster: list[PlayerRow]
def query_status(
host: str, port: int, password: str, *, timeout: float = 2.0
) -> StatusResponse:
"""Connect to the RCON port, authenticate, send `status`, return parsed result."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
try:
sock.connect((host, port))
except OSError as exc:
raise RconError(f"connect failed: {exc}") from exc
try:
_send_packet(sock, 1, SERVERDATA_AUTH, password)
# Drain the leading empty type-0 packet; then read the real auth response.
r1 = _recv_packet(sock)
r2 = _recv_packet(sock)
auth = r2 if r1[1] == SERVERDATA_RESPONSE_VALUE else r1
if auth[0] == -1:
raise RconAuthError("bad rcon password")
_send_packet(sock, 2, SERVERDATA_EXECCOMMAND, "status")
_, _, body = _recv_packet(sock)
except (OSError, socket.timeout) as exc:
raise RconError(f"rcon i/o error: {exc}") from exc
finally:
sock.close()
return parse_status(body)
def _send_packet(sock: socket.socket, req_id: int, ptype: int, body: str) -> None:
body_bytes = body.encode("utf-8") + b"\x00\x00"
size = 4 + 4 + len(body_bytes)
sock.sendall(struct.pack("<iii", size, req_id, ptype) + body_bytes)
def _recv_packet(sock: socket.socket) -> tuple[int, int, str]:
size = struct.unpack("<i", _recvall(sock, 4))[0]
payload = _recvall(sock, size)
req_id, ptype = struct.unpack("<ii", payload[:8])
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
return req_id, ptype, body
def _recvall(sock: socket.socket, n: int) -> bytes:
data = b""
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise RconError("rcon connection closed")
data += chunk
return data
# --- Status parsing -------------------------------------------------------
_MAP_RE = re.compile(r"^map\s*:\s*(\S+)", re.MULTILINE)
_PLAYERS_RE = re.compile(
r"^players\s*:\s*(\d+)\s+humans,\s*(\d+)\s+bots\s*\((\d+)\s+max\)"
r"\s*\((not hibernating|hibernating)\)",
re.MULTILINE,
)
# A status player row: starts with `#`, then variable numeric prefixes,
# then a quoted name, then STEAM_X:Y:Z, then connected time, then ping.
_PLAYER_RE = re.compile(
r'^#\s+(?:\d+\s+)+"(?P<name>[^"]*)"\s+'
r"(?P<sid>STEAM_\d+:(?P<y>\d+):(?P<z>\d+))\s+"
r"(?P<connected>[\d:]+)\s+"
r"(?P<ping>\d+)\s+",
re.MULTILINE,
)
def parse_status(body: str) -> StatusResponse:
map_match = _MAP_RE.search(body)
if not map_match:
raise RconError(f"status: no map line in response\n{body!r}")
players_match = _PLAYERS_RE.search(body)
if not players_match:
raise RconError(f"status: no players line\n{body!r}")
roster: list[PlayerRow] = []
for m in _PLAYER_RE.finditer(body):
y = int(m.group("y"))
z = int(m.group("z"))
roster.append(
PlayerRow(
steam_id_64=str(_STEAM_ID_BASE + (z * 2) + y),
name=m.group("name"),
connected_seconds=_parse_duration(m.group("connected")),
ping=int(m.group("ping")),
)
)
return StatusResponse(
map=map_match.group(1),
players=int(players_match.group(1)),
bots=int(players_match.group(2)),
max_players=int(players_match.group(3)),
hibernating=(players_match.group(4) == "hibernating"),
roster=roster,
)
def _parse_duration(text: str) -> int:
"""Parse Source's connected duration: HH:MM:SS or MM:SS -> seconds."""
try:
parts = [int(p) for p in text.split(":")]
except ValueError as exc:
raise RconError(f"unparseable connected duration: {text!r}") from exc
if len(parts) == 2:
return parts[0] * 60 + parts[1]
if len(parts) == 3:
return parts[0] * 3600 + parts[1] * 60 + parts[2]
raise RconError(f"unparseable connected duration: {text!r}")

View file

@ -0,0 +1,76 @@
"""Steam Web API client for player profile lookups.
Mirrors the shape of l4d2web/services/steam_workshop.py:17-43:
- single thread-local requests.Session
- 30s timeout
- HTTPS only
Difference: GetPlayerSummaries requires an API key in the querystring,
unlike the anonymous workshop endpoints.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Iterable
import requests
GET_PLAYER_SUMMARIES_URL = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/"
)
REQUEST_TIMEOUT_SECONDS = 30.0
MAX_IDS_PER_CALL = 100
_session_local = threading.local()
def _session() -> requests.Session:
sess = getattr(_session_local, "session", None)
if sess is None:
sess = requests.Session()
_session_local.session = sess
return sess
def _session_get(url: str, params: dict, timeout: float = REQUEST_TIMEOUT_SECONDS):
"""Indirection seam so tests can monkeypatch a fake here."""
return _session().get(url, params=params, timeout=timeout)
@dataclass(slots=True, frozen=True)
class SteamProfile:
steam_id_64: str
persona_name: str
avatar_url: str
def fetch_profiles_batch(
steam_ids: Iterable[str], *, api_key: str
) -> list[SteamProfile]:
"""Resolve a batch of SteamID64 strings to persona name + avatar URL.
Steam's API caps each call at 100 IDs; this helper chunks transparently.
IDs that Steam can't resolve (private, deleted) are simply absent from
the response and from the returned list.
"""
ids = list(steam_ids)
out: list[SteamProfile] = []
for i in range(0, len(ids), MAX_IDS_PER_CALL):
chunk = ids[i : i + MAX_IDS_PER_CALL]
params = {"key": api_key, "steamids": ",".join(chunk)}
resp = _session_get(GET_PLAYER_SUMMARIES_URL, params=params)
resp.raise_for_status()
payload = resp.json() or {}
players = (payload.get("response") or {}).get("players") or []
for p in players:
out.append(
SteamProfile(
steam_id_64=str(p["steamid"]),
persona_name=str(p.get("personaname", "")),
avatar_url=str(p.get("avatarmedium", "")),
)
)
return out

View file

@ -0,0 +1,57 @@
<section class="panel live-state"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="every {{ poll_seconds }}s"
hx-swap="outerHTML">
<h2 class="section-title">Live state</h2>
{% if not snapshot or not snapshot_fresh %}
<p class="muted">No data — server is not currently reporting.</p>
{% else %}
<p class="server-live-summary">
{{ snapshot.players }}/{{ snapshot.max_players }}
{% if snapshot.hibernating %}· idle{% endif %}
· {{ snapshot.map }}
<small class="muted">
polled {{ ((now - snapshot.last_seen_at).total_seconds() | int) }}s ago
</small>
</p>
{% endif %}
{% if current_players %}
<h3 class="section-subtitle">Current players</h3>
<ul class="player-grid">
{% for session, profile in current_players %}
<li class="player-card">
{% if profile and profile.avatar_url %}
<img class="avatar" src="{{ profile.avatar_url }}" alt="" loading="lazy">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
<span class="meta">
joined {{ ((now - session.joined_at).total_seconds() // 60) | int }}m ago
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if recent_players %}
<h3 class="section-subtitle">Recent players</h3>
<ul class="player-grid recent">
{% for row in recent_players %}
<li class="player-card">
{% if row.avatar_url %}
<img class="avatar" src="{{ row.avatar_url }}" alt="" loading="lazy">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name">{{ row.persona_name or row.name_at_join }}</span>
<span class="meta">
last seen {{ ((now - row.last_seen).total_seconds() // 60) | int }}m ago
</span>
</li>
{% endfor %}
</ul>
{% endif %}
</section>

View file

@ -51,6 +51,10 @@
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label> <label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="stack workshop-refresh-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Refresh from Steam</button>
</form>
{% endif %} {% endif %}
<div id="overlay-item-table"> <div id="overlay-item-table">

View file

@ -19,6 +19,12 @@
<h2 class="section-title">Server Log</h2> <h2 class="section-title">Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> <pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
<section class="panel live-state"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="load, every 5s"
hx-swap="outerHTML">
</section>
<h2 class="section-title">Files</h2> <h2 class="section-title">Files</h2>
{% if not file_tree_root_entries %} {% if not file_tree_root_entries %}
<p class="muted">No files yet — start the server to mount its runtime.</p> <p class="muted">No files yet — start the server to mount its runtime.</p>

View file

@ -13,18 +13,30 @@
{% endif %} {% endif %}
</div> </div>
<table class="table"> <table class="table">
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead> <thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th><th>Live</th></tr></thead>
<tbody> <tbody>
{% for server, blueprint in rows %} {% for server, blueprint in rows %}
{% set ls = live_state_by_server.get(server.id) %}
<tr> <tr>
<td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td> <td><a href="/servers/{{ server.id }}">{{ server.name }}</a></td>
<td>{{ server.port }}</td> <td>{{ server.port }}</td>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td> <td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ server.desired_state }}</td> <td>{{ server.desired_state }}</td>
<td>{{ server.actual_state }}</td> <td>{{ server.actual_state }}</td>
<td class="server-live">
{% if server.actual_state != 'running' %}
<span class="muted"></span>
{% elif ls is none or not ls.fresh %}
<span class="muted" title="no recent data">?</span>
{% elif ls.hibernating %}
{{ ls.players }}/{{ ls.max_players }} · idle · {{ ls.map }}
{% else %}
{{ ls.players }}/{{ ls.max_players }} · {{ ls.map }}
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="5" class="muted">No servers configured.</td></tr> <tr><td colspan="6" class="muted">No servers configured.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

64
l4d2web/tests/test_cli.py Normal file
View file

@ -0,0 +1,64 @@
"""Tests for the l4d2web Flask CLI subcommands."""
from __future__ import annotations
from click.testing import CliRunner
import pytest
from sqlalchemy import select
from l4d2web.app import create_app
from l4d2web.cli import workshop_refresh
from l4d2web.db import init_db, session_scope
from l4d2web.models import Job
@pytest.fixture
def app_env(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'cli.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
return app
def test_workshop_refresh_enqueues_job(app_env):
runner = CliRunner()
with app_env.app_context():
result = runner.invoke(workshop_refresh, [])
assert result.exit_code == 0, result.output
assert "enqueued refresh_workshop_items job" in result.output
with session_scope() as db:
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
assert len(jobs) == 1
assert jobs[0].state == "queued"
assert jobs[0].user_id is None
assert jobs[0].server_id is None
def test_workshop_refresh_is_idempotent_when_job_queued(app_env):
runner = CliRunner()
with app_env.app_context():
first = runner.invoke(workshop_refresh, [])
second = runner.invoke(workshop_refresh, [])
assert first.exit_code == 0
assert second.exit_code == 0
assert "already queued" in second.output
with session_scope() as db:
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
assert len(jobs) == 1, "must not insert a second job when one is already queued"
@pytest.mark.parametrize("active_state", ["running", "cancelling"])
def test_workshop_refresh_is_idempotent_when_job_active(app_env, active_state):
runner = CliRunner()
with app_env.app_context():
with session_scope() as db:
db.add(Job(
user_id=None, server_id=None,
operation="refresh_workshop_items", state=active_state,
))
result = runner.invoke(workshop_refresh, [])
assert result.exit_code == 0
assert f"already {active_state}" in result.output
with session_scope() as db:
jobs = db.scalars(select(Job).where(Job.operation == "refresh_workshop_items")).all()
assert len(jobs) == 1

View file

@ -343,3 +343,45 @@ def test_initialize_fails_fast_on_uncached_workshop_items(
assert all("initialize" not in cmd for cmd in invocations), invocations assert all("initialize" not in cmd for cmd in invocations), invocations
# ---------------------------------------------------------------------------
# build_server_spec_payload — rcon_password injection
# ---------------------------------------------------------------------------
def _make_server_blueprint(rcon: str = "") -> tuple[Server, Blueprint]:
bp = Blueprint(
id=1, user_id=1, name="bp",
arguments='["-tickrate","60"]',
config='["sv_consistency 1","mp_gamemode coop"]',
)
srv = Server(
id=1, user_id=1, blueprint_id=1, name="s", port=27500,
rcon_password=rcon,
)
return srv, bp
def test_build_server_spec_payload_appends_rcon_password_last() -> None:
from l4d2web.services.l4d2_facade import build_server_spec_payload
srv, bp = _make_server_blueprint(rcon="topsecret123")
overlays = [(7, "/overlays/7", True), (8, "/overlays/8", False)]
spec = build_server_spec_payload(srv, bp, overlays)
cfg = spec["config"]
# rcon_password line is the LAST entry — overlay exec lines + blueprint
# config + rcon_password.
assert cfg[-1] == 'rcon_password "topsecret123"'
# Lines before our injection still contain the blueprint config.
assert "sv_consistency 1" in cfg
assert "mp_gamemode coop" in cfg
def test_build_server_spec_payload_omits_rcon_password_when_empty() -> None:
from l4d2web.services.l4d2_facade import build_server_spec_payload
srv, bp = _make_server_blueprint(rcon="")
spec = build_server_spec_payload(srv, bp, [])
for line in spec["config"]:
assert not line.startswith("rcon_password ")

View file

@ -0,0 +1,371 @@
"""Live-state poller tests.
Each test seeds an app + DB, monkeypatches the RCON client to return a
canned StatusResponse, and asserts on what the poller writes.
"""
from __future__ import annotations
from datetime import datetime, timedelta, UTC
import pytest
from sqlalchemy import select
from l4d2web.app import create_app
from l4d2web.db import init_db, session_scope
from l4d2web.models import (
Blueprint,
Server,
ServerLiveState,
ServerPlayerSession,
SteamUserProfile,
User,
)
from l4d2web.services import live_state_poller
from l4d2web.services import steam_users
from l4d2web.services.rcon import PlayerRow, StatusResponse
def _seed(tmp_path):
db_url = f"sqlite:///{tmp_path/'p.db'}"
import os
os.environ["DATABASE_URL"] = db_url
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "x"})
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x"); db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
s = Server(
user_id=u.id, blueprint_id=bp.id, name="s", port=27500,
rcon_password="pw", actual_state="running",
)
db.add(s); db.flush()
return app, s.id
def _status(players: int, map_: str = "c1m1_hotel", hibernating: bool = False,
roster: list[PlayerRow] | None = None) -> StatusResponse:
return StatusResponse(
map=map_, players=players, max_players=4, bots=0,
hibernating=hibernating, roster=roster or [],
)
def test_rle_bumps_last_seen_when_state_unchanged(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
monkeypatch.setattr(
live_state_poller, "query_status",
lambda host, port, password, timeout: _status(players=0),
)
with app.app_context():
live_state_poller.poll_once()
live_state_poller.poll_once()
with session_scope() as db:
rows = db.scalars(
select(ServerLiveState).where(ServerLiveState.server_id == sid)
.order_by(ServerLiveState.started_at)
).all()
assert len(rows) == 1
assert rows[0].players == 0
assert rows[0].last_seen_at >= rows[0].started_at
def test_rle_inserts_new_row_on_state_change(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
snapshots = iter([_status(players=0), _status(players=1)])
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: next(snapshots),
)
with app.app_context():
live_state_poller.poll_once()
live_state_poller.poll_once()
with session_scope() as db:
rows = db.scalars(
select(ServerLiveState).where(ServerLiveState.server_id == sid)
.order_by(ServerLiveState.started_at)
).all()
assert [r.players for r in rows] == [0, 1]
def test_skips_servers_without_rcon_password(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
with session_scope() as db:
s = db.scalar(select(Server).where(Server.id == sid))
s.rcon_password = ""
called: list = []
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: called.append(1) or _status(0),
)
with app.app_context():
live_state_poller.poll_once()
assert called == []
def test_skips_non_running_servers(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
with session_scope() as db:
s = db.scalar(select(Server).where(Server.id == sid))
s.actual_state = "stopped"
called: list = []
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: called.append(1) or _status(0),
)
with app.app_context():
live_state_poller.poll_once()
assert called == []
def _player(steam_id: str = "76561197960828710", name: str = "Alice",
connected: int = 21, ping: int = 60) -> PlayerRow:
return PlayerRow(
steam_id_64=steam_id, name=name, connected_seconds=connected, ping=ping,
)
def test_new_player_opens_session_with_backfilled_join(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: _status(players=1, roster=[_player(connected=30)]),
)
with app.app_context():
live_state_poller.poll_once()
with session_scope() as db:
sessions = db.scalars(
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
).all()
assert len(sessions) == 1
s = sessions[0]
assert s.steam_id_64 == "76561197960828710"
assert s.name_at_join == "Alice"
assert s.left_at is None
assert s.min_ping == 60
assert s.max_ping == 60
# joined_at should be ~30s before now
delta = (datetime.now(UTC).replace(tzinfo=None) - s.joined_at).total_seconds()
assert 25 <= delta <= 60
def test_session_closes_when_player_no_longer_in_roster(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
snapshots = iter([
_status(players=1, roster=[_player()]),
_status(players=0, roster=[]),
])
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: next(snapshots),
)
with app.app_context():
live_state_poller.poll_once()
live_state_poller.poll_once()
with session_scope() as db:
sessions = db.scalars(
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
).all()
assert len(sessions) == 1
assert sessions[0].left_at is not None
def test_session_ping_range_extends(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
snapshots = iter([
_status(players=1, roster=[_player(ping=60)]),
_status(players=1, roster=[_player(ping=200)]),
_status(players=1, roster=[_player(ping=40)]),
])
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: next(snapshots),
)
with app.app_context():
live_state_poller.poll_once()
live_state_poller.poll_once()
live_state_poller.poll_once()
with session_scope() as db:
s = db.scalar(select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid))
assert s.min_ping == 40
assert s.max_ping == 200
def test_session_skips_bots(tmp_path, monkeypatch) -> None:
# Bots have non-STEAM uniqueid; our parser already drops them, but verify
# that even if a non-STEAM steam_id_64 makes it through, sessions filter
# it out. We exercise the filter directly by giving a string that doesn't
# match the expected SteamID64 numeric form.
app, sid = _seed(tmp_path)
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: _status(
players=0, roster=[_player(steam_id="BOT", name="Coach")]
),
)
with app.app_context():
live_state_poller.poll_once()
with session_scope() as db:
sessions = db.scalars(
select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)
).all()
assert sessions == []
def test_enriches_missing_profile(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
app.config["STEAM_WEB_API_KEY"] = "KEY"
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: _status(players=1, roster=[_player()]),
)
captured: list = []
def fake_fetch(ids, *, api_key):
captured.append((list(ids), api_key))
return [steam_users.SteamProfile(
steam_id_64="76561197960828710",
persona_name="Alice",
avatar_url="https://avatars.../alice_medium.jpg",
)]
monkeypatch.setattr(live_state_poller, "fetch_profiles_batch", fake_fetch)
with app.app_context():
live_state_poller.poll_once()
assert captured and captured[0][1] == "KEY"
with session_scope() as db:
p = db.scalar(select(SteamUserProfile))
assert p is not None
assert p.persona_name == "Alice"
def test_skips_enrichment_when_api_key_unset(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
app.config["STEAM_WEB_API_KEY"] = ""
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: _status(players=1, roster=[_player()]),
)
monkeypatch.setattr(
live_state_poller, "fetch_profiles_batch",
lambda ids, *, api_key: pytest.fail("must not call without key"),
)
with app.app_context():
live_state_poller.poll_once()
def test_retention_trims_old_rows(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
app.config["LIVE_STATE_HISTORY_DAYS"] = 30
long_ago = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=45)
with session_scope() as db:
db.add(ServerLiveState(
server_id=sid, started_at=long_ago, last_seen_at=long_ago,
players=0, max_players=4, bots=0, map="old", hibernating=True,
))
db.add(ServerPlayerSession(
server_id=sid, steam_id_64="76561197960828710",
joined_at=long_ago, left_at=long_ago,
name_at_join="OldPlayer", min_ping=10, max_ping=10,
))
with app.app_context():
live_state_poller.prune_history()
with session_scope() as db:
snaps = db.scalars(select(ServerLiveState)).all()
sess = db.scalars(select(ServerPlayerSession)).all()
assert snaps == []
assert sess == []
def test_close_stuck_sessions_after_threshold(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
app.config["STUCK_SESSION_SECONDS"] = 60
way_back = datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=2)
with session_scope() as db:
db.add(ServerPlayerSession(
server_id=sid, steam_id_64="76561197960828710",
joined_at=way_back, left_at=None,
name_at_join="GhostPlayer", min_ping=10, max_ping=10,
))
# Server is in `targets` but the RCON call fails — i.e., we haven't seen
# this server respond in a long time. Poller must close stuck sessions.
def boom(*a, **kw):
from l4d2web.services.rcon import RconError
raise RconError("simulated outage")
monkeypatch.setattr(live_state_poller, "query_status", boom)
with app.app_context():
live_state_poller.poll_once()
with session_scope() as db:
row = db.scalar(select(ServerPlayerSession))
assert row.left_at is not None
def test_start_live_state_poller_skipped_during_testing(monkeypatch, tmp_path) -> None:
from l4d2web import app as app_module
called: list = []
monkeypatch.setattr(
app_module, "start_live_state_poller", lambda app: called.append(app)
)
app_module.create_app({"TESTING": True, "DATABASE_URL": f"sqlite:///{tmp_path/'x.db'}", "SECRET_KEY": "k"})
assert called == []
def test_start_live_state_poller_started_outside_testing(monkeypatch, tmp_path) -> None:
from l4d2web import app as app_module
called: list = []
monkeypatch.setattr(
app_module, "start_live_state_poller", lambda app: called.append(app)
)
app = app_module.create_app(
{"TESTING": False, "DATABASE_URL": f"sqlite:///{tmp_path/'x.db'}", "SECRET_KEY": "k"}
)
assert called == [app]
def test_skips_enrichment_when_cache_is_fresh(tmp_path, monkeypatch) -> None:
app, sid = _seed(tmp_path)
app.config["STEAM_WEB_API_KEY"] = "KEY"
with session_scope() as db:
db.add(SteamUserProfile(
steam_id_64="76561197960828710",
persona_name="cached",
avatar_url="cached.jpg",
fetched_at=datetime.now(UTC).replace(tzinfo=None),
))
monkeypatch.setattr(
live_state_poller, "query_status",
lambda *a, **kw: _status(players=1, roster=[_player()]),
)
called: list = []
monkeypatch.setattr(
live_state_poller, "fetch_profiles_batch",
lambda ids, *, api_key: called.append(list(ids)) or [],
)
with app.app_context():
live_state_poller.poll_once()
assert called == []

View file

@ -0,0 +1,69 @@
"""Tests for migration 0010_server_live_state."""
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
_ALEMBIC_DIR = Path(__file__).resolve().parents[1] / "alembic"
def _alembic_config(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(_ALEMBIC_DIR))
cfg.set_main_option("sqlalchemy.url", db_url)
return cfg
def test_migration_0010_backfills_rcon_password(tmp_path: Path, monkeypatch) -> None:
db_path = tmp_path / "t.db"
db_url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", db_url)
cfg = _alembic_config(db_url)
# Run alembic up to 0009 only.
command.upgrade(cfg, "0009_user_password_changed_at")
engine = create_engine(db_url)
with engine.begin() as conn:
conn.execute(sa.text(
"INSERT INTO users (id, username, password_digest, admin, active, "
"created_at, updated_at, password_changed_at) "
"VALUES (1, 'u', 'x', 0, 1, '2026-01-01', '2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO blueprints (id, user_id, name, arguments, config, "
"created_at, updated_at) "
"VALUES (1, 1, 'bp', '[]', '[]', '2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
"desired_state, actual_state, last_error, created_at, updated_at) "
"VALUES (1, 1, 1, 's1', 27600, 'stopped', 'unknown', '', "
"'2026-01-01', '2026-01-01')"
))
conn.execute(sa.text(
"INSERT INTO servers (id, user_id, blueprint_id, name, port, "
"desired_state, actual_state, last_error, created_at, updated_at) "
"VALUES (2, 1, 1, 's2', 27601, 'stopped', 'unknown', '', "
"'2026-01-01', '2026-01-01')"
))
# Apply migration 0010.
command.upgrade(cfg, "0010_server_live_state")
with engine.connect() as conn:
rows = conn.execute(sa.text("SELECT id, rcon_password FROM servers ORDER BY id")).fetchall()
assert len(rows) == 2
for row in rows:
assert len(row.rcon_password) >= 32
assert row.rcon_password.replace("_", "").replace("-", "").isalnum()
inspector = inspect(engine)
assert "server_live_state" in inspector.get_table_names()
assert "server_player_session" in inspector.get_table_names()
assert "steam_user_profile" in inspector.get_table_names()

View file

@ -1,5 +1,15 @@
from sqlalchemy import select
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, User from l4d2web.models import (
Blueprint,
Server,
ServerLiveState,
ServerPlayerSession,
SteamUserProfile,
User,
now_utc as now_utc_aware,
)
def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None: def test_create_user_and_blueprint(tmp_path, monkeypatch) -> None:
@ -38,3 +48,63 @@ def test_user_has_password_changed_at_default(tmp_path, monkeypatch):
assert user.password_changed_at is not None assert user.password_changed_at is not None
assert user.password_changed_at >= before assert user.password_changed_at >= before
def test_server_has_rcon_password_column(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x")
db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]")
db.add(bp); db.flush()
s = Server(
user_id=u.id, blueprint_id=bp.id, name="s", port=27500,
rcon_password="abc",
)
db.add(s); db.flush()
assert db.scalar(select(Server.rcon_password).where(Server.id == s.id)) == "abc"
def test_server_live_state_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x"); db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27501, rcon_password="x")
db.add(s); db.flush()
row = ServerLiveState(
server_id=s.id, started_at=now_utc_aware(), last_seen_at=now_utc_aware(),
players=2, max_players=4, bots=0, map="c1m1_hotel", hibernating=False,
)
db.add(row); db.flush()
def test_server_player_session_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
u = User(username="u", password_digest="x"); db.add(u); db.flush()
bp = Blueprint(user_id=u.id, name="bp", arguments="[]", config="[]"); db.add(bp); db.flush()
s = Server(user_id=u.id, blueprint_id=bp.id, name="s", port=27502, rcon_password="x")
db.add(s); db.flush()
row = ServerPlayerSession(
server_id=s.id, steam_id_64="76561197960828710",
joined_at=now_utc_aware(), left_at=None,
name_at_join="Crone", min_ping=42, max_ping=185,
)
db.add(row); db.flush()
def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path/'t.db'}")
init_db()
with session_scope() as db:
row = SteamUserProfile(
steam_id_64="76561197960828710",
persona_name="MrCool42",
avatar_url="https://avatars.cloudflare.steamstatic.com/abc_medium.jpg",
fetched_at=now_utc_aware(),
)
db.add(row); db.flush()

View file

@ -37,6 +37,7 @@ def _add_workshop_item(steam_id: str, *, downloaded: bool, cache_root: Path, con
if downloaded: if downloaded:
cache_root.mkdir(parents=True, exist_ok=True) cache_root.mkdir(parents=True, exist_ok=True)
(cache_root / f"{steam_id}.vpk").write_bytes(content) (cache_root / f"{steam_id}.vpk").write_bytes(content)
os.utime(cache_root / f"{steam_id}.vpk", (1700000000, 1700000000))
with session_scope() as s: with session_scope() as s:
wi = WorkshopItem( wi = WorkshopItem(
steam_id=steam_id, steam_id=steam_id,
@ -135,27 +136,6 @@ def test_workshop_builder_creates_absolute_symlinks(env: Path) -> None:
assert link_b.resolve() == (cache_root / "1002.vpk").resolve() assert link_b.resolve() == (cache_root / "1002.vpk").resolve()
def test_workshop_builder_skips_uncached_items_with_warning(env: Path) -> None:
_, overlay_id = _create_user_and_overlay("ws", "workshop")
cache_root = env / "workshop_cache"
cached = _add_workshop_item("1001", downloaded=True, cache_root=cache_root)
uncached = _add_workshop_item("9999", downloaded=False, cache_root=cache_root)
_associate(overlay_id, cached)
_associate(overlay_id, uncached)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
overlay_builders.BUILDERS["workshop"].build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False
)
addons = env / "overlays" / "7" / "left4dead2" / "addons"
assert (addons / "1001.vpk").is_symlink()
assert not (addons / "9999.vpk").exists(), "must NOT create dangling symlink"
assert any("9999" in line and ("skip" in line.lower() or "uncached" in line.lower()) for line in err + out), err + out
def test_workshop_builder_rerun_is_idempotent(env: Path) -> None: def test_workshop_builder_rerun_is_idempotent(env: Path) -> None:
_, overlay_id = _create_user_and_overlay("ws", "workshop") _, overlay_id = _create_user_and_overlay("ws", "workshop")
@ -380,3 +360,399 @@ def test_script_builder_cleans_up_tmpfile_on_failure(env, monkeypatch) -> None:
should_cancel=lambda: False, should_cancel=lambda: False,
) )
assert not os.path.exists(captured["script_path"]) assert not os.path.exists(captured["script_path"])
def test_sleep_with_cancel_returns_normally_when_not_cancelled():
from l4d2web.services.overlay_builders import _sleep_with_cancel
cancelled = _sleep_with_cancel(0.05, lambda: False, poll_interval=0.01)
assert cancelled is False
def test_sleep_with_cancel_returns_early_when_cancelled():
import time
from l4d2web.services.overlay_builders import _sleep_with_cancel
flag = {"cancel": False}
def cancel_check():
return flag["cancel"]
import threading
threading.Timer(0.05, lambda: flag.update(cancel=True)).start()
start = time.monotonic()
cancelled = _sleep_with_cancel(5.0, cancel_check, poll_interval=0.01)
elapsed = time.monotonic() - start
assert cancelled is True
assert elapsed < 0.5, f"should have woken up promptly, slept {elapsed:.3f}s"
def test_download_with_retry_succeeds_on_first_attempt(env, tmp_path, monkeypatch):
from l4d2web.services import overlay_builders, steam_workshop
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
calls = []
def fake_download(meta, cache_root, *, should_cancel=None):
calls.append(1)
return cache_root / f"{meta.steam_id}.vpk"
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
)
out, err, on_stdout, on_stderr = _capture_logs()
overlay_builders._download_with_retry(
meta, tmp_path / "cache",
on_stderr=on_stderr, should_cancel=lambda: False,
)
assert calls == [1]
assert err == []
def test_download_with_retry_retries_then_succeeds(env, tmp_path, monkeypatch):
import requests
from l4d2web.services import overlay_builders, steam_workshop
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
attempts = {"n": 0}
def fake_download(meta, cache_root, *, should_cancel=None):
attempts["n"] += 1
if attempts["n"] < 3:
raise requests.ConnectionError("boom")
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
)
out, err, on_stdout, on_stderr = _capture_logs()
overlay_builders._download_with_retry(
meta, tmp_path / "cache",
on_stderr=on_stderr, should_cancel=lambda: False,
)
assert attempts["n"] == 3
assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2
def test_download_with_retry_exhausts_and_raises(env, tmp_path, monkeypatch):
import requests
from l4d2web.services import overlay_builders, steam_workshop
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
def fake_download(meta, cache_root, *, should_cancel=None):
raise requests.ConnectionError("permanent")
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
)
out, err, on_stdout, on_stderr = _capture_logs()
with pytest.raises(requests.ConnectionError):
overlay_builders._download_with_retry(
meta, tmp_path / "cache",
on_stderr=on_stderr, should_cancel=lambda: False,
)
# Two stderr "attempt N/3 failed" lines for attempts 1 and 2; the final
# attempt re-raises without logging.
assert sum(1 for line in err if "attempt" in line and "failed" in line) == 2
def test_download_with_retry_propagates_interrupted(env, tmp_path, monkeypatch):
from l4d2web.services import overlay_builders, steam_workshop
def fake_download(meta, cache_root, *, should_cancel=None):
raise InterruptedError("cancelled")
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
)
out, err, on_stdout, on_stderr = _capture_logs()
with pytest.raises(InterruptedError):
overlay_builders._download_with_retry(
meta, tmp_path / "cache",
on_stderr=on_stderr, should_cancel=lambda: False,
)
def test_download_with_retry_bails_when_cancelled_during_backoff(env, tmp_path, monkeypatch):
import requests
from l4d2web.services import overlay_builders, steam_workshop
def fake_download(meta, cache_root, *, should_cancel=None):
raise requests.ConnectionError("boom")
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: True)
meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="x", filename="x.vpk", file_url="http://e/x.vpk",
file_size=1, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1,
)
out, err, on_stdout, on_stderr = _capture_logs()
with pytest.raises(InterruptedError):
overlay_builders._download_with_retry(
meta, tmp_path / "cache",
on_stderr=on_stderr, should_cancel=lambda: False,
)
def _make_meta_from_db_row(steam_id: str, *, file_size: int, time_updated: int):
from l4d2web.services import steam_workshop
return steam_workshop.WorkshopMetadata(
steam_id=steam_id, title=f"item-{steam_id}", filename=f"orig-{steam_id}.vpk",
file_url=f"https://example.com/{steam_id}.vpk", file_size=file_size,
time_updated=time_updated, preview_url="", consumer_app_id=550, result=1,
)
def test_workshop_build_downloads_uncached_and_stamps_timestamp(env, tmp_path, monkeypatch):
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
cache_root = tmp_path / "workshop_cache"
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("2001", downloaded=False, cache_root=cache_root)
_associate(overlay_id, item_id)
download_calls = []
def fake_download(meta, cache_root_arg, *, should_cancel=None):
download_calls.append(meta.steam_id)
cache_root_arg.mkdir(parents=True, exist_ok=True)
(cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"data")
os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated))
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
overlay = s.scalar(__import__("sqlalchemy").select(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
assert download_calls == ["2001"]
with session_scope() as s:
from sqlalchemy import select as _select
wi = s.scalar(_select(WorkshopItem).where(WorkshopItem.id == item_id))
assert wi.last_downloaded_at is not None
assert wi.last_error == ""
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
assert (addons / "2001.vpk").is_symlink()
def test_workshop_build_skips_already_cached(env, tmp_path, monkeypatch):
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
cache_root = tmp_path / "workshop_cache"
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("2002", downloaded=True, cache_root=cache_root)
# Make the cache file's (mtime, size) match the DB row exactly.
file_path = cache_root / "2002.vpk"
os.utime(file_path, (1700000000, 1700000000))
with session_scope() as s:
from sqlalchemy import select as _sel, update as _upd
s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values(
file_size=os.path.getsize(file_path), time_updated=1700000000,
))
_associate(overlay_id, item_id)
called = []
monkeypatch.setattr(
overlay_builders, "download_to_cache",
lambda *a, **kw: called.append(1),
)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
assert called == [], "should not call downloader for an already-cached item"
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
assert (addons / "2002.vpk").is_symlink()
def test_workshop_build_redownloads_stale_cache(env, tmp_path, monkeypatch):
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
cache_root = tmp_path / "workshop_cache"
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("2003", downloaded=True, cache_root=cache_root)
with session_scope() as s:
from sqlalchemy import update as _upd
s.execute(_upd(WorkshopItem).where(WorkshopItem.id == item_id).values(
file_size=99, time_updated=1800000000,
))
_associate(overlay_id, item_id)
download_calls = []
def fake_download(meta, cache_root_arg, *, should_cancel=None):
download_calls.append(meta.steam_id)
(cache_root_arg / f"{meta.steam_id}.vpk").write_bytes(b"99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____99bytes____")
os.utime(cache_root_arg / f"{meta.steam_id}.vpk", (meta.time_updated, meta.time_updated))
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
assert download_calls == ["2003"]
def test_workshop_build_skips_items_with_no_file_url(env, tmp_path, monkeypatch):
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
with session_scope() as s:
wi = WorkshopItem(
steam_id="2004", title="gone", filename="",
file_url="", file_size=0, time_updated=0, preview_url="",
last_downloaded_at=None, last_error="steam result 9",
)
s.add(wi)
s.flush()
item_id = wi.id
_associate(overlay_id, item_id)
monkeypatch.setattr(
overlay_builders, "download_to_cache",
lambda *a, **kw: (_ for _ in ()).throw(AssertionError("must not be called")),
)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
assert any("2004" in line and "skipped" in line for line in err)
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
assert not (addons / "2004.vpk").exists()
def test_workshop_build_fails_when_all_retries_exhausted(env, tmp_path, monkeypatch):
import requests
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("2005", downloaded=False, cache_root=tmp_path / "workshop_cache")
_associate(overlay_id, item_id)
monkeypatch.setattr(overlay_builders, "_sleep_with_cancel", lambda *a, **kw: False)
monkeypatch.setattr(
overlay_builders, "download_to_cache",
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("net")),
)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
with pytest.raises(requests.ConnectionError):
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
with session_scope() as s:
from sqlalchemy import select as _sel
wi = s.scalar(_sel(WorkshopItem).where(WorkshopItem.id == item_id))
assert "download failed" in wi.last_error
def test_workshop_build_cancels_cleanly_during_download_phase(env, tmp_path, monkeypatch):
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("2006", downloaded=False, cache_root=tmp_path / "workshop_cache")
_associate(overlay_id, item_id)
cancel_flag = {"v": False}
def fake_download(meta, cache_root, *, should_cancel=None):
cancel_flag["v"] = True
raise InterruptedError("cancelled")
monkeypatch.setattr(overlay_builders, "download_to_cache", fake_download)
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
with pytest.raises(InterruptedError):
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr,
should_cancel=lambda: cancel_flag["v"],
)
def test_workshop_build_refuses_to_overwrite_non_symlink_file(env, tmp_path, monkeypatch):
"""If a plain file collides with a workshop symlink name, the build logs
a refusal and leaves the file alone instead of crashing."""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
cache_root = tmp_path / "workshop_cache"
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("3001", downloaded=True, cache_root=cache_root)
_associate(overlay_id, item_id)
# Pre-create a plain file (not a symlink) where the builder would place its symlink.
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
addons.mkdir(parents=True, exist_ok=True)
(addons / "3001.vpk").write_bytes(b"manual file, don't touch")
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
# The plain file is still there, unchanged.
assert (addons / "3001.vpk").exists()
assert not (addons / "3001.vpk").is_symlink()
assert (addons / "3001.vpk").read_bytes() == b"manual file, don't touch"
# And a refusal message was logged.
assert any("refusing to overwrite non-symlink" in line for line in err)
def test_workshop_build_refuses_to_overwrite_foreign_symlink(env, tmp_path, monkeypatch):
"""A symlink pointing outside the workshop cache is left alone — not
overwritten, not failed."""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
cache_root = tmp_path / "workshop_cache"
user_id, overlay_id = _create_user_and_overlay("ws", "workshop")
item_id = _add_workshop_item("3002", downloaded=True, cache_root=cache_root)
_associate(overlay_id, item_id)
# Pre-create a symlink pointing outside the cache root.
foreign_target = tmp_path / "elsewhere" / "thing.vpk"
foreign_target.parent.mkdir(parents=True, exist_ok=True)
foreign_target.write_bytes(b"some other vpk")
addons = tmp_path / "overlays" / "7" / "left4dead2" / "addons"
addons.mkdir(parents=True, exist_ok=True)
os.symlink(foreign_target, addons / "3002.vpk")
out, err, on_stdout, on_stderr = _capture_logs()
with session_scope() as s:
from sqlalchemy import select as _sel
overlay = s.scalar(_sel(Overlay).where(Overlay.id == overlay_id))
s.expunge(overlay)
overlay_builders.WorkshopBuilder().build(
overlay, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=lambda: False,
)
# The foreign symlink still points where it did.
assert (addons / "3002.vpk").is_symlink()
assert os.readlink(addons / "3002.vpk") == str(foreign_target)
assert any("refusing to overwrite foreign symlink" in line for line in err)

View file

@ -742,3 +742,35 @@ def test_overlay_detail_no_global_source_block(auth_client_with_server) -> None:
assert "Global source" not in text assert "Global source" not in text
assert "source_url" not in text assert "source_url" not in text
def test_workshop_overlay_detail_renders_refresh_button(auth_client_with_server) -> None:
"""The owner sees a 'Refresh from Steam' button on their workshop overlay."""
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("ws-refresh-button", "workshop", user_id)
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Refresh from Steam" in text
assert f'action="/overlays/{overlay_id}/refresh"' in text
def test_workshop_overlay_refresh_button_hidden_during_build(auth_client_with_server) -> None:
"""While a build is in flight the Refresh button hides, matching the Add form."""
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("ws-refresh-hidden", "workshop", user_id)
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Refresh from Steam" not in text
# Positive companion: confirm the build-running guard actually fired,
# otherwise the negative could pass vacuously on a broken page.
assert "building…" in text

155
l4d2web/tests/test_rcon.py Normal file
View file

@ -0,0 +1,155 @@
"""Source RCON client tests against an in-process TCP fixture.
The handshake quirk we verified live: after a SERVERDATA_AUTH (type=3) the
server sends a SERVERDATA_RESPONSE_VALUE (type=0) FIRST and THEN the
SERVERDATA_AUTH_RESPONSE (type=2). The auth response's req_id == -1 means
bad password. The client must consume both packets before sending the
command.
"""
from __future__ import annotations
import socket
import struct
import threading
from contextlib import contextmanager
from typing import Iterator
import pytest
from l4d2web.services.rcon import (
RconAuthError,
RconError,
query_status,
)
def _pack(req_id: int, ptype: int, body: str) -> bytes:
body_bytes = body.encode("utf-8") + b"\x00\x00"
size = 4 + 4 + len(body_bytes)
return struct.pack("<iii", size, req_id, ptype) + body_bytes
def _unpack_one(conn: socket.socket) -> tuple[int, int, str]:
raw_size = conn.recv(4)
size = struct.unpack("<i", raw_size)[0]
payload = b""
while len(payload) < size:
payload += conn.recv(size - len(payload))
req_id, ptype = struct.unpack("<ii", payload[:8])
body = payload[8:].rstrip(b"\x00").decode("utf-8", errors="replace")
return req_id, ptype, body
@contextmanager
def fake_rcon_server(handler) -> Iterator[int]:
"""Start a TCP server on an ephemeral port; handler(conn) runs in a thread.
Handler exceptions propagate at context exit so a buggy handler surfaces
as a real test failure instead of degrading into a client-side timeout.
"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
server.listen(1)
port = server.getsockname()[1]
server.settimeout(3.0)
handler_error: list[BaseException] = []
def serve() -> None:
try:
conn, _ = server.accept()
try:
handler(conn)
finally:
conn.close()
except BaseException as exc:
handler_error.append(exc)
t = threading.Thread(target=serve, daemon=True)
t.start()
try:
yield port
finally:
server.close()
t.join(timeout=1.0)
if handler_error:
raise AssertionError("fake_rcon_server handler raised") from handler_error[0]
def test_auth_success_then_status(monkeypatch: pytest.MonkeyPatch) -> None:
response_body = (
"hostname: Left 4 Dead 2\n"
"version : 2.2.4.3 9309 secure (unknown)\n"
"udp/ip : 127.0.0.1:27016 [ public 1.2.3.4:27016 ]\n"
"os : Linux Dedicated\n"
"map : c1m2_streets\n"
"players : 1 humans, 0 bots (4 max) (not hibernating) (reserved 1860000e7e5e446)\n"
"\n"
"# userid name uniqueid connected ping loss state rate adr\n"
'# 2 1 "Crone" STEAM_1:0:12376499 00:21 185 20 active 30000 91.55.5.100:27005\n'
"#end\n"
)
def handler(conn: socket.socket) -> None:
req_id, ptype, body = _unpack_one(conn)
assert ptype == 3
assert body == "letmein"
conn.sendall(_pack(req_id, 0, ""))
conn.sendall(_pack(req_id, 2, ""))
cmd_id, cmd_type, cmd = _unpack_one(conn)
assert cmd_type == 2
assert cmd == "status"
conn.sendall(_pack(cmd_id, 0, response_body))
with fake_rcon_server(handler) as port:
result = query_status("127.0.0.1", port, "letmein", timeout=2.0)
assert result.map == "c1m2_streets"
assert result.players == 1
assert result.bots == 0
assert result.max_players == 4
assert result.hibernating is False
assert len(result.roster) == 1
p = result.roster[0]
assert p.name == "Crone"
assert p.steam_id_64 == "76561197985018726" # 76561197960265728 + 0 + 12376499*2
assert p.connected_seconds == 21
assert p.ping == 185
def test_auth_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(conn: socket.socket) -> None:
req_id, _, _ = _unpack_one(conn)
conn.sendall(_pack(req_id, 0, ""))
conn.sendall(_pack(-1, 2, "")) # bad password sentinel
with fake_rcon_server(handler) as port:
with pytest.raises(RconAuthError):
query_status("127.0.0.1", port, "wrong", timeout=2.0)
def test_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(conn: socket.socket) -> None:
import time
time.sleep(3.0)
with fake_rcon_server(handler) as port:
with pytest.raises(RconError):
query_status("127.0.0.1", port, "x", timeout=0.3)
def test_parse_duration_handles_hours() -> None:
from l4d2web.services.rcon import _parse_duration
assert _parse_duration("00:21") == 21
assert _parse_duration("01:23:45") == 5025
assert _parse_duration("12:00") == 720
def test_parse_duration_rejects_malformed_as_rcon_error() -> None:
from l4d2web.services.rcon import _parse_duration
for bad in ["", ":", "abc", "1:", ":5", "1:2:3:4"]:
with pytest.raises(RconError):
_parse_duration(bad)

View file

@ -427,6 +427,147 @@ def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
assert response.headers["Location"] == f"/servers/{server_id}" assert response.headers["Location"] == f"/servers/{server_id}"
def test_create_server_generates_rcon_password(user_client_with_blueprints) -> None:
from sqlalchemy import select
from l4d2web.models import Server
client, data = user_client_with_blueprints
res = client.post(
"/servers",
data={"name": "fresh", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert res.status_code in (200, 201, 302)
with session_scope() as db:
row = db.scalar(select(Server).where(Server.name == "fresh"))
assert row is not None
assert len(row.rcon_password) >= 32
def test_servers_index_renders_live_state_badge(user_client_with_blueprints) -> None:
from datetime import timedelta
from sqlalchemy import select
from l4d2web.models import Server, ServerLiveState
client, data = user_client_with_blueprints
# Seed one server with a recent snapshot, one without.
now = datetime.now(UTC).replace(tzinfo=None)
with session_scope() as db:
s_active = Server(
user_id=data["user_id"],
blueprint_id=data["blueprint_id"],
name="active",
port=27700,
rcon_password="x",
actual_state="running",
)
s_stale = Server(
user_id=data["user_id"],
blueprint_id=data["blueprint_id"],
name="stale",
port=27701,
rcon_password="x",
actual_state="running",
)
db.add_all([s_active, s_stale])
db.flush()
db.add(
ServerLiveState(
server_id=s_active.id,
started_at=now,
last_seen_at=now,
players=2,
max_players=4,
bots=0,
map="c1m2_streets",
hibernating=False,
)
)
old = now - timedelta(minutes=5)
db.add(
ServerLiveState(
server_id=s_stale.id,
started_at=old,
last_seen_at=old,
players=0,
max_players=4,
bots=0,
map="c1m1_hotel",
hibernating=True,
)
)
res = client.get("/servers")
html = res.get_data(as_text=True)
assert "2/4" in html
assert "c1m2_streets" in html
# Stale server's map MUST NOT render — fresh badge condition must guard it.
assert "c1m1_hotel" not in html
def test_live_state_fragment_renders_current_and_recent(user_client_with_blueprints) -> None:
from datetime import timedelta
from sqlalchemy import select
from l4d2web.models import (
Server, ServerLiveState, ServerPlayerSession, SteamUserProfile,
)
client, data = user_client_with_blueprints
now = datetime.now(UTC).replace(tzinfo=None)
with session_scope() as db:
srv = Server(
user_id=data["user_id"], blueprint_id=data["blueprint_id"],
name="srv", port=27800, rcon_password="x", actual_state="running",
)
db.add(srv); db.flush()
srv_id = srv.id
db.add(ServerLiveState(
server_id=srv_id, started_at=now, last_seen_at=now,
players=1, max_players=4, bots=0, map="c1m2_streets", hibernating=False,
))
db.add(ServerPlayerSession(
server_id=srv_id, steam_id_64="76561197960828710",
joined_at=now - timedelta(minutes=5), left_at=None,
name_at_join="Crone", min_ping=40, max_ping=60,
))
db.add(ServerPlayerSession(
server_id=srv_id, steam_id_64="76561198021234567",
joined_at=now - timedelta(hours=2), left_at=now - timedelta(hours=1),
name_at_join="OldPlayer", min_ping=20, max_ping=80,
))
db.add(SteamUserProfile(
steam_id_64="76561197960828710",
persona_name="MrCool42",
avatar_url="https://avatars.cloudflare.steamstatic.com/cur_medium.jpg",
fetched_at=now,
))
db.add(SteamUserProfile(
steam_id_64="76561198021234567",
persona_name="OldPersona",
avatar_url="https://avatars.cloudflare.steamstatic.com/old_medium.jpg",
fetched_at=now,
))
res = client.get(f"/servers/{srv_id}/live-state")
assert res.status_code == 200
html = res.get_data(as_text=True)
# Summary
assert "1/4" in html
assert "c1m2_streets" in html
# Current player block
assert "MrCool42" in html
assert "cur_medium.jpg" in html
assert "40-60" in html or "4060" in html
# Recent block — only OldPlayer, not MrCool42
assert "OldPersona" in html
assert "old_medium.jpg" in html
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None: def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
from sqlalchemy import select from sqlalchemy import select

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
import pytest
from l4d2web.services import steam_users
class _FakeResponse:
def __init__(self, json_body: dict[str, Any], status: int = 200) -> None:
self._body = json_body
self.status_code = status
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
def json(self) -> dict[str, Any]:
return self._body
def _patched_get(monkeypatch: pytest.MonkeyPatch, body: dict, capture: list) -> None:
def fake_get(url: str, params: dict, timeout: float = 30.0) -> _FakeResponse:
capture.append({"url": url, "params": params, "timeout": timeout})
return _FakeResponse(body)
monkeypatch.setattr(steam_users, "_session_get", fake_get)
def test_fetch_profiles_batch_builds_correct_request(monkeypatch: pytest.MonkeyPatch) -> None:
captured: list = []
body = {"response": {"players": [
{"steamid": "76561197960828710", "personaname": "Alice",
"avatarmedium": "https://avatars.../alice_medium.jpg"},
]}}
_patched_get(monkeypatch, body, captured)
profiles = steam_users.fetch_profiles_batch(
["76561197960828710", "76561198021234567"], api_key="KEY"
)
assert captured[0]["url"].endswith("/GetPlayerSummaries/v0002/")
assert captured[0]["params"]["key"] == "KEY"
assert captured[0]["params"]["steamids"] == "76561197960828710,76561198021234567"
assert len(profiles) == 1
p = profiles[0]
assert p.steam_id_64 == "76561197960828710"
assert p.persona_name == "Alice"
assert p.avatar_url.endswith("alice_medium.jpg")
def test_fetch_profiles_batch_skips_private_or_missing(monkeypatch: pytest.MonkeyPatch) -> None:
# The Steam API simply omits non-resolvable IDs from the response. Caller
# should accept that and return only what's there.
body = {"response": {"players": [
{"steamid": "76561197960828710", "personaname": "Alice",
"avatarmedium": "https://avatars.../alice_medium.jpg"},
]}}
_patched_get(monkeypatch, body, [])
profiles = steam_users.fetch_profiles_batch(
["76561197960828710", "76561197999999999"], api_key="KEY"
)
assert len(profiles) == 1
assert profiles[0].steam_id_64 == "76561197960828710"
def test_fetch_profiles_batch_chunks_by_100(monkeypatch: pytest.MonkeyPatch) -> None:
ids = [str(76561197960000000 + i) for i in range(150)]
calls: list = []
body = {"response": {"players": []}}
_patched_get(monkeypatch, body, calls)
steam_users.fetch_profiles_batch(ids, api_key="KEY")
assert len(calls) == 2
assert calls[0]["params"]["steamids"].count(",") == 99 # 100 ids -> 99 commas
assert calls[1]["params"]["steamids"].count(",") == 49 # 50 ids

View file

@ -283,3 +283,172 @@ def test_other_user_cannot_modify_workshop_overlay(overlay_for):
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert response.status_code == 403 assert response.status_code == 403
def test_overlay_refresh_owner_can_refresh_and_enqueues_build(overlay_for):
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with _patch_steam([_meta("1001")]):
user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
with session_scope() as session:
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
job.state = "succeeded"
fresh_meta = steam_workshop.WorkshopMetadata(
steam_id="1001", title="Item 1001 (updated)", filename="1001.vpk",
file_url="https://example.com/1001.vpk", file_size=99, time_updated=1800000000,
preview_url="https://example.com/preview-1001.jpg", consumer_app_id=550, result=1,
)
with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[fresh_meta]) as fetch:
response = user_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"].startswith("/jobs/")
fetch.assert_called_once_with(["1001"], mode="refresh")
with session_scope() as session:
wi = session.query(WorkshopItem).filter_by(steam_id="1001").one()
assert wi.title == "Item 1001 (updated)"
assert wi.time_updated == 1800000000
new_jobs = session.query(Job).filter_by(
operation="build_overlay", overlay_id=overlay_id, state="queued"
).all()
assert len(new_jobs) == 1
def test_overlay_refresh_returns_400_when_overlay_empty(overlay_for):
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with patch.object(steam_workshop, "fetch_metadata_batch") as fetch:
response = user_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 400
fetch.assert_not_called()
def test_overlay_refresh_forbidden_for_non_owner(overlay_for, env_user):
app, login, _user_id, _admin_id, overlay_id = overlay_for
with session_scope() as session:
bob = User(username="bob", password_digest=hash_password("x"), admin=False)
session.add(bob)
session.flush()
bob_id = bob.id
bob_client = login(bob_id)
response = bob_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_overlay_refresh_admin_can_refresh_anyone(overlay_for):
app, login, user_id, admin_id, overlay_id = overlay_for
user_client = login(user_id)
with _patch_steam([_meta("1001")]):
user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
admin_client = login(admin_id)
with _patch_steam([_meta("1001")]):
response = admin_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
def test_overlay_refresh_502_on_steam_error(overlay_for):
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with _patch_steam([_meta("1001")]):
user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
with session_scope() as session:
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
job.state = "succeeded"
baseline_job_count = session.query(Job).filter_by(
operation="build_overlay", overlay_id=overlay_id, state="queued"
).count()
import requests as _requests
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=_requests.ConnectionError("boom")):
response = user_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 502
assert b"steam api error" in response.data
with session_scope() as session:
n = session.query(Job).filter_by(
operation="build_overlay", overlay_id=overlay_id, state="queued"
).count()
assert n == baseline_job_count
def test_overlay_refresh_non_requests_exception_propagates(overlay_for):
"""A non-requests exception (e.g., ValueError from a server-side data
issue) must not be disguised as a 502 let it bubble up as 500."""
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with _patch_steam([_meta("1001")]):
user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
with session_scope() as session:
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
job.state = "succeeded"
# Flask in TESTING mode re-raises uncaught exceptions instead of returning
# 500, so we assert the ValueError propagates rather than checking a status.
with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=ValueError("bad steam_id in db")):
with pytest.raises(ValueError, match="bad steam_id in db"):
user_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
def test_overlay_refresh_missing_item_records_last_error(overlay_for):
app, login, user_id, _admin_id, overlay_id = overlay_for
user_client = login(user_id)
with _patch_steam([_meta("1001")]):
user_client.post(
f"/overlays/{overlay_id}/items",
data={"input": "1001", "input_mode": "items"},
headers={"X-CSRF-Token": "test-token"},
)
with session_scope() as session:
for job in session.query(Job).filter_by(operation="build_overlay", state="queued"):
job.state = "succeeded"
with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[]):
response = user_client.post(
f"/overlays/{overlay_id}/refresh",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
wi = session.query(WorkshopItem).filter_by(steam_id="1001").one()
assert "no entry" in wi.last_error
new_jobs = session.query(Job).filter_by(
operation="build_overlay", overlay_id=overlay_id, state="queued"
).all()
assert len(new_jobs) == 1, "refresh should enqueue build even when Steam returns no entries"