Ctrl K
ComunidadGuías…

src/ui/ssh-form.js

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

Archivo original ↗Preguntar a KiBot
import { wizardApiError } from "./session.js";

const element = (id) => document.getElementById(id);

async function sshRead(token, path) {
  const response = await fetch(path, { headers: { "X-Setup-Token": token }, credentials: "omit", mode: "same-origin", redirect: "error", cache: "no-store" });
  const result = await response.json();
  if (!response.ok) throw wizardApiError(result, response.status, "The SSH session could not be inspected.");
  return result;
}

const HOST_REPORT_NOTE = "A pass only repeats what Docker reported. It does not prove that a later build will work.";
// Short names for the checks that can fail, shown in the closed row.
const HOST_REPORT_CHECKS = { login: "Sign-in", cpu: "CPU", network: "Network" };
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;

// The host report as plain text for one flow: a one-line label that counts the
// warnings and names each failed check, the facts Docker reported, and the rows
// that warn or fail for that flow, problems first. Rows for other flows and
// facts the probe did not learn are left out.
export function describeHostReport(report, flow) {
  if (!report || typeof report !== "object" || !Array.isArray(report.rows)) return null;
  const fact = (value) => (typeof value === "string" && value && value.length <= 80 ? value : null);
  const host = report.host ?? {};
  const facts = [fact(host.os), fact(host.arch),
    fact(host.dockerVersion) && `Docker ${host.dockerVersion}`,
    fact(host.composeVersion) && `Compose ${host.composeVersion}`].filter(Boolean);
  const rows = report.rows.filter((row) => ["warn", "fail"].includes(row?.level) &&
    typeof row.text === "string" && row.text && row.text.length <= 240 &&
    (!Array.isArray(row.flows) || row.flows.length === 0 || row.flows.includes(flow)))
    .sort((a, b) => Number(b.level === "fail") - Number(a.level === "fail"));
  const failed = [...new Set(rows.filter(({ level }) => level === "fail")
    .map(({ id }) => (Object.hasOwn(HOST_REPORT_CHECKS, id) ? HOST_REPORT_CHECKS[id] : "Server")))];
  const warnings = rows.filter(({ level }) => level === "warn").length;
  const counts = [failed.length && `${failed.join(" and ")} ${failed.length === 1 ? "problem" : "problems"}`,
    warnings && plural(warnings, "warning")].filter(Boolean).join(", ");
  return {
    label: `Server check: ${counts || "no warnings"}`,
    facts: `${facts.length ? facts.join(", ") : "Server facts are unavailable"}.`,
    issues: rows.map(({ level, text }) => ({ level, text })),
  };
}

