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.
#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.
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.
#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.
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>
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>
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>
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>
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>
/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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>