2026-03-05 00:04:26 -06:00
|
|
|
import { encodeMessage, decodeMessage, WireMessage } from "./protocol";
|
|
|
|
|
import { Buffer } from "node:buffer";
|
2026-04-17 13:49:06 -05:00
|
|
|
|
|
|
|
|
let _geoip: typeof import("geoip-lite") extends { default: infer D } ? D : never;
|
|
|
|
|
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 {
|
|
|
|
|
consumeThumbnailRequest,
|
|
|
|
|
getThumbnail,
|
|
|
|
|
generateThumbnail,
|
|
|
|
|
setLatestFrame,
|
|
|
|
|
} from "./thumbnails";
|
|
|
|
|
import { upsertClientRow } from "./db";
|
|
|
|
|
import { metrics } from "./metrics";
|
|
|
|
|
|
2026-04-14 20:31:01 -05: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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-09 22:43:33 -05:00
|
|
|
const CLIENT_DB_SYNC_INTERVAL_MS = Number(process.env.OVERLORD_CLIENT_DB_SYNC_MS || 5000);
|
|
|
|
|
const lastClientDbSync = new Map<string, number>();
|
2026-03-22 23:21:27 -05:00
|
|
|
const pendingClientDbUpdates = new Map<
|
|
|
|
|
string,
|
|
|
|
|
Partial<ClientInfo> & {
|
|
|
|
|
id: string;
|
|
|
|
|
lastSeen?: number;
|
|
|
|
|
online?: number;
|
|
|
|
|
}
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
function queueClientDbUpdate(
|
|
|
|
|
partial: Partial<ClientInfo> & {
|
|
|
|
|
id: string;
|
|
|
|
|
lastSeen?: number;
|
|
|
|
|
online?: number;
|
|
|
|
|
},
|
|
|
|
|
): void {
|
|
|
|
|
const existing = pendingClientDbUpdates.get(partial.id);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
pendingClientDbUpdates.set(partial.id, { ...partial });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pendingClientDbUpdates.set(partial.id, {
|
|
|
|
|
...existing,
|
|
|
|
|
...partial,
|
|
|
|
|
id: partial.id,
|
|
|
|
|
lastSeen: partial.lastSeen ?? existing.lastSeen,
|
|
|
|
|
online: partial.online ?? existing.online,
|
|
|
|
|
pingMs: partial.pingMs ?? existing.pingMs,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flushQueuedClientDbUpdates(): void {
|
|
|
|
|
if (pendingClientDbUpdates.size === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (const update of pendingClientDbUpdates.values()) {
|
|
|
|
|
upsertClientRow(update);
|
|
|
|
|
}
|
|
|
|
|
pendingClientDbUpdates.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInterval(flushQueuedClientDbUpdates, CLIENT_DB_SYNC_INTERVAL_MS);
|
2026-03-09 22:43:33 -05:00
|
|
|
|
|
|
|
|
function shouldSyncClientToDb(clientId: string, now: number): boolean {
|
|
|
|
|
const last = lastClientDbSync.get(clientId) || 0;
|
|
|
|
|
if (now - last < CLIENT_DB_SYNC_INTERVAL_MS) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
lastClientDbSync.set(clientId, now);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearClientSyncState(clientId: string): void {
|
|
|
|
|
lastClientDbSync.delete(clientId);
|
2026-03-22 23:21:27 -05:00
|
|
|
pendingClientDbUpdates.delete(clientId);
|
2026-03-09 22:43:33 -05:00
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-04-17 13:49:06 -05:00
|
|
|
export async function handleHello(
|
2026-03-05 00:04:26 -06:00
|
|
|
info: ClientInfo,
|
|
|
|
|
payload: WireMessage,
|
|
|
|
|
ws: any,
|
|
|
|
|
ip?: string,
|
|
|
|
|
) {
|
|
|
|
|
if (ip) {
|
|
|
|
|
info.ip = ip;
|
|
|
|
|
}
|
2026-04-14 20:31:01 -05:00
|
|
|
info.hwid = sanitizeInfoString((payload as any).hwid);
|
|
|
|
|
info.host = sanitizeInfoString(payload.host);
|
|
|
|
|
info.os = sanitizeInfoString(payload.os);
|
|
|
|
|
info.arch = sanitizeInfoString(payload.arch, 32);
|
|
|
|
|
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;
|
2026-04-07 10:36:29 -05:00
|
|
|
info.isAdmin = !!(payload as any).isAdmin;
|
2026-04-14 20:31:01 -05:00
|
|
|
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
|
|
|
}
|
2026-04-14 20:31:01 -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-04-17 13:49:06 -05:00
|
|
|
const geoip = await getGeoip();
|
2026-03-05 00:04:26 -06:00
|
|
|
const geo = ip ? geoip.lookup(ip) : undefined;
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
upsertClientRow({
|
|
|
|
|
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,
|
2026-04-01 17:27:31 -05:00
|
|
|
cpu: info.cpu,
|
|
|
|
|
gpu: info.gpu,
|
|
|
|
|
ram: info.ram,
|
2026-04-07 10:36:29 -05:00
|
|
|
isAdmin: info.isAdmin,
|
2026-04-08 20:59:46 -05:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sendPingRequest(info, ws, "hello");
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function handlePing(info: ClientInfo, payload: WireMessage, ws: any) {
|
|
|
|
|
//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,
|
2026-04-07 10:36:29 -05:00
|
|
|
isAdmin: info.isAdmin,
|
2026-03-09 22:43:33 -05:00
|
|
|
});
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
ws.send(encodeMessage({ type: "pong", ts: payload.ts || Date.now() }));
|
|
|
|
|
sendPingRequest(info, ws, "client_ping");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function sendPingRequest(info: ClientInfo, ws: any, reason: string) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (
|
|
|
|
|
info.lastPingNonce !== undefined &&
|
|
|
|
|
info.lastPingSent &&
|
|
|
|
|
now-info.lastPingSent < MAX_PING_RTT_MS
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
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 }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
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({
|
2026-03-13 13:33:39 -05:00
|
|
|
id: info.id,
|
|
|
|
|
pingMs: info.pingMs,
|
|
|
|
|
lastSeen: info.lastSeen,
|
|
|
|
|
online: 1,
|
2026-04-07 10:36:29 -05:00
|
|
|
isAdmin: info.isAdmin,
|
2026-03-13 13:33:39 -05:00
|
|
|
});
|
|
|
|
|
lastClientDbSync.set(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,
|
2026-04-07 10:36:29 -05:00
|
|
|
isAdmin: info.isAdmin,
|
2026-03-10 20:37:32 -05:00
|
|
|
});
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function handleFrame(info: ClientInfo, payload: any) {
|
|
|
|
|
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 sentToViewers = false;
|
|
|
|
|
try {
|
|
|
|
|
const globalAny: any = globalThis as any;
|
2026-03-08 22:31:11 -05:00
|
|
|
if (header?.webcam) {
|
|
|
|
|
if (globalAny.__webcamBroadcast) {
|
|
|
|
|
sentToViewers = globalAny.__webcamBroadcast(info.id, bytes, header);
|
|
|
|
|
}
|
|
|
|
|
if (sentToViewers) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if (header?.hvnc) {
|
2026-03-05 00:04:26 -06:00
|
|
|
if (globalAny.__hvncBroadcast) {
|
|
|
|
|
sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header);
|
|
|
|
|
}
|
|
|
|
|
if (sentToViewers) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if (globalAny.__rdBroadcast) {
|
|
|
|
|
sentToViewers = globalAny.__rdBroadcast(info.id, bytes, header);
|
|
|
|
|
if (sentToViewers) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
if (safeFormat) {
|
2026-03-09 22:43:33 -05:00
|
|
|
const now = Date.now();
|
2026-03-22 23:21:27 -05:00
|
|
|
const thumbnailRequested = consumeThumbnailRequest(info.id);
|
|
|
|
|
const hasThumbnail = Boolean(getThumbnail(info.id));
|
|
|
|
|
if (thumbnailRequested || !hasThumbnail) {
|
|
|
|
|
setLatestFrame(info.id, bytes, safeFormat);
|
|
|
|
|
void generateThumbnail(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)) {
|
2026-04-07 10:36:29 -05:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|