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>
20 KiB
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:
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 ofquery_statusso 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
\x00bytes (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.
- Reject empty / whitespace-only
- Trim trailing whitespace from the joined body. Otherwise return verbatim.
- Existing
RconError/RconAuthErrorexception 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:
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 columnsid,user_id,server_id,command (Text),reply (Text, server_default=""),is_error (Boolean, server_default="0"),created_at; plusop.create_index("ix_cmdhist_user_server_id", ...).downgrade(): drop index then table.test_alembic_migrations.pyauto-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 insideexecute_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
CommandHistoryrow (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.
- Success with reply →
- On
ValueErrorfrom 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.
limitclamped 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
<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):
<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.htmlis the single source of truth for line layout — same template, same look, whether the line came from this session or last week. hx-indicatorgives visible feedback during slow commands (achangelevelcan sit at ~10s+).maxlength="1000"on the input mirrors the server-side cap.- The
hx-on::after-requestinline 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 thehx-on::after-requestscroll. - On first focus of the input: lazy-fetch
/servers/<id>/console/history?limit=50and 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:afterRequestwith 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_commandhappy path, single-packet replyexecute_commandmulti-packet reply (>4096 B) reassembled in orderexecute_commandempty reply (server returns only the sentinel)execute_commandbad password →RconAuthErrorexecute_commandsocket 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,
CommandHistoryrow inserted withreplypopulated andis_error=False - empty RCON reply → 200, fragment renders
(no reply), history row inserted withreply="",is_error=False - RCON error (mock
execute_commandto raise) → 200, error fragment, history row inserted withis_error=Trueand the exception message inreply - empty/oversized command (validation error before wire) → 200, error fragment, no history row
- CSRF token missing → rejected
GET /console/historyreturns newest-firstGET /console/history?before=<id>paginates correctlyGET /console/history?limit=10000is clamped to ≤200
test_page_routes.py (extend existing if present, otherwise add):
server_detailreturns the last 50CommandHistoryrows 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 justserver_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
pytest l4d2web/tests/test_rcon.py l4d2web/tests/test_console_routes.py l4d2web/tests/test_alembic_migrations.py— unit + migration tests pass.- 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. - Send
cvarlist— a large multi-packet response — and confirm the full output reassembles, not truncated. - Send
say hello— transcript shows> say hellofollowed by(no reply)in muted text; the line appears in the Server Log panel above. - 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. - Send an invalid command (e.g.
nonsense_cvar) — reply renders normally (RCON tolerates unknown commands). - Send a command with embedded null bytes (via curl, since the browser strips them) — returns 200 with an error fragment, no history row.
- Send a 2000-char command — rejected with an error fragment, no history row.
- Reload the page — the transcript reappears identical to before,
showing the same
> status,> say hello,> nonsense_cvarlines with their replies, scrolled to the bottom. Errors are still red. - Focus the input, press ArrowUp — the previous command reappears. ArrowDown restores the empty state.
- Send 60+ commands, then ArrowUp past the in-memory page boundary — older commands load on demand.
- Stop the server, try to send a command — surfaces as a styled
console-errorline ("connect failed") rather than a 500; a history row IS inserted withis_error=True, so the error replays on next page load. - 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. - 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.