Commit graph

66 commits

Author SHA1 Message Date
Stephen Dennis
5a42ea2837 fix(color): honour no_flash on live co_render_* paths (#1935)
g_no_flash was only checked inside convert_color/ColorTransitionANSI,
which no longer sits on the output path. Live rendering uses
co_render_ansi{16,256}/truecolor, which always emitted SGR 5 for blink.

Thread a bNoFlash argument through those renderers (same shape as
bNoBleed). net.cpp passes g_no_flash; other callers pass 0. Regenerate
color_ops.c from color_ops.rl.

Unit tests: blink emits SGR 5 with bNoFlash=0 and not with bNoFlash=1.
Dead convert_color left for a separate cleanup pass.
2026-08-01 19:41:21 -06:00
Stephen Dennis
63aac9bc21 fix(proxy): WebSocket frag/UTF-8 and grpc-web parse fail-closed (#1886 #1887)
#1886: reject orphan CONTINUATION and new TEXT/BINARY while reassembly is
pending (1002); validate TEXT UTF-8 on complete and assembled messages
(1007); clear fragOpcode when reassembly finishes.  Mirrors engine #792.

#1887: gate every grpc-web RPC on ParseFromString success; return
INVALID_ARGUMENT (3) on truncated or malformed bodies instead of running
with default-valued requests.

proxy_regression covers fragmentation, UTF-8, and ParseFromString rejects.
2026-07-31 09:24:27 -06:00
Stephen Dennis
337b20b6d3 fix(hydra/tls): defer WILL EOR until handshake; cap TLS egress (#1846 #1847)
TLS telnet accept sent WILL EOR via safeWrite before setFrontDoorTls, so
the first server bytes were plaintext IAC and TLS clients aborted.  Defer
negotiation and banner until processIncoming reports established.

TLS safeWrite bypassed the #1096 256 KiB slow-client cap on plain/cipher
queues; a non-reading peer could grow until OOM or IoBuffer throw.  Cap
combined backlog, close only that front door, and catch alloc failures.
2026-07-31 07:26:48 -06:00
Stephen Dennis
c46fb0a5b2 chore(int64): mail_selector, sort keys, and remaining product atoi (#1402)
- Widen mail_selector low/high/days to int64_t (engine + module headers).
- mail_mod message-list parse: atol → mux_atoi64.
- color_ops sort: parse_i64 via strtoll (regenerate color_ops.c).
- DBT TINYMUX_DBT_PAD env: strtoll instead of atoi.
- Hydra proxy session timestamps: std::atoll (time_t-width).

Product softcode/server path is free of C atoi/atol and int sinks after
mux_atoi64. Offline convert/lexers and mux_atol unit tests stay as-is.
2026-07-28 19:46:25 -06:00
Stephen Dennis
52f8c08eb9 fix(hydra): Pass 10 gRPC residual — caps, encoding, PID (#1265–#1269)
#1265: Bound WorkQueue to MAX_PENDING=1024 (blocking enqueue) and wait on
futures in GameSession reader so a stream cannot flood the main loop.

#1266: Cap concurrent subscribers per session (MAX_SUBSCRIBERS=8); reject
GameSession/Subscribe/SubscribeGmcp/WS with RESOURCE_EXHAUSTED / close.

#1267: grpc-web SendInput routes through TelnetBridge::convertInput like
native gRPC and WS GameSession.

#1268: Reject gRPC/WS/grpc-web input lines above MAX_INPUT_LINE_LENGTH
(8192, shared with front-door telnet assembly); drop oversized GMCP.

#1269: GetGameStatus returns host PIDs only for admin accounts; any
authenticated session still sees running/up.

#1270: proxy_regression covers subscriber cap, line-limit constants,
work-queue cap constant, and convertInput non-UTF8 target.
2026-07-25 19:29:20 -06:00
Stephen Dennis
76504ffae0 fix(hydra): address review follow-ups on #1100/#1101/#1102
From the PR review of the Pass 4 Hydra Mediums:

#1100 — clamp scrollback_lines.  makeScrollback() fed config_.scrollbackLines
straight into ScrollBack's up-front buffer_.resize(cap); a negative value
parses to SIZE_MAX (std::stoul) and would OOM/throw one vector per session,
and the config was previously inert.  Clamp to MAX_SCROLLBACK_LINES (1M).

#1100 — document the max_sessions structural no-op.  One session per account
is enforced by the reuse-existing / restore-saved paths above the guard, so
countSessionsForAccount() is always 0 there and the check never refuses.
Rewrite the comment to say so honestly (defense-in-depth backstop, not a
tunable policy — multiple connections are links within the one session), so
operators are not misled into expecting max_sessions_per_account > 1 to work.

#1101 — wire the SB-overflow disconnect.  appendSb capped gmcpBuf/otherSBBuf
and set sbOverflow, but no production caller read the flag, so an overflowing
peer stayed connected and its post-reset bytes desynced into the regular
stream.  Both onFrontDoorData and onBackDoorData now close the connection when
telnetState.sbOverflow is set (and return without further touching fd/link).

#1102 — fix the loopback allow-list for IPv6.  clientIp carries a ":port"
suffix and brackets IPv6 ("[::1]:port"), so `ip == "127.0.0.1"` / `== "::1"`
never matched — IPv4 passed only via the "127." prefix and IPv6 loopback was
wrongly 403'd.  Add isLoopbackClientIp() that strips the port/brackets and
matches 127.0.0.0/8, ::1, and v4-mapped loopback (host-extraction logic
unit-tested standalone across IPv4/IPv6/bare/mapped/negative cases).

Full proxy build (GRPC=1) clean; proxy_regression green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:41:46 -06:00
Stephen Dennis
cb4c101125 fix: resolve Pass 4 Hydra Medium audit defects #1095–#1102
- #1095: WS RSV + control FIN/len≤125 before ext-len (no large PING→PONG)
- #1096: cap writeBuffers_ (256 KiB, close slow FD); subscriber queues drop oldest at 256
- #1097: gRPC/grpc-web login lockout by peer IP; /passwd requires old password
- #1098: RAND_bytes hard-fail; password/scrollback salts via CSPRNG
- #1099: ProcessManager::stopAndWait with SIGKILL escalate; restart + dtor wait/reap
- #1100: scrollback_lines applied on session create/restore; max_sessions_per_account enforced
- #1101: telnet IAC SB reassembly capped at 64 KiB
- #1102: /metrics loopback-only; ListGames/GetGameStatus require session

proxy_regression: ok (RSV, large PING, SB cap + prior WS cases)
hydra: builds with GRPC=1
2026-07-24 18:22:17 -06:00
Stephen Dennis
2fc76ae2ae Add Hydra proxy metrics endpoint 2026-03-29 09:43:06 -06:00
Stephen Dennis
9b11371260 Fix Hydra telnet negotiation state tracking 2026-03-29 02:24:48 -06:00
Stephen Dennis
bbc2992e95 Fix Hydra telnet negotiation correctness issues
Split charsetPayload into separate request/accepted fields so both
survive in a single read.  Suppress duplicate WILL responses when the
server echoes back DO after an unsolicited WILL on connect.  Add
regression tests for split IAC EOR and combined charset subneg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 02:18:57 -06:00
Stephen Dennis
1f73fbb8bf Propagate Hydra end-of-record boundaries 2026-03-29 02:12:03 -06:00
Stephen Dennis
7eb4552dc5 Track Hydra backdoor charset negotiation 2026-03-29 01:55:46 -06:00
Stephen Dennis
8f25f33cd9 Advertise Hydra backdoor telnet capabilities 2026-03-29 01:51:57 -06:00
Stephen Dennis
abe92b5d0a Handle Hydra backdoor TTYPE negotiation 2026-03-29 01:49:24 -06:00
Stephen Dennis
1baa843572 Fix Hydra stream translation boundaries 2026-03-29 01:43:55 -06:00
Stephen Dennis
b0cf2049e8 Strip all telnet sequences from back-door game output
The telnet parser was leaking IAC WILL/DO/WONT/DONT and non-GMCP
subnegotiation bodies into the regular text stream on the back-door
path.  This caused raw telnet bytes (e.g., FF FB 19 = IAC WILL
TERMINAL-TYPE) to reach GameOutput.text and fail proto UTF-8
validation.

- Add stripTelnet flag to splitGmcp; back-door path passes true
- Handle WONT/DONT (previously only WILL/DO were recognized)
- Add InOtherSB/InOtherSBIAC states to consume non-GMCP subneg
  bodies instead of falling back to Normal after the option byte
- Front-door path unchanged (telnet sequences still pass through)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 03:58:49 +00:00
Stephen Dennis
fdeaf228d4 Consolidate UTF-8 helpers into utf8_utils.h
Move issueTypeName() and sanitizeProtoTextForLog() from duplicated
static functions in session_manager.cpp and grpc_server.cpp into
utf8_utils.h. Remove unused parameter from truncated lambda.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:28:02 +00:00
Stephen Dennis
51df4b3322 Fix Hydra UTF-8 output handling and diagnostics 2026-03-28 18:19:56 -06:00
Stephen Dennis
5270fee1f7 Fix Hydra TLS front-door writes and proto Makefile race 2026-03-28 17:59:28 -06:00
Stephen Dennis
fd2ec4cac0 Add /passwd command to Hydra telnet interface
Wires up AccountManager::changePassword() which was implemented but
not exposed. Requires TLS. Updates session scrollback key on success.
Added to command help text.

Note: gRPC clients (Win32 GUI, Console) need a ChangePassword RPC
added to hydra.proto for client-side /hpasswd support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:28:40 -06:00
Stephen Dennis
d0f427fa6f Fix duplicate game links and stale postWrite calls in Hydra
/connect now rejects duplicates to the same game (if active/connecting/
reconnecting) and reuses dead link slots instead of always appending.

Scrollback replay callbacks switched from raw postWrite() to safeWrite()
— three instances of the same D-1 bug pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:22:12 -06:00
Stephen Dennis
72f61943b7 Write buffer: offset tracking instead of O(n) erase on partial writes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:34:35 -06:00
Stephen Dennis
23e8d7fc82 Fix Hydra write path: non-blocking send with event-driven buffering (D-1)
safeWrite() now does immediate ::send() with EAGAIN/partial fallback
to per-connection write buffers drained on EPOLLOUT. No polling or
sleeps — purely event-driven.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:22:24 -06:00
Stephen Dennis
f70bb68910 Hydra deployment: fix proto drift, add gRPC to Makefile, document issues
- Fix mutable_system_notice() -> mutable_notice() to match hydra.proto
- Add gRPC/protobuf support to standalone Makefile (GRPC=1 by default)
- Document deployment issues found during first standup:
  D-1: GANL postWrite discards data (writes never reach client)
  D-2: Proto field name drift (fixed)
  D-3: NGINX stream port conflict with Hydra telnet listener

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:05:02 +00:00
Stephen Dennis
dde45b2ade Consolidate duplicate base64 into shared base64.h/cpp
Moved base64 encode/decode from websocket.cpp and grpc_web.cpp into a
single shared implementation.  Three overloads: raw bytes, std::string
encode, and std::string decode.  Removed declarations from grpc_web.h.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:35:17 -06:00
Stephen Dennis
793809093c Add production deployment support for Hydra proxy
Systemd:
- hydra.service with security hardening (NoNewPrivileges, ProtectSystem,
  PrivateTmp), restart-on-failure, env file for master key, SIGHUP reload

Health check:
- GET /healthz on grpc-web listeners returns HTTP 200 "ok"
- Documented in hydra.conf.example for ALB/NLB configuration

Graceful drain:
- On SIGTERM, sessions are notified and flushed, gRPC server stops
  accepting new RPCs, then a 3-second drain loop flushes pending writes
  while rejecting new connections
- TimeoutStopSec=10 in systemd unit gives margin beyond the 3s drain

Build hardening:
- -fstack-protector-strong, -D_FORTIFY_SOURCE=2, -Wformat-security
- Full RELRO: -Wl,-z,relro,-z,now
- Existing -pie retained

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:30:36 -06:00
Stephen Dennis
01830e3082 Fix LBUF_SIZE discrepancy, rate limit pruning, insecure gRPC binding
H-8: LBUF_SIZE discrepancy (8000 vs 32768)
- Override color_ops.h fallback to match engine's LBUF_SIZE=32768
- TelnetBridge: all three methods (ingestGameOutput, renderForClient,
  charsetEncodeFromUtf8) now use heap-allocated buffers scaled to input
  size, with LBUF_SIZE as minimum capacity

M-10: IP-based rate limit bypass via pruning
- Pruning now removes expired accountCreateTimes entries first, then
  only erases the IP tracker if accountCreateTimes is also empty.
  Previously, pruning cleared the entire entry, resetting the 1-hour
  account creation window.

L-6: gRPC insecure listener restricted to loopback
- Without TLS cert/key, GrpcServer::start() now rejects non-loopback
  bind addresses (must be 127.0.0.1, [::1], or localhost).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:26:49 -06:00
Stephen Dennis
79eb9e499e Fix scrollback load in telnet attach path to use dbPersistId
The front-door session-resume path loaded scrollback via the rotatable
persistId instead of the stable dbPersistId.  After a gRPC re-auth
rotated the token, a subsequent telnet login would miss the persisted
scrollback still stored under the original DB row ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:07:05 -06:00
Stephen Dennis
eae87e864f Fix token rotation cascade, gRPC rate limit, TLS fail-fast
Fixes from code review of the previous two commits:

1. High: Token rotation no longer destroys scrollback/link state.
   Added dbPersistId to track the SQLite row's primary key separately
   from the in-memory API token. Rotation only changes the in-memory
   persistId; the database row (and its CASCADE-linked scrollback) is
   untouched. flushSession and deleteSession use dbPersistId for all
   SQLite operations.

2. Medium: Account creation rate limit now covers gRPC native
   (extracts IP from ServerContext::peer()) and grpc-web (uses
   FrontDoorState::clientIp). createAccountAndGetSession takes a
   clientIp parameter; checkAccountCreateRate/recordAccountCreate
   moved to public interface.

3. Medium: Partial gRPC TLS config (one of cert/key set, other
   missing) now fails at config parse time. If TLS is configured
   and startup fails, Hydra exits rather than silently falling back
   to insecure credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:03:11 -06:00
Stephen Dennis
6a449179c5 Hydra proxy: session tokens, gRPC TLS, remaining review fixes
High:
- H-4: Session token TTL (session_token_ttl, default 24h) and rotation
  on re-authentication. Expired tokens rejected in findByPersistId.
- H-5: gRPC TLS support via grpc_tls_cert/grpc_tls_key config options.
  Warning logged when using insecure credentials.

Medium:
- M-1: Replace deprecated getpass() with termios echo-disable on POSIX
- M-3: O(1) findByPersistId via unordered_map persistIdIndex_
- M-4: Account creation rate-limited to 2/hour per IP
- M-6: strerror_r portability guard for GNU vs XSI semantics
- M-7: GMCP cache capped at 64 packages per session
- M-8: OutputItem::render uses heap buffer (4x input + 256) instead of
  fixed 8000-byte stack buffer

Low:
- L-3: SIGHUP log rotation via logReopen() (for logrotate integration)
- L-4: Remove dead stub files front_door.cpp/h, back_door.cpp/h
- L-5: Adaptive event loop poll: 10ms when active, ramps to 100ms idle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:51:37 -06:00
Stephen Dennis
b42aae8fb5 Harden Hydra proxy for internet-facing deployment
Security fixes from code review (ISSUES.md):
- TLS policy: allow_plaintext (default no), credential commands always
  require TLS, tls_required per game block (default yes)
- Admin authorization on /start /stop /restart and gRPC process RPCs
- CORS wildcard replaced with configurable cors_origin list
- Unbounded buffer DoS: cap lineBuf (8KB), httpBuf (1MB), WS fragBuf
- Constant-time password comparison via CRYPTO_memcmp
- PBKDF2 key derivation uniformly (replaces lossy crypt_r on POSIX)
- Session idle/detached timeout reaping in runTimers
- IP tracker map periodic pruning
- Content-Length stoul wrapped in try/catch
- ListenConfig bools initialized

Bug fixes:
- handleGrpcWebRequest used undeclared 'handle' (should be fd.handle)
- setFrontDoorTls called before front-door entry existed in map

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:41:51 -06:00
Stephen Dennis
36fe493e92 Implement WsGameSession server-side integration
Step 5/7 of WebSocket GameSession transport. Five integration points:

a) Handshake discrimination: after wsProcessHandshake(), check
   isGameSession and switch proto to WsGameSession, skip banner.
b) First-message auth: validate session_id from SetPreferences,
   register subscriber, replay GMCP cache, apply preferences.
   Send system_notice + close on auth failure.
c) ClientMessage dispatch: input_line → safeWrite to active link,
   ping → pong sentinel, preferences → renderFormat/NAWS/ttype,
   gmcp → forward to active link (port of grpc_server.cpp logic).
d) drainWsGameSessions(): main-loop poll that drains subscriber
   queues, serializes ServerMessage, sends as binary WS frames.
   Called each iteration after runTimers().
e) onFrontDoorClose: unsubscribe from OutputQueue. Detach guard
   updated to fds.empty() && !hasSubscribers() so subscriber-only
   sessions (gRPC or WsGameSession) stay attached.

