← Files OneGateARCHIVED FILE

skills/onegate-dapp-debug/scripts/runtime/debug-target-gateway.mjs

17 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

import { EventEmitter } from "node:events";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";

import { RemoteDebuggerAdvertiser } from "./remote-debugger-advertiser.mjs";
import {
  base64UrlDecode,
  base64UrlEncode,
  createEphemeralKey,
  createPairingInvitation,
  RemoteDebuggerIdentityStore,
  FramedSocket,
  keyId,
  protocolError,
  REMOTE_PROTOCOL_VERSION,
  SecureChannel,
  sha256,
  verifyIdentitySignature,
} from "./remote-protocol.mjs";

const CONNECTION_TIMEOUT_MS = 15_000;
const REQUEST_TIMEOUT_MS = 60_000;
const PAIRING_LIFETIME_MS = 2 * 60_000;

function requireString(value, name, maximumLength = 500) {
  if (typeof value !== "string" || value.trim().length === 0 || value.length > maximumLength) {
    throw protocolError("INVALID_ARGUMENT", `${name} must be a non-empty string of at most ${maximumLength} characters.`);
  }
  return value;
}

function publicAddresses() {
  const addresses = [];
  for (const entries of Object.values(os.networkInterfaces())) {
    for (const entry of entries ?? []) {
      if (entry.internal || entry.family !== "IPv4" || entry.address.startsWith("169.254.")) continue;
      addresses.push(entry.address);
    }
  }
  return [...new Set(addresses)]
    .sort((left, right) => addressPreference(left) - addressPreference(right))
    .slice(0, 4);
}

function addressPreference(address) {
  if (address.startsWith("192.168.")) return 0;
  if (address.startsWith("10.")) return 1;
  const second = Number.parseInt(address.split(".")[1], 10);
  if (address.startsWith("172.") && second >= 16 && second <= 31) return 2;
  return 3;
}

function endpointFor(address, port) {
  return `tcp://${address}:${port}`;
}

class DebugTargetStore {
  constructor(directory) {
    this.directory = directory;
    this.filePath = path.join(directory, "remote-debug-targets.json");
  }

  async load() {
    try {
      const document = JSON.parse(await readFile(this.filePath, "utf8"));
      if (document.schemaVersion !== 1 || !Array.isArray(document.debugTargets)) {
        throw new Error("Unsupported trusted debug-target document.");
      }
      return new Map(document.debugTargets.map((debugTarget) => [debugTarget.id, debugTarget]));
    } catch (error) {
      if (error?.code === "ENOENT") return new Map();
      throw error;
    }
  }

  async save(debugTargets) {
    await mkdir(this.directory, { recursive: true });
    const temporaryPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
    await writeFile(temporaryPath, `${JSON.stringify({
      schemaVersion: 1,
      debugTargets: [...debugTargets.values()],
    }, null, 2)}\n`, { mode: 0o600 });
    await rename(temporaryPath, this.filePath);
  }
}

class DebugTargetConnection extends EventEmitter {
  constructor({ record, socket, framed, channel }) {
    super();
    this.record = record;
    this.socket = socket;
    this.framed = framed;
    this.channel = channel;
    this.pending = new Map();
    this.nextRequestId = 1;
  }

  start() {
    this.readLoop = this.#readLoop();
    this.readLoop.catch((error) => this.#close(error));
  }

  async request(method, params = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
    const id = `${process.pid}-${this.nextRequestId++}`;
    const promise = new Promise((resolve, reject) => {
      const entry = { resolve, reject };
      entry.timer = setTimeout(() => {
        this.pending.delete(id);
        reject(protocolError("DEBUG_TARGET_TIMEOUT", `The debug target did not answer ${method}.`));
      }, timeoutMs);
      this.pending.set(id, entry);
    });
    this.#send({ kind: "request", id, method, params });
    return promise;
  }

  sendEvent(method, params = {}) {
    this.#send({ kind: "event", method, params });
  }

  close() {
    this.framed.destroy();
  }

  #send(message) {
    this.framed.write(this.channel.encrypt(Buffer.from(JSON.stringify(message), "utf8")));
  }

