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.
381 lines
14 KiB
C++
381 lines
14 KiB
C++
#include "config.h"
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
|
|
// Trim whitespace from both ends.
|
|
static std::string trim(const std::string& s) {
|
|
size_t start = s.find_first_not_of(" \t\r\n");
|
|
if (start == std::string::npos) return "";
|
|
size_t end = s.find_last_not_of(" \t\r\n");
|
|
return s.substr(start, end - start + 1);
|
|
}
|
|
|
|
// Case-insensitive comparison.
|
|
static std::string toLower(const std::string& s) {
|
|
std::string r = s;
|
|
std::transform(r.begin(), r.end(), r.begin(),
|
|
[](unsigned char c) { return std::tolower(c); });
|
|
return r;
|
|
}
|
|
|
|
// Parse a duration string into seconds.
|
|
// Accepts: bare integer (seconds), or integer with suffix s/m/h/d.
|
|
// Returns -1 on parse failure.
|
|
static int parseDuration(const std::string& s) {
|
|
if (s.empty()) return -1;
|
|
|
|
// Check for suffix
|
|
char suffix = s.back();
|
|
int multiplier = 1;
|
|
std::string digits = s;
|
|
|
|
if (std::isalpha(static_cast<unsigned char>(suffix))) {
|
|
digits = s.substr(0, s.size() - 1);
|
|
switch (std::tolower(static_cast<unsigned char>(suffix))) {
|
|
case 's': multiplier = 1; break;
|
|
case 'm': multiplier = 60; break;
|
|
case 'h': multiplier = 3600; break;
|
|
case 'd': multiplier = 86400; break;
|
|
default: return -1;
|
|
}
|
|
}
|
|
|
|
try {
|
|
int val = std::stoi(digits);
|
|
if (val < 0) return -1;
|
|
return val * multiplier;
|
|
} catch (...) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// Split a line into key and value at first whitespace.
|
|
static bool splitKeyValue(const std::string& line, std::string& key,
|
|
std::string& value) {
|
|
size_t pos = line.find_first_of(" \t");
|
|
if (pos == std::string::npos) {
|
|
key = line;
|
|
value.clear();
|
|
return true;
|
|
}
|
|
key = line.substr(0, pos);
|
|
value = trim(line.substr(pos + 1));
|
|
return true;
|
|
}
|
|
|
|
// Parse "key=value" from a whitespace-separated token list.
|
|
static std::string extractParam(const std::string& params,
|
|
const std::string& name) {
|
|
size_t pos = params.find(name + "=");
|
|
if (pos == std::string::npos) return "";
|
|
size_t start = pos + name.size() + 1;
|
|
size_t end = params.find_first_of(" \t", start);
|
|
if (end == std::string::npos) end = params.size();
|
|
return params.substr(start, end - start);
|
|
}
|
|
|
|
// #1897: parse a TCP port; reject wrap-prone OOR values (stoi → uint16_t).
|
|
// Valid range is 1..65535 (port 0 is never a useful bind/connect target here).
|
|
//
|
|
static bool parseTcpPort(const std::string& text, uint16_t& out,
|
|
std::string& errorMsg, const char* what) {
|
|
try {
|
|
size_t consumed = 0;
|
|
const long port = std::stol(text, &consumed, 10);
|
|
if (consumed != text.size() || port < 1 || port > 65535) {
|
|
errorMsg = std::string(what) + ": port out of range (1-65535): '"
|
|
+ text + "'";
|
|
return false;
|
|
}
|
|
out = static_cast<uint16_t>(port);
|
|
return true;
|
|
} catch (...) {
|
|
errorMsg = std::string(what) + ": invalid port '" + text + "'";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Parse a listen directive: "listen <type> <host:port> [cert=... key=...]"
|
|
static bool parseListenLine(const std::string& value, ListenConfig& lc,
|
|
std::string& errorMsg) {
|
|
std::istringstream ss(value);
|
|
std::string type, hostport, rest;
|
|
ss >> type >> hostport;
|
|
std::getline(ss, rest);
|
|
rest = trim(rest);
|
|
|
|
// #1895: unknown types used to leave all flags false and become a
|
|
// silent plaintext Telnet listener (e.g. "websockt" typo). Fail closed.
|
|
//
|
|
std::string lt = toLower(type);
|
|
lc.tls = false;
|
|
lc.websocket = false;
|
|
lc.grpcWeb = false;
|
|
if (lt == "telnet") {
|
|
// plaintext Telnet — explicit opt-in only
|
|
} else if (lt == "telnet+tls") {
|
|
lc.tls = true;
|
|
} else if (lt == "websocket") {
|
|
lc.websocket = true;
|
|
} else if (lt == "websocket+tls") {
|
|
lc.websocket = true;
|
|
lc.tls = true;
|
|
} else if (lt == "grpc-web" || lt == "grpcweb") {
|
|
lc.grpcWeb = true;
|
|
} else if (lt == "grpc-web+tls" || lt == "grpcweb+tls") {
|
|
lc.grpcWeb = true;
|
|
lc.tls = true;
|
|
} else {
|
|
errorMsg = "listen: unknown type '" + type
|
|
+ "' (expected telnet, telnet+tls, websocket, websocket+tls, "
|
|
"grpc-web[,+tls])";
|
|
return false;
|
|
}
|
|
|
|
// Parse host:port
|
|
size_t colon = hostport.rfind(':');
|
|
if (colon == std::string::npos) {
|
|
errorMsg = "listen: expected host:port, got '" + hostport + "'";
|
|
return false;
|
|
}
|
|
lc.host = hostport.substr(0, colon);
|
|
if (!parseTcpPort(hostport.substr(colon + 1), lc.port, errorMsg, "listen")) {
|
|
return false;
|
|
}
|
|
|
|
if (lc.tls) {
|
|
lc.certFile = extractParam(rest, "cert");
|
|
lc.keyFile = extractParam(rest, "key");
|
|
if (lc.certFile.empty() || lc.keyFile.empty()) {
|
|
errorMsg = "listen: TLS listener requires cert= and key=";
|
|
return false;
|
|
}
|
|
}
|
|
// Non-TLS websocket/grpc-web may have optional params in rest
|
|
// (currently none, but future-proof)
|
|
return true;
|
|
}
|
|
|
|
// Parse retry schedule: "5s 10s 30s 60s"
|
|
static std::vector<int> parseRetrySchedule(const std::string& s) {
|
|
std::vector<int> schedule;
|
|
std::istringstream ss(s);
|
|
std::string token;
|
|
while (ss >> token) {
|
|
int val = 0;
|
|
try {
|
|
// Strip trailing 's' if present
|
|
if (!token.empty() && token.back() == 's') {
|
|
val = std::stoi(token.substr(0, token.size() - 1));
|
|
} else {
|
|
val = std::stoi(token);
|
|
}
|
|
} catch (...) {
|
|
continue;
|
|
}
|
|
if (val > 0) schedule.push_back(val);
|
|
}
|
|
return schedule;
|
|
}
|
|
|
|
static ganl::EncodingType parseCharset(const std::string& s) {
|
|
std::string l = toLower(s);
|
|
if (l == "utf-8" || l == "utf8") return ganl::EncodingType::Utf8;
|
|
if (l == "latin-1" || l == "latin1" || l == "iso-8859-1")
|
|
return ganl::EncodingType::Latin1;
|
|
if (l == "cp437") return ganl::EncodingType::Cp437;
|
|
if (l == "cp1252") return ganl::EncodingType::Cp1252;
|
|
if (l == "ascii" || l == "us-ascii") return ganl::EncodingType::Ascii;
|
|
return ganl::EncodingType::Utf8;
|
|
}
|
|
|
|
// Parse a game block. Called after seeing "game "name" {"
|
|
static bool parseGameBlock(std::ifstream& file, GameConfig& game,
|
|
int& lineNum, std::string& errorMsg) {
|
|
std::string line;
|
|
while (std::getline(file, line)) {
|
|
lineNum++;
|
|
line = trim(line);
|
|
if (line.empty() || line[0] == '#') continue;
|
|
if (line == "}") return true;
|
|
|
|
std::string key, value;
|
|
splitKeyValue(line, key, value);
|
|
key = toLower(key);
|
|
|
|
if (key == "host") {
|
|
game.host = value;
|
|
} else if (key == "port") {
|
|
if (!parseTcpPort(value, game.port, errorMsg, "game")) {
|
|
return false;
|
|
}
|
|
} else if (key == "socket") {
|
|
game.socketPath = value;
|
|
game.transport = GameTransport::Unix;
|
|
} else if (key == "transport") {
|
|
game.transport = (toLower(value) == "unix")
|
|
? GameTransport::Unix : GameTransport::Tcp;
|
|
} else if (key == "type") {
|
|
game.type = (toLower(value) == "local")
|
|
? GameType::Local : GameType::Remote;
|
|
} else if (key == "binary") {
|
|
game.binary = value;
|
|
} else if (key == "workdir") {
|
|
game.workdir = value;
|
|
} else if (key == "autostart") {
|
|
game.autostart = (toLower(value) == "yes");
|
|
} else if (key == "reconnect") {
|
|
game.reconnect = (toLower(value) == "yes");
|
|
} else if (key == "retry") {
|
|
game.retrySchedule = parseRetrySchedule(value);
|
|
} else if (key == "charset") {
|
|
game.charset = parseCharset(value);
|
|
} else if (key == "tls_required") {
|
|
game.tlsRequired = (toLower(value) != "no");
|
|
} else if (key == "tls") {
|
|
game.tls = (toLower(value) == "yes");
|
|
} else if (key == "tls_verify") {
|
|
game.tlsVerify = (toLower(value) == "yes");
|
|
} else if (key == "tls_ca") {
|
|
game.tlsCaFile = value;
|
|
}
|
|
// Ignore unknown keys for forward compatibility
|
|
}
|
|
errorMsg = "game block: unexpected end of file (missing '}')";
|
|
return false;
|
|
}
|
|
|
|
bool loadConfig(const std::string& path, HydraConfig& config,
|
|
std::string& errorMsg) {
|
|
std::ifstream file(path);
|
|
if (!file.is_open()) {
|
|
errorMsg = "cannot open config file: " + path;
|
|
return false;
|
|
}
|
|
|
|
std::string line;
|
|
int lineNum = 0;
|
|
while (std::getline(file, line)) {
|
|
lineNum++;
|
|
line = trim(line);
|
|
if (line.empty() || line[0] == '#') continue;
|
|
|
|
std::string key, value;
|
|
splitKeyValue(line, key, value);
|
|
key = toLower(key);
|
|
|
|
if (key == "listen") {
|
|
ListenConfig lc;
|
|
if (!parseListenLine(value, lc, errorMsg)) {
|
|
errorMsg = "line " + std::to_string(lineNum) + ": " + errorMsg;
|
|
return false;
|
|
}
|
|
config.listeners.push_back(lc);
|
|
} else if (key == "database") {
|
|
config.databasePath = value;
|
|
} else if (key == "master_key") {
|
|
config.masterKeyPath = value;
|
|
} else if (key == "scrollback_lines") {
|
|
try { config.scrollbackLines = std::stoul(value); }
|
|
catch (...) {}
|
|
} else if (key == "session_idle_timeout") {
|
|
int d = parseDuration(value);
|
|
if (d >= 0) config.sessionIdleTimeout = d;
|
|
} else if (key == "detached_session_timeout") {
|
|
int d = parseDuration(value);
|
|
if (d >= 0) config.detachedSessionTimeout = d;
|
|
} else if (key == "link_reconnect_timeout") {
|
|
int d = parseDuration(value);
|
|
if (d >= 0) config.linkReconnectTimeout = d;
|
|
} else if (key == "session_token_ttl") {
|
|
int d = parseDuration(value);
|
|
if (d >= 0) config.sessionTokenTtl = d;
|
|
} else if (key == "allow_plaintext") {
|
|
config.allowPlaintext = (toLower(value) == "yes");
|
|
} else if (key == "cors_origin") {
|
|
config.corsOrigins.push_back(value);
|
|
} else if (key == "grpc_listen") {
|
|
config.grpcListenAddr = value;
|
|
} else if (key == "grpc_tls_cert") {
|
|
config.grpcTlsCert = value;
|
|
} else if (key == "grpc_tls_key") {
|
|
config.grpcTlsKey = value;
|
|
} else if (key == "max_sessions_per_account") {
|
|
try { config.maxSessionsPerAccount = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "max_frontdoors_per_session") {
|
|
try { config.maxFrontDoorsPerSession = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "max_links_per_session") {
|
|
try { config.maxLinksPerSession = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "max_scrollback_memory_mb") {
|
|
try { config.maxScrollbackMemoryMb = std::stoul(value); }
|
|
catch (...) {}
|
|
} else if (key == "max_connections_per_ip") {
|
|
try { config.maxConnectionsPerIp = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "connect_rate_limit") {
|
|
try { config.connectRateLimit = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "failed_login_lockout") {
|
|
try { config.failedLoginLockout = std::stoi(value); }
|
|
catch (...) {}
|
|
} else if (key == "failed_login_lockout_minutes") {
|
|
int d = parseDuration(value);
|
|
if (d >= 0) config.failedLoginLockoutMinutes = d;
|
|
} else if (key == "log_file") {
|
|
config.logFile = value;
|
|
} else if (key == "log_level") {
|
|
config.logLevel = toLower(value);
|
|
} else if (key == "game") {
|
|
// Parse: game "Name" {
|
|
GameConfig game;
|
|
// Extract quoted name
|
|
size_t q1 = value.find('"');
|
|
size_t q2 = value.find('"', q1 + 1);
|
|
if (q1 == std::string::npos || q2 == std::string::npos) {
|
|
errorMsg = "line " + std::to_string(lineNum)
|
|
+ ": game name must be quoted";
|
|
return false;
|
|
}
|
|
game.name = value.substr(q1 + 1, q2 - q1 - 1);
|
|
|
|
// Expect '{' after the name
|
|
std::string rest = trim(value.substr(q2 + 1));
|
|
if (rest != "{") {
|
|
errorMsg = "line " + std::to_string(lineNum)
|
|
+ ": expected '{' after game name";
|
|
return false;
|
|
}
|
|
|
|
if (!parseGameBlock(file, game, lineNum, errorMsg)) {
|
|
errorMsg = "line " + std::to_string(lineNum) + ": " + errorMsg;
|
|
return false;
|
|
}
|
|
|
|
// Default retry schedule if not specified
|
|
if (game.retrySchedule.empty()) {
|
|
game.retrySchedule = {5, 10, 30, 60};
|
|
}
|
|
|
|
config.games.push_back(game);
|
|
}
|
|
// Ignore unknown top-level keys for forward compatibility
|
|
}
|
|
|
|
if (config.listeners.empty()) {
|
|
errorMsg = "no listeners configured";
|
|
return false;
|
|
}
|
|
|
|
// Validate gRPC TLS config: both cert and key must be provided together
|
|
if (!config.grpcTlsCert.empty() != !config.grpcTlsKey.empty()) {
|
|
errorMsg = "grpc_tls_cert and grpc_tls_key must both be set (or both omitted)";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|