WsGameSession handles live in frontDoors_ map but NOT in
session.frontDoors — invisible to sendToClient() and the text
fan-out loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:32:16 -06:00
Stephen Dennis
dd13abe370 Port Hydra proxy to compile on Windows
hydra_log.cpp: localtime_r → _localtime64_s on Win32.

session_manager.cpp: Remove stale sys/socket.h include (unused,
GANL abstracts all networking).

hydra_main.cpp: Replace sigaction with SetConsoleCtrlHandler on
Win32. Replace getpass() with console API readPassword(). Guard
recv() call with platform-appropriate types.

account_manager.cpp: Replace crypt_r (glibc-only) with
PBKDF2-HMAC-SHA256 via OpenSSL on Win32. Guard file permission
checks (S_IROTH/S_IWOTH) as POSIX-only. Use CreateFileA/WriteFile
for master key generation on Win32.

process_manager.cpp/h: Full Win32 implementation using
CreateProcess, TerminateProcess, WaitForSingleObject,
GetExitCodeProcess. HANDLE instead of pid_t. CTRL_BREAK_EVENT
for graceful shutdown instead of SIGTERM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:38:22 -06:00
Stephen Dennis
83136cec4f Fix all four Hydra proxy issues
1. GANL Integration — closed, already fully GANL-based.

