Ctrl K
ComunidadGuías…

electron/desktop-companion-client.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The device credential stays in Electron's encrypted document; this loopback relay injects the bearer and strips browser-only headers.
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { createServer } from "node:http";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import path from "node:path";

export const DESKTOP_COMPANION_FIELD = "desktopCompanionRemote";
const DEVICE_TOKEN = /^omb_[A-Za-z0-9_-]{43}$/;
const DEVICE_ID = /^[0-9a-f-]{36}$/i;
const PAIRING_CODE = /^\d{6}$/;
const MAX_ERROR_BYTES = 64 * 1024;

/** Losing either argument makes the renderer unsafe or makes a live relay look local. */
export function desktopCompanionRendererArguments(localOrigin, remoteAccess) {
  return [
    `--omb-local-origin=${localOrigin}`,
    ...(remoteAccess ? ["--openmausbot-remote-client"] : []),
  ];
}

const MIME = {
  ".html": "text/html",
  ".js": "text/javascript",
  ".css": "text/css",
  ".svg": "image/svg+xml",
  ".png": "image/png",
  ".ico": "image/x-icon",
  ".json": "application/json",
  ".woff2": "font/woff2",
};

const cleanLabel = (value, fallback) => {
  const label = String(value ?? "")
    // oxlint-disable-next-line no-control-regex -- strips control characters from a display label
    .replace(/[\u0000-\u001f\u007f]/g, " ")
    .trim()
    .slice(0, 80);
  return label || fallback;
};

/** The third Cloudflare account's zone apex, "" until the owner names it. Set it, ship that release, and only then
 * add the control-plane entry with that release as its minAppVersion. */
export const NEXT_MANAGED_COMPANION_HOST_SUFFIX = "";

/** Every zone the control plane makes addresses in, lowercase apex names. Ship a new domain here first. */
export const MANAGED_COMPANION_HOST_SUFFIXES = Object.freeze(
  ["openmausbot.com", "mausbot.si", NEXT_MANAGED_COMPANION_HOST_SUFFIX].filter(Boolean),
);

/** Only the control plane's `c-<32 hex>` names: any other name in those zones never receives a pairing code. */
export function managedCompanionHostname(hostname) {
  const match = /^c-[0-9a-f]{32}\.(.+)$/.exec(String(hostname ?? ""));
  return Boolean(match) && MANAGED_COMPANION_HOST_SUFFIXES.includes(match[1]);
}

/** HTTPS, or a Tailscale MagicDNS name (WireGuard protects its HTTP); a LAN IP would put the long-lived bearer on Wi-Fi in plaintext. */
export function normalizeDesktopCompanionEndpoint(value) {
  let raw = String(value ?? "").trim();
  if (!raw) return "";
  if (!/^https?:\/\//i.test(raw)) {
    raw = /\.ts\.net(?::\d+)?\/?$/i.test(raw) ? `http://${raw}` : `https://${raw}`;
  }
  let parsed;
  try {
    parsed = new URL(raw);
  } catch {
    return "";
  }
  const hostname = parsed.hostname.toLowerCase();
  const tailscaleHttp = parsed.protocol === "http:" && hostname.endsWith(".ts.net");
  const managedHttps =
    parsed.protocol === "https:" && managedCompanionHostname(hostname);
  if (
    (!tailscaleHttp && !managedHttps) ||
    parsed.username ||
    parsed.password ||
    (parsed.pathname !== "" && parsed.pathname !== "/") ||
    parsed.search ||
    parsed.hash
  ) {
    return "";
  }
  if (tailscaleHttp && !parsed.port) parsed.port = "8810";
  const port = Number(parsed.port);
  if (parsed.port && (!Number.isSafeInteger(port) || port < 1 || port > 65_535)) return "";
  return parsed.origin;
}

export const normalizeTailscaleCompanionEndpoint = normalizeDesktopCompanionEndpoint;
export function desktopCompanionAccess(credentials) {
  const stored = credentials?.[DESKTOP_COMPANION_FIELD];
  if (!stored || typeof stored !== "object" || Array.isArray(stored)) return null;
  const endpoint = normalizeDesktopCompanionEndpoint(stored.endpoint);
  const token = typeof stored.token === "string" ? stored.token : "";
  const deviceId = typeof stored.deviceId === "string" ? stored.deviceId : "";
  if (!endpoint || !DEVICE_TOKEN.test(token) || !DEVICE_ID.test(deviceId)) return null;
  return {
    endpoint,
    token,
    deviceId,
    serverName: cleanLabel(stored.serverName, new URL(endpoint).hostname),
  };
}

export function withDesktopCompanionAccess(credentials, access) {
  const normalized = desktopCompanionAccess({ [DESKTOP_COMPANION_FIELD]: access });
  if (!normalized) throw new Error("The remote computer returned an invalid device credential");
  return { ...credentials, [DESKTOP_COMPANION_FIELD]: normalized };
}

export function withoutDesktopCompanionAccess(credentials) {
  const next = { ...credentials };
  delete next[DESKTOP_COMPANION_FIELD];
  return next;
}

