mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
stoi + uint16_t cast wrapped -1/65536 to unexpected ports. Parse with stol, require 1..65535, fail loadConfig with a clear error for listeners and game backends.
875 lines
36 KiB
C++
875 lines
36 KiB
C++
#include "telnet_bridge.h"
|
|
#include "telnet_stream.h"
|
|
#include "telnet_utils.h"
|
|
#include "websocket.h"
|
|
#include "config.h"
|
|
#include "session_manager.h" // HydraSession::OutputQueue caps (#1266/#1268)
|
|
#include "work_queue.h" // WorkQueue::MAX_PENDING (#1265)
|
|
#ifdef GRPC_ENABLED
|
|
#include "hydra.pb.h"
|
|
#endif
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
[[noreturn]] void fail(const std::string& message) {
|
|
std::cerr << "proxy_regression: " << message << '\n';
|
|
std::exit(1);
|
|
}
|
|
|
|
void expect(bool condition, const std::string& message) {
|
|
if (!condition) {
|
|
fail(message);
|
|
}
|
|
}
|
|
|
|
bool isAscii(const std::string& s) {
|
|
return std::all_of(s.begin(), s.end(), [](unsigned char ch) {
|
|
return ch < 0x80;
|
|
});
|
|
}
|
|
|
|
std::string bytes(std::initializer_list<unsigned int> values) {
|
|
std::string out;
|
|
out.reserve(values.size());
|
|
for (unsigned int value : values) {
|
|
out.push_back(static_cast<char>(value));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void testSplitGmcpAcrossReads() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
const std::string chunk1 = "look\r\n" + bytes({0xff, 0xfa, 0xc9}) + "Core.Hello ";
|
|
const std::string chunk2 = "{}" + bytes({0xff, 0xf0});
|
|
|
|
splitTelnetStream(chunk1.data(), chunk1.size(), state, regular, gmcp, signals);
|
|
expect(regular == "look\r\n", "chunk1 regular text mismatch");
|
|
expect(gmcp.empty(), "chunk1 should not complete GMCP");
|
|
expect(state.state == TelnetParseState::InGmcpSB,
|
|
"chunk1 should leave parser inside GMCP subnegotiation");
|
|
|
|
splitTelnetStream(chunk2.data(), chunk2.size(), state, regular, gmcp, signals);
|
|
expect(regular == "look\r\n", "chunk2 should not alter regular text");
|
|
expect(gmcp.size() == 1, "chunk2 should complete one GMCP message");
|
|
expect(gmcp[0].payload == "Core.Hello {}",
|
|
"completed GMCP payload mismatch");
|
|
expect(state.state == TelnetParseState::Normal,
|
|
"parser should return to Normal after GMCP");
|
|
}
|
|
|
|
void testSplitTelnetNegotiationAcrossReads() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
const std::string chunk1 = bytes({0xff, 0xfb});
|
|
const std::string chunk2 = bytes({0xc9});
|
|
|
|
splitTelnetStream(chunk1.data(), chunk1.size(), state, regular, gmcp, signals);
|
|
expect(!signals.sawWillGmcp, "partial WILL GMCP should not fire early");
|
|
expect(state.state == TelnetParseState::SawCmd,
|
|
"parser should remember partial WILL command");
|
|
|
|
splitTelnetStream(chunk2.data(), chunk2.size(), state, regular, gmcp, signals);
|
|
expect(signals.sawWillGmcp, "WILL GMCP should be detected across reads");
|
|
expect(state.state == TelnetParseState::Normal,
|
|
"parser should return to Normal after option byte");
|
|
}
|
|
|
|
void testStripNonGmcpSubnegotiationAcrossReads() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
const std::string chunk1 = "A" + bytes({0xff, 0xfa, 0x1f});
|
|
const std::string chunk2 = bytes({0x00, 0x50, 0x00, 0x28, 0xff, 0xf0}) + "B";
|
|
|
|
splitTelnetStream(chunk1.data(), chunk1.size(), state, regular, gmcp, signals, true);
|
|
splitTelnetStream(chunk2.data(), chunk2.size(), state, regular, gmcp, signals, true);
|
|
|
|
expect(regular == "AB", "stripTelnet should remove split NAWS subnegotiation");
|
|
expect(gmcp.empty(), "NAWS test should not create GMCP messages");
|
|
}
|
|
|
|
void testSplitTtypeSignals() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfd}).data(), 2, state, regular, gmcp, signals, true);
|
|
expect(!signals.sawDoTtype, "partial DO TTYPE should not fire early");
|
|
expect(state.state == TelnetParseState::SawCmd,
|
|
"partial DO TTYPE should leave parser in SawCmd");
|
|
|
|
std::string ttypeOpt = bytes({0x18});
|
|
splitTelnetStream(ttypeOpt.data(), ttypeOpt.size(), state, regular, gmcp, signals, true);
|
|
expect(signals.sawDoTtype, "DO TTYPE should be detected across reads");
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfe, 0x18}).data(), 3, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDontTtype, "DONT TTYPE should be detected");
|
|
|
|
const std::string sendChunk1 = bytes({0xff, 0xfa, 0x18, 0x01});
|
|
const std::string sendChunk2 = bytes({0xff, 0xf0});
|
|
splitTelnetStream(sendChunk1.data(), sendChunk1.size(), state, regular, gmcp, signals, true);
|
|
expect(!signals.sawTtypeSend, "partial TTYPE SEND should not fire early");
|
|
splitTelnetStream(sendChunk2.data(), sendChunk2.size(), state, regular, gmcp, signals, true);
|
|
expect(signals.sawTtypeSend, "TTYPE SEND should be detected across reads");
|
|
|
|
expect(buildTelnetCommandFrame(telnet::WILL, telnet::TTYPE)
|
|
== bytes({0xff, 0xfb, 0x18}),
|
|
"WILL TTYPE frame encoding mismatch");
|
|
expect(buildTtypeIsFrame("xterm-256color")
|
|
== bytes({0xff, 0xfa, 0x18, 0x00}) + "xterm-256color" + bytes({0xff, 0xf0}),
|
|
"TTYPE IS frame encoding mismatch");
|
|
|
|
expect(buildTelnetCommandFrame(telnet::DO, telnet::GMCP)
|
|
== bytes({0xff, 0xfd, 0xc9}),
|
|
"DO GMCP frame encoding mismatch");
|
|
expect(buildTelnetCommandFrame(telnet::WILL, telnet::NAWS)
|
|
== bytes({0xff, 0xfb, 0x1f}),
|
|
"WILL NAWS frame encoding mismatch");
|
|
expect(buildCharsetAcceptedFrame("UTF-8")
|
|
== bytes({0xff, 0xfa, 0x2a, 0x02}) + "UTF-8" + bytes({0xff, 0xf0}),
|
|
"CHARSET ACCEPTED frame encoding mismatch");
|
|
expect(buildCharsetRejectedFrame()
|
|
== bytes({0xff, 0xfa, 0x2a, 0x03, 0xff, 0xf0}),
|
|
"CHARSET REJECTED frame encoding mismatch");
|
|
}
|
|
|
|
void testSplitCharsetSignals() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfd}).data(), 2, state, regular, gmcp, signals, true);
|
|
expect(!signals.sawDoCharset, "partial DO CHARSET should not fire early");
|
|
splitTelnetStream(bytes({0x2a}).data(), 1, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDoCharset, "DO CHARSET should be detected across reads");
|
|
|
|
const std::string req1 = bytes({0xff, 0xfa, 0x2a, 0x01, '|'});
|
|
const std::string req2 = "UTF-8|US-ASCII" + bytes({0xff, 0xf0});
|
|
splitTelnetStream(req1.data(), req1.size(), state, regular, gmcp, signals, true);
|
|
expect(!signals.sawCharsetRequest, "partial CHARSET REQUEST should not fire early");
|
|
splitTelnetStream(req2.data(), req2.size(), state, regular, gmcp, signals, true);
|
|
expect(signals.sawCharsetRequest, "CHARSET REQUEST should be detected across reads");
|
|
expect(signals.charsetRequestPayload == "|UTF-8|US-ASCII",
|
|
"CHARSET REQUEST payload mismatch");
|
|
|
|
const std::string accepted = bytes({0xff, 0xfa, 0x2a, 0x02}) + "ISO-8859-1" + bytes({0xff, 0xf0});
|
|
splitTelnetStream(accepted.data(), accepted.size(), state, regular, gmcp, signals, true);
|
|
expect(signals.sawCharsetAccepted, "CHARSET ACCEPTED should be detected");
|
|
expect(signals.charsetAcceptedPayload == "ISO-8859-1",
|
|
"CHARSET ACCEPTED payload mismatch");
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfe, 0x2a}).data(), 3, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDontCharset, "DONT CHARSET should be detected");
|
|
}
|
|
|
|
void testCharsetRequestAndAcceptedInSameRead() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
// CHARSET REQUEST followed immediately by CHARSET ACCEPTED in one read.
|
|
const std::string combined =
|
|
bytes({0xff, 0xfa, 0x2a, 0x01, ';'}) + "UTF-8;US-ASCII" + bytes({0xff, 0xf0})
|
|
+ bytes({0xff, 0xfa, 0x2a, 0x02}) + "CP437" + bytes({0xff, 0xf0});
|
|
splitTelnetStream(combined.data(), combined.size(), state, regular, gmcp, signals, true);
|
|
|
|
expect(signals.sawCharsetRequest, "REQUEST should fire in combined read");
|
|
expect(signals.charsetRequestPayload == ";UTF-8;US-ASCII",
|
|
"REQUEST payload mismatch in combined read");
|
|
expect(signals.sawCharsetAccepted, "ACCEPTED should fire in combined read");
|
|
expect(signals.charsetAcceptedPayload == "CP437",
|
|
"ACCEPTED payload mismatch in combined read");
|
|
}
|
|
|
|
void testSplitEorAcrossReads() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
// IAC in first chunk, EOR_CMD in second chunk.
|
|
splitTelnetStream(bytes({0xff}).data(), 1, state, regular, gmcp, signals, true);
|
|
expect(!signals.sawEor, "partial IAC EOR should not fire early");
|
|
expect(state.state == TelnetParseState::SawIAC,
|
|
"parser should be in SawIAC after lone IAC byte");
|
|
|
|
splitTelnetStream(bytes({0xef}).data(), 1, state, regular, gmcp, signals, true);
|
|
expect(signals.sawEor, "IAC EOR should be detected across reads");
|
|
expect(state.state == TelnetParseState::Normal,
|
|
"parser should return to Normal after split EOR");
|
|
}
|
|
|
|
void testSplitEorSignals() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfd}).data(), 2, state, regular, gmcp, signals, true);
|
|
expect(!signals.sawDoEor, "partial DO EOR should not fire early");
|
|
splitTelnetStream(bytes({0x19}).data(), 1, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDoEor, "DO EOR should be detected across reads");
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfe, 0x19}).data(), 3, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDontEor, "DONT EOR should be detected");
|
|
|
|
splitTelnetStream(bytes({0xff, 0xfe, 0x1f}).data(), 3, state, regular, gmcp, signals, true);
|
|
expect(signals.sawDontNaws, "DONT NAWS should be detected");
|
|
|
|
splitTelnetStream(bytes({0xff, 0xef}).data(), 2, state, regular, gmcp, signals, true);
|
|
expect(signals.sawEor, "standalone EOR should be detected");
|
|
|
|
expect(buildTelnetCommandFrame(telnet::DO, telnet::EOR_OPT) == bytes({0xff, 0xfd, 0x19}),
|
|
"DO EOR option frame encoding mismatch");
|
|
expect(buildTelnetTwoByteCommand(telnet::EOR_CMD) == bytes({0xff, 0xef}),
|
|
"IAC EOR frame encoding mismatch");
|
|
}
|
|
|
|
void testAsciiBridgeConversion() {
|
|
TelnetBridge bridge;
|
|
std::string utf8 = "caf\xc3\xa9 \xe2\x98\x83";
|
|
|
|
std::string toAscii = bridge.convertInput(
|
|
ganl::EncodingType::Utf8,
|
|
ganl::EncodingType::Ascii,
|
|
utf8);
|
|
expect(isAscii(toAscii), "convertInput should produce pure ASCII for ASCII games");
|
|
|
|
std::string rendered = bridge.renderForClient(
|
|
ganl::EncodingType::Ascii,
|
|
ColorDepth::None,
|
|
utf8);
|
|
expect(isAscii(rendered), "renderForClient should produce pure ASCII for ASCII clients");
|
|
}
|
|
|
|
// Build a single masked client→server WebSocket frame.
|
|
std::string maskedFrame(uint8_t finOpcode, const std::string& payload,
|
|
uint8_t m0 = 0x01, uint8_t m1 = 0x02,
|
|
uint8_t m2 = 0x03, uint8_t m3 = 0x04) {
|
|
expect(payload.size() < 126, "maskedFrame helper is short-payload only");
|
|
std::string frame;
|
|
frame.push_back(static_cast<char>(finOpcode));
|
|
frame.push_back(static_cast<char>(0x80 | payload.size())); // MASK + len
|
|
frame.push_back(static_cast<char>(m0));
|
|
frame.push_back(static_cast<char>(m1));
|
|
frame.push_back(static_cast<char>(m2));
|
|
frame.push_back(static_cast<char>(m3));
|
|
const uint8_t mask[4] = {m0, m1, m2, m3};
|
|
for (size_t i = 0; i < payload.size(); i++) {
|
|
frame.push_back(static_cast<char>(
|
|
static_cast<uint8_t>(payload[i]) ^ mask[i % 4]));
|
|
}
|
|
return frame;
|
|
}
|
|
|
|
void testWebSocketMaskEnforcement() {
|
|
WsState ws;
|
|
std::string responses;
|
|
const std::string unmasked = bytes({0x81, 0x02}) + "hi";
|
|
auto messages = wsDecodeFrames(ws, unmasked.data(), unmasked.size(), responses);
|
|
|
|
// #1094: unmasked frames deliver a synthetic CLOSE so callers close the FD.
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"unmasked client frame should deliver CLOSE for teardown");
|
|
expect(responses == wsCloseFrame(1002),
|
|
"unmasked client frame should trigger protocol-error close");
|
|
|
|
WsState maskedWs;
|
|
responses.clear();
|
|
const std::string masked = maskedFrame(0x81, "hi");
|
|
messages = wsDecodeFrames(maskedWs, masked.data(), masked.size(), responses);
|
|
|
|
expect(responses.empty(), "masked frame should not trigger close");
|
|
expect(messages.size() == 1, "masked frame should decode to one message");
|
|
expect(messages[0].opcode == WS_OP_TEXT, "masked frame opcode mismatch");
|
|
expect(messages[0].payload == "hi", "masked frame payload mismatch");
|
|
}
|
|
|
|
// #1886: RFC 6455 fragmentation state + TEXT UTF-8 validation.
|
|
void testWebSocketFragmentationAndUtf8() {
|
|
// Orphan CONTINUATION (no message in progress) → 1002.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
const std::string frame = maskedFrame(0x80, "x"); // FIN + CONTINUATION
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"orphan CONTINUATION should deliver CLOSE");
|
|
expect(responses == wsCloseFrame(1002),
|
|
"orphan CONTINUATION should close with 1002");
|
|
}
|
|
|
|
// TEXT while fragmented message pending → 1002.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
// First fragment: FIN=0 TEXT "he"
|
|
auto m1 = wsDecodeFrames(ws, maskedFrame(0x01, "he").data(),
|
|
maskedFrame(0x01, "he").size(), responses);
|
|
expect(m1.empty() && responses.empty(),
|
|
"first TEXT fragment should not deliver yet");
|
|
// New TEXT while reassembly pending
|
|
const std::string second = maskedFrame(0x81, "no");
|
|
auto m2 = wsDecodeFrames(ws, second.data(), second.size(), responses);
|
|
expect(m2.size() == 1 && m2[0].opcode == WS_OP_CLOSE,
|
|
"TEXT during reassembly should deliver CLOSE");
|
|
expect(responses == wsCloseFrame(1002),
|
|
"TEXT during reassembly should close with 1002");
|
|
}
|
|
|
|
// Valid fragmented TEXT reassembles.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
const std::string f1 = maskedFrame(0x01, "hel");
|
|
const std::string f2 = maskedFrame(0x80, "lo");
|
|
auto m1 = wsDecodeFrames(ws, f1.data(), f1.size(), responses);
|
|
expect(m1.empty(), "partial TEXT fragment holds");
|
|
auto m2 = wsDecodeFrames(ws, f2.data(), f2.size(), responses);
|
|
expect(m2.size() == 1 && m2[0].opcode == WS_OP_TEXT
|
|
&& m2[0].payload == "hello",
|
|
"fragmented TEXT should reassemble to hello");
|
|
expect(responses.empty(), "valid fragments should not close");
|
|
}
|
|
|
|
// Invalid UTF-8 on complete single-frame TEXT → 1007.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
// Lone continuation byte 0x80 is invalid UTF-8.
|
|
const std::string frame = maskedFrame(0x81, bytes({0x80}));
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"invalid UTF-8 TEXT should deliver CLOSE");
|
|
expect(responses == wsCloseFrame(1007),
|
|
"invalid UTF-8 TEXT should close with 1007");
|
|
}
|
|
|
|
// Invalid UTF-8 on assembled fragmented TEXT → 1007.
|
|
// Split multi-byte sequence across fragments: lead C3 in first, missing cont.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
// First: FIN=0 TEXT with incomplete lead 0xC3 (would need 0xA9 for é)
|
|
const std::string f1 = maskedFrame(0x01, bytes({0xC3}));
|
|
// Second: FIN=1 CONTINUATION with invalid byte 0xFF (not a cont)
|
|
const std::string f2 = maskedFrame(0x80, bytes({0xFF}));
|
|
wsDecodeFrames(ws, f1.data(), f1.size(), responses);
|
|
auto m2 = wsDecodeFrames(ws, f2.data(), f2.size(), responses);
|
|
expect(m2.size() == 1 && m2[0].opcode == WS_OP_CLOSE,
|
|
"invalid UTF-8 across fragments should CLOSE");
|
|
expect(responses == wsCloseFrame(1007),
|
|
"invalid fragmented UTF-8 should close with 1007");
|
|
}
|
|
|
|
// Valid multi-byte UTF-8 split across fragments assembles.
|
|
{
|
|
WsState ws;
|
|
std::string responses;
|
|
// U+00E9 é is C3 A9 — split as C3 | A9
|
|
const std::string f1 = maskedFrame(0x01, bytes({0xC3}));
|
|
const std::string f2 = maskedFrame(0x80, bytes({0xA9}));
|
|
wsDecodeFrames(ws, f1.data(), f1.size(), responses);
|
|
auto m2 = wsDecodeFrames(ws, f2.data(), f2.size(), responses);
|
|
expect(m2.size() == 1 && m2[0].opcode == WS_OP_TEXT
|
|
&& m2[0].payload == bytes({0xC3, 0xA9}),
|
|
"valid UTF-8 split across fragments should reassemble");
|
|
expect(responses.empty(), "valid split UTF-8 should not close");
|
|
}
|
|
}
|
|
|
|
// #1093: after a 16-bit extended length, MaskKey must start at index 0.
|
|
// Frame: FIN+TEXT, mask, len=126, length=0x0002, mask 01 02 03 04, "hi" xored.
|
|
void testWebSocketExtLenMaskKey() {
|
|
WsState ws;
|
|
std::string responses;
|
|
const uint8_t m0 = 0x01, m1 = 0x02, m2 = 0x03, m3 = 0x04;
|
|
const std::string frame = bytes({
|
|
0x81, 0xFE, 0x00, 0x02, m0, m1, m2, m3,
|
|
static_cast<uint8_t>('h' ^ m0),
|
|
static_cast<uint8_t>('i' ^ m1)
|
|
});
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(responses.empty(), "ext-len masked TEXT should not close");
|
|
expect(messages.size() == 1, "ext-len masked TEXT should yield one message");
|
|
expect(messages[0].opcode == WS_OP_TEXT, "ext-len opcode TEXT");
|
|
expect(messages[0].payload == "hi", "ext-len payload unmasked correctly");
|
|
}
|
|
|
|
// #1093: after a 64-bit extended length, MaskKey must start at index 0.
|
|
// This is the out-of-bounds case: pre-fix, lenBytesRead was still 8 entering
|
|
// MaskKey, so maskKey[8] (past uint8_t maskKey[4]) was written — an OOB write
|
|
// that UBSan/ASan traps. The 16-bit case above only desyncs framing (index 2
|
|
// is still in-bounds), so this case is what actually guards the memory-safety
|
|
// fix. Frame: FIN+TEXT, mask, len=127, 64-bit length=0x...0002, mask, "hi".
|
|
void testWebSocketExtLen64MaskKey() {
|
|
WsState ws;
|
|
std::string responses;
|
|
const uint8_t m0 = 0x01, m1 = 0x02, m2 = 0x03, m3 = 0x04;
|
|
const std::string frame = bytes({
|
|
0x81, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02,
|
|
m0, m1, m2, m3,
|
|
static_cast<uint8_t>('h' ^ m0),
|
|
static_cast<uint8_t>('i' ^ m1)
|
|
});
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(responses.empty(), "ext-len64 masked TEXT should not close");
|
|
expect(messages.size() == 1, "ext-len64 masked TEXT should yield one message");
|
|
expect(messages[0].opcode == WS_OP_TEXT, "ext-len64 opcode TEXT");
|
|
expect(messages[0].payload == "hi", "ext-len64 payload unmasked correctly");
|
|
}
|
|
|
|
// #1094: client CLOSE must be delivered so the session path can close.
|
|
void testWebSocketCloseDelivered() {
|
|
WsState ws;
|
|
std::string responses;
|
|
// Empty CLOSE, masked, mask all zero.
|
|
const std::string frame = bytes({0x88, 0x80, 0x00, 0x00, 0x00, 0x00});
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"client CLOSE should be delivered");
|
|
expect(responses == wsCloseFrame(1000),
|
|
"client CLOSE should echo close response");
|
|
}
|
|
|
|
// #1095: RSV bits must force protocol close.
|
|
void testWebSocketRsvRejected() {
|
|
WsState ws;
|
|
std::string responses;
|
|
// FIN+TEXT with RSV1 set, masked, empty payload.
|
|
const std::string frame = bytes({0xC1, 0x80, 0x00, 0x00, 0x00, 0x00});
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"RSV frame should deliver CLOSE");
|
|
expect(responses == wsCloseFrame(1002),
|
|
"RSV frame should trigger protocol-error close");
|
|
}
|
|
|
|
// #1095: control frames cannot use extended length (large PING → PONG).
|
|
void testWebSocketLargePingRejected() {
|
|
WsState ws;
|
|
std::string responses;
|
|
// FIN+PING, mask, len=126 (illegal for control).
|
|
const std::string frame = bytes({0x89, 0xFE, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00});
|
|
auto messages = wsDecodeFrames(ws, frame.data(), frame.size(), responses);
|
|
expect(messages.size() == 1 && messages[0].opcode == WS_OP_CLOSE,
|
|
"ext-len PING should deliver CLOSE");
|
|
expect(responses == wsCloseFrame(1002),
|
|
"ext-len PING should trigger protocol-error close");
|
|
}
|
|
|
|
// #1101: unbounded SB reassembly must trip the cap.
|
|
void testTelnetSbCap() {
|
|
TelnetParseState state;
|
|
std::string regular;
|
|
std::vector<TelnetGmcpMessage> gmcp;
|
|
TelnetSignals signals;
|
|
|
|
// IAC SB GMCP then flood payload without SE.
|
|
std::string flood = bytes({0xff, 0xfa, 0xc9});
|
|
flood.append(TELNET_SB_MAX + 64, 'X');
|
|
splitTelnetStream(flood.data(), flood.size(), state, regular, gmcp, signals);
|
|
expect(state.sbOverflow, "SB flood should set sbOverflow");
|
|
expect(state.state == TelnetParseState::Normal,
|
|
"SB overflow should reset parser to Normal");
|
|
expect(state.gmcpBuf.empty(), "SB overflow should clear gmcpBuf");
|
|
expect(gmcp.empty(), "incomplete GMCP after overflow must not deliver");
|
|
}
|
|
|
|
// #1266: subscriber *count* is capped (queue depth was already #1096).
|
|
void testSubscriberCountCap() {
|
|
HydraSession::OutputQueue oq;
|
|
std::vector<std::shared_ptr<HydraSession::SubscriberQueue>> held;
|
|
held.reserve(HydraSession::MAX_SUBSCRIBERS);
|
|
for (size_t i = 0; i < HydraSession::MAX_SUBSCRIBERS; i++) {
|
|
auto [id, sq] = oq.addSubscriber(true, false);
|
|
expect(sq != nullptr, "subscriber within cap should succeed");
|
|
expect(id > 0, "subscriber id should be positive");
|
|
held.push_back(sq);
|
|
}
|
|
auto [id, sq] = oq.addSubscriber(true, true);
|
|
expect(sq == nullptr, "subscriber over cap must return null queue");
|
|
expect(id == -1, "subscriber over cap must return id -1");
|
|
expect(oq.subscribers.size() == HydraSession::MAX_SUBSCRIBERS,
|
|
"map size must stay at MAX_SUBSCRIBERS");
|
|
}
|
|
|
|
// #1268: input line limit is shared with front-door telnet assembly.
|
|
void testInputLineLimitShared() {
|
|
expect(HydraSession::MAX_INPUT_LINE_LENGTH == 8192,
|
|
"MAX_INPUT_LINE_LENGTH is 8192");
|
|
expect(FrontDoorState::MAX_LINE_LENGTH == HydraSession::MAX_INPUT_LINE_LENGTH,
|
|
"front-door MAX_LINE_LENGTH matches session input limit");
|
|
}
|
|
|
|
// #1265: work queue documents a hard pending cap.
|
|
void testWorkQueuePendingCapConstant() {
|
|
expect(WorkQueue::MAX_PENDING == 1024, "WorkQueue::MAX_PENDING is 1024");
|
|
expect(WorkQueue::MAX_PENDING > 0, "WorkQueue pending cap is positive");
|
|
}
|
|
|
|
// #1267-class: convertInput is the authority for non-UTF8 game targets
|
|
// (grpc-web SendInput must call the same path — regression locks the helper).
|
|
void testConvertInputNonUtf8Target() {
|
|
TelnetBridge bridge;
|
|
// High-bit UTF-8 must not pass through as raw bytes to an ASCII game.
|
|
const std::string utf8Euro = "\xE2\x82\xAC"; // U+20AC
|
|
std::string out = bridge.convertInput(
|
|
ganl::EncodingType::Utf8,
|
|
ganl::EncodingType::Ascii,
|
|
utf8Euro);
|
|
expect(isAscii(out), "convertInput to Ascii must yield pure ASCII");
|
|
expect(!out.empty(), "convertInput should produce a fallback for Euro");
|
|
}
|
|
|
|
// #1885: truncated multi-byte UTF-8 must not advance the charset encoder
|
|
// past the end of the input. Lead-only / partial sequences fall back one
|
|
// byte at a time; convertInput and renderForClient both take this path.
|
|
void testTruncatedUtf8CharsetEncode() {
|
|
TelnetBridge bridge;
|
|
|
|
const struct {
|
|
const char* label;
|
|
std::string input;
|
|
} cases[] = {
|
|
{"trailing C2 (2-byte lead)", bytes({0xC2})},
|
|
{"trailing E2 82 (3-byte partial)", bytes({0xE2, 0x82})},
|
|
{"trailing F0 9F 92 (4-byte partial)", bytes({0xF0, 0x9F, 0x92})},
|
|
{"ascii + trailing C2", bytes({'o', 'k', 0xC2})},
|
|
};
|
|
|
|
for (const auto& c : cases) {
|
|
std::string toLatin1 = bridge.convertInput(
|
|
ganl::EncodingType::Utf8,
|
|
ganl::EncodingType::Latin1,
|
|
c.input);
|
|
expect(isAscii(toLatin1) || toLatin1.size() <= c.input.size() + 4,
|
|
std::string("convertInput Latin1 truncated UTF-8 must not OOB: ")
|
|
+ c.label);
|
|
// One replacement per remaining truncated byte, no runaway growth.
|
|
expect(toLatin1.size() <= c.input.size(),
|
|
std::string("convertInput Latin1 should not expand truncated: ")
|
|
+ c.label);
|
|
|
|
std::string rendered = bridge.renderForClient(
|
|
ganl::EncodingType::Latin1,
|
|
ColorDepth::None,
|
|
c.input);
|
|
expect(rendered.size() <= c.input.size() + 16,
|
|
std::string("renderForClient truncated UTF-8 must not OOB: ")
|
|
+ c.label);
|
|
}
|
|
|
|
// Complete multi-byte still approximates (does not crash / hang).
|
|
std::string euro = bridge.convertInput(
|
|
ganl::EncodingType::Utf8,
|
|
ganl::EncodingType::Latin1,
|
|
bytes({0xE2, 0x82, 0xAC}));
|
|
expect(euro.size() == 1, "complete Euro should encode to one Latin1/approx byte");
|
|
}
|
|
|
|
// #1897: ports must not wrap via uint16_t cast of out-of-range stoi values.
|
|
void testPortRangeRejected() {
|
|
auto writeConf = [](const std::string& body) -> std::string {
|
|
char path[] = "/tmp/hydra-cfg-port-XXXXXX";
|
|
int fd = mkstemp(path);
|
|
expect(fd >= 0, "mkstemp for port test");
|
|
expect(write(fd, body.data(), body.size())
|
|
== static_cast<ssize_t>(body.size()),
|
|
"write port fixture");
|
|
close(fd);
|
|
return path;
|
|
};
|
|
|
|
const char* badListen[] = {
|
|
"listen telnet 127.0.0.1:0\n",
|
|
"listen telnet 127.0.0.1:-1\n",
|
|
"listen telnet 127.0.0.1:65536\n",
|
|
"listen telnet 127.0.0.1:99999\n",
|
|
};
|
|
for (const char* body : badListen) {
|
|
std::string path = writeConf(body);
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(!loadConfig(path, cfg, err),
|
|
std::string("OOR listen port must fail: ") + body);
|
|
expect(err.find("port") != std::string::npos,
|
|
std::string("error should mention port: ") + err);
|
|
unlink(path.c_str());
|
|
}
|
|
|
|
// Valid boundaries.
|
|
for (const char* port : {"1", "65535", "4202"}) {
|
|
std::string body = std::string("listen telnet 127.0.0.1:") + port + "\n";
|
|
std::string path = writeConf(body);
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(loadConfig(path, cfg, err),
|
|
std::string("valid port must load: ") + port + " err=" + err);
|
|
expect(cfg.listeners.size() == 1
|
|
&& cfg.listeners[0].port == static_cast<uint16_t>(std::stoi(port)),
|
|
std::string("port value stored: ") + port);
|
|
unlink(path.c_str());
|
|
}
|
|
|
|
// Game backend port.
|
|
{
|
|
std::string body =
|
|
"listen telnet 127.0.0.1:4202\n"
|
|
"game \"t\" {\n"
|
|
" host 127.0.0.1\n"
|
|
" port 65536\n"
|
|
"}\n";
|
|
std::string path = writeConf(body);
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(!loadConfig(path, cfg, err),
|
|
"OOR game port must fail loadConfig");
|
|
unlink(path.c_str());
|
|
}
|
|
{
|
|
std::string body =
|
|
"listen telnet 127.0.0.1:4202\n"
|
|
"game \"t\" {\n"
|
|
" host 127.0.0.1\n"
|
|
" port 2860\n"
|
|
"}\n";
|
|
std::string path = writeConf(body);
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(loadConfig(path, cfg, err),
|
|
std::string("valid game port must load: ") + err);
|
|
expect(cfg.games.size() == 1 && cfg.games[0].port == 2860,
|
|
"game port 2860 stored");
|
|
unlink(path.c_str());
|
|
}
|
|
}
|
|
|
|
// #1895: unknown listen types must fail closed, not become plaintext Telnet.
|
|
void testUnknownListenerTypeRejected() {
|
|
auto writeConf = [](const std::string& listenLine) -> std::string {
|
|
char path[] = "/tmp/hydra-cfg-test-XXXXXX";
|
|
int fd = mkstemp(path);
|
|
expect(fd >= 0, "mkstemp for config test");
|
|
std::string body = listenLine + "\n";
|
|
expect(write(fd, body.data(), body.size())
|
|
== static_cast<ssize_t>(body.size()),
|
|
"write config fixture");
|
|
close(fd);
|
|
return path;
|
|
};
|
|
|
|
// Typo: was silently accepted as plaintext Telnet.
|
|
{
|
|
std::string path = writeConf("listen websockt 127.0.0.1:4203");
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(!loadConfig(path, cfg, err),
|
|
"unknown listen type websockt must fail loadConfig");
|
|
expect(err.find("unknown type") != std::string::npos
|
|
|| err.find("websockt") != std::string::npos,
|
|
"error should mention unknown type");
|
|
unlink(path.c_str());
|
|
}
|
|
|
|
// Empty / garbage type token.
|
|
{
|
|
std::string path = writeConf("listen not-a-proto 127.0.0.1:1");
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(!loadConfig(path, cfg, err),
|
|
"garbage listen type must fail loadConfig");
|
|
unlink(path.c_str());
|
|
}
|
|
|
|
// Known types still load.
|
|
{
|
|
std::string path = writeConf("listen websocket 127.0.0.1:4203");
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(loadConfig(path, cfg, err),
|
|
std::string("valid websocket listen must load: ") + err);
|
|
expect(cfg.listeners.size() == 1 && cfg.listeners[0].websocket
|
|
&& !cfg.listeners[0].tls && !cfg.listeners[0].grpcWeb,
|
|
"websocket listen flags");
|
|
unlink(path.c_str());
|
|
}
|
|
{
|
|
std::string path = writeConf("listen telnet 127.0.0.1:4202");
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(loadConfig(path, cfg, err),
|
|
std::string("valid telnet listen must load: ") + err);
|
|
expect(cfg.listeners.size() == 1 && !cfg.listeners[0].websocket
|
|
&& !cfg.listeners[0].tls && !cfg.listeners[0].grpcWeb,
|
|
"telnet listen is plaintext flags-all-false");
|
|
unlink(path.c_str());
|
|
}
|
|
{
|
|
std::string path = writeConf(
|
|
"listen telnet+tls 127.0.0.1:4202 cert=c.pem key=k.pem");
|
|
HydraConfig cfg;
|
|
std::string err;
|
|
expect(loadConfig(path, cfg, err),
|
|
std::string("valid telnet+tls listen must load: ") + err);
|
|
expect(cfg.listeners.size() == 1 && cfg.listeners[0].tls
|
|
&& !cfg.listeners[0].websocket,
|
|
"telnet+tls flags");
|
|
unlink(path.c_str());
|
|
}
|
|
}
|
|
|
|
#ifdef GRPC_ENABLED
|
|
// #1887: truncated / malformed protobuf must not parse as a default request.
|
|
// The grpc-web dispatcher gates every RPC on ParseFromString; these cases
|
|
// pin that contract without spinning up SessionManager.
|
|
void testMalformedProtobufRejected() {
|
|
const std::string truncated = bytes({0x0A, 0x05, 0x61}); // incomplete string field
|
|
const std::string garbage = bytes({0xFF, 0xFF, 0xFF, 0xFF, 0x0F});
|
|
|
|
hydra::AuthRequest auth;
|
|
expect(!auth.ParseFromString(truncated),
|
|
"truncated AuthRequest must fail ParseFromString");
|
|
expect(!auth.ParseFromString(garbage),
|
|
"garbage AuthRequest must fail ParseFromString");
|
|
|
|
hydra::SessionRequest sess;
|
|
expect(!sess.ParseFromString(truncated),
|
|
"truncated SessionRequest must fail ParseFromString");
|
|
expect(!sess.ParseFromString(garbage),
|
|
"garbage SessionRequest must fail ParseFromString");
|
|
|
|
hydra::ScrollBackRequest scroll;
|
|
expect(!scroll.ParseFromString(truncated),
|
|
"truncated ScrollBackRequest must fail ParseFromString");
|
|
expect(!scroll.ParseFromString(garbage),
|
|
"garbage ScrollBackRequest must fail ParseFromString");
|
|
|
|
// Empty body is a valid empty message for some types — that is OK.
|
|
hydra::Empty empty;
|
|
expect(empty.ParseFromString(std::string()),
|
|
"empty body should parse as Empty");
|
|
}
|
|
#endif
|
|
|
|
// #1286: a producer blocked on a full queue must be releasable.
|
|
//
|
|
// cv_space_ is only notified by processPending(), so after the main loop's
|
|
// final drain nothing wakes a parked producer. ~GrpcServer's Shutdown()
|
|
// then waits for in-flight RPCs that can never finish. stop() is the way
|
|
// out.
|
|
//
|
|
// Note on failure mode: if stop() regresses, the producer stays parked and
|
|
// this case hangs rather than reporting. That is deliberate -- releasing
|
|
// it any other way would need processPending(), which wants a live
|
|
// SessionManager/AccountManager/ProcessManager. A hang here means stop()
|
|
// no longer wakes waiters.
|
|
void testWorkQueueStopReleasesBlockedProducer() {
|
|
WorkQueue q;
|
|
|
|
auto noop = [](SessionManager&, AccountManager&, const HydraConfig&,
|
|
ProcessManager&) { return true; };
|
|
for (size_t i = 0; i < WorkQueue::MAX_PENDING; i++) {
|
|
q.enqueue<bool>(noop);
|
|
}
|
|
expect(q.pending() == WorkQueue::MAX_PENDING,
|
|
"queue fills to exactly MAX_PENDING");
|
|
|
|
// One more must block: there is no space and nothing is draining.
|
|
// Keep the work future: releasing enqueue() is only half of it.
|
|
std::future<bool> work;
|
|
auto producer = std::async(std::launch::async,
|
|
[&q, &noop, &work] { work = q.enqueue<bool>(noop); });
|
|
|
|
expect(producer.wait_for(std::chrono::milliseconds(250))
|
|
== std::future_status::timeout,
|
|
"producer blocks once the queue is full");
|
|
|
|
q.stop();
|
|
|
|
expect(producer.wait_for(std::chrono::seconds(5))
|
|
== std::future_status::ready,
|
|
"stop() releases a producer blocked on a full queue");
|
|
|
|
producer.get();
|
|
|
|
// The caller's future must also resolve. Returning from enqueue() is
|
|
// not enough: every gRPC handler immediately calls future.get(), so a
|
|
// future that never settles just moves the parked thread from enqueue()
|
|
// to get() -- still an in-flight RPC, and ~GrpcServer's Shutdown() still
|
|
// waits on it. hydra_main declares WorkQueue before the GrpcServer
|
|
// unique_ptr, so ~GrpcServer runs first and ~WorkQueue never gets to
|
|
// break the promise. This assertion is what distinguishes completing
|
|
// the item from queueing it past the cap.
|
|
expect(work.valid(), "cancelled enqueue still returns a usable future");
|
|
if (work.valid()) {
|
|
expect(work.wait_for(std::chrono::seconds(2))
|
|
== std::future_status::ready,
|
|
"the caller's future resolves rather than hanging get()");
|
|
expect(work.get() == false,
|
|
"a cancelled item yields the default result, not an exception");
|
|
}
|
|
|
|
// Idempotent: a second stop() must not deadlock or throw.
|
|
q.stop();
|
|
expect(true, "stop() is idempotent");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
testSplitGmcpAcrossReads();
|
|
testSplitTelnetNegotiationAcrossReads();
|
|
testStripNonGmcpSubnegotiationAcrossReads();
|
|
testSplitTtypeSignals();
|
|
testSplitCharsetSignals();
|
|
testCharsetRequestAndAcceptedInSameRead();
|
|
testSplitEorSignals();
|
|
testSplitEorAcrossReads();
|
|
testAsciiBridgeConversion();
|
|
testWebSocketMaskEnforcement();
|
|
testWebSocketFragmentationAndUtf8();
|
|
testWebSocketExtLenMaskKey();
|
|
testWebSocketExtLen64MaskKey();
|
|
testWebSocketCloseDelivered();
|
|
testWebSocketRsvRejected();
|
|
testWebSocketLargePingRejected();
|
|
testTelnetSbCap();
|
|
testSubscriberCountCap();
|
|
testInputLineLimitShared();
|
|
testWorkQueuePendingCapConstant();
|
|
testConvertInputNonUtf8Target();
|
|
testTruncatedUtf8CharsetEncode();
|
|
testUnknownListenerTypeRejected();
|
|
testPortRangeRejected();
|
|
#ifdef GRPC_ENABLED
|
|
testMalformedProtobufRejected();
|
|
#endif
|
|
testWorkQueueStopReleasesBlockedProducer();
|
|
std::cout << "proxy_regression: ok\n";
|
|
return 0;
|
|
}
|