2. Status Dumps — add SessionManager::dumpStatus() triggered by
   SIGUSR1. Logs session/link/scrollback state and metrics.

3. Duration Strings — add parseDuration() supporting s/m/h/d
   suffixes. Applied to all timeout config fields.

4. Scrollback Re-encryption — add ScrollBack::reencryptInDb()
   that decrypts with old key and re-encrypts with new key.
   Wire into changePassword() for all sessions owned by the
   account. Signature updated to accept old key and return new.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:28:02 -06:00
Stephen Dennis
4d18e7bc7f Add GMCP state cache with replay on client attach
Hydra now caches the last GMCP payload per package (e.g. Char.Vitals,
Room.Info) in HydraSession::gmcpCache. When a new front-door or gRPC
subscriber attaches to an existing session, the cached GMCP state is
replayed so the client's vitals bar, room info, etc. aren't blank
after reconnect.

- session_manager.h: gmcpCache field (map<string, string>) on HydraSession,
  replayGmcpCache() method declaration
- session_manager.cpp: cache updated on every incoming GMCP from game,
  replayed to telnet front-doors in showGameMenu(), replayed to gRPC
  subscribers via replayGmcpCache()
- grpc_server.cpp: GameSession handler calls replayGmcpCache() after
  subscriber registration via work queue