async function errorMessage(response) {
  const text = (await response.text()).slice(0, MAX_ERROR_BYTES);
  try {
    const body = JSON.parse(text);
    if (typeof body?.error === "string" && body.error.trim()) return body.error.trim();
  } catch {}
  return `The remote computer answered HTTP ${response.status}`;
}

export async function pairDesktopCompanion({
  endpoint: rawEndpoint,
  code: rawCode,
  deviceName,
  fetchImpl = fetch,
  requestId = randomUUID(),
}) {
  const endpoint = normalizeDesktopCompanionEndpoint(rawEndpoint);
  if (!endpoint) {
    throw new Error("Enter the OpenMausBot HTTPS companion address or full Tailscale name ending in .ts.net");
  }
  const code = String(rawCode ?? "").trim();
  if (!PAIRING_CODE.test(code)) throw new Error("Enter the six-digit code shown on the other computer");
  const response = await fetchImpl(`${endpoint}/api/pair`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      code,
      deviceName: cleanLabel(deviceName, "Desktop companion"),
      pairRequestId: requestId,
    }),
    signal: AbortSignal.timeout(8_000),
  });
  if (!response.ok) throw new Error(await errorMessage(response));
  const body = await response.json();
  const access = {
    endpoint,
    token: body?.token,
    deviceId: body?.device?.id,
    serverName: body?.serverName,
  };
  const normalized = desktopCompanionAccess({ [DESKTOP_COMPANION_FIELD]: access });
  if (!normalized) throw new Error("The remote computer returned an invalid pairing response");
  return normalized;
}

function isLoopbackHost(host) {
  if (!host) return false;
  const value = String(host).trim().toLowerCase();
  let hostname = value;
  if (value.startsWith("[")) {
    const close = value.indexOf("]");
    if (close < 0) return false;
    hostname = value.slice(1, close);
  } else {
    const first = value.indexOf(":");
    if (first >= 0 && first === value.lastIndexOf(":")) hostname = value.slice(0, first);
  }
  if (hostname === "localhost" || hostname === "localhost.") return true;
  if (isIP(hostname) === 4) return hostname.startsWith("127.");
  return hostname === "::1" || hostname === "0:0:0:0:0:0:0:1";
}

function allowedOrigin(origin, host) {
  if (!origin) return true;
  if (!isLoopbackHost(host)) return false;
  try {
    const parsed = new URL(String(origin));
    const expected = new URL(`http://${host}`);
    return parsed.protocol === "http:" && parsed.origin === expected.origin;
  } catch {
    return false;
  }
}

function json(res, status, body) {
  if (res.headersSent) return res.destroy();
  const text = JSON.stringify(body);
  res.writeHead(status, {
    "content-type": "application/json",
    "content-length": Buffer.byteLength(text),
    "cache-control": "private, no-store",
  });
  res.end(text);
}

function serveStatic(req, res, staticDir) {
  if (req.method !== "GET") return json(res, 404, { error: "no route" });
  const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
  const relative = requestPath === "/" ? "index.html" : requestPath.slice(1);
  const resolved = path.resolve(staticDir, relative);
  const root = path.resolve(staticDir) + path.sep;
  const file = resolved.startsWith(root) ? resolved : path.join(staticDir, "index.html");
  let data;
  let served = file;
  try {
    data = fs.readFileSync(file);
  } catch {
    served = path.join(staticDir, "index.html");
    try {
      data = fs.readFileSync(served);
    } catch {
      return json(res, 503, { error: "The desktop UI is not built" });
    }
  }
  res.writeHead(200, {
    "content-type": MIME[path.extname(served)] ?? "application/octet-stream",
    "content-length": data.length,
    "cache-control": path.extname(served) === ".html" ? "no-store" : "public, max-age=31536000, immutable",
  });
  res.end(data);
}

export function desktopCompanionProxyTarget(endpoint, requestUrl) {
  const incoming = new URL(requestUrl ?? "/", "http://loopback.invalid");
  const target = new URL(`${incoming.pathname}${incoming.search}`, endpoint);
  if (target.origin !== new URL(endpoint).origin) throw new Error("Invalid remote request target");
  return target;
}

export function proxyDesktopCompanionApi(req, res, access) {
  const target = desktopCompanionProxyTarget(access.endpoint, req.url);
  const request = target.protocol === "https:" ? httpsRequest : httpRequest;
  const headers = {
    accept: String(req.headers.accept ?? "*/*"),
    authorization: `Bearer ${access.token}`,
  };
  for (const name of ["content-type", "content-length", "last-event-id"]) {
    const value = req.headers[name];
    if (typeof value === "string") headers[name] = value;
  }
  const upstream = request(target, { method: req.method, headers }, (remote) => {
    upstream.setTimeout(0);
    const responseHeaders = {};
    for (const name of ["content-type", "content-length", "content-disposition", "cache-control"]) {
      const value = remote.headers[name];
      if (value !== undefined) responseHeaders[name] = value;
    }
    res.writeHead(remote.statusCode ?? 502, responseHeaders);
    remote.pipe(res);
    remote.on("error", () => res.destroy());
  });
  upstream.setTimeout(30_000, () => upstream.destroy(new Error("The remote computer did not answer")));
  upstream.on("error", (error) => {
    if (res.headersSent) return res.destroy();
    json(res, 502, { error: error.message || "Could not reach the remote computer" });
  });
  res.once("close", () => upstream.destroy());
  req.pipe(upstream);
}

