diff --git a/docs/superpowers/plans/2026-05-14-rcon-console.md b/docs/superpowers/plans/2026-05-14-rcon-console.md new file mode 100644 index 0000000..2948e39 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-rcon-console.md @@ -0,0 +1,387 @@ +# Add an RCON console to the server detail page + +## Context + +The server detail page (`/servers/`) already exposes the RCON password, +live state polling, log streaming, and start/stop actions, but to send any +arbitrary command (`changelevel`, `sm_kick`, `mp_*`, `say`, etc.) the user +has to open a separate RCON client and reconnect. Adding an inline console +turns the web UI into a complete operator tool for the owner of a server: +type a command, see the reply, recall earlier commands via persisted +history. + +Scope is intentionally narrow: +- One server, one user (the owner). Multi-user shared console = not now. +- Per-user history persisted across reloads. +- No blocklist — owner already has the RCON password and can run anything + via any RCON client; the UI is a thin wrapper. + +## Design decisions (already settled) + +| Topic | Choice | +|---|---| +| UI placement | Panel on `server_detail.html`, between **Live State** and **Files**. | +| Output transport | **HTMX append swap**, not SSE. RCON is request/response — SSE adds no value. Matches existing inline-form / `hx-swap` patterns in the codebase. | +| Safety | Owner-of-server check only (`Server.user_id == current_user.id`). No command blocklist. **No admin override** — admins can already SSH if needed; an unaudited UI backdoor isn't worth the asymmetry. | +| History | New `command_history` table, scoped per (user, server). Stores **command + reply + error flag** so the full transcript can be replayed on page reload. | +| Transcript on page load | **Replays the last 50 rows** for this user+server, rendered server-side into the transcript via the same `_console_line.html` partial used for live additions. Visually identical to live lines (no "old vs new" distinction — the whole point is page-reload continuity). | +| Transcript height | Fixed max-height ~400 px, internal vertical scroll. New lines auto-scroll to the bottom on add AND on initial load. Page layout below stays stable. | +| Clear button | None. Reload doesn't help (it replays). If anyone wants to drop history, that's a separate concern handled later. | +| RCON timeout | **30s per command.** Comfortably covers a cold map load with custom add-ons (community-observed worst case ~25s on modest hardware). 3× the python-valve default. Far below `director_transition_timeout` (120s) so no aliasing. If a command exceeds 30s, the RCON exec packet was already sent — the server still did the work; the user just doesn't see the textual reply but sees the effect in the Server Log SSE panel above. | +| Worker model | Rely on `gunicorn --threads N` (or whatever the existing deployment uses for the long-lived SSE log streams). Threads share memory; one stuck `changelevel` holds a thread, not a process. Don't scale processes — adding hundreds of workers wastes RAM (~100 MB each); threads cost nothing. | + +## Server-side changes + +### 1. Extend `l4d2web/services/rcon.py` + +The wire-protocol layer already exists (`l4d2web/services/rcon.py:64`). +Add a generic command executor with **multi-packet response handling**: + +```python +def execute_command( + host: str, port: int, password: str, command: str, *, timeout: float = 30.0 +) -> str: + """Authenticate, send a single command, return the joined reply body. + + Implements the trailing-marker pattern: after the exec packet we + immediately send an empty SERVERDATA_RESPONSE_VALUE packet with a + sentinel req_id. We then read response packets, concatenating bodies, + until we see the sentinel echo back. This is the only reliable way + to detect end-of-output, because Source RCON splits replies >4096 B + across multiple packets with no length header. + """ +``` + +Implementation notes: +- Factor `_connect_and_auth(sock, password)` out of `query_status` so + both functions share the auth dance. +- Use req_id `0xDEADBEEF` (or any constant ≠ the exec req_id) for the + sentinel; read packets until one comes back with that req_id. +- Input validation **inside this function** (not just at the route): + - Reject empty / whitespace-only `command` → `ValueError`. + - Reject embedded `\x00` bytes (would corrupt the null-terminated + wire format) → `ValueError`. + - Cap length at 1000 chars (RCON packet limit is 4096 incl. headers; + no real command needs more). Longer → `ValueError`. +- Trim trailing whitespace from the joined body. Otherwise return verbatim. +- Existing `RconError` / `RconAuthError` exception types are reused. + +Tests in `l4d2web/tests/test_rcon.py` (extend the `FakeRconServer` to +support multi-packet replies): +- happy path: single-packet response +- multi-packet response (synthesize a >4096 B reply) +- empty reply (server replies only with the sentinel — case for `say`) +- bad password → `RconAuthError` +- timeout (fake server sleeps longer than the test timeout) +- input validation: empty / null byte / oversized → `ValueError` + +### 2. New `CommandHistory` model (`l4d2web/models.py`) + +Append at the bottom of `models.py`: + +```python +class CommandHistory(Base): + __tablename__ = "command_history" + __table_args__ = ( + Index("ix_cmdhist_user_server_id", "user_id", "server_id", "id"), + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + server_id: Mapped[int] = mapped_column(ForeignKey("servers.id", ondelete="CASCADE"), nullable=False) + command: Mapped[str] = mapped_column(Text, nullable=False) + reply: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default="") + is_error: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False) +``` + +Index `(user_id, server_id, id)` because every lookup is "latest N for +this user+server", ordered by `id DESC`. + +A row is persisted on **every** RCON outcome — successful reply, +empty reply, and error (auth fail, connect refused, `RconError`). The +`is_error` flag drives the red styling on replay, so the transcript +looks identical after a page reload. + +**Storage cost**: most replies are <500 B; `status` ~1 KB; +`sm plugins list` a few KB; `cvarlist` can be 50 KB+. A power user +running 100 commands/day at an average ~2 KB → ~73 MB/year. SQLite +handles that without complaint; a trim job (cap N per user/server, +e.g. last 5000) can be added if anyone ever notices. + +**Privacy note for the implementer**: replies from `status` include +player names (user-controlled strings from random Steam users) and +SteamID64s. Treat them as untrusted text on output (handled by Jinja +auto-escaping — see §5) and don't surface them outside this user's +session. + +### 3. New alembic migration `0012_command_history.py` + +Mirror `l4d2web/alembic/versions/0011_server_hostname.py`: +- `revision = "0012_command_history"` +- `down_revision = "0011_server_hostname"` +- `upgrade()`: `op.create_table("command_history", …)` with columns + `id`, `user_id`, `server_id`, `command (Text)`, `reply (Text, server_default="")`, + `is_error (Boolean, server_default="0")`, `created_at`; plus + `op.create_index("ix_cmdhist_user_server_id", ...)`. +- `downgrade()`: drop index then table. +- `test_alembic_migrations.py` auto-discovers revisions (skim once to + confirm; no edit if so). + +### 4. New route module `l4d2web/routes/console_routes.py` + +Two endpoints, both `@require_login`, both verify ownership with +**404** on miss (matches the existing pattern at +`page_routes.py:303` — no admin backdoor). + +**`POST /servers//console`** — submit a command. +- CSRF-checked (form field `csrf_token`). +- Form field `command`. Validation happens twice: at the route (return a + user-facing error fragment for empty / oversized) and inside + `execute_command` (defence in depth — never trust a single layer). +- Calls + `rcon.execute_command("127.0.0.1", server.port, server.rcon_password, command)`. +- **Every outcome persists a `CommandHistory` row** (so the transcript + fully reconstructs on page reload): + - Success with reply → `command`, `reply`, `is_error=False`. + - Success with empty reply (e.g. `say`) → `command`, `reply=""`, + `is_error=False`. Template renders `(no reply)` in muted text. + - `RconAuthError` / `RconError` / connect-failed → `command`, + `reply=`, `is_error=True`. Red styling on render. +- On `ValueError` from input validation (empty / null byte / oversized): + render an error fragment, **do not** insert history (the command + never reached the wire — nothing happened to remember). +- Returns 200 in all cases (errors are rendered, not raised) so HTMX + appends them to the transcript like any other line. + +**`GET /servers//console/history?before=&limit=50`** — paged +history for up-arrow navigation. +- Returns JSON `[{"id": …, "command": …}, …]` ordered newest-first. +- The client owns the input state; this stays JSON, not HTML. +- `limit` clamped to ≤200. + +Register the blueprint in `l4d2web/app.py` alongside the other +`*_routes` modules. + +**Also extend `server_detail()` in `page_routes.py`** to fetch the last +50 `CommandHistory` rows for this `(user, server)`, ordered oldest-first +(so they iterate naturally in the template), and pass as +`console_history` in the render context. Use the same `session_scope` +block that already loads `server` and `blueprint` (`page_routes.py:301`) +— one extra `db.scalars(select(CommandHistory)…)` call, no new round +trip cost. + +### 5. Template fragment `templates/_console_line.html` + +```jinja2 +
+
> {{ command }}
+ {% if reply %} +
{{ reply }}
+ {% else %} +
(no reply)
+ {% endif %} +
+``` + +**XSS reminder for the implementer:** `reply` originates from the game +server's RCON output — we do not trust it. **Never use `|safe`**, never +`{{ reply|markdown }}`, never anything that bypasses Jinja's default +HTML escaping. The existing `{{ reply }}` is the right call. + +### 6. Console panel in `templates/server_detail.html` + +Insert between the existing live-state section (line 33–37) and the +Files section (line 39): + +```jinja2 +