Core.KeepAlive synthesis was already implemented by the Ubuntu agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:15:37 -06:00
Stephen Dennis
d2c762523f Fix SetPreferences overwrite, GetScrollBack color, terminal_type
Three remaining review findings:

1. ColorFormat enum: added COLOR_UNSPECIFIED = 0 as proto3 default.
   Existing values shifted to 1-5.  SetPreferences handler skips
   color update when COLOR_UNSPECIFIED (resize-only updates no longer
   reset color to TrueColor).  Subscribe/GetScrollBack treat 0 as
   "server default (TrueColor)".

2. GetScrollBack now renders PUA text at the requested color_format
   using OutputItem::render() during replay.

3. terminal_type from SetPreferences is stored in HydraSession for
   future TTYPE forwarding to back-door games.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:01:14 -06:00
Stephen Dennis
ca97aa48e8 Fix five review findings: stream race, IAC escape, O(N) memory, strerror
1. GameSession concurrent Write race: pong replies are now queued
   through the subscriber's output queue instead of written directly
   from the reader thread.  renderFormat updates are protected by
   the output-queue mutex.

2. IAC escaping in telnet_utils.h: buildGmcpFrame() and buildNawsFrame()
   now escape 0xFF payload bytes as IAC IAC per RFC 854.

3. O(N) scrollback memory check replaced with atomic global counter.
   SessionManager::globalScrollbackBytes_ is updated incrementally on
   each append (O(1)) instead of scanning all sessions.

