add recording, fix solpush, persistence tests and tiny improvements
This commit is contained in:
@@ -40,6 +40,7 @@ import { handlePermissionGroupsRoutes } from "./server/routes/permission-groups-
|
||||
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
||||
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
||||
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
|
||||
import { handleRemoteDesktopRecordingRoutes } from "./server/routes/rd-recording-routes";
|
||||
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
||||
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
||||
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
||||
@@ -531,6 +532,9 @@ async function startServer() {
|
||||
pendingCommandReplies,
|
||||
broadcastNotificationsCleared: notificationPluginHandlers.broadcastNotificationsCleared,
|
||||
},
|
||||
rdRecording: {
|
||||
secureHeaders,
|
||||
},
|
||||
wsUpgrade: {
|
||||
isAuthorizedAgentRequest: isAuthorizedAgent,
|
||||
},
|
||||
@@ -687,6 +691,7 @@ async function startServer() {
|
||||
(req, url) => handleWebrtcRoutes(req, url),
|
||||
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
||||
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
||||
(req, url) => handleRemoteDesktopRecordingRoutes(req, url, routeDeps.rdRecording),
|
||||
],
|
||||
}),
|
||||
websocket: createWebSocketRuntime({
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureDataDir } from "../paths";
|
||||
import { logger } from "../logger";
|
||||
import * as sessionManager from "../sessions/sessionManager";
|
||||
import { safeSendViewer } from "./ws-viewer-utils";
|
||||
|
||||
export type RdRecordingStatus =
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "failed";
|
||||
|
||||
export type RdRecordingSummary = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
status: RdRecordingStatus;
|
||||
startedAt: number;
|
||||
stoppedAt?: number;
|
||||
requestedByUserId?: number;
|
||||
requestedByUsername?: string;
|
||||
sourceFps?: number;
|
||||
targetFps: number;
|
||||
segmentSeconds: number;
|
||||
compact: boolean;
|
||||
encodingMode: "copy" | "compact" | "transcode";
|
||||
framesWritten: number;
|
||||
framesDropped: number;
|
||||
framesSkipped: number;
|
||||
bytesWritten: number;
|
||||
error?: string;
|
||||
files: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
downloadUrl: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ActiveRecording = Omit<RdRecordingSummary, "files"> & {
|
||||
dir: string;
|
||||
process: ChildProcessWithoutNullStreams;
|
||||
inputCodec: "mjpeg" | "h264";
|
||||
compact: boolean;
|
||||
encodingMode: "copy" | "compact" | "transcode";
|
||||
h264Started: boolean;
|
||||
lastFrameAt: number;
|
||||
lastDebugAt: number;
|
||||
stderr: string;
|
||||
writeBlocked: boolean;
|
||||
finalized: boolean;
|
||||
};
|
||||
|
||||
type StartOptions = {
|
||||
clientId: string;
|
||||
requestedByUserId?: number;
|
||||
requestedByUsername?: string;
|
||||
fps?: number;
|
||||
sourceFps?: number;
|
||||
inputCodec?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const activeRecordings = new Map<string, ActiveRecording>();
|
||||
|
||||
function sanitizePathPart(value: string): string {
|
||||
return String(value || "client")
|
||||
.replace(/[^a-z0-9._-]+/gi, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 96) || "client";
|
||||
}
|
||||
|
||||
function recordingRoot(): string {
|
||||
return path.join(ensureDataDir(), "rd-recordings");
|
||||
}
|
||||
|
||||
function clientRecordingDir(clientId: string): string {
|
||||
return path.join(recordingRoot(), sanitizePathPart(clientId));
|
||||
}
|
||||
|
||||
function metadataPath(dir: string): string {
|
||||
return path.join(dir, "recording.json");
|
||||
}
|
||||
|
||||
function ffmpegPath(): string {
|
||||
return process.env.OVERLORD_FFMPEG_PATH?.trim() || "ffmpeg";
|
||||
}
|
||||
|
||||
function envNumber(name: string, fallback: number, min: number, max: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
const value = raw ? Number(raw) : fallback;
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function requestedFps(raw?: number): number | undefined {
|
||||
if (!raw || !Number.isFinite(raw)) return undefined;
|
||||
return Math.max(1, Math.min(120, Math.floor(raw)));
|
||||
}
|
||||
|
||||
function targetFps(raw?: number): number {
|
||||
return requestedFps(raw) || envNumber("OVERLORD_RD_RECORD_FPS", 15, 1, 120);
|
||||
}
|
||||
|
||||
function h264InputFps(raw?: number): number {
|
||||
const fallback = envNumber("OVERLORD_RD_RECORD_H264_FPS", 60, 1, 120);
|
||||
if (!raw || !Number.isFinite(raw)) return fallback;
|
||||
return Math.max(1, Math.min(120, Math.floor(raw)));
|
||||
}
|
||||
|
||||
function segmentSeconds(): number {
|
||||
return envNumber("OVERLORD_RD_RECORD_SEGMENT_SECONDS", 600, 30, 3600);
|
||||
}
|
||||
|
||||
function videoBitrate(): string {
|
||||
return process.env.OVERLORD_RD_RECORD_BITRATE?.trim() || "2500k";
|
||||
}
|
||||
|
||||
function compactCrf(): number {
|
||||
return envNumber("OVERLORD_RD_RECORD_COMPACT_CRF", 30, 18, 40);
|
||||
}
|
||||
|
||||
function compactPreset(): string {
|
||||
const value = process.env.OVERLORD_RD_RECORD_COMPACT_PRESET?.trim() || "veryfast";
|
||||
return /^(ultrafast|superfast|veryfast|faster|fast|medium|slow|slower|veryslow)$/i.test(value)
|
||||
? value.toLowerCase()
|
||||
: "veryfast";
|
||||
}
|
||||
|
||||
function recordingInputCodec(raw?: string): "mjpeg" | "h264" {
|
||||
return String(raw || "").toLowerCase() === "h264" ? "h264" : "mjpeg";
|
||||
}
|
||||
|
||||
function frameFormatLabel(format: unknown): string {
|
||||
if (typeof format === "number") {
|
||||
return format === 1 ? "jpeg(1)" :
|
||||
format === 2 ? "blocks(2)" :
|
||||
format === 3 ? "blocks_raw(3)" :
|
||||
format === 4 ? "h264(4)" :
|
||||
`unknown(${format})`;
|
||||
}
|
||||
return String(format || "jpeg").toLowerCase();
|
||||
}
|
||||
|
||||
function h264StreamStartFlags(bytes: Uint8Array): { sps: boolean; pps: boolean; idr: boolean } {
|
||||
const flags = { sps: false, pps: false, idr: false };
|
||||
for (let i = 0; i + 4 < bytes.length; i++) {
|
||||
let startCodeLen = 0;
|
||||
if (bytes[i] === 0x00 && bytes[i + 1] === 0x00 && bytes[i + 2] === 0x01) {
|
||||
startCodeLen = 3;
|
||||
} else if (
|
||||
i + 4 < bytes.length &&
|
||||
bytes[i] === 0x00 &&
|
||||
bytes[i + 1] === 0x00 &&
|
||||
bytes[i + 2] === 0x00 &&
|
||||
bytes[i + 3] === 0x01
|
||||
) {
|
||||
startCodeLen = 4;
|
||||
}
|
||||
if (!startCodeLen) continue;
|
||||
const nalIndex = i + startCodeLen;
|
||||
if (nalIndex >= bytes.length) break;
|
||||
const nalType = bytes[nalIndex] & 0x1f;
|
||||
if (nalType === 5) flags.idr = true;
|
||||
if (nalType === 7) flags.sps = true;
|
||||
if (nalType === 8) flags.pps = true;
|
||||
i = nalIndex;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function listSegmentFiles(clientId: string, recordingId: string): RdRecordingSummary["files"] {
|
||||
const dir = path.join(clientRecordingDir(clientId), recordingId);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const prefix = `${recordingId}-`;
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.startsWith(prefix) && (name.endsWith(".mp4") || name.endsWith(".webm")))
|
||||
.sort()
|
||||
.map((name) => {
|
||||
const filePath = path.join(dir, name);
|
||||
let size = 0;
|
||||
try {
|
||||
size = fs.statSync(filePath).size;
|
||||
} catch {}
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
downloadUrl: `/api/clients/${encodeURIComponent(clientId)}/rd/recordings/${encodeURIComponent(recordingId)}/${encodeURIComponent(name)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toSummary(recording: ActiveRecording): RdRecordingSummary {
|
||||
return {
|
||||
id: recording.id,
|
||||
clientId: recording.clientId,
|
||||
status: recording.status,
|
||||
startedAt: recording.startedAt,
|
||||
stoppedAt: recording.stoppedAt,
|
||||
requestedByUserId: recording.requestedByUserId,
|
||||
requestedByUsername: recording.requestedByUsername,
|
||||
sourceFps: recording.sourceFps,
|
||||
targetFps: recording.targetFps,
|
||||
segmentSeconds: recording.segmentSeconds,
|
||||
compact: recording.compact,
|
||||
encodingMode: recording.encodingMode,
|
||||
framesWritten: recording.framesWritten,
|
||||
framesDropped: recording.framesDropped,
|
||||
framesSkipped: recording.framesSkipped,
|
||||
bytesWritten: recording.bytesWritten,
|
||||
error: recording.error,
|
||||
files: listSegmentFiles(recording.clientId, recording.id),
|
||||
};
|
||||
}
|
||||
|
||||
function writeMetadata(recording: ActiveRecording): void {
|
||||
try {
|
||||
fs.writeFileSync(metadataPath(recording.dir), JSON.stringify(toSummary(recording), null, 2));
|
||||
} catch (err) {
|
||||
logger.warn(`[rd-recording] failed to write metadata for ${recording.clientId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastStatus(clientId: string): void {
|
||||
const status = getRemoteDesktopRecordingStatus(clientId);
|
||||
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
|
||||
safeSendViewer(session.viewer, { type: "recording_status", recording: status });
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeRecording(recording: ActiveRecording, status: RdRecordingStatus, error?: string): void {
|
||||
if (recording.finalized) return;
|
||||
recording.finalized = true;
|
||||
if (status === "stopped" && recording.framesWritten === 0) {
|
||||
status = "failed";
|
||||
error = error || "No compatible video frames were received while recording.";
|
||||
}
|
||||
recording.status = status;
|
||||
recording.stoppedAt = Date.now();
|
||||
if (error) recording.error = error;
|
||||
activeRecordings.delete(recording.clientId);
|
||||
writeMetadata(recording);
|
||||
logger.info(`[rd-recording] ${status} client=${recording.clientId} id=${recording.id} frames=${recording.framesWritten} dropped=${recording.framesDropped} skipped=${recording.framesSkipped}`);
|
||||
broadcastStatus(recording.clientId);
|
||||
}
|
||||
|
||||
export function startRemoteDesktopRecording(options: StartOptions): RdRecordingSummary {
|
||||
const existing = activeRecordings.get(options.clientId);
|
||||
if (existing) return toSummary(existing);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const dir = path.join(clientRecordingDir(options.clientId), id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const inputCodec = recordingInputCodec(options.inputCodec);
|
||||
const requestedOutputFps = requestedFps(options.fps);
|
||||
const sourceFps = inputCodec === "h264" ? h264InputFps(options.sourceFps) : undefined;
|
||||
const fps = inputCodec === "h264"
|
||||
? Math.min(requestedOutputFps || sourceFps || h264InputFps(), sourceFps || h264InputFps())
|
||||
: targetFps(options.fps);
|
||||
const h264FpsLimited = inputCodec === "h264" && !!requestedOutputFps && !!sourceFps && requestedOutputFps < sourceFps;
|
||||
const seconds = segmentSeconds();
|
||||
const compact = !!options.compact;
|
||||
const encodingMode: ActiveRecording["encodingMode"] = compact
|
||||
? "compact"
|
||||
: inputCodec === "h264" && !h264FpsLimited
|
||||
? "copy"
|
||||
: "transcode";
|
||||
const outputFile = path.join(dir, `${id}-001.mp4`);
|
||||
const h264InputArgs = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-r",
|
||||
String(sourceFps || fps),
|
||||
"-f",
|
||||
"h264",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-an",
|
||||
];
|
||||
const args = inputCodec === "h264"
|
||||
? encodingMode === "copy"
|
||||
? [
|
||||
...h264InputArgs,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
...h264InputArgs,
|
||||
...(h264FpsLimited ? ["-vf", `fps=${fps}`] : []),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
compact ? compactPreset() : "veryfast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
...(compact ? ["-crf", String(compactCrf())] : ["-b:v", videoBitrate()]),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputFile,
|
||||
]
|
||||
: [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-framerate",
|
||||
String(fps),
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
compact ? compactPreset() : "veryfast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
...(compact ? ["-crf", String(compactCrf())] : ["-b:v", videoBitrate()]),
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputFile,
|
||||
];
|
||||
|
||||
const child = spawn(ffmpegPath(), args, { windowsHide: true });
|
||||
const recording: ActiveRecording = {
|
||||
id,
|
||||
clientId: options.clientId,
|
||||
status: "starting",
|
||||
startedAt: Date.now(),
|
||||
requestedByUserId: options.requestedByUserId,
|
||||
requestedByUsername: options.requestedByUsername,
|
||||
sourceFps,
|
||||
targetFps: fps,
|
||||
segmentSeconds: seconds,
|
||||
framesWritten: 0,
|
||||
framesDropped: 0,
|
||||
framesSkipped: 0,
|
||||
bytesWritten: 0,
|
||||
dir,
|
||||
process: child,
|
||||
inputCodec,
|
||||
compact,
|
||||
encodingMode,
|
||||
h264Started: false,
|
||||
lastFrameAt: 0,
|
||||
lastDebugAt: 0,
|
||||
stderr: "",
|
||||
writeBlocked: false,
|
||||
finalized: false,
|
||||
};
|
||||
|
||||
activeRecordings.set(options.clientId, recording);
|
||||
writeMetadata(recording);
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
recording.stderr = `${recording.stderr}${chunk.toString()}`.slice(-4000);
|
||||
});
|
||||
child.stdin.on("drain", () => {
|
||||
recording.writeBlocked = false;
|
||||
});
|
||||
child.stdin.on("error", (err) => {
|
||||
if (!recording.finalized) {
|
||||
recording.error = (err as Error).message;
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
finalizeRecording(recording, "failed", `ffmpeg failed to start: ${(err as Error).message}`);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (recording.finalized) return;
|
||||
const ok = recording.status === "stopping" || code === 0;
|
||||
const err = ok ? undefined : (recording.stderr || `ffmpeg exited with code=${code} signal=${signal || ""}`).trim();
|
||||
logger.debug(`[rd-recording] ffmpeg exit client=${recording.clientId} id=${recording.id} code=${code ?? "null"} signal=${signal || ""} frames=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped}${recording.stderr ? ` stderr=${recording.stderr.slice(-500)}` : ""}`);
|
||||
finalizeRecording(recording, ok ? "stopped" : "failed", err);
|
||||
});
|
||||
|
||||
const outputCodec = encodingMode === "copy" ? "copy" : "libx264";
|
||||
const outputRate = encodingMode === "copy" ? "source" : compact ? `crf${compactCrf()}` : videoBitrate();
|
||||
const outputPreset = encodingMode === "copy" ? "source" : compact ? compactPreset() : "veryfast";
|
||||
logger.info(`[rd-recording] started client=${options.clientId} id=${id} source_fps=${sourceFps || "n/a"} target_fps=${fps} fps_limited=${h264FpsLimited} segment=${seconds}s input=${inputCodec} output=mp4 mode=${encodingMode} codec=${outputCodec} bitrate=${outputRate} preset=${outputPreset} file=${outputFile}`);
|
||||
broadcastStatus(options.clientId);
|
||||
return toSummary(recording);
|
||||
}
|
||||
|
||||
export function recordRemoteDesktopFrame(clientId: string, header: any, bytes: Uint8Array): void {
|
||||
const recording = activeRecordings.get(clientId);
|
||||
if (!recording || recording.status === "stopping") return;
|
||||
|
||||
const rawFormat = header?.format ?? "jpeg";
|
||||
const format = typeof rawFormat === "number" ? rawFormat : String(rawFormat).toLowerCase();
|
||||
const isH264 = format === 4 || format === "h264";
|
||||
const isJpeg = format === 1 || format === "jpeg" || format === "jpg";
|
||||
if ((recording.inputCodec === "h264" && !isH264) || (recording.inputCodec === "mjpeg" && !isJpeg)) {
|
||||
recording.framesSkipped += 1;
|
||||
if (recording.framesSkipped <= 5 || recording.framesSkipped % 60 === 0) {
|
||||
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=unsupported_format expected=${recording.inputCodec} format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (recording.inputCodec === "h264" && !recording.h264Started) {
|
||||
const flags = h264StreamStartFlags(bytes);
|
||||
if (!flags.sps || !flags.pps) {
|
||||
recording.framesSkipped += 1;
|
||||
if (recording.framesSkipped <= 5 || recording.framesSkipped % 60 === 0) {
|
||||
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=waiting_for_h264_headers format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} sps=${flags.sps} pps=${flags.pps} idr=${flags.idr} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
recording.h264Started = true;
|
||||
logger.debug(`[rd-recording] h264 stream start client=${clientId} id=${recording.id} bytes=${bytes.byteLength} sps=${flags.sps} pps=${flags.pps} idr=${flags.idr}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const shouldThrottle = recording.inputCodec !== "h264";
|
||||
const minInterval = Math.floor(1000 / Math.max(1, recording.targetFps));
|
||||
if (shouldThrottle && recording.lastFrameAt && now - recording.lastFrameAt < minInterval) {
|
||||
recording.framesSkipped += 1;
|
||||
if (recording.framesSkipped <= 5 || recording.framesSkipped % 120 === 0) {
|
||||
logger.debug(`[rd-recording] skip frame client=${clientId} id=${recording.id} reason=fps_throttle format=${frameFormatLabel(rawFormat)} deltaMs=${now - recording.lastFrameAt} minMs=${minInterval} skipped=${recording.framesSkipped} written=${recording.framesWritten}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recording.process.stdin.writable) {
|
||||
recording.framesDropped += 1;
|
||||
if (recording.framesDropped <= 5 || recording.framesDropped % 30 === 0) {
|
||||
logger.debug(`[rd-recording] drop frame client=${clientId} id=${recording.id} reason=stdin_closed writable=${recording.process.stdin.writable} dropped=${recording.framesDropped} written=${recording.framesWritten}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (recording.inputCodec !== "h264" && recording.writeBlocked) {
|
||||
recording.framesDropped += 1;
|
||||
if (recording.framesDropped <= 5 || recording.framesDropped % 30 === 0) {
|
||||
logger.debug(`[rd-recording] drop frame client=${clientId} id=${recording.id} reason=stdin_backpressure writable=${recording.process.stdin.writable} dropped=${recording.framesDropped} written=${recording.framesWritten}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
recording.status = "recording";
|
||||
recording.lastFrameAt = now;
|
||||
recording.framesWritten += 1;
|
||||
recording.bytesWritten += bytes.byteLength;
|
||||
recording.writeBlocked = !recording.process.stdin.write(Buffer.from(bytes));
|
||||
if (recording.framesWritten === 1 || now - recording.lastDebugAt >= 5000) {
|
||||
recording.lastDebugAt = now;
|
||||
logger.debug(`[rd-recording] write frame client=${clientId} id=${recording.id} format=${frameFormatLabel(rawFormat)} bytes=${bytes.byteLength} written=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped} stdinBlocked=${recording.writeBlocked}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopRemoteDesktopRecording(clientId: string, reason = "stopped"): RdRecordingSummary | null {
|
||||
const recording = activeRecordings.get(clientId);
|
||||
if (!recording) return null;
|
||||
logger.debug(`[rd-recording] stop requested client=${clientId} id=${recording.id} reason=${reason} status=${recording.status} frames=${recording.framesWritten} skipped=${recording.framesSkipped} dropped=${recording.framesDropped}`);
|
||||
recording.status = "stopping";
|
||||
recording.error = reason === "stopped" ? recording.error : reason;
|
||||
writeMetadata(recording);
|
||||
try {
|
||||
recording.process.stdin.end();
|
||||
} catch {}
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!recording.finalized) {
|
||||
try {
|
||||
recording.process.kill("SIGKILL");
|
||||
} catch {}
|
||||
finalizeRecording(recording, "failed", recording.error || "ffmpeg did not stop cleanly");
|
||||
}
|
||||
}, 5000);
|
||||
if (typeof (killTimer as any).unref === "function") {
|
||||
(killTimer as any).unref();
|
||||
}
|
||||
broadcastStatus(clientId);
|
||||
return toSummary(recording);
|
||||
}
|
||||
|
||||
export function getRemoteDesktopRecordingStatus(clientId: string): RdRecordingSummary | null {
|
||||
const active = activeRecordings.get(clientId);
|
||||
if (active) return toSummary(active);
|
||||
return listRemoteDesktopRecordings(clientId)[0] || null;
|
||||
}
|
||||
|
||||
export function listRemoteDesktopRecordings(clientId: string): RdRecordingSummary[] {
|
||||
const active = activeRecordings.get(clientId);
|
||||
const root = clientRecordingDir(clientId);
|
||||
const summaries: RdRecordingSummary[] = [];
|
||||
if (fs.existsSync(root)) {
|
||||
for (const id of fs.readdirSync(root)) {
|
||||
const meta = path.join(root, id, "recording.json");
|
||||
if (!fs.existsSync(meta)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(meta, "utf8")) as RdRecordingSummary;
|
||||
if (parsed.clientId !== clientId || parsed.id !== id) continue;
|
||||
parsed.files = listSegmentFiles(clientId, id);
|
||||
summaries.push(parsed);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (active && !summaries.some((item) => item.id === active.id)) {
|
||||
summaries.push(toSummary(active));
|
||||
}
|
||||
return summaries.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
export function getRemoteDesktopRecordingFile(
|
||||
clientId: string,
|
||||
recordingId: string,
|
||||
fileName: string,
|
||||
): { path: string; size: number } | null {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(recordingId)) return null;
|
||||
if (!new RegExp(`^${recordingId.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}-\\d+\\.(mp4|webm)$`).test(fileName)) {
|
||||
return null;
|
||||
}
|
||||
const filePath = path.join(clientRecordingDir(clientId), recordingId, fileName);
|
||||
const root = path.resolve(clientRecordingDir(clientId), recordingId);
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!resolved.startsWith(root + path.sep)) return null;
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isFile()) return null;
|
||||
return { path: resolved, size: stat.size };
|
||||
}
|
||||
|
||||
export function stopAllRemoteDesktopRecordings(reason = "server_shutdown"): void {
|
||||
for (const clientId of Array.from(activeRecordings.keys())) {
|
||||
stopRemoteDesktopRecording(clientId, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
|
||||
import {
|
||||
getRemoteDesktopRecordingFile,
|
||||
getRemoteDesktopRecordingStatus,
|
||||
listRemoteDesktopRecordings,
|
||||
} from "../rd-recording";
|
||||
|
||||
type RdRecordingRouteDeps = {
|
||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||
};
|
||||
|
||||
function forbiddenResponse(error: unknown): Response {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
async function authorize(req: Request, clientId: string): Promise<Response | null> {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
try {
|
||||
requireClientAccess(user, clientId);
|
||||
requireFeatureAccess(user, "remote_desktop");
|
||||
} catch (error) {
|
||||
return forbiddenResponse(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleRemoteDesktopRecordingRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: RdRecordingRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
const listMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/rd\/recordings$/);
|
||||
if (listMatch && req.method === "GET") {
|
||||
const clientId = decodeURIComponent(listMatch[1]);
|
||||
const denied = await authorize(req, clientId);
|
||||
if (denied) return denied;
|
||||
return Response.json({
|
||||
active: getRemoteDesktopRecordingStatus(clientId),
|
||||
items: listRemoteDesktopRecordings(clientId),
|
||||
});
|
||||
}
|
||||
|
||||
const fileMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/rd\/recordings\/([^/]+)\/([^/]+)$/);
|
||||
if (fileMatch && req.method === "GET") {
|
||||
const clientId = decodeURIComponent(fileMatch[1]);
|
||||
const recordingId = decodeURIComponent(fileMatch[2]);
|
||||
const fileName = decodeURIComponent(fileMatch[3]);
|
||||
const denied = await authorize(req, clientId);
|
||||
if (denied) return denied;
|
||||
|
||||
const file = getRemoteDesktopRecordingFile(clientId, recordingId, fileName);
|
||||
if (!file) return new Response("Not found", { status: 404 });
|
||||
const contentType = fileName.toLowerCase().endsWith(".mp4") ? "video/mp4" : "video/webm";
|
||||
|
||||
return new Response(Bun.file(file.path), {
|
||||
headers: {
|
||||
...deps.secureHeaders(contentType),
|
||||
"Content-Length": String(file.size),
|
||||
"Content-Disposition": `attachment; filename="${fileName.replace(/"/g, "")}"`,
|
||||
"Cache-Control": "no-store, private",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType }
|
||||
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
||||
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
||||
import { clearThumbnail } from "../../thumbnails";
|
||||
import { stopRemoteDesktopRecording } from "../rd-recording";
|
||||
|
||||
const OFFLINE_GRACE_MS = (() => {
|
||||
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
|
||||
@@ -991,6 +992,7 @@ export function handleWebSocketClose(
|
||||
deps.rdStreamingState.delete(clientId);
|
||||
deps.hvncStreamingState.delete(clientId);
|
||||
deps.webcamStreamingState.delete(clientId);
|
||||
stopRemoteDesktopRecording(clientId, "client disconnected");
|
||||
logger.info(`[close] ${clientId} code=${code} reason=${reason} disconnect_reason=${storedDisconnectReason || "unknown"}`);
|
||||
|
||||
if (role === "client" && currentClient) {
|
||||
|
||||
@@ -60,6 +60,7 @@ function checkOperatorAccess(
|
||||
|
||||
type AuthenticatedUser = {
|
||||
userId: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
@@ -187,6 +188,7 @@ async function tryClientViewerUpgrade(
|
||||
ip: getRequestIp(req, server),
|
||||
userRole: user.role,
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
};
|
||||
if (route.sessionId) {
|
||||
data.sessionId = route.sessionId();
|
||||
@@ -219,6 +221,7 @@ async function tryGlobalViewerUpgrade(
|
||||
ip: getRequestIp(req, server),
|
||||
userRole: user.role,
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
lookupP2PSession,
|
||||
type P2PSession,
|
||||
} from "./webrtc-p2p-sessions";
|
||||
import {
|
||||
getRemoteDesktopRecordingStatus,
|
||||
recordRemoteDesktopFrame,
|
||||
startRemoteDesktopRecording,
|
||||
stopRemoteDesktopRecording,
|
||||
} from "./rd-recording";
|
||||
|
||||
let _cachedInjectionDll: Uint8Array | null = null;
|
||||
let _dllCachePath: string | null = null;
|
||||
@@ -161,6 +167,7 @@ export const rdStreamingState = new Map<string, {
|
||||
codec: string;
|
||||
duplication: boolean;
|
||||
maxHeight: number;
|
||||
lastFps: number;
|
||||
}>();
|
||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||
const RD_INPUT_TTL_MS = 10_000;
|
||||
@@ -314,7 +321,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
return;
|
||||
}
|
||||
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0 };
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||
|
||||
logger.debug(`[rd] inbound viewer msg type=${payload.type} client=${clientId}`);
|
||||
switch (payload.type) {
|
||||
@@ -365,6 +372,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
const otherViewers = sessionManager.getRdSessionsForClient(clientId)
|
||||
.filter(s => s.id !== ws.data.sessionId);
|
||||
if (otherViewers.length === 0) {
|
||||
stopRemoteDesktopRecording(clientId, "desktop stopped");
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
sendDesktopCommand(target, "webrtc_stop", { kind: "desktop" });
|
||||
if (state.isStreaming) {
|
||||
@@ -380,6 +388,46 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "desktop_record_start": {
|
||||
if (!state.isStreaming) {
|
||||
safeSendViewer(ws, {
|
||||
type: "recording_status",
|
||||
recording: null,
|
||||
error: "Start the remote desktop stream before recording.",
|
||||
});
|
||||
break;
|
||||
}
|
||||
const inputCodec = String(state.codec || "").toLowerCase() === "h264" ? "h264" : "mjpeg";
|
||||
const compact = !!(payload as any).compact;
|
||||
const requestedFps = Number((payload as any).fps) || 0;
|
||||
logger.debug(`[rd] recording start requested client=${clientId} current_quality=${state.quality || 90} current_codec=${state.codec || "(default)"} current_duplication=${!!state.duplication} recorder_input=${inputCodec} compact=${compact} requested_fps=${requestedFps || "source"} source_fps=${state.lastFps || 0} stream_unchanged=true`);
|
||||
if (inputCodec === "h264") {
|
||||
sendDesktopCommand(target, "desktop_request_keyframe", {
|
||||
reason: "server_recording",
|
||||
});
|
||||
}
|
||||
const recording = startRemoteDesktopRecording({
|
||||
clientId,
|
||||
requestedByUserId: ws.data.userId,
|
||||
requestedByUsername: (ws.data as any).username,
|
||||
fps: requestedFps || undefined,
|
||||
sourceFps: inputCodec === "h264" ? (state.lastFps || undefined) : undefined,
|
||||
inputCodec,
|
||||
compact,
|
||||
});
|
||||
safeSendViewer(ws, { type: "recording_status", recording });
|
||||
break;
|
||||
}
|
||||
case "desktop_record_stop": {
|
||||
logger.debug(`[rd] recording stop requested client=${clientId} current_quality=${state.quality || 90} current_codec=${state.codec || "(default)"} current_duplication=${!!state.duplication}`);
|
||||
const recording = stopRemoteDesktopRecording(clientId, "stopped");
|
||||
safeSendViewer(ws, { type: "recording_status", recording: recording || getRemoteDesktopRecordingStatus(clientId) });
|
||||
break;
|
||||
}
|
||||
case "desktop_record_status": {
|
||||
safeSendViewer(ws, { type: "recording_status", recording: getRemoteDesktopRecordingStatus(clientId) });
|
||||
break;
|
||||
}
|
||||
case "desktop_select_display": {
|
||||
const newDisplay = Number(payload.display) || 0;
|
||||
if (state.display !== newDisplay) {
|
||||
@@ -593,17 +641,30 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
const clientId = payload.clientId as string;
|
||||
const header = payload.header;
|
||||
const bytes = payload.data as Uint8Array;
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0 };
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: false, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||
if (!state.isStreaming) {
|
||||
rdStreamingState.set(clientId, { ...state, isStreaming: true });
|
||||
state.isStreaming = true;
|
||||
}
|
||||
const frameFps = Number(header?.fps) || 0;
|
||||
if (frameFps > 0) state.lastFps = frameFps;
|
||||
rdStreamingState.set(clientId, state);
|
||||
broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||
}
|
||||
|
||||
function broadcastRemoteDesktopFrame(clientId: string, bytes: Uint8Array, header?: any): boolean {
|
||||
const frameFps = Number(header?.fps) || 0;
|
||||
if (frameFps > 0) {
|
||||
const state = rdStreamingState.get(clientId) || { isStreaming: true, display: 0, quality: 90, codec: "", duplication: false, maxHeight: 0, lastFps: 0 };
|
||||
state.lastFps = frameFps;
|
||||
rdStreamingState.set(clientId, state);
|
||||
}
|
||||
recordRemoteDesktopFrame(clientId, header, bytes);
|
||||
const buf = buildViewerFrameBuffer(bytes, header);
|
||||
broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||
}
|
||||
|
||||
(globalThis as any).__rdBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
const buf = buildViewerFrameBuffer(bytes, header);
|
||||
return broadcastFrameToViewers(sessionManager.getRdSessionsForClient(clientId), buf, header);
|
||||
return broadcastRemoteDesktopFrame(clientId, bytes, header);
|
||||
};
|
||||
|
||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SocketData = {
|
||||
ip?: string;
|
||||
userRole?: string;
|
||||
userId?: number;
|
||||
username?: string;
|
||||
wasKnown?: boolean;
|
||||
autoTasksRan?: boolean;
|
||||
autoDeploysRan?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user