  async #readLoop() {
    while (true) {
      const frame = await this.framed.read(24 * 60 * 60 * 1000);
      const message = JSON.parse(this.channel.decrypt(frame).toString("utf8"));
      if (message?.kind === "response") {
        const entry = this.pending.get(message.id);
        if (!entry) continue;
        this.pending.delete(message.id);
        clearTimeout(entry.timer);
        if (message.error) {
          entry.reject(protocolError(message.error.code ?? "DEBUG_TARGET_ERROR", message.error.message ?? "Debug-target request failed."));
        } else {
          entry.resolve(message.result);
        }
      } else if (message?.kind === "event" && typeof message.method === "string") {
        this.emit("event", message.method, message.params ?? {});
      } else {
        throw protocolError("INVALID_MESSAGE", "The debug target sent an invalid secure message.");
      }
    }
  }

  #close(error) {
    if (this.closed) return;
    this.closed = true;
    this.channel.dispose();
    for (const entry of this.pending.values()) {
      clearTimeout(entry.timer);
      entry.reject(error);
    }
    this.pending.clear();
    this.emit("close", error);
  }
}

export class DebugTargetGateway extends EventEmitter {
  constructor({ stateDirectory, debuggerName = os.hostname() }) {
    super();
    this.stateDirectory = stateDirectory;
    this.debuggerName = debuggerName;
    this.identityStore = new RemoteDebuggerIdentityStore({ directory: stateDirectory });
    this.debugTargetStore = new DebugTargetStore(stateDirectory);
    this.pairings = new Map();
    this.connections = new Map();
  }