4. strerror() replaced with strerror_r() in safeWrite for thread safety.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:51:27 -06:00
Stephen Dennis
706800347f Enforce maxScrollbackMemoryMb with per-session memory tracking
ScrollBack now tracks approximate memory usage (sum of text + source
string sizes) via memoryBytes().  Updated on every append — adds new
line size, subtracts evicted line size when the ring buffer wraps.

SessionManager checks the global total across all sessions before
each append in onBackDoorData().  When the configured limit is hit,
a LOG_WARN fires (once per crossing).  The ring buffer's fixed
capacity continues to evict oldest entries, bounding per-session
growth.  The global check prevents runaway memory from many active
sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:29:59 -06:00
Stephen Dennis
c0fce9d267 Narrow scrollback flush window for sessions with gRPC subscribers
Sessions with active gRPC subscribers now flush scrollback every 15s
instead of 60s, reducing the crash-loss window from ~60s to ~15s.
Sessions without subscribers keep the 60s interval to avoid unnecessary
disk I/O.  Combined with the existing flush-on-detach and
flush-on-shutdown, the OutputQueue persistence gap is mitigated to
an acceptable level for ungraceful crashes only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:22:48 -06:00
Stephen Dennis
1b3a6fc85f Add GMCP synthesis: Core.Hello, Core.KeepAlive, Hydra.Links
Three synthesized GMCP messages:

