Ctrl K
ComunidadGuías…

src/fleet.js

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

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: GPL-3.0-or-later
/**
 * Fleet reporting (enterprise): push a report to a central server so
 * companies can collect health data from many machines in one place.
 * The hosted dashboard is the paid service; this client is free and open.
 */
import os from "node:os";
import { readFileSync } from "node:fs";

/** Stable per-machine id from /etc/machine-id (null when unreadable). */
export function machineId() {
  try {
    const id = readFileSync("/etc/machine-id", "utf8").trim();
    return id || null;
  } catch {
    return null;
  }
}

/**
 * Hosts where plaintext HTTP is still acceptable: loopback endpoints never
 * leave the machine, so a local dev/test server stays legal with or without
 * auth. Anything else over http:// puts the report (and the Bearer token) on
 * the wire in the clear.
 */
const PLAINTEXT_OK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);

/**
 * Non-loopback addresses that a misconfigured or attacker-influenced endpoint
 * could use to reach internal services: RFC1918, link-local (incl. the cloud
 * metadata address), CGNAT, and the IPv6 link-local/ULA ranges. Loopback is
 * deliberately NOT here — a local dev/test server stays legal as before; the
 * protection is against a report (and a Bearer token) being sent to some
 * other host on the LAN or inside a network. Only literal addresses and
 * IP-shaped hostnames are caught synchronously; a name that resolves to a
 * private IP is out of scope for this pure check (documented).
 */
const PRIVATE_IPV4 = /^(?:10\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/;
const PRIVATE_IPV6 = /^(?:fe80|fec0|fc|fd)[0-9a-f:]*$/;

function isPrivateLiteral(host) {
  const h = host.replace(/^\[|\]$/g, "").toLowerCase();
  return PRIVATE_IPV4.test(h) || PRIVATE_IPV6.test(h);
}

/** Strip credentials from a URL before it is shown in a message or a log. */
export function redactUrl(url) {
  return String(url).replace(/\/\/[^/@\s]+@/, "//<redacted>@");
}

/**
 * Validate a fleet endpoint URL up front, so a typo fails with a clear
 * message instead of a generic fetch error three steps into the run.
 * Only http(s) is accepted — the client never POSTs a report anywhere else.
 * When `apiKey` is set, plain HTTP is refused for every non-loopback host:
 * the Authorization header would be readable by anything between here and
 * the endpoint. Private/LAN literals are refused unless `allowPrivate` is
 * set (self-hosted servers are legitimate — but opting in must be explicit).
 * Returns an error string, or null when the URL is usable.
 * Pure, exported for tests and reused by --push, --alert and --heartbeat.
 */
export function validatePushUrl(url, { apiKey, allowPrivate = false } = {}) {
  if (!url || typeof url !== "string" || url.trim() === "") return "--push requires a URL";
  let parsed;
  try {
    parsed = new URL(url.trim());
  } catch {
    return `invalid URL "${redactUrl(url)}" — did you forget https:// ?`;
  }
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
    return `invalid URL "${redactUrl(url)}" — only http:// and https:// endpoints are supported`;
  }
  if (!parsed.hostname) return `invalid URL "${redactUrl(url)}" — missing host name`;
  // Node keeps the brackets on IPv6 hostnames ("[::1]"); strip them for the
  // loopback comparison, same as the dashboard server's origin check.
  const host = parsed.hostname.replace(/^\[|\]$/g, "");
  if (apiKey && parsed.protocol === "http:" && !PLAINTEXT_OK_HOSTS.has(host)) {
    return `insecure endpoint "${redactUrl(url)}" — FLEET_API_KEY is set and would travel unencrypted over HTTP; use https:// (loopback URLs are exempt)`;
  }
  if (!allowPrivate && isPrivateLiteral(host)) {
    return `endpoint "${redactUrl(url)}" points at a private/LAN address — pass --allow-private-endpoint if this is a self-hosted server on your network`;
  }
  return null;
}

/**
 * POST a report to a fleet endpoint. The payload carries a stable machineId
 * and, when the client has history, the diffSinceLast so a fleet dashboard
 * can show what changed on each machine without recomputing it.
 * Returns the server response; throws on network or HTTP errors.
 */
export async function pushReport(url, data, { apiKey, allowPrivate = false } = {}) {
  const err = validatePushUrl(url, { apiKey, allowPrivate });
  if (err) throw new Error(err.replace(/^--push /, "")); // defense in depth; CLI validates earlier
  const headers = { "Content-Type": "application/json" };
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
  const res = await fetch(url, {
    method: "POST",
    headers,
    signal: AbortSignal.timeout(10000),
    // A redirect could bounce an allowed URL onto an internal target (SSRF);
    // reports go exactly where the user pointed them, or nowhere.
    redirect: "error",
    body: JSON.stringify({
      ...data,
      agent: "linux-doctor",
      hostname: os.hostname(),
      machineId: machineId(),
      sentAt: new Date().toISOString(),
    }),
  });
  if (!res.ok) throw new Error(`fleet server responded ${res.status}`);
  return res;
}