  async start() {
    if (this.server) return this.summary();
    [this.identity, this.debugTargets] = await Promise.all([
      this.identityStore.load(),
      this.debugTargetStore.load(),
    ]);
    this.server = net.createServer((socket) => void this.#accept(socket));
    await new Promise((resolve, reject) => {
      this.server.once("error", reject);
      this.server.listen(0, "0.0.0.0", () => {
        this.server.off("error", reject);
        resolve();
      });
    });
    this.port = this.server.address().port;
    this.addresses = publicAddresses();
    this.advertiser = new RemoteDebuggerAdvertiser({
      debuggerId: this.identity.id,
      debuggerName: this.debuggerName,
      port: this.port,
      addresses: this.addresses,
    });
    await this.advertiser.start().catch((error) => this.emit("diagnostic", {
      level: "warning",
      code: "MDNS_UNAVAILABLE",
      message: error.message,
    }));
    return this.summary();
  }

  summary() {
    return {
      debuggerId: this.identity?.id,
      debuggerName: this.debuggerName,
      port: this.port,
      endpoints: this.port ? this.addresses.map((address) => endpointFor(address, this.port)) : [],
      trustedDebugTargetCount: this.debugTargets?.size ?? 0,
      connectedDebugTargetCount: this.connections.size,
    };
  }

  async createPairing({ debuggerName, lifetimeMs = PAIRING_LIFETIME_MS } = {}) {
    await this.start();
    const pairingId = randomUUID();
    const pairingSecret = randomBytes(32);
    const expiresAt = new Date(Date.now() + lifetimeMs);
    const name = debuggerName?.trim() || this.debuggerName;
    const endpoints = this.addresses.slice(0, 2).map((address) => endpointFor(address, this.port));
    if (endpoints.length === 0) {
      throw protocolError("NO_LAN_ENDPOINT", "No non-loopback IPv4 network endpoint is available for pairing.");
    }
    const invitation = createPairingInvitation({
      pairingId,
      expiresAt,
      debuggerName: name,
      debuggerPublicKey: this.identity.publicRaw,
      pairingSecret,
      endpoints,
    });
    this.pairings.set(pairingId, {
      pairingId,
      pairingSecret,
      expiresAt,
      debuggerName: name,
      invitation,
      status: "waiting",
    });
    return {
      pairingId,
      expiresAt: expiresAt.toISOString(),
      debuggerId: this.identity.id,
      debuggerName: name,
      endpoints,
      invitation,
      status: "waiting",
    };
  }

  pairingStatus(pairingId) {
    const pairing = this.pairings.get(requireString(pairingId, "pairingId"));
    if (!pairing) throw protocolError("PAIRING_NOT_FOUND", `Pairing was not found: ${pairingId}`);
    if (pairing.status === "waiting" && pairing.expiresAt <= new Date()) pairing.status = "expired";
    return {
      pairingId,
      status: pairing.status,
      expiresAt: pairing.expiresAt.toISOString(),
      debugTarget: pairing.debugTarget,
    };
  }

  cancelPairing(pairingId) {
    const pairing = this.pairings.get(requireString(pairingId, "pairingId"));
    if (!pairing) throw protocolError("PAIRING_NOT_FOUND", `Pairing was not found: ${pairingId}`);
    pairing.status = "cancelled";
    pairing.pairingSecret.fill(0);
    return { pairingId, status: "cancelled" };
  }

  listDebugTargets() {
    return {
      debugTargets: [...this.debugTargets.values()].map((debugTarget) => ({
        id: debugTarget.id,
        name: debugTarget.name,
        platform: debugTarget.platform,
        pairedAt: debugTarget.pairedAt,
        lastConnectedAt: debugTarget.lastConnectedAt,
        online: this.connections.has(debugTarget.id),
      })),
    };
  }

  async forgetDebugTarget(debugTargetId) {
    const id = requireString(debugTargetId, "debugTargetId");
    this.connections.get(id)?.close();
    if (!this.debugTargets.delete(id)) throw protocolError("DEBUG_TARGET_NOT_FOUND", `Debug target was not found: ${id}`);
    await this.debugTargetStore.save(this.debugTargets);
    return { debugTargetId: id, forgotten: true };
  }

  getConnection(debugTargetId) {
    const id = debugTargetId ?? (this.connections.size === 1 ? [...this.connections.keys()][0] : undefined);
    if (!id) {
      throw protocolError(
        this.connections.size === 0 ? "DEBUG_TARGET_OFFLINE" : "DEBUG_TARGET_REQUIRED",
        this.connections.size === 0
          ? "No trusted OneGate debug target is connected."
          : "More than one OneGate debug target is connected; specify a debug-target id.",
      );
    }
    const connection = this.connections.get(id);
    if (!connection) throw protocolError("DEBUG_TARGET_OFFLINE", `OneGate debug target is offline: ${id}`);
    return connection;
  }

  async request(debugTargetId, method, params) {
    return this.getConnection(debugTargetId).request(method, params);
  }

  async stop() {
    for (const connection of this.connections.values()) connection.close();
    this.connections.clear();
    for (const pairing of this.pairings.values()) pairing.pairingSecret.fill(0);
    this.pairings.clear();
    await this.advertiser?.stop().catch(() => undefined);
    if (this.server) await new Promise((resolve) => this.server.close(resolve));
    this.server = undefined;
  }

  async #accept(socket) {
    if ((this.handshakeCount ?? 0) >= 8) {
      socket.destroy();
      return;
    }
    this.handshakeCount = (this.handshakeCount ?? 0) + 1;
    socket.setNoDelay(true);
    socket.setKeepAlive(true, 10_000);
    const framed = new FramedSocket(socket);
    try {
      const hello = await framed.readJson(CONNECTION_TIMEOUT_MS);
      const connection = await this.#handshake(socket, framed, hello);
      const old = this.connections.get(connection.record.id);
      old?.close();
      this.connections.set(connection.record.id, connection);
      connection.on("event", (method, params) => this.emit("debugTargetEvent", connection.record.id, method, params));
      connection.once("close", (error) => {
        if (this.connections.get(connection.record.id) === connection) {
          this.connections.delete(connection.record.id);
          this.emit("debugTargetDisconnected", connection.record.id, error);
        }
      });
      connection.start();
      this.emit("debugTargetConnected", connection.record.id);
    } catch (error) {
      framed.destroy();
      this.emit("diagnostic", {
        level: "warning",
        code: error?.code ?? "HANDSHAKE_FAILED",
        message: error instanceof Error ? error.message : String(error),
      });
    } finally {
      this.handshakeCount -= 1;
    }
  }

  async #handshake(socket, framed, hello) {
    if (hello?.type !== "clientHello" || typeof hello.payload !== "string" || typeof hello.signature !== "string") {
      throw protocolError("UNSUPPORTED_PROTOCOL", "Invalid remote-debug client hello.");
    }
    const clientPayload = base64UrlDecode(hello.payload);
    const clientHello = JSON.parse(clientPayload.toString("utf8"));
    if (clientHello?.version !== REMOTE_PROTOCOL_VERSION) {
      throw protocolError("UNSUPPORTED_PROTOCOL", "Invalid remote-debug client hello.");
    }
    const signature = base64UrlDecode(hello.signature);
    const debugTargetPublicKey = base64UrlDecode(clientHello.identityKey);
    const debugTargetId = keyId(debugTargetPublicKey);
    if (clientHello.debugTargetId !== debugTargetId || !verifyIdentitySignature(debugTargetPublicKey, clientPayload, signature)) {
      throw protocolError("DEBUG_TARGET_AUTH_FAILED", "The debug-target identity signature is invalid.");
    }