1. Core.Hello — sent to the game on back-door connect, identifying
   Hydra as the client proxy with version info.

2. Core.KeepAlive — sent every 60s to active GMCP-enabled game links,
   preventing idle disconnection when the player is connected to Hydra
   but not actively typing.

3. Hydra.Links — pushed to GMCP-enabled front-doors whenever link
   state changes (connect, disconnect, reconnect).  Provides structured
   JSON with link number, game name, state, and active flag so clients
   can build a link status UI without parsing text messages.

Extracted sendHydraLinksGmcp() helper to avoid duplication across
the three call sites (connect, disconnect/reconnect, dead).

Also fixes s.id → s.persistId reference from the naming cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:20:24 -06:00
Stephen Dennis
9033f3dac0 Add terminal size to Subscribe for legacy/grpc-web clients
SessionRequest now carries terminal_width and terminal_height fields
(proto fields 3-4).  When a Subscribe RPC provides non-zero values,
the server forwards NAWS to the active game back-door link so the
game renders at the correct width.

Implemented in both native gRPC (grpc_server.cpp) and grpc-web
(session_manager.cpp handleGrpcWebRequest) Subscribe handlers.
HTML5 client sends approximate terminal dimensions based on window
size in _startSubscribe().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:15:48 -06:00
Stephen Dennis
348d8f500c Standardize session ID naming across Hydra proxy
Three ID types existed with confusing names:
- HydraSession::id (uint64, in-memory) vs persistId (string, SQLite)
- FrontDoorState::sessionId (uint64) looked like proto session_id (string)
- SavedSession::id (string) same value as persistId but named differently

Renames:
- HydraSession::id -> HydraSession::internalId
- FrontDoorState::sessionId -> FrontDoorState::internalSessionId
- BackDoorMapEntry::sessionId -> BackDoorMapEntry::internalSessionId
- SavedSession::id -> SavedSession::persistId
- saveSession/deleteSession parameter: sessionId -> persistId

No behavioral change. All internal uses updated across session_manager
and account_manager.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:14:20 -06:00
Stephen Dennis
541d842887 Defer link reconnection until player authenticates after restart
Restored sessions no longer eagerly reconnect back-door links.
Instead, link configuration is saved in pendingLinksJson and links
are reconnected when the player logs in (telnet or gRPC), which also
provides the scrollback encryption key.

This closes the scrollback gap: previously, game output received
between Hydra restart and player login could not be flushed to
SQLite (no encryption key), so a second crash would lose it.  Now
no output accumulates without a key because links don't reconnect
until the key is available.

On login, the session also loads persisted scrollback from SQLite
(now that the key is available) and notifies the player that saved
links are reconnecting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:09:44 -06:00
Stephen Dennis
bc01d39b9c Centralize GMCP and NAWS frame builders in telnet_utils.h
New shared header proxy/telnet_utils.h provides:
- telnet:: namespace with IAC, SB, SE, WILL, DO, GMCP, NAWS constants
- buildGmcpFrame(payload) — IAC SB GMCP payload IAC SE
- buildNawsFrame(width, height) — IAC SB NAWS w h IAC SE

