Files
overlord/Overlord-Server/src/wsHandlers.ts
T

317 lines
9.8 KiB
TypeScript
Raw Normal View History

2026-05-06 20:07:15 -05:00
import { encodeMessage, type Hello, type Ping, type WireMessage } from "./protocol";
2026-05-06 20:07:15 -05:00
let _geoip: typeof import("geoip-lite");
async function getGeoip() {
if (!_geoip) {
_geoip = (await import("geoip-lite")).default;
}
return _geoip;
}
2026-03-05 00:04:26 -06:00
import { ClientInfo } from "./types";
import {
2026-05-18 09:55:05 -05:00
isThumbnailRequested,
hasThumbnail,
2026-03-05 00:04:26 -06:00
setLatestFrame,
2026-05-18 09:55:05 -05:00
notifyThumbnailGenerated,
requestThumbnailRegen,
2026-03-05 00:04:26 -06:00
} from "./thumbnails";
import { metrics } from "./metrics";
2026-06-29 00:01:07 -05:00
import { flushQueuedClientDbUpdates, markClientDbSynced, queueClientDbUpdate, shouldSyncClientToDb } from "./client-db-sync";
import { setClientDisconnectInfo } from "./db";
2026-05-29 15:41:08 -05:00
export { clearClientSyncState } from "./client-db-sync";
2026-03-05 00:04:26 -06:00
/** Strip control chars and clamp length on client-supplied info strings. */
function sanitizeInfoString(val: unknown, maxLen = 256): string | undefined {
if (typeof val !== "string") return undefined;
return val.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, maxLen);
}
2026-04-29 00:22:37 -05:00
function sanitizeMonitorCount(val: unknown): number | undefined {
if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
const n = Math.floor(val);
if (n < 0) return 0;
if (n > 32) return 32;
return n;
}
2026-05-30 22:44:55 -05:00
function sanitizePercent(val: unknown): number | undefined {
if (typeof val !== "number" || !Number.isFinite(val)) return undefined;
const n = Math.round(val);
if (n < 0 || n > 100) return undefined;
return n;
}
2026-04-29 00:22:37 -05:00
function sanitizeJsonField(
val: unknown,
opts: { maxJsonBytes: number; maxArrayLen?: number; maxKeys?: number },
): unknown | undefined {
if (val === null || val === undefined) return undefined;
if (typeof val !== "object") return undefined;
if (Array.isArray(val) && opts.maxArrayLen !== undefined && val.length > opts.maxArrayLen) {
return undefined;
}
if (!Array.isArray(val) && opts.maxKeys !== undefined) {
const keys = Object.keys(val as Record<string, unknown>);
if (keys.length > opts.maxKeys) return undefined;
}
let serialized: string;
try {
serialized = JSON.stringify(val);
} catch {
return undefined;
}
if (serialized.length > opts.maxJsonBytes) return undefined;
return val;
}
2026-03-05 00:04:26 -06:00
const MAX_PING_RTT_MS = 15_000;
export async function handleHello(
2026-03-05 00:04:26 -06:00
info: ClientInfo,
2026-05-06 20:07:15 -05:00
payload: Hello,
2026-03-05 00:04:26 -06:00
ws: any,
ip?: string,
) {
if (ip) {
info.ip = ip;
}
2026-05-14 19:56:50 -05:00
const reportedPublicIP = sanitizeInfoString((payload as any).publicIP, 64);
if (reportedPublicIP && /^[0-9a-fA-F:.]{3,45}$/.test(reportedPublicIP)) {
info.ip = reportedPublicIP;
}
info.hwid = sanitizeInfoString((payload as any).hwid);
info.host = sanitizeInfoString(payload.host);
info.os = sanitizeInfoString(payload.os);
info.arch = sanitizeInfoString(payload.arch, 32);
info.hostArch = sanitizeInfoString((payload as any).hostArch, 32) || info.arch;
info.version = sanitizeInfoString(payload.version, 64);
info.user = sanitizeInfoString(payload.user);
2026-04-29 00:22:37 -05:00
info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors;
const cleanMonitorInfo = sanitizeJsonField((payload as any).monitorInfo, {
maxJsonBytes: 8 * 1024,
maxArrayLen: 32,
});
if (cleanMonitorInfo !== undefined) {
info.monitorInfo = cleanMonitorInfo as any;
}
2026-03-29 13:39:51 -05:00
info.inMemory = !!(payload as any).inMemory;
info.isAdmin = !!(payload as any).isAdmin;
info.elevation = sanitizeInfoString((payload as any).elevation, 32) ?? info.elevation;
2026-04-29 00:22:37 -05:00
const cleanPermissions = sanitizeJsonField((payload as any).permissions, {
maxJsonBytes: 4 * 1024,
maxKeys: 64,
});
if (cleanPermissions !== undefined && !Array.isArray(cleanPermissions)) {
info.permissions = cleanPermissions as any;
2026-04-13 10:49:36 -05:00
}
info.cpu = sanitizeInfoString((payload as any).cpu) || info.cpu;
info.gpu = sanitizeInfoString((payload as any).gpu) || info.gpu;
info.ram = sanitizeInfoString((payload as any).ram, 64) || info.ram;
2026-05-30 22:44:55 -05:00
const batteryPercent = sanitizePercent((payload as any).batteryPercent);
if (batteryPercent !== undefined) {
info.batteryPercent = batteryPercent;
info.batteryCharging = !!(payload as any).batteryCharging;
}
const geoip = await getGeoip();
2026-05-14 19:56:50 -05:00
const geo = info.ip ? geoip.lookup(info.ip) : undefined;
2026-03-05 00:04:26 -06:00
const countryRaw =
geo?.country || (payload as any).country || info.country || "ZZ";
const country = /^[A-Z]{2}$/i.test(countryRaw)
? countryRaw.toUpperCase()
: "ZZ";
info.country = country;
info.lastSeen = Date.now();
info.online = true;
2026-06-29 00:01:07 -05:00
const lastCrashReason = sanitizeInfoString((payload as any).lastCrashReason, 64);
const lastCrashDetail = sanitizeInfoString((payload as any).lastCrashDetail, 1200);
2026-03-05 00:04:26 -06:00
2026-06-08 22:33:34 -05:00
queueClientDbUpdate({
2026-03-05 00:04:26 -06:00
id: info.id,
hwid: info.hwid,
role: info.role,
ip: info.ip,
host: info.host,
os: info.os,
arch: info.arch,
version: info.version,
user: info.user,
monitors: info.monitors,
country: info.country,
cpu: info.cpu,
gpu: info.gpu,
ram: info.ram,
2026-05-30 22:44:55 -05:00
batteryPercent: info.batteryPercent,
batteryCharging: info.batteryCharging,
isAdmin: info.isAdmin,
elevation: info.elevation,
2026-04-13 10:49:36 -05:00
permissions: info.permissions,
2026-03-05 00:04:26 -06:00
lastSeen: info.lastSeen,
online: 1,
});
2026-06-29 00:01:07 -05:00
if (lastCrashReason) {
flushQueuedClientDbUpdates();
setClientDisconnectInfo(info.id, lastCrashReason, lastCrashDetail);
}
2026-03-05 00:04:26 -06:00
sendPingRequest(info, ws, "hello");
}
2026-05-06 20:07:15 -05:00
export function handlePing(info: ClientInfo, payload: Ping, ws: any) {
2026-03-05 00:04:26 -06:00
//console.log(`[ping] from client=${info.id} ts=${payload.ts ?? ""}`);
2026-03-09 22:43:33 -05:00
const now = Date.now();
info.lastSeen = now;
2026-03-05 00:04:26 -06:00
info.online = true;
2026-03-09 22:43:33 -05:00
if (shouldSyncClientToDb(info.id, now)) {
2026-03-22 23:21:27 -05:00
queueClientDbUpdate({
2026-03-09 22:43:33 -05:00
id: info.id,
lastSeen: info.lastSeen,
online: 1,
isAdmin: info.isAdmin,
2026-03-09 22:43:33 -05:00
});
}
const ts = typeof payload.ts === "number" && Number.isFinite(payload.ts)
? payload.ts
: Date.now();
ws.send(encodeMessage({ type: "pong", ts }));
2026-03-05 00:04:26 -06:00
}
export function sendPingRequest(
info: ClientInfo,
ws: any,
reason: string,
minIntervalMs = 1_000,
): boolean {
2026-03-05 00:04:26 -06:00
const now = Date.now();
if (minIntervalMs > 0 && info.lastPingSent && now - info.lastPingSent < minIntervalMs) {
return false;
2026-03-05 00:04:26 -06:00
}
const nonce = now + Math.floor(Math.random() * 1000);
info.lastPingSent = now;
info.lastPingNonce = nonce;
//console.log(`[ping] send ping to client=${info.id} reason=${reason} nonce=${nonce}`);
ws.send(encodeMessage({ type: "ping", ts: nonce }));
return true;
2026-03-05 00:04:26 -06:00
}
export function handlePong(info: ClientInfo, payload: WireMessage) {
const tsRaw = (payload as any).ts;
const ts = typeof tsRaw === "number" ? tsRaw : Number(tsRaw);
if (!Number.isFinite(ts)) {
return;
}
const now = Date.now();
const maxRttMs = MAX_PING_RTT_MS;
const expectedNonce = info.lastPingNonce;
if (expectedNonce === undefined) {
return;
}
if (ts !== expectedNonce) {
return;
}
if (!info.lastPingSent) {
return;
}
const rtt = now - info.lastPingSent;
2026-03-10 20:37:32 -05:00
const nowTs = Date.now();
info.lastSeen = nowTs;
2026-05-04 09:58:59 -05:00
info.lastPongAt = nowTs;
2026-03-10 20:37:32 -05:00
info.online = true;
info.lastPingNonce = undefined;
2026-03-05 00:04:26 -06:00
if (rtt >= 0 && rtt < maxRttMs) {
info.pingMs = rtt;
2026-03-22 23:21:27 -05:00
queueClientDbUpdate({
id: info.id,
pingMs: info.pingMs,
lastSeen: info.lastSeen,
online: 1,
isAdmin: info.isAdmin,
});
2026-05-29 15:41:08 -05:00
markClientDbSynced(info.id, nowTs);
2026-03-05 00:04:26 -06:00
metrics.recordPing(rtt);
} else {
2026-03-10 20:37:32 -05:00
if (shouldSyncClientToDb(info.id, nowTs)) {
2026-03-22 23:21:27 -05:00
queueClientDbUpdate({
2026-03-10 20:37:32 -05:00
id: info.id,
lastSeen: info.lastSeen,
online: 1,
isAdmin: info.isAdmin,
2026-03-10 20:37:32 -05:00
});
}
2026-03-05 00:04:26 -06:00
}
}
2026-07-11 14:55:26 -05:00
export function handleScreenshotThumbnailResult(info: ClientInfo, payload: any): boolean {
const requested = isThumbnailRequested(info.id);
if (!requested || payload?.error) return false;
let bytes: Uint8Array | null = null;
if (payload?.data instanceof Uint8Array) {
bytes = payload.data;
} else if (payload?.data instanceof ArrayBuffer) {
bytes = new Uint8Array(payload.data);
} else if (ArrayBuffer.isView(payload?.data)) {
bytes = new Uint8Array(payload.data.buffer, payload.data.byteOffset, payload.data.byteLength);
}
if (!bytes?.byteLength) return false;
const rawFormat = String(payload?.format || "jpeg").toLowerCase();
const format = rawFormat === "jpg" ? "jpeg" : rawFormat;
if (format !== "jpeg" && format !== "webp") return false;
setLatestFrame(info.id, bytes, format);
void requestThumbnailRegen(info.id).then((ok) => {
if (ok) notifyThumbnailGenerated(info.id);
});
return true;
}
export function handleFrame(info: ClientInfo, payload: any): boolean {
2026-03-05 00:04:26 -06:00
const bytes = payload.data as unknown as Uint8Array;
const header = (payload as any).header;
const allowedFormats = ["jpeg", "jpg", "webp"];
const fmt = String(header?.format || "").toLowerCase();
const safeFormat = allowedFormats.includes(fmt) ? fmt : "";
metrics.recordBytesReceived(bytes.length);
let handledByViewerRelay = false;
2026-03-05 00:04:26 -06:00
try {
const globalAny: any = globalThis as any;
2026-03-08 22:31:11 -05:00
if (header?.webcam) {
if (globalAny.__webcamBroadcast) {
handledByViewerRelay = globalAny.__webcamBroadcast(info.id, bytes, header);
2026-03-08 22:31:11 -05:00
}
} else if (header?.hvnc) {
2026-03-05 00:04:26 -06:00
if (globalAny.__hvncBroadcast) {
handledByViewerRelay = globalAny.__hvncBroadcast(info.id, bytes, header);
2026-03-05 00:04:26 -06:00
}
} else if (globalAny.__rdBroadcast) {
handledByViewerRelay = globalAny.__rdBroadcast(info.id, bytes, header);
2026-03-05 00:04:26 -06:00
}
} catch {}
if (safeFormat) {
2026-03-09 22:43:33 -05:00
const now = Date.now();
2026-05-18 09:55:05 -05:00
const thumbnailRequested = isThumbnailRequested(info.id);
if (thumbnailRequested || !hasThumbnail(info.id)) {
2026-03-22 23:21:27 -05:00
setLatestFrame(info.id, bytes, safeFormat);
2026-05-18 09:55:05 -05:00
void requestThumbnailRegen(info.id).then((ok) => {
if (ok) notifyThumbnailGenerated(info.id);
});
2026-03-05 00:04:26 -06:00
}
2026-03-09 22:43:33 -05:00
info.lastSeen = now;
info.online = true;
if (shouldSyncClientToDb(info.id, now)) {
queueClientDbUpdate({ id: info.id, lastSeen: now, online: 1, isAdmin: info.isAdmin });
2026-03-09 22:43:33 -05:00
}
2026-03-05 00:04:26 -06:00
}
return handledByViewerRelay || safeFormat !== "";
2026-03-05 00:04:26 -06:00
}