DS2Revival/mods/multiplayer/news.cpp

216 lines
10 KiB
C++

// In-game news / version check (docs/multiplayer/news.md).
//
// The game asks http://motd.gamespy.com/motd/vercheck.asp?... through WinHTTP (not WSOCK32, so the
// gethostbyname redirect does not see it) once per start, and only when the registry value DistroId is
// non-zero. This file:
// - sends those requests to [Network] NewsUrl (default https://dsii.uk; http://127.0.0.x:port for a test server;
// empty = leave GameSpy's address alone) by hooking WinHttpConnect (server name and port) and
// WinHttpOpenRequest (adds WINHTTP_FLAG_SECURE for an https base). The path and query stay as the game
// built them, so the server sees /motd/vercheck.asp?productid=...
// - makes DS2Multiplayer's virtual product key serve a non-zero DistroId (SetupVirtualProductKey) so the check
// runs on Steam. Nothing else in the exe reads DistroId.
#include "multiplayer.h"
#include <shellapi.h>
#include <winhttp.h>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <mutex>
namespace {
using WinHttpConnect_t = HINTERNET(WINAPI*)(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD);
using WinHttpOpenRequest_t = HINTERNET(WINAPI*)(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD);
WinHttpConnect_t g_connect;
WinHttpOpenRequest_t g_openRequest;
wchar_t g_host[256];
INTERNET_PORT g_port;
bool g_secure;
constexpr size_t kMaxTracked = 16;
std::mutex g_lock;
size_t g_next;
bool HasSuffix(LPCWSTR name, const wchar_t* suffix) {
size_t n = name ? wcslen(name) : 0, k = wcslen(suffix);
return n > k && _wcsicmp(name + n - k, suffix) == 0;
}
bool g_newsScreen = true; // [Network] NewsScreen: also Broken World's News screen (www.gaspowered.com/gasservices)
enum Kind : unsigned char { kNone = 0, kGamespy = 1, kGas = 2 }; // what a redirected connection was meant for
// motd.gamespy.com (the version check) and, with NewsScreen, www.gaspowered.com (Broken World's News screen).
Kind HostKind(LPCWSTR name) {
if (HasSuffix(name, L".gamespy.com")) return kGamespy;
if (g_newsScreen && HasSuffix(name, L".gaspowered.com")) return kGas;
return kNone;
}
struct Tracked {
HINTERNET h;
Kind kind;
};
Tracked g_redirected[kMaxTracked];
void Track(HINTERNET h, Kind kind) {
std::lock_guard<std::mutex> g(g_lock);
for (auto& e : g_redirected)
if (e.h == h) e = Tracked{}; // a recycled handle value must not keep an old meaning
if (kind != kNone && h) {
g_redirected[g_next] = Tracked{h, kind};
g_next = (g_next + 1) % kMaxTracked;
}
}
Kind KindOf(HINTERNET h) {
std::lock_guard<std::mutex> g(g_lock);
for (auto& e : g_redirected)
if (e.h == h) return e.kind;
return kNone;
}
HINTERNET WINAPI HookConnect(HINTERNET session, LPCWSTR server, INTERNET_PORT port, DWORD reserved) {
Kind kind = HostKind(server);
if (kind) ds2::log().Write("news: %ls:%u -> %ls:%u%s", server, port, g_host, g_port, g_secure ? " (https)" : "");
HINTERNET h = g_connect(session, kind ? g_host : server, kind ? g_port : port, reserved);
Track(h, kind);
return h;
}
// Broken World's News screen asks /gasservices?func=getCurrentServer (the news), and its Registration tab sends the
// player's e-mail address and postal code to the same host (func=regNewsletter). Only the news request is ever
// forwarded: anything else on that connection fails to open, as it does today with the host gone, so those details
// never leave the PC (and never reach our server).
bool GasRequestAllowed(LPCWSTR object) {
static const wchar_t kNews[] = L"/gasservices?func=getCurrentServer";
static const wchar_t kMessages[] = L"/gasservices?func=currentMessages";
return object && (wcsncmp(object, kNews, wcslen(kNews)) == 0 || wcsncmp(object, kMessages, wcslen(kMessages)) == 0);
}
HINTERNET WINAPI HookOpenRequest(HINTERNET connect, LPCWSTR verb, LPCWSTR object, LPCWSTR version, LPCWSTR referrer,
LPCWSTR* accept, DWORD flags) {
wchar_t path[600];
Kind kind = KindOf(connect);
if (kind == kGas && !GasRequestAllowed(object)) {
ds2::log().Write("news: refused a request to the old Gas Powered Games host (only the news is forwarded; nothing is sent): path starts %.24ls", object ? object : L"(none)");
SetLastError(ERROR_WINHTTP_CANNOT_CONNECT);
return nullptr;
}
if (kind != kNone) {
// https base: TLS for everything that goes there; http base (a test server): none, even if the game asked for it
flags = g_secure ? (flags | WINHTTP_FLAG_SECURE) : (flags & ~static_cast<DWORD>(WINHTTP_FLAG_SECURE));
// Mark the check as the plugin's (a hosts-file player's game would start its own downloader on the news link)
// and say which item was read; the query is the game's own, so the marker is appended with '&'.
if (kind == kGamespy && object && wcsncmp(object, L"/motd/vercheck.asp?", 19) == 0 && wcslen(object) < 400) {
char seen[48];
mp::ReadSeen(seen, sizeof(seen));
wchar_t id[48] = {};
for (int i = 0; seen[i] && i < 40; ++i)
id[i] = (seen[i] >= '0' && seen[i] <= '9') || (seen[i] >= 'a' && seen[i] <= 'z') || seen[i] == '-' ? seen[i] : L'_';
swprintf_s(path, L"%s&rv=1&seen=%s", object, id);
object = path;
}
}
return g_openRequest(connect, verb, object, version, referrer, accept, flags);
}
// Private test instances (DS2DEV_DOCS): never start a browser or another program on the PC of whoever runs the test.
bool Harness() { return GetEnvironmentVariableA("DS2DEV_DOCS", nullptr, 0) != 0; }
HINSTANCE WINAPI GuardShellExecuteA(HWND, LPCSTR, LPCSTR file, LPCSTR params, LPCSTR, INT) {
ds2::log().Write("news: [test instance] ShellExecute blocked: %s %s", file ? file : "", params ? params : "");
return reinterpret_cast<HINSTANCE>(33);
}
BOOL WINAPI GuardShellExecuteExA(SHELLEXECUTEINFOA* info) {
ds2::log().Write("news: [test instance] ShellExecuteEx blocked: %s %s", info && info->lpFile ? info->lpFile : "",
info && info->lpParameters ? info->lpParameters : "");
if (info) { info->hInstApp = reinterpret_cast<HINSTANCE>(33); info->hProcess = nullptr; }
return TRUE;
}
using CreateProcessA_t = BOOL(WINAPI*)(LPCSTR, LPSTR, LPSECURITY_ATTRIBUTES, LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCSTR,
LPSTARTUPINFOA, LPPROCESS_INFORMATION);
CreateProcessA_t g_createProcess;
BOOL WINAPI GuardCreateProcessA(LPCSTR app, LPSTR cmd, LPSECURITY_ATTRIBUTES pa, LPSECURITY_ATTRIBUTES ta, BOOL inherit,
DWORD flags, LPVOID env, LPCSTR dir, LPSTARTUPINFOA si, LPPROCESS_INFORMATION pi) {
const char* what = cmd ? cmd : (app ? app : "");
if (strstr(what, "rundll32") || strstr(what, "url.dll") || strstr(what, "http")) {
ds2::log().Write("news: [test instance] CreateProcess blocked: %s", what);
SetLastError(ERROR_ACCESS_DENIED);
return FALSE;
}
return g_createProcess(app, cmd, pa, ta, inherit, flags, env, dir, si, pi);
}
} // namespace
namespace mp {
// "https://dsii.uk", "http://127.0.0.91:8091", optional trailing slash. False if off or unusable.
bool ParseNewsUrl(const char* url, wchar_t* host, size_t cap, unsigned short* port, bool* secure) {
if (!url || !*url) return false;
const char* p;
if (_strnicmp(url, "https://", 8) == 0) { p = url + 8; *secure = true; }
else if (_strnicmp(url, "http://", 7) == 0) { p = url + 7; *secure = false; }
else return false;
size_t n = 0;
while (p[n] && p[n] != '/' && p[n] != ':') ++n;
if (!n || n >= cap) return false;
for (size_t i = 0; i < n; ++i) host[i] = static_cast<unsigned char>(p[i]);
host[n] = 0;
long v = *secure ? 443 : 80;
if (p[n] == ':') {
v = strtol(p + n + 1, nullptr, 10);
if (v < 1 || v > 65535) return false;
}
*port = static_cast<unsigned short>(v);
return true;
}
bool NewsEnabled(const ds2::Ini& ini) {
char url[300];
ini.String("Network", "NewsUrl", "https://dsii.uk", url, sizeof(url));
wchar_t host[256];
unsigned short port;
bool secure;
return ParseNewsUrl(url, host, 256, &port, &secure);
}
void InstallNews(const ds2::Ini& ini, ds2::Exe exe) {
if (Harness()) { // private test instances: Broken World's "More" button must not start a browser
ds2::HookImport("SHELL32.dll", "ShellExecuteA", reinterpret_cast<void*>(GuardShellExecuteA), "ShellExecuteA (test instance guard)");
ds2::HookImport("SHELL32.dll", "ShellExecuteExA", reinterpret_cast<void*>(GuardShellExecuteExA), "ShellExecuteExA (test instance guard)");
g_createProcess = static_cast<CreateProcessA_t>(ds2::HookImport("KERNEL32.dll", "CreateProcessA", reinterpret_cast<void*>(GuardCreateProcessA),
"CreateProcessA (test instance guard)"));
}
char url[300];
ini.String("Network", "NewsUrl", "https://dsii.uk", url, sizeof(url));
if (!url[0]) {
ds2::log().Write("news: NewsUrl empty - the version check keeps GameSpy's address");
return;
}
g_newsScreen = ini.Bool("Network", "NewsScreen", true);
if (!ParseNewsUrl(url, g_host, 256, &g_port, &g_secure)) {
ds2::log().Write("news: NewsUrl '%s' is not an http:// or https:// address - ignored", url);
return;
}
// The request hook first: a redirected connection without it would speak plain HTTP to a TLS port.
g_openRequest = static_cast<WinHttpOpenRequest_t>(
ds2::HookImport("WINHTTP.dll", "WinHttpOpenRequest", reinterpret_cast<void*>(HookOpenRequest), "WinHttpOpenRequest (news redirect)"));
if (!g_openRequest) return;
g_connect = static_cast<WinHttpConnect_t>(
ds2::HookImport("WINHTTP.dll", "WinHttpConnect", reinterpret_cast<void*>(HookConnect), "WinHttpConnect (news redirect)"));
if (!g_connect) return;
ds2::log().Write("news: version check goes to %ls:%u (%s)", g_host, g_port, g_secure ? "https" : "http");
NewsTarget target;
wcscpy_s(target.host, g_host);
target.port = g_port;
target.secure = g_secure;
InstallNewsUi(ini, exe, target);
}
} // namespace mp