Eliminates duplicate manual frame construction in grpc_server.cpp
(GMCP 6 lines → 1, NAWS 10 lines → 1) and replaces the static
helper in session_manager.cpp.  Local T_* constants now alias the
shared telnet:: namespace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:04:42 -06:00
Stephen Dennis
3dd350fafd Replace all raw send() with GANL postWrite via safeWrite helper
Eliminate 19 raw send() calls across session_manager.cpp and
grpc_server.cpp.  All writes now go through engine_.postWrite()
via a safeWrite() helper method, which handles:
- Non-blocking I/O (GANL buffers if socket not ready)
- Partial write completion
- Thread-safe write ordering through GANL's write queue

ReplayContext extended with engine pointer so scroll-back replay
callbacks can also use the safe write path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:59:47 -06:00
Stephen Dennis
8237a3abd1 Wire TLS listener config into Hydra front-door infrastructure
Previously hydra.conf parsed cert= and key= for TLS listeners but
hydra_main.cpp ignored them, creating plain listeners only.

Now:
- Initialize OpenSSLTransport with cert/key from first TLS listener
- Create TLS-tagged listener variants (telnet+tls, websocket+tls, etc.)
- On Accept for TLS listeners, create server-side TLS session context
  and mark the front-door connection for TLS
- FrontDoorState gains tlsTransport and tlsEstablished fields
- SessionManager::setFrontDoorTls() for hydra_main to call

Also update client/ISSUES.md with recently fixed items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:58:02 -06:00
Stephen Dennis
634ce31e94 Defer color rendering to per-subscriber read time
OutputQueue now stores PUA-encoded UTF-8 instead of pre-rendered
TrueColor ANSI.  Each SubscriberQueue has a RenderFormat preference
(TrueColor/256/16/PUA/Plain) and OutputItem::render() converts at
read time via the appropriate co_render_* function.

This enables different gRPC clients on the same Hydra session to
receive different color formats — e.g., a terminal client getting
TrueColor while a bot gets PLAIN text.  Previously all subscribers
received the same pre-rendered TrueColor regardless of capability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:33:10 -06:00
Stephen Dennis
d0d5d05e4a Fix gRPC subscriber queue stealing across Hydra sessions
Replace the single shared OutputQueue per session with per-subscriber
queues. Each Subscribe, SubscribeGmcp, or GameSession call registers
a SubscriberQueue via addSubscriber(wantsOutput, wantsGmcp). Producers
replicate items to all active subscriber queues via pushOutput() and
pushGmcp(). Each consumer drains only its own queue.

Previously, all consumers popped from the same std::queue, causing
the first consumer to wake to steal items from other subscribers.
Two gRPC clients on the same Hydra session would randomly miss
game output and GMCP messages.

- session_manager.h: SubscriberQueue struct, addSubscriber/
  removeSubscriber/pushOutput/pushGmcp/hasSubscribers methods
- session_manager.cpp: producers call pushOutput/pushGmcp under lock
- grpc_server.cpp: GameSession, Subscribe, SubscribeGmcp each register
  their own SubscriberQueue and drain from it exclusively

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:22:12 -06:00
Stephen Dennis
621a80ba6e Fix Connect dialog label visibility and layout
Fix two ISSUES.md bugs:

1. resumeSavedSession() now preserves original created/lastActivity
   timestamps from saved data instead of resetting to time(nullptr).
   This was already fixed in restoreAllSessions() but the interactive
   resume path was missed.

2. flushSession() now remaps activeLink when compacting dead links
   from links_json.  Previously the saved activeLink index could point
   past the compacted array, silently switching the user to link 1
   after a restart instead of preserving their active link choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:20:11 -06:00
Stephen Dennis
0f8d30a3a1 Enforce connection rate limits and login lockout
Seven of eight config resource limits now enforced:

- maxConnectionsPerIp: reject new connections from IPs at the limit
- connectRateLimit: reject if >N connections/minute from same IP
- failedLoginLockout: lock out IP after N consecutive failed logins
- failedLoginLockoutMinutes: lockout duration
- maxFrontDoorsPerSession: reject attachment if session is full
- maxLinksPerSession: already enforced (connectToGame)

Per-IP tracking via IpTracker: connection count, connect timestamps,
failed login count, lockout expiry.  Client IP extracted from GANL's
IoEvent.remoteAddress on accept and stored in FrontDoorState for
cleanup on close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:18:25 -06:00