evOo-local/packages/core/src/node/screen-capability.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

172 lines
6.1 KiB
TypeScript

import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
export const SCREEN_PROXY_TTL_MS = 60 * 60_000;
export const SCREEN_TARGET_ENDPOINT = "/api/internal/screen-target";
export const SCREEN_RECHECK_MS = 1_000;
export interface ScreenCapabilityScope {
botId: string;
computerId: string;
botGeneration: number;
computerGeneration: number;
controlLeaseId: string | null;
}
export interface ScreenProxyTarget {
protocol: "http:" | "https:";
hostname: string;
port: number;
path: string;
interactive: boolean;
}
export function isScreenProxyTarget(value: unknown): value is ScreenProxyTarget {
if (!value || typeof value !== "object") return false;
const target = value as Record<string, unknown>;
return (
(target.protocol === "http:" || target.protocol === "https:") &&
typeof target.hostname === "string" &&
target.hostname.length > 0 &&
!/[\s/]/.test(target.hostname) &&
typeof target.port === "number" &&
Number.isInteger(target.port) &&
target.port > 0 &&
target.port <= 65535 &&
typeof target.path === "string" &&
target.path.startsWith("/") &&
!/[\r\n]/.test(target.path) &&
typeof target.interactive === "boolean"
);
}
export function sealScreenCapability(
url: string,
secret: string,
origin: string,
scope: ScreenCapabilityScope,
now = Date.now(),
) {
const target = new URL(url);
if (target.protocol !== "https:" && target.protocol !== "http:")
throw new Error("Invalid screen protocol");
const policy = target.searchParams.get("view_only") === "false" ? "control" : "view";
const expiresAt = now + SCREEN_PROXY_TTL_MS;
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", createHash("sha256").update(secret).digest(), iv, {
authTagLength: 16,
});
cipher.setAAD(Buffer.from(`${policy}:${expiresAt}`));
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify({ url, scope }), "utf8"),
cipher.final(),
]);
const token = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
const prefix = `/novnc/session/${policy}/${expiresAt}.${token}`;
const result = new URL(`${prefix}${target.pathname || "/"}`, new URL(origin).origin);
// noVNC reads these from the browser URL. Keep its socket inside the capability
// route while the provider's nested socket token stays sealed server-side.
result.search = new URLSearchParams({
autoconnect: "true",
resize: "scale",
view_only: policy === "control" ? "false" : "true",
// Our embed resolves the socket relative to its own capability directory;
// stock noVNC resolves it from the origin root.
path: target.pathname === "/embed.html" ? "websockify" : `${prefix.slice(1)}/websockify`,
}).toString();
return result.toString();
}
export function openScreenCapability(
path: string | undefined,
secret: string,
now = Date.now(),
): { scope: ScreenCapabilityScope; expiresAt: number; target: ScreenProxyTarget } | null {
const match = path?.match(
/^\/novnc\/session\/(view|control)\/(\d+)\.([A-Za-z0-9_-]+)(\/[^?]*)?(\?.*)?$/,
);
if (!match) return null;
const expiresAt = Number(match[2]);
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return null;
try {
const sealed = Buffer.from(match[3]!, "base64url");
if (sealed.length < 28) return null;
const decipher = createDecipheriv(
"aes-256-gcm",
createHash("sha256").update(secret).digest(),
sealed.subarray(0, 12),
{ authTagLength: 16 },
);
decipher.setAAD(Buffer.from(`${match[1]}:${expiresAt}`));
decipher.setAuthTag(sealed.subarray(12, 28));
const payload = JSON.parse(
Buffer.concat([decipher.update(sealed.subarray(28)), decipher.final()]).toString("utf8"),
);
const scope = payload.scope as ScreenCapabilityScope;
if (
!scope ||
typeof scope.botId !== "string" ||
typeof scope.computerId !== "string" ||
!Number.isSafeInteger(scope.botGeneration) ||
!Number.isSafeInteger(scope.computerGeneration) ||
(scope.controlLeaseId !== null && typeof scope.controlLeaseId !== "string")
)
return null;
const target = new URL(payload.url);
if (target.protocol !== "https:" && target.protocol !== "http:") return null;
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
if (
target.protocol === "http:" &&
(!isAllowedTargetName(target.hostname) || port < 1024 || port > 65535)
)
return null;
const interactive = match[1] === "control";
const requestedPath = `${match[4] || target.pathname || "/"}${match[5] || ""}`;
return {
scope,
expiresAt,
target: {
protocol: target.protocol,
hostname: target.hostname,
port,
path: screenPolicyPath(remoteTargetPath(target, requestedPath), interactive),
interactive,
},
};
} catch {
return null;
}
}
export function screenPolicyPath(requestedPath: string, interactive: boolean) {
const parsed = new URL(requestedPath, "http://screen.invalid");
if (parsed.pathname === "/embed.html" || parsed.pathname === "/vnc.html") {
parsed.searchParams.set("view_only", interactive ? "false" : "true");
}
return `${parsed.pathname}${parsed.search}`;
}
function remoteTargetPath(target: URL, requestedPath: string) {
const requested = new URL(requestedPath, "https://screen.invalid");
const path = requested.pathname || target.pathname || "/";
if (path === "/websockify" && target.searchParams.has("path")) {
const socket = new URL(target.searchParams.get("path")!, target);
return `${socket.pathname}${socket.search}`;
}
if (path === target.pathname || path === "/websockify") {
return `${path}${target.search}`;
}
return `${path}${requested.search}`;
}
function isAllowedTargetName(hostname: string) {
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
/^10\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname) ||
/^172\.(?:1[6-9]|2\d|3[01])\.(?:\d{1,3})\.\d{1,3}$/.test(hostname) ||
/^192\.168\.(?:\d{1,3})\.\d{1,3}$/.test(hostname) ||
/^rakazo-bot-[a-zA-Z0-9_.-]+$/.test(hostname)
);
}