# 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 `