function websocketHeaders(req, authorization) {
  if (req.method !== "GET" || String(req.headers.upgrade ?? "").toLowerCase() !== "websocket") return null;
  const headers = { authorization, connection: "Upgrade", upgrade: "websocket" };
  for (const name of [
    "sec-websocket-key",
    "sec-websocket-version",
    "sec-websocket-protocol",
    "sec-websocket-extensions",
  ]) {
    const value = req.headers[name];
    if (typeof value === "string") headers[name] = value;
  }
  return headers;
}

function writeUpgrade(socket, response) {
  const lines = [];
  for (const name of [
    "upgrade",
    "connection",
    "sec-websocket-accept",
    "sec-websocket-protocol",
    "sec-websocket-extensions",
  ]) {
    const value = response.headers[name];
    if (typeof value === "string") lines.push(`${name}: ${value}`);
  }
  socket.write(
    `HTTP/1.1 ${response.statusCode ?? 101} ${response.statusMessage ?? "Switching Protocols"}\r\n`
      + `${lines.join("\r\n")}\r\n\r\n`,
  );
}

export function proxyDesktopCompanionUpgrade(req, socket, head, access, activeSockets = new Set()) {
  let target;
  try {
    target = desktopCompanionProxyTarget(access.endpoint, req.url);
  } catch {
    socket.destroy();
    return;
  }
  if (!target.pathname.startsWith("/vps-viewer/")) return socket.destroy();
  const headers = websocketHeaders(req, `Bearer ${access.token}`);
  if (!headers) return socket.destroy();
  const request = target.protocol === "https:" ? httpsRequest : httpRequest;
  const upstream = request(target, { method: "GET", headers });
  upstream.setTimeout(30_000, () => upstream.destroy(new Error("The remote viewer did not answer")));
  upstream.once("upgrade", (response, remote, remoteHead) => {
    upstream.setTimeout(0);
    remote.setTimeout(0);
    writeUpgrade(socket, response);
    activeSockets.add(socket);
    activeSockets.add(remote);
    const release = () => {
      activeSockets.delete(socket);
      activeSockets.delete(remote);
    };
    // `pipe` forwards no error, so a reset on one end must end the other.
    const teardown = () => {
      socket.destroy();
      remote.destroy();
    };
    socket.on("error", teardown);
    remote.on("error", teardown);
    socket.once("close", release);
    remote.once("close", release);
    if (head.length) remote.write(head);
    if (remoteHead.length) socket.write(remoteHead);
    socket.pipe(remote).pipe(socket);
  });
  upstream.once("response", (response) => {
    response.resume();
    socket.destroy();
  });
  upstream.once("error", () => socket.destroy());
  socket.once("close", () => upstream.destroy());
  upstream.end();
}

function listen(server, port) {
  return new Promise((resolve) => {
    const onError = (error) => {
      server.off("listening", onListening);
      resolve({ ok: false, error });
    };
    const onListening = () => {
      server.off("error", onError);
      resolve({ ok: true });
    };
    server.once("error", onError);
    server.once("listening", onListening);
    server.listen(port, "127.0.0.1");
  });
}

export async function startDesktopCompanionRelay({
  access,
  staticDir,
  ports = [8798, 18_798, 28_798],
}) {
  const normalized = desktopCompanionAccess({ [DESKTOP_COMPANION_FIELD]: access });
  if (!normalized) throw new Error("The saved remote computer credential is invalid");
  for (const port of ports) {
    const activeSockets = new Set();
    const server = createServer((req, res) => {
      if (!isLoopbackHost(req.headers.host) || !allowedOrigin(req.headers.origin, req.headers.host)) {
        return json(res, 403, { error: "forbidden: loopback client required" });
      }
      let pathname;
      try {
        pathname = new URL(req.url ?? "/", "http://localhost").pathname;
      } catch {
        return json(res, 400, { error: "invalid request target" });
      }
      if (pathname.startsWith("/api/") || pathname.startsWith("/vps-viewer/")) {
        return proxyDesktopCompanionApi(req, res, normalized);
      }
      return serveStatic(req, res, staticDir);
    });
    server.on("upgrade", (req, socket, head) => {
      // Node removes its own error listener from a socket it hands to `upgrade`.
      socket.on("error", () => socket.destroy());
      if (!isLoopbackHost(req.headers.host) || !allowedOrigin(req.headers.origin, req.headers.host)) {
        return socket.destroy();
      }
      proxyDesktopCompanionUpgrade(req, socket, head, normalized, activeSockets);
    });
    const result = await listen(server, port);
    if (result.ok) return {
      server,
      port,
      access: normalized,
      close() {
        for (const socket of activeSockets) socket.destroy();
        activeSockets.clear();
        server.close();
      },
    };
    server.close();
    if (result.error?.code !== "EADDRINUSE") throw result.error;
  }
  throw new Error("Every desktop companion relay port is already in use");
}