Plan for adding a per-server RCON console: HTMX append-swap input form, fixed-height scrolling transcript replayed from CommandHistory on load, multi-packet response handling, owner-only access, 30s timeout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
20 KiB
Markdown
387 lines
20 KiB
Markdown
# Add an RCON console to the server detail page
|
||
|
||
## Context
|
||
|
||
The server detail page (`/servers/<id>`) 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/<id>/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=<exception message>`, `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/<id>/console/history?before=<id>&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
|
||
<div class="console-line{% if error %} console-error{% endif %}">
|
||
<div class="console-prompt">> {{ command }}</div>
|
||
{% if reply %}
|
||
<pre class="console-reply">{{ reply }}</pre>
|
||
{% else %}
|
||
<div class="console-reply muted">(no reply)</div>
|
||
{% endif %}
|
||
</div>
|
||
```
|
||
|
||
**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
|
||
<h2 class="section-title">Console</h2>
|
||
<section class="panel console-panel">
|
||
<div id="console-transcript-{{ server.id }}"
|
||
class="console-transcript"
|
||
data-autoscroll>
|
||
{% for h in console_history %}
|
||
{% include "_console_line.html" with context %}
|
||
{# Loops with h.command, h.reply, h.is_error, h.created_at #}
|
||
{% endfor %}
|
||
</div>
|
||
<form hx-post="/servers/{{ server.id }}/console"
|
||
hx-target="#console-transcript-{{ server.id }}"
|
||
hx-swap="beforeend"
|
||
hx-indicator=".console-spinner"
|
||
hx-on::after-request="this.command.value=''; this.command.focus(); this.closest('section').querySelector('[data-autoscroll]').scrollTop = 1e9"
|
||
class="console-input-form"
|
||
data-console-form data-server-id="{{ server.id }}">
|
||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||
<span class="console-prompt-glyph">></span>
|
||
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
|
||
placeholder="status, changelevel c1m1_hotel, sm_kick …">
|
||
<span class="console-spinner" aria-hidden="true">…</span>
|
||
<button type="submit">Send</button>
|
||
</form>
|
||
</section>
|
||
```
|
||
|
||
- 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/<id>/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=<oldest_cached_id>`. 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 `<script defer>` line in `base.html` next to the other
|
||
small static JS modules (same pattern as `sse.js`).
|
||
|
||
### 8. Concurrency sanity (no code, just verifying the design)
|
||
|
||
`live_state_poller.py` already opens fresh RCON connections every 5s
|
||
against the same port. SrcDS handles concurrent RCON sessions cleanly
|
||
(each is independently auth'd, no shared state). The console adds at
|
||
most one more concurrent connection per active user — well within
|
||
limits. No locking needed.
|
||
|
||
### 9. Minimal CSS in `static/css/`
|
||
|
||
Monospace transcript, dark background, `console-error` styled like the
|
||
existing error pills. Match the visual weight of the existing log-stream
|
||
`<pre>` block on the detail page — no new design system.
|
||
|
||
## Files to touch
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `l4d2web/services/rcon.py` | Add `execute_command()` with multi-packet handling + input validation; extract `_connect_and_auth()` |
|
||
| `l4d2web/tests/test_rcon.py` | Extend `FakeRconServer` for multi-packet; add success / multi-packet / empty / bad-pw / timeout / validation tests |
|
||
| `l4d2web/models.py` | Add `CommandHistory` (with `reply`, `is_error`) |
|
||
| `l4d2web/alembic/versions/0012_command_history.py` | New migration |
|
||
| `l4d2web/routes/console_routes.py` | **NEW** — POST + GET endpoints |
|
||
| `l4d2web/routes/page_routes.py` | Extend `server_detail()` to fetch last 50 history rows and pass `console_history` |
|
||
| `l4d2web/app.py` | Register the new blueprint |
|
||
| `l4d2web/templates/_console_line.html` | **NEW** fragment |
|
||
| `l4d2web/templates/server_detail.html` | Insert console panel section (with server-rendered replay loop) |
|
||
| `l4d2web/static/js/console-history.js` | **NEW** up/down history nav + initial scroll-to-bottom |
|
||
| `l4d2web/templates/base.html` | `<script defer src="…/console-history.js">` |
|
||
| `l4d2web/static/css/*.css` | Console panel styling (fixed-height scroll transcript, error variant) |
|
||
| `l4d2web/tests/test_console_routes.py` | **NEW** route tests |
|
||
|
||
## Tests to write explicitly
|
||
|
||
**`test_rcon.py`** (extending existing file):
|
||
- `execute_command` happy path, single-packet reply
|
||
- `execute_command` multi-packet reply (>4096 B) reassembled in order
|
||
- `execute_command` empty reply (server returns only the sentinel)
|
||
- `execute_command` bad password → `RconAuthError`
|
||
- `execute_command` socket timeout → `RconError`
|
||
- Input validation: empty / whitespace-only / null-byte / oversized → `ValueError`
|
||
|
||
**`test_console_routes.py`** (new):
|
||
- not logged in → 302 to login
|
||
- logged in but not server owner → **404** (not 403 — match
|
||
`page_routes.py:303`)
|
||
- valid command → 200, fragment HTML rendered, `CommandHistory` row
|
||
inserted with `reply` populated and `is_error=False`
|
||
- empty RCON reply → 200, fragment renders `(no reply)`, history row
|
||
inserted with `reply=""`, `is_error=False`
|
||
- RCON error (mock `execute_command` to raise) → 200, error fragment,
|
||
history row inserted with `is_error=True` and the exception message
|
||
in `reply`
|
||
- empty/oversized command (validation error before wire) → 200, error
|
||
fragment, **no** history row
|
||
- CSRF token missing → rejected
|
||
- `GET /console/history` returns newest-first
|
||
- `GET /console/history?before=<id>` paginates correctly
|
||
- `GET /console/history?limit=10000` is clamped to ≤200
|
||
|
||
**`test_page_routes.py`** (extend existing if present, otherwise add):
|
||
- `server_detail` returns the last 50 `CommandHistory` rows for the
|
||
viewing user only, oldest-first in the rendered page (newest at the
|
||
bottom of the transcript)
|
||
- a history row belonging to another user for the same server is **not**
|
||
visible (ownership scoping is by `user_id`, not just `server_id`)
|
||
|
||
## What we are deliberately NOT doing
|
||
|
||
- No command blocklist or admin gate — owner already has the password.
|
||
- **No admin override** to console other users' servers (admins can SSH if
|
||
they truly need to; UI backdoor would be unaudited and asymmetric).
|
||
- No shared multi-user view of the same console.
|
||
- No streaming output (RCON doesn't stream; replies are one-shot).
|
||
- No autocomplete of cvars — out of scope; up-arrow history is enough.
|
||
- No "Clear transcript" button — the transcript replays on every page
|
||
load by design. Discarding history is a different concern (delete
|
||
rows from the DB) and is out of scope for v1.
|
||
- No history-trim job — file an issue if anyone hits >100k rows; not
|
||
worth pre-empting at this scale.
|
||
- No mirroring of server-log lines into the console transcript — the
|
||
Server Log panel above already serves that purpose.
|
||
|
||
## Verification
|
||
|
||
1. `pytest l4d2web/tests/test_rcon.py l4d2web/tests/test_console_routes.py l4d2web/tests/test_alembic_migrations.py` — unit + migration tests pass.
|
||
2. Boot the web app locally, log in, open a server detail page for a
|
||
running server, send `status` — multi-line reply renders in the
|
||
transcript; the input clears and refocuses; spinner shows during
|
||
the request; the transcript scrolls to the new line at the bottom.
|
||
3. Send `cvarlist` — a large multi-packet response — and confirm the
|
||
full output reassembles, not truncated.
|
||
4. Send `say hello` — transcript shows `> say hello` followed by
|
||
`(no reply)` in muted text; the line appears in the Server Log
|
||
panel above.
|
||
5. Send `changelevel c1m1_hotel` — request takes ~10–20s, spinner
|
||
visible the whole time, then a (likely empty) reply appears, and
|
||
the live-state panel updates to the new map within 5s.
|
||
6. Send an invalid command (e.g. `nonsense_cvar`) — reply renders
|
||
normally (RCON tolerates unknown commands).
|
||
7. Send a command with embedded null bytes (via curl, since the
|
||
browser strips them) — returns 200 with an error fragment, no
|
||
history row.
|
||
8. Send a 2000-char command — rejected with an error fragment, no
|
||
history row.
|
||
9. **Reload the page** — the transcript reappears identical to before,
|
||
showing the same `> status`, `> say hello`, `> nonsense_cvar` lines
|
||
with their replies, scrolled to the bottom. Errors are still red.
|
||
10. Focus the input, press ArrowUp — the previous command reappears.
|
||
ArrowDown restores the empty state.
|
||
11. Send 60+ commands, then ArrowUp past the in-memory page boundary —
|
||
older commands load on demand.
|
||
12. Stop the server, try to send a command — surfaces as a styled
|
||
`console-error` line ("connect failed") rather than a 500; **a
|
||
history row IS inserted** with `is_error=True`, so the error
|
||
replays on next page load.
|
||
13. Log in as a different user, visit `/servers/<other-user-id>` —
|
||
404, no console rendered. POST to that URL also 404. The other
|
||
user's transcript is not visible.
|
||
14. Confirm that a `cvarlist`-class large reply persists fully in the
|
||
DB (`SELECT length(reply) FROM command_history ORDER BY id DESC LIMIT 1;`)
|
||
and replays in full on page reload.
|