function make(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

// Shows the report in every <details data-host-report> on the page. One that
// starts hidden is a row of its own. One that starts visible is the step's own
// disclosure, which keeps its summary and text when there is no report. The
// summary carries the one-line label and the details go into a bounded region,
// so a report never opens by itself and costs at most one line while closed.
const hostReportBoxes = new WeakMap();
export function renderHostReport(report, flow) {
  const view = describeHostReport(report, flow);
  const key = JSON.stringify(view);
  for (const box of document.querySelectorAll("[data-host-report]")) {
    let home = hostReportBoxes.get(box);
    if (!home) {
      const summary = box.querySelector("summary");
      home = { summary, label: summary.textContent, own: box.hidden, region: null, key: undefined };
      hostReportBoxes.set(box, home);
    }
    if (home.key === key) continue;
    home.key = key;
    home.region?.remove();
    home.region = null;
    home.summary.textContent = view ? view.label : home.label;
    if (home.own) {
      box.hidden = !view;
      if (!view) box.open = false;
    }
    if (!view) continue;
    const region = make("div", "rm-disclosure__region");
    region.setAttribute("role", "region");
    region.setAttribute("aria-label", "Server check details");
    region.setAttribute("tabindex", "0");
    region.append(make("p", "", view.facts));
    if (view.issues.length) {
      const list = make("ul", "rm-disclosure__list");
      list.append(...view.issues.map(({ level, text }) => make("li", "", `${level === "fail" ? "Problem" : "Warning"}: ${text}`)));
      region.append(list);
    }
    region.append(make("p", "", HOST_REPORT_NOTE));
    box.querySelector(".rm-disclosure__body").prepend(region);
    home.region = region;
  }
}

// Reads the report for the verified connection and shows it for one flow. The
// server answers from what the connection probe already learned, with no remote
// call; it adds the network row when the selected n8n container and network are
// sent. The report only informs, so a failed read hides it instead of raising
// an error.
let hostReportRead = 0;
export async function showHostReport(token, { flow, container, network }) {
  const read = ++hostReportRead;
  const query = container && network ? `?${new URLSearchParams({ container, network })}` : "";
  let report = null;
  try { report = await sshRead(token, `/api/ssh/host-report${query}`); } catch { /* Shown as no report. */ }
  if (read === hostReportRead) renderHostReport(report, flow);
}

export function clearHostReport() {
  hostReportRead++;
  renderHostReport(null);
}

export function sameSshIdentity(left, right) {
  return Boolean(left && right && ["host", "port", "fingerprint", "username", "authentication", "privilege", "loginUid", "effectiveUid", "scope", "generation"].every((key) => left[key] === right[key]));
}

// Marks a rejected field invalid and links the visible error to it, keeping any
// hint already in aria-describedby. clearFieldError undoes both on correction.
export function setFieldError(input, errorId) {
  input.setAttribute("aria-invalid", "true");
  const ids = (input.getAttribute("aria-describedby") ?? "").split(/\s+/u).filter(Boolean);
  if (!ids.includes(errorId)) input.setAttribute("aria-describedby", [...ids, errorId].join(" "));
}

export function clearFieldError(input, errorId) {
  input.removeAttribute("aria-invalid");
  const ids = (input.getAttribute("aria-describedby") ?? "").split(/\s+/u).filter((id) => id && id !== errorId);
  if (ids.length) input.setAttribute("aria-describedby", ids.join(" "));
  else input.removeAttribute("aria-describedby");
}

const REJECTED_FIELDS = [["Hostname", "host"], ["Port", "port"], ["Username", "username"], ["Password", "password"]];

// The server names fields technically ("Hostname is invalid."). Marks the
// matching field invalid and returns the message using the field's visible
// label, for example "Server address is invalid.". Other messages pass through.
export function markRejectedField(text, find, errorId) {
  const match = REJECTED_FIELDS.find(([prefix]) => text.startsWith(`${prefix} is invalid.`));
  if (!match) return text;
  const input = find(match[1]);
  setFieldError(input, errorId);
  const label = input.closest?.(".rm-field")?.querySelector(".rm-field__label")?.textContent.trim();
  return label ? `${label}${text.slice(match[0].length)}` : text;
}

export function validateSshIdentity(identity) {
  if (!identity || typeof identity.host !== "string" || !identity.host ||
    !Number.isSafeInteger(identity.port) || identity.port < 1 || identity.port > 65535 ||
    !/^SHA256:[A-Za-z0-9+/]{43}$/u.test(identity.fingerprint) ||
    typeof identity.username !== "string" || !/^[a-z_][a-z0-9_-]{0,31}$/iu.test(identity.username) ||
    !["agent", "password"].includes(identity.authentication) ||
    !["root", "sudo-n"].includes(identity.privilege) ||
    !Number.isSafeInteger(identity.loginUid) || identity.loginUid < 0 || identity.effectiveUid !== 0 ||
    !Number.isSafeInteger(identity.generation) || identity.generation < 1 ||
    identity.scope !== (identity.privilege === "root" ? "vps" : "local-model-only") ||
    (identity.privilege === "root" && identity.loginUid !== 0)) {
    throw new Error("The verified administrative SSH identity is unavailable. Disconnect and reconnect.");
  }
  return identity;
}

export async function readSshIdentity(token, { allowModelOnly = false } = {}) {
  const result = await sshRead(token, "/api/ssh/connection");
  if (result?.connected === false) throw new Error("Connect to the VPS first.");
  const identity = validateSshIdentity(result);
  if (!allowModelOnly && identity.scope !== "vps") {
    throw new Error("This connection is local-model-only (sudo -n). Use the Local model page, or disconnect and reconnect with a UID 0 account for this VPS flow.");
  }
  return identity;
}

// A review with its own host-key field keeps the server line short; other reviews
// show the fingerprint at the end of the server line.
function showReviewIdentity(server, fingerprint) {
  const fingerprintField = element("ssh-review-fingerprint");
  if (fingerprintField) fingerprintField.textContent = fingerprint;
  element("ssh-review-identity").textContent = fingerprintField || !server ? server : `${server} · ${fingerprint}`;
}

// `hostReport` returns the page's flow and current selection for the host
// report, as { flow, container, network }. Without it the guard leaves the
// report alone. Pages call refreshHostReport() after the selection changes.
export function createCredentialSshGuard({ token, onMismatch, onIdentityDecision = () => {}, hostReport = null }) {
  let adopted = null;
  let initialAdoptionAvailable = true;
  const isRemote = path => ["/api/discover", "/api/networks", "/api/plan", "/api/install"].includes(path) ||
    path.startsWith("/api/assistant/") || path.startsWith("/api/vps/supergrok/") || path.startsWith("/api/vps/claude/");
  function adopt(identity) {
    validateSshIdentity(identity);
    if (identity.scope !== "vps") throw new Error("This connection is local-model-only. Reconnect with a UID 0 account for this VPS flow.");
    adopted = Object.freeze({ ...identity });
    onIdentityDecision();
    initialAdoptionAvailable = false;
    const server = `${identity.username}@${identity.host}:${identity.port} · ${identity.authentication} · ${identity.privilege} · UID ${identity.loginUid} → ${identity.effectiveUid}`;
    element("ssh-session").textContent = `${server} · ${identity.fingerprint}`;
    showReviewIdentity(server, identity.fingerprint);
    element("ssh-disconnect").hidden = false;
    if (hostReport) void showHostReport(token, hostReport());
  }
  function rejectIdentity(error) {
    initialAdoptionAvailable = false;
    error.sshIdentityUnverified = true;
    adopted = null;
    onIdentityDecision();
    onMismatch();
    showReviewIdentity("", "");
    element("ssh-session").textContent = error.message;
    element("ssh-disconnect").hidden = error.message === "Connect to the VPS first.";
    if (hostReport) clearHostReport();
    throw error;
  }
  async function verify() {
    try {
      const current = await readSshIdentity(token);
      if (!adopted || !sameSshIdentity(adopted, current)) {
        throw new Error("The authenticated VPS changed. Disconnect and reconnect before discovering, reviewing or applying this action.");
      }
    } catch (error) {
      rejectIdentity(error);
    }
  }
  return {
    adoptedIdentity: () => adopted,
    async adoptCurrent() {
      if (!initialAdoptionAvailable) return verify();
      initialAdoptionAvailable = false;
      try { adopt(await readSshIdentity(token)); } catch (error) { rejectIdentity(error); }
    },
    async before(path) { if (isRemote(path)) await verify(); },
    async after(path, result) {
      if (path === "/api/ssh/connect") adopt(result.identity);
      else if (path === "/api/disconnect") {
        adopted = null;
        onIdentityDecision();
        showReviewIdentity("", "");
        element("ssh-session").textContent = "No authenticated VPS session.";
        if (hostReport) clearHostReport();
      }
      // A pending image sign-in keeps the verified SSH connection until polling finishes.
      else if (path === "/api/assistant/install" || (path === "/api/install" && result.images?.state !== "pending")) {
        adopted = null;
        onIdentityDecision();
      }
      else if (isRemote(path)) await verify();
    },
    refreshHostReport() { if (hostReport && adopted) void showHostReport(token, hostReport()); },
  };
}

export function bindSshAuthentication({ token, trustId, onChange, allowSudo = false, shouldApplyConnectionStatus = () => true }) {
  let locked = false;
  const useAgent = () => element("ssh-authentication").value === "agent";
  function sync(options = {}) {
    if (options.locked !== undefined) locked = options.locked;
    const trusted = options.trusted ?? element(trustId).checked;
    element("password").disabled = locked || !trusted || useAgent();
    element("password").required = !useAgent();
    element("ssh-authentication").disabled = locked;
    element("username").disabled = locked;
    if (allowSudo) element("ssh-privilege").disabled = locked;
    if (useAgent()) element("password").value = "";
  }
  for (const id of ["ssh-authentication", ...(allowSudo ? ["ssh-privilege"] : [])]) {
    element(id).addEventListener("change", () => { element("password").value = ""; onChange(); sync(); });
  }
  element("username").addEventListener("input", onChange);
  element("ssh-disconnect").addEventListener("click", async () => {
    element("ssh-disconnect").disabled = true;
    try {
      const response = await fetch("/api/disconnect", { method: "POST", headers: { "X-Setup-Token": token, "Content-Type": "application/json" }, body: "{}", credentials: "omit", mode: "same-origin", redirect: "error" });
      const result = await response.json();
      if (!response.ok) throw wizardApiError(result, response.status, "Disconnect failed.");
      window.location.reload();
    } catch (error) {
      element("ssh-session").textContent = error.message;
      element("ssh-disconnect").disabled = false;
    }
  });
  sync();
  if (token) {
    void sshRead(token, "/api/ssh/capabilities").then(({ agent }) => {
      element("ssh-agent-status").textContent = agent?.status === "configured"
        ? `Local agent configured (${agent.transport === "windows-openssh-pipe" ? "Windows OpenSSH pipe" : "Unix socket"}); keys and server authentication are untested.`
        : "No usable local SSH agent is configured for this Relmio process. Load your key locally and relaunch Relmio, or choose password authentication.";
    }).catch(() => { element("ssh-agent-status").textContent = "Local SSH agent capability could not be checked. Authentication is not verified."; });
    void readSshIdentity(token, { allowModelOnly: true }).then((identity) => {
      if (!shouldApplyConnectionStatus()) return;
      element("ssh-session").textContent = `Connected as ${identity.username}@${identity.host}:${identity.port}, ${identity.authentication}, ${identity.privilege}, UID ${identity.loginUid} → ${identity.effectiveUid}. ${identity.scope === "local-model-only" ? "Model-only session; OAuth bridge, Assistant, SuperGrok and Claude require a root reconnect." : "Verified root VPS session."}`;
      element("ssh-disconnect").hidden = false;
    }).catch((error) => {
      if (!shouldApplyConnectionStatus()) return;
      element("ssh-session").textContent = error.message === "Connect to the VPS first." ? "No authenticated VPS session." : error.message;
    });
  }
  return {
    sync,
    request(expectedFingerprint) {
      const agent = useAgent();
      const request = {
        host: element("host").value, port: Number(element("port").value), username: element("username").value,
        expectedFingerprint, useAgent: agent, privilege: allowSudo ? element("ssh-privilege").value : "root",
        ...(agent ? {} : { password: element("password").value }),
      };
      element("password").value = "";
      return request;
    },
  };
}