    let secret;
    let pairing;
    let record;
    if (clientHello.mode === "pair") {
      pairing = this.pairings.get(clientHello.pairingId);
      if (!pairing || pairing.status !== "waiting" || pairing.expiresAt <= new Date()) {
        throw protocolError("PAIRING_EXPIRED", "The pairing request is not active.");
      }
      secret = pairing.pairingSecret;
    } else if (clientHello.mode === "trusted") {
      record = this.debugTargets.get(debugTargetId);
      if (!record || record.publicKey !== clientHello.identityKey) {
        throw protocolError("DEBUG_TARGET_NOT_TRUSTED", "The debug target is not trusted by this remote debugger.");
      }
      secret = base64UrlDecode(record.reconnectSecret);
    } else {
      throw protocolError("INVALID_HANDSHAKE", "Unknown remote-debug connection mode.");
    }

    const ephemeral = createEphemeralKey();
    const unsignedServer = {
      type: "serverHello",
      version: REMOTE_PROTOCOL_VERSION,
      debuggerId: this.identity.id,
      debuggerName: pairing?.debuggerName ?? this.debuggerName,
      identityKey: base64UrlEncode(this.identity.publicDer),
      ephemeralKey: base64UrlEncode(ephemeral.publicDer),
      nonce: base64UrlEncode(randomBytes(16)),
      clientHash: base64UrlEncode(sha256(clientPayload)),
    };
    const serverPayload = Buffer.from(JSON.stringify(unsignedServer), "utf8");
    const transcriptHash = sha256(clientPayload, serverPayload);
    framed.writeJson({
      type: "serverHello",
      payload: base64UrlEncode(serverPayload),
      signature: base64UrlEncode(this.identity.sign(transcriptHash)),
    });
    const channel = new SecureChannel(
      "remote-debugger",
      ephemeral.derive(base64UrlDecode(clientHello.ephemeralKey)),
      secret,
      transcriptHash,
    );
    if (clientHello.mode === "trusted") secret.fill(0);

    if (clientHello.mode === "pair") {
      const reconnectSecret = randomBytes(32);
      framed.write(channel.encrypt(Buffer.from(JSON.stringify({
        kind: "pair.complete",
        reconnectSecret: base64UrlEncode(reconnectSecret),
      }), "utf8")));
      const acknowledgement = JSON.parse(channel.decrypt(await framed.read(CONNECTION_TIMEOUT_MS)).toString("utf8"));
      if (acknowledgement?.kind !== "pair.ack") {
        throw protocolError("PAIRING_FAILED", "The debug target did not acknowledge persistent trust.");
      }
      record = {
        id: debugTargetId,
        name: requireString(clientHello.debugTargetName, "debugTargetName", 200),
        platform: typeof clientHello.platform === "string" ? clientHello.platform : "unknown",
        publicKey: clientHello.identityKey,
        reconnectSecret: base64UrlEncode(reconnectSecret),
        pairedAt: new Date().toISOString(),
        lastConnectedAt: new Date().toISOString(),
      };
      reconnectSecret.fill(0);
      this.debugTargets.set(record.id, record);
      await this.debugTargetStore.save(this.debugTargets);
      pairing.status = "paired";
      pairing.debugTarget = { id: record.id, name: record.name, platform: record.platform };
      pairing.pairingSecret.fill(0);
    } else {
      framed.write(channel.encrypt(Buffer.from(JSON.stringify({ kind: "ready" }), "utf8")));
      const acknowledgement = JSON.parse(channel.decrypt(await framed.read(CONNECTION_TIMEOUT_MS)).toString("utf8"));
      if (acknowledgement?.kind !== "ready.ack") {
        throw protocolError("HANDSHAKE_FAILED", "The trusted debug target did not acknowledge the remote debugger.");
      }
      record.lastConnectedAt = new Date().toISOString();
      await this.debugTargetStore.save(this.debugTargets);
    }
    socket.setTimeout(0);
    return new DebugTargetConnection({ record, socket, framed, channel });
  }
}

SHA-256: 2b33ffd77da2ed1b04887731e88c9ebb031264a03142585512d79a8fde81d090