mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-23 19:56:03 +02:00
712 lines
28 KiB
JavaScript
712 lines
28 KiB
JavaScript
// CheatSpecter dev bridge.
|
||
//
|
||
// Browsers cannot open raw TCP sockets, and the PS5 debugger only speaks raw
|
||
// TCP. This small server sits between them on a computer: it serves the web
|
||
// app and relays a WebSocket to the debugger's TCP port, byte for byte.
|
||
//
|
||
// The on-console payload will replace this with the same three things:
|
||
// GET / the web app
|
||
// GET /api/info { mode, version }
|
||
// WS /ws binary frames <-> debugger TCP stream
|
||
// WS /events debugger stop reports (the console connects back to us
|
||
// on TCP 755 when a debugger attaches; those bytes are
|
||
// forwarded here)
|
||
//
|
||
// Extras that only make sense on a computer: LAN discovery, a local cheats
|
||
// folder, and a built-in demo console (--demo).
|
||
//
|
||
// node bridge/server.mjs [--demo] [--open] [--port 8744]
|
||
|
||
import http from 'node:http';
|
||
import net from 'node:net';
|
||
import dgram from 'node:dgram';
|
||
import os from 'node:os';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { spawn } from 'node:child_process';
|
||
import { WebSocketServer } from 'ws';
|
||
import { DEMO_TITLES, demoLibraryResponse } from './demo-library.mjs';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const DIST = path.join(ROOT, 'dist');
|
||
const CHEATS_DIR = path.join(ROOT, 'cheats');
|
||
const LEARN_DIR = path.join(ROOT, 'learn');
|
||
const TABLE_MAX = 4 << 20;
|
||
const PREF_MAX = 64 << 10;
|
||
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
||
|
||
const DEBUGGER_PORT = 744;
|
||
const DISCOVERY_PORT = 1010;
|
||
const EVENT_PORT = 755;
|
||
/** Where the CheatSpecter payload on the console answers. */
|
||
const DEFAULT_PAYLOAD_PORT = 8744;
|
||
const DISCOVERY_MAGIC = Buffer.from([0xaa, 0xaa, 0xff, 0xff]); // 0xFFFFAAAA little-endian
|
||
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.png': 'image/png',
|
||
'.ico': 'image/x-icon',
|
||
'.webmanifest': 'application/manifest+json',
|
||
'.wasm': 'application/wasm',
|
||
};
|
||
|
||
// ---- which addresses the bridge will relay to --------------------------------
|
||
|
||
/**
|
||
* The bridge only relays to consoles on the local network. Without this it
|
||
* would be an open proxy for anything that can reach it.
|
||
*/
|
||
export function isPrivateAddress(host) {
|
||
if (net.isIPv4(host)) {
|
||
const [a, b] = host.split('.').map(Number);
|
||
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* A page from another site must not be able to drive the bridge, so the
|
||
* WebSocket is only accepted from the app this server itself served.
|
||
*/
|
||
function sameOrigin(req) {
|
||
const origin = req.headers.origin;
|
||
if (!origin) return true; // not a browser (tests, scripts on this machine)
|
||
try {
|
||
return new URL(origin).host === req.headers.host;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---- discovery ---------------------------------------------------------------
|
||
|
||
function broadcastAddresses() {
|
||
const out = new Set(['255.255.255.255']);
|
||
for (const list of Object.values(os.networkInterfaces())) {
|
||
for (const i of list ?? []) {
|
||
if (i.family !== 'IPv4' || i.internal) continue;
|
||
const ip = i.address.split('.').map(Number);
|
||
const mask = i.netmask.split('.').map(Number);
|
||
out.add(ip.map((octet, n) => (octet | (~mask[n] & 0xff)) & 0xff).join('.'));
|
||
}
|
||
}
|
||
return [...out];
|
||
}
|
||
|
||
/** Asks the LAN "is there a debugger here?" and collects whoever echoes. */
|
||
function discover(extraProbes, waitMs = 900) {
|
||
return new Promise((resolve) => {
|
||
const found = new Map();
|
||
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||
const finish = () => {
|
||
try {
|
||
sock.close();
|
||
} catch {
|
||
// already closed
|
||
}
|
||
resolve([...found.values()]);
|
||
};
|
||
sock.on('error', finish);
|
||
sock.on('message', (msg, rinfo) => {
|
||
if (msg.length !== 4 || !msg.equals(DISCOVERY_MAGIC)) return;
|
||
const probe = extraProbes.find((p) => p.udpPort === rinfo.port && p.host === rinfo.address);
|
||
if (probe) found.set(`${probe.host}:${probe.port}`, { host: probe.host, port: probe.port, label: probe.label });
|
||
else if (rinfo.port === DISCOVERY_PORT) found.set(rinfo.address, { host: rinfo.address, port: DEBUGGER_PORT, label: 'PS5' });
|
||
});
|
||
sock.bind(0, () => {
|
||
try {
|
||
sock.setBroadcast(true);
|
||
} catch {
|
||
// some platforms refuse; unicast probes still work
|
||
}
|
||
for (const addr of broadcastAddresses()) sock.send(DISCOVERY_MAGIC, DISCOVERY_PORT, addr, () => {});
|
||
for (const p of extraProbes) sock.send(DISCOVERY_MAGIC, p.udpPort, p.host, () => {});
|
||
setTimeout(finish, waitMs);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- http --------------------------------------------------------------------
|
||
|
||
function sendJson(res, code, body) {
|
||
const data = JSON.stringify(body);
|
||
res.writeHead(code, { 'content-type': MIME['.json'], 'cache-control': 'no-store' });
|
||
res.end(data);
|
||
}
|
||
|
||
function serveFile(res, file, cache = 'no-cache') {
|
||
fs.readFile(file, (err, data) => {
|
||
if (err) {
|
||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||
res.end('Not found');
|
||
return;
|
||
}
|
||
res.writeHead(200, {
|
||
'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
||
'cache-control': cache,
|
||
});
|
||
res.end(data);
|
||
});
|
||
}
|
||
|
||
/** Resolves a URL path inside a folder, refusing anything that escapes it. */
|
||
function safeJoin(base, urlPath) {
|
||
let rel;
|
||
try {
|
||
rel = decodeURIComponent(urlPath);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const full = path.resolve(base, '.' + path.posix.normalize('/' + rel));
|
||
return full === base || full.startsWith(base + path.sep) ? full : null;
|
||
}
|
||
|
||
// ---- the demo console's stand-in for the cheat engine ---------------------------
|
||
//
|
||
// The real engine is C code inside the payload (payload/src/engine.c). The demo
|
||
// console has no payload, so GET /api/engine is answered here in the same
|
||
// shape, from the cheat file the app saved: which cheats are marked to keep,
|
||
// and which of those the engine would turn down, by the engine's own rules.
|
||
// It does not touch the demo game, and says so in each cheat's detail.
|
||
|
||
const DEMO_KEPT_DETAIL = 'The demo console does not keep cheats on by itself. On a real PS5 this would be switched on whenever the game runs.';
|
||
const DEMO_REFUSALS = {
|
||
review: 'This cheat has not been looked at yet. Open CheatSpecter and review it first.',
|
||
pageOnly: 'Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.',
|
||
kind: 'Only patches and held values can be kept on by the PS5.',
|
||
notHeld: 'Only a value that is held in place can be kept on. Turn on "Hold it at this value" in CheatSpecter first.',
|
||
patchAddress: 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location in CheatSpecter first.',
|
||
valueAddress: 'This value uses a raw memory address, which changes every time the game starts. Give it a pointer path in CheatSpecter first.',
|
||
};
|
||
|
||
/** The version as it appears in a file name (fileVersion() in src/lib/tablestore.ts). */
|
||
const demoFileVersion = (version) => String(version ?? '').replace(/[^0-9A-Za-z.]/g, '').replace(/^\.+/, '').slice(0, 16) || 'unknown';
|
||
|
||
/** Why the engine would not run an entry marked to keep, or null when it would. */
|
||
function demoRefusal(e) {
|
||
if (e.reviewed === false) return DEMO_REFUSALS.review;
|
||
if (e.pageOnly === true) return DEMO_REFUSALS.pageOnly;
|
||
if (e.type !== 'patch' && e.type !== 'value') return DEMO_REFUSALS.kind;
|
||
if (e.type === 'value' && e.freeze !== true) return DEMO_REFUSALS.notHeld;
|
||
const places = e.type === 'patch' && Array.isArray(e.more) ? [e, ...e.more] : [e];
|
||
if (places.some((p) => p?.at?.kind === 'address')) return e.type === 'patch' ? DEMO_REFUSALS.patchAddress : DEMO_REFUSALS.valueAddress;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param {{titleId:string, version:string, pid:number}|null} game the game the demo console is running
|
||
* @param {Map<string, Buffer>} tables saved cheat files, keyed "<title id>-<version>"
|
||
*/
|
||
export function demoEngineStatus(game, tables) {
|
||
if (!game) return { running: true, game: null, file: '', cheats: [] };
|
||
const status = { running: true, game: { titleId: game.titleId, version: game.version, pid: game.pid }, file: '', cheats: [] };
|
||
// This version's own file, else the highest-versioned one for the game.
|
||
const own = `${game.titleId}-${demoFileVersion(game.version)}`;
|
||
const others = [...tables.keys()].filter((k) => k.startsWith(`${game.titleId}-`)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||
const key = tables.has(own) ? own : others[others.length - 1];
|
||
if (!key) return status;
|
||
status.file = `${key}.cspector`;
|
||
let doc = null;
|
||
try {
|
||
doc = JSON.parse(tables.get(key).toString('utf8').replace(/^\uFEFF/, ''));
|
||
} catch {
|
||
// reported below
|
||
}
|
||
const legacy = doc?.format === 'cheatspecter/1';
|
||
const list = legacy ? doc.entries : doc?.format === 'cheatspecter' ? doc.cheats : null;
|
||
const says = (legacy ? doc : doc?.game) ?? {};
|
||
status.exact =
|
||
key === own &&
|
||
(!says.titleId || String(says.titleId).toUpperCase() === game.titleId) &&
|
||
(!says.version || demoFileVersion(says.version) === demoFileVersion(game.version));
|
||
if (!Array.isArray(list)) {
|
||
status.error = 'The cheat file for this game is damaged (it is not valid JSON), so no cheats were switched on. Save your cheats again from CheatSpecter.';
|
||
return status;
|
||
}
|
||
for (const e of list) {
|
||
if (!e || typeof e !== 'object' || typeof e.name !== 'string' || e.keep !== true) continue;
|
||
if (!['patch', 'value', 'inject', 'call', 'script'].includes(e.type)) continue;
|
||
const refusal = demoRefusal(e);
|
||
status.cheats.push(refusal ? { name: e.name, state: 'refused', detail: refusal } : { name: e.name, state: 'waiting', detail: DEMO_KEPT_DETAIL });
|
||
}
|
||
return status;
|
||
}
|
||
|
||
/** Community tables dropped into the cheats/ folder: JSON, SHN or MC4. */
|
||
const LOCAL_TABLE_RE = /\.(json|shn|mc4)$/i;
|
||
|
||
function localCheatIndex() {
|
||
let files = [];
|
||
try {
|
||
files = fs.readdirSync(CHEATS_DIR).filter((f) => LOCAL_TABLE_RE.test(f));
|
||
} catch {
|
||
return '';
|
||
}
|
||
const lines = [];
|
||
for (const f of files) {
|
||
let name = '';
|
||
try {
|
||
name = String(JSON.parse(fs.readFileSync(path.join(CHEATS_DIR, f), 'utf8').replace(/^/, '')).name ?? '');
|
||
} catch {
|
||
// listed anyway; the app reports the parse error when it is opened
|
||
}
|
||
lines.push(`${f}=${name}`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* @param {object} [opts]
|
||
* @param {number} [opts.port]
|
||
* @param {string} [opts.host]
|
||
* @param {{host:string, port:number, udpPort:number, label:string, game?:() => ({titleId:string, version:string, pid:number}|null)}|null} [opts.demo]
|
||
* the built-in demo console; `game` says which game it is running, for the stand-in engine status
|
||
* @param {number|null} [opts.eventPort] where consoles connect back with debugger events (default 755, null = off)
|
||
*/
|
||
export function startBridge(opts = {}) {
|
||
const demo = opts.demo ?? null;
|
||
const PAYLOAD_PORT = opts.payloadPort ?? DEFAULT_PAYLOAD_PORT;
|
||
/** The demo console's saved cheat files and settings, kept only while the bridge runs. */
|
||
const demoTables = new Map();
|
||
const demoPrefs = new Map();
|
||
const allowed = (host, port) =>
|
||
(isPrivateAddress(host) && port === DEBUGGER_PORT) || (demo && host === demo.host && port === demo.port);
|
||
|
||
// ---- debugger events ----
|
||
// On attach the console opens a TCP connection back to the machine that sent
|
||
// the attach, always to port 755. Whatever arrives is passed to the browsers
|
||
// that asked for events from that console.
|
||
const eventPort = opts.eventPort === undefined ? EVENT_PORT : opts.eventPort;
|
||
const eventSubs = new Map(); // console address -> Set<WebSocket>
|
||
let eventsError = eventPort === null ? 'Debugger events are turned off' : null;
|
||
const eventServer = net.createServer((sock) => {
|
||
const from = (sock.remoteAddress ?? '').replace(/^::ffff:/, '');
|
||
sock.on('error', () => {});
|
||
sock.on('data', (chunk) => {
|
||
for (const ws of eventSubs.get(from) ?? []) {
|
||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||
}
|
||
});
|
||
// The console closes this when the session ends (detach, game exit, error).
|
||
sock.on('close', () => {
|
||
for (const ws of eventSubs.get(from) ?? []) {
|
||
if (ws.readyState === ws.OPEN) ws.close(4005, 'The console ended the debugging session');
|
||
}
|
||
});
|
||
});
|
||
const eventsReady = new Promise((resolve) => {
|
||
if (eventPort === null) return resolve();
|
||
eventServer.once('error', (err) => {
|
||
eventsError =
|
||
err.code === 'EADDRINUSE'
|
||
? `Port ${eventPort} is in use by another program (another debugger tool?)`
|
||
: err.code === 'EACCES'
|
||
? `This system does not let CheatSpecter listen on port ${eventPort} without administrator rights`
|
||
: `Could not listen on port ${eventPort} (${err.code})`;
|
||
resolve();
|
||
});
|
||
eventServer.listen(eventPort, '0.0.0.0', () => resolve());
|
||
});
|
||
|
||
/**
|
||
* The game library, game names, "start this game", the cheat files and
|
||
* settings saved on the PS5 and the engine that keeps cheats on by itself
|
||
* are all handled by the CheatSpecter payload on the console (the debugger
|
||
* can do none of them), so these requests are passed on to it.
|
||
*/
|
||
async function consoleApi(req, res, url) {
|
||
const p = url.pathname;
|
||
const host = url.searchParams.get('host') ?? '';
|
||
const launching = p.startsWith('/api/launch/');
|
||
// The engine: GET /api/engine says what it is keeping on, POST /api/engine/reload makes it read the file again.
|
||
const engineReload = p === '/api/engine/reload';
|
||
const engine = p === '/api/engine' || engineReload;
|
||
const table = /^\/api\/tables\/([A-Z]{4}\d{5})(?:\/([0-9A-Za-z][0-9A-Za-z.]{0,15}))?$/.exec(p);
|
||
const saving = !!table && !!table[2] && req.method === 'PUT';
|
||
const deleting = !!table && !!table[2] && req.method === 'DELETE';
|
||
// Cheat files other tools keep on the console: a list for one game, and one file. Read-only.
|
||
const consoleCheats = /^\/api\/console-cheats\/([A-Z]{4}\d{5})(?:\/([A-Za-z0-9._()+%\- /]{1,300}))?$/.exec(p);
|
||
// Settings: GET and PUT /api/prefs/<name>, the name being lower-case letters, digits and dashes only.
|
||
const pref = /^\/api\/prefs\/([a-z0-9-]{1,32})$/.exec(p);
|
||
const storing = !!pref && req.method === 'PUT';
|
||
const methodOk = launching || engineReload ? req.method === 'POST' : saving || storing || deleting || req.method === 'GET';
|
||
if (!methodOk || !sameOrigin(req)) {
|
||
res.writeHead(sameOrigin(req) ? 405 : 403);
|
||
res.end();
|
||
return;
|
||
}
|
||
if (p.startsWith('/api/prefs/') && !pref) {
|
||
res.writeHead(400);
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
let body;
|
||
if (saving || storing) {
|
||
const chunks = [];
|
||
const limit = storing ? PREF_MAX : TABLE_MAX;
|
||
let size = 0;
|
||
for await (const chunk of req) {
|
||
size += chunk.length;
|
||
// Too much is read to its end and dropped, so the sender gets the answer instead of a cut connection; within reason.
|
||
if (size > limit + (16 << 20)) break;
|
||
if (size <= limit) chunks.push(chunk);
|
||
}
|
||
if (size > limit) {
|
||
res.writeHead(413);
|
||
res.end();
|
||
return;
|
||
}
|
||
body = Buffer.concat(chunks);
|
||
}
|
||
|
||
if (demo && host === demo.host) {
|
||
if (engine) {
|
||
// The demo console has no engine. The stand-in answers in the engine's shape and takes a reload without doing anything.
|
||
if (engineReload) {
|
||
res.writeHead(204, { 'cache-control': 'no-store' });
|
||
res.end();
|
||
} else sendJson(res, 200, demoEngineStatus(demo.game ? demo.game() : null, demoTables));
|
||
return;
|
||
}
|
||
if (pref) {
|
||
if (storing) {
|
||
let ok = true;
|
||
try {
|
||
JSON.parse(body.toString('utf8'));
|
||
} catch {
|
||
ok = false;
|
||
}
|
||
if (ok) demoPrefs.set(pref[1], body);
|
||
res.writeHead(ok ? 204 : 400, { 'cache-control': 'no-store' });
|
||
res.end();
|
||
} else if (demoPrefs.has(pref[1])) {
|
||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||
res.end(demoPrefs.get(pref[1]));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
return;
|
||
}
|
||
if (launching) {
|
||
const known = DEMO_TITLES.some((t) => p === `/api/launch/${t.id}`);
|
||
const running = p === '/api/launch/PPSA99999';
|
||
sendJson(res, running ? 200 : known ? 500 : 404, running
|
||
? { ok: true }
|
||
: { ok: false, error: known ? 'The demo console only has one game it can actually run' : 'That game is not on this console' });
|
||
return;
|
||
}
|
||
if (consoleCheats) {
|
||
// The demo console has no other tools' cheat folders.
|
||
if (consoleCheats[2]) {
|
||
res.writeHead(404);
|
||
res.end();
|
||
} else sendJson(res, 200, { files: [] });
|
||
return;
|
||
}
|
||
if (table) {
|
||
const [, tid, version] = table;
|
||
if (!version) {
|
||
const versions = [...demoTables.keys()].filter((k) => k.startsWith(`${tid}-`)).map((k) => k.slice(10));
|
||
sendJson(res, 200, { folder: '/data/cheatspecter/cheats', versions });
|
||
} else if (saving) {
|
||
demoTables.set(`${tid}-${version}`, body);
|
||
res.writeHead(204);
|
||
res.end();
|
||
} else if (deleting) {
|
||
const had = demoTables.delete(`${tid}-${version}`);
|
||
res.writeHead(had ? 204 : 404);
|
||
res.end();
|
||
} else if (demoTables.has(`${tid}-${version}`)) {
|
||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||
res.end(demoTables.get(`${tid}-${version}`));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
return;
|
||
}
|
||
const answer = demoLibraryResponse(p);
|
||
if (!answer) {
|
||
res.writeHead(404);
|
||
res.end();
|
||
return;
|
||
}
|
||
res.writeHead(200, { 'content-type': answer.type, 'cache-control': 'no-store' });
|
||
res.end(answer.body);
|
||
return;
|
||
}
|
||
if (!isPrivateAddress(host) || !(table || engine || pref || consoleCheats || /^\/api\/(library|appmeta\/[A-Z]{4}\d{5}\/[a-z0-9.]+|launch\/[A-Z]{4}\d{5})$/.test(p))) {
|
||
res.writeHead(400);
|
||
res.end();
|
||
return;
|
||
}
|
||
try {
|
||
const upstream = await fetch(`http://${host}:${PAYLOAD_PORT}${p}`, {
|
||
method: req.method,
|
||
body,
|
||
signal: AbortSignal.timeout(launching ? 30_000 : 20_000),
|
||
});
|
||
const answer = Buffer.from(await upstream.arrayBuffer());
|
||
res.writeHead(upstream.status, {
|
||
'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
|
||
'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
|
||
'x-content-type-options': 'nosniff',
|
||
});
|
||
res.end(answer);
|
||
} catch {
|
||
// Nothing answering on the payload's port: it has not been loaded on this console.
|
||
sendJson(res, 502, { ok: false, error: 'payload-missing' });
|
||
}
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||
const p = url.pathname;
|
||
|
||
if (p.startsWith('/api/tables/') || p.startsWith('/api/console-cheats/') || p === '/api/library' || p.startsWith('/api/appmeta/') || p.startsWith('/api/launch/') || p === '/api/engine' || p === '/api/engine/reload' || p.startsWith('/api/prefs/')) {
|
||
void consoleApi(req, res, url);
|
||
return;
|
||
}
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||
res.writeHead(405);
|
||
res.end();
|
||
return;
|
||
}
|
||
if (p === '/api/info') {
|
||
sendJson(res, 200, {
|
||
mode: 'bridge',
|
||
version: VERSION,
|
||
events: eventsError === null,
|
||
...(demo ? { demo: { host: demo.host, port: demo.port, label: demo.label } } : {}),
|
||
});
|
||
return;
|
||
}
|
||
if (p === '/api/discover') {
|
||
discover(demo ? [demo] : []).then((list) => sendJson(res, 200, list));
|
||
return;
|
||
}
|
||
if (p === '/api/local-cheats') {
|
||
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
||
res.end(localCheatIndex());
|
||
return;
|
||
}
|
||
if (p.startsWith('/local-cheats/')) {
|
||
const file = safeJoin(CHEATS_DIR, p.slice('/local-cheats/'.length));
|
||
if (!file || !LOCAL_TABLE_RE.test(file)) {
|
||
res.writeHead(404);
|
||
res.end();
|
||
return;
|
||
}
|
||
serveFile(res, file, 'no-store');
|
||
return;
|
||
}
|
||
|
||
// The Learn section's lessons normally come from GitHub. With the demo
|
||
// console (which is also how `npm run dev` runs) the checkout's own copy is
|
||
// served, so lessons can be written and tried before they are published.
|
||
if (demo && p.startsWith('/learn/')) {
|
||
const lesson = safeJoin(LEARN_DIR, p.slice('/learn/'.length));
|
||
if (!lesson || !lesson.endsWith('.json') || !fs.existsSync(lesson)) {
|
||
res.writeHead(404);
|
||
res.end();
|
||
return;
|
||
}
|
||
serveFile(res, lesson, 'no-store');
|
||
return;
|
||
}
|
||
|
||
const file = safeJoin(DIST, p === '/' ? 'index.html' : p.slice(1));
|
||
if (!file) {
|
||
res.writeHead(404);
|
||
res.end();
|
||
return;
|
||
}
|
||
if (!fs.existsSync(DIST)) {
|
||
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
||
res.end('The web app has not been built yet. Run "npm run build" (or "npm start", which does it for you).');
|
||
return;
|
||
}
|
||
// Hashed asset names never change content, so they can be cached hard.
|
||
serveFile(res, file, p.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||
});
|
||
|
||
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||
|
||
server.on('upgrade', (req, socket, head) => {
|
||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||
if ((url.pathname !== '/ws' && url.pathname !== '/events') || !sameOrigin(req)) {
|
||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||
socket.destroy();
|
||
return;
|
||
}
|
||
wss.handleUpgrade(req, socket, head, (ws) => (url.pathname === '/ws' ? relay(ws, url) : subscribe(ws, url)));
|
||
});
|
||
|
||
function subscribe(ws, url) {
|
||
const host = url.searchParams.get('host') ?? '';
|
||
if (!isPrivateAddress(host)) {
|
||
ws.close(4003, 'That address is not a console on your local network');
|
||
return;
|
||
}
|
||
if (eventsError) {
|
||
ws.close(4004, eventsError);
|
||
return;
|
||
}
|
||
if (!eventSubs.has(host)) eventSubs.set(host, new Set());
|
||
eventSubs.get(host).add(ws);
|
||
ws.on('close', () => eventSubs.get(host)?.delete(ws));
|
||
ws.on('error', () => {});
|
||
}
|
||
|
||
function relay(ws, url) {
|
||
const host = url.searchParams.get('host') ?? '';
|
||
const port = Number(url.searchParams.get('port') ?? DEBUGGER_PORT);
|
||
if (!allowed(host, port)) {
|
||
ws.close(4003, 'That address is not a console on your local network');
|
||
return;
|
||
}
|
||
|
||
const tcp = net.connect({ host, port });
|
||
tcp.setNoDelay(true);
|
||
let open = false;
|
||
const pending = [];
|
||
|
||
const timer = setTimeout(() => {
|
||
if (!open) tcp.destroy(new Error('timeout'));
|
||
}, 5000);
|
||
|
||
tcp.on('connect', () => {
|
||
open = true;
|
||
clearTimeout(timer);
|
||
for (const chunk of pending) tcp.write(chunk);
|
||
pending.length = 0;
|
||
});
|
||
tcp.on('data', (chunk) => {
|
||
if (ws.readyState !== ws.OPEN) return;
|
||
ws.send(chunk, { binary: true });
|
||
// Slow browser, fast console: stop reading until the socket drains.
|
||
if (ws.bufferedAmount > 8 * 1024 * 1024) {
|
||
tcp.pause();
|
||
const resume = () => {
|
||
if (ws.readyState !== ws.OPEN) return;
|
||
if (ws.bufferedAmount < 1024 * 1024) tcp.resume();
|
||
else setTimeout(resume, 20);
|
||
};
|
||
setTimeout(resume, 20);
|
||
}
|
||
});
|
||
tcp.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
const reason = !open
|
||
? err.code === 'ECONNREFUSED'
|
||
? 'The console refused the connection. Is the ps5debug payload loaded?'
|
||
: 'Could not reach the console. Check that it is on and on the same network.'
|
||
: 'Lost the connection to the console';
|
||
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) ws.close(4001, reason);
|
||
});
|
||
tcp.on('close', () => {
|
||
clearTimeout(timer);
|
||
if (ws.readyState === ws.OPEN) ws.close(4002, 'The console closed the connection');
|
||
});
|
||
|
||
ws.on('message', (data, isBinary) => {
|
||
if (!isBinary) return;
|
||
if (open) tcp.write(data);
|
||
else pending.push(data);
|
||
});
|
||
ws.on('close', () => tcp.destroy());
|
||
ws.on('error', () => tcp.destroy());
|
||
}
|
||
|
||
const ready = eventsReady.then(
|
||
() =>
|
||
new Promise((resolve, reject) => {
|
||
server.once('error', reject);
|
||
server.listen(opts.port ?? 8744, opts.host ?? '0.0.0.0', () => resolve(server.address().port));
|
||
}),
|
||
);
|
||
|
||
return {
|
||
ready,
|
||
server,
|
||
close() {
|
||
for (const c of wss.clients) c.terminate();
|
||
server.closeAllConnections?.();
|
||
if (eventServer.listening) eventServer.close();
|
||
return new Promise((resolve) => server.close(() => resolve()));
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---- command line ------------------------------------------------------------
|
||
|
||
function lanAddresses() {
|
||
const out = [];
|
||
for (const list of Object.values(os.networkInterfaces())) {
|
||
for (const i of list ?? []) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function openBrowser(url) {
|
||
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
||
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
||
try {
|
||
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
||
} catch {
|
||
// the URL is printed either way
|
||
}
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
const args = process.argv.slice(2);
|
||
const flag = (name) => args.includes(name);
|
||
const portArg = args.indexOf('--port');
|
||
const port = portArg >= 0 ? Number(args[portArg + 1]) : Number(process.env.PORT ?? 8744);
|
||
|
||
let demo = null;
|
||
// In demo mode nothing real will connect back, so use an unprivileged port
|
||
// that works on every system.
|
||
const DEMO_EVENT_PORT = 17755 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||
if (flag('--demo')) {
|
||
const { startMock, MOCK_TCP_PORT, MOCK_UDP_PORT, GAME } = await import('../mock/mock-ps5.mjs');
|
||
// A real console takes a while to scan; the pause makes the demo show progress too.
|
||
const mock = startMock({ scanDelayMs: 250, eventPort: DEMO_EVENT_PORT });
|
||
await mock.ready;
|
||
demo = {
|
||
host: '127.0.0.1',
|
||
port: MOCK_TCP_PORT,
|
||
udpPort: MOCK_UDP_PORT,
|
||
label: 'Demo console',
|
||
game: () => (mock.state.foreground ? { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid } : null),
|
||
};
|
||
}
|
||
|
||
const bridge = startBridge({ port, demo, eventPort: demo ? DEMO_EVENT_PORT : EVENT_PORT });
|
||
bridge.ready
|
||
.then((actual) => {
|
||
const local = `http://localhost:${actual}`;
|
||
console.log(`\n CheatSpecter ${VERSION}${demo ? ' (demo console running)' : ''}\n`);
|
||
console.log(` On this computer: ${local}`);
|
||
for (const ip of lanAddresses()) console.log(` Phone or tablet: http://${ip}:${actual}`);
|
||
console.log('\n Leave this window open while you use it. Close it to stop.\n');
|
||
if (flag('--open')) openBrowser(local);
|
||
})
|
||
.catch((err) => {
|
||
if (err.code === 'EADDRINUSE') {
|
||
console.error(`\n Port ${port} is already in use. CheatSpecter may already be running: http://localhost:${port}\n`);
|
||
if (flag('--open')) openBrowser(`http://localhost:${port}`);
|
||
} else {
|
||
console.error(err);
|
||
}
|
||
process.exit(1);
|
||
});
|
||
}
|