src/fleet.js
Rama main · réplica de lectura del repositorio original.
// 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;
}