Console

+
+
+ {% for h in console_history %} + {% include "_console_line.html" with context %} + {# Loops with h.command, h.reply, h.is_error, h.created_at #} + {% endfor %} +
+
+ + > + + + +
+
+``` + +- Transcript is server-side rendered with the last 50 history rows on + page load. `_console_line.html` is the single source of truth for + line layout — same template, same look, whether the line came from + this session or last week. +- `hx-indicator` gives visible feedback during slow commands (a + `changelevel` can sit at ~10s+). +- `maxlength="1000"` on the input mirrors the server-side cap. +- The `hx-on::after-request` inline scrolls the transcript to the + bottom after each new line. On initial page load, the JS module + scrolls to the bottom once after the DOM is ready (so the most + recent history is visible, not the oldest). + +**Cross-feature interaction (do not "fix"):** Silent or slow commands +(`say`, `kick`, `changelevel`) will produce empty or terse RCON replies +in this transcript. The actual game-side effect is already visible in +the **Server Log** SSE panel right above. A future implementer should +NOT try to mirror server-log lines back into the console transcript — +that's a redundancy, not a feature. + +### 7. New `static/js/console-history.js` + +Tiny module bound to `[data-console-form]`: +- **On DOM ready**: scroll each `[data-autoscroll]` transcript to the + bottom so the most recent replayed lines are visible. This is the + initial-load equivalent of the `hx-on::after-request` scroll. +- **On first focus** of the input: lazy-fetch + `/servers//console/history?limit=50` and cache the array in + memory. (Distinct from the rendered-on-load transcript: this cache + is *just commands* for up/down recall — replies don't matter for + navigation, so the JSON endpoint stays narrow.) +- **ArrowUp / ArrowDown**: walk the cached array, set `input.value`. + - ArrowUp from a non-history state: snapshot the current value so + ArrowDown can restore it. +- **ArrowUp past the end**: fetch next page using + `?before=`. If empty, stop. +- **After a successful submit** (`htmx:afterRequest` with 2xx): + prepend the just-sent command to the in-memory cache so it's + instantly recallable. + +Loaded via a `