feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es vor dem Speichern und bestätigt den SHA-256-Fingerprint. Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch Pin ist im Schema, im Client und per CHECK ausgeschlossen. Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock und im gebauten Stand auf Desktop und 390 px. Closes #002 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { pfad, type ProxmoxClient } from "./client";
|
||||
import { ProxmoxFehler } from "./fehler";
|
||||
|
||||
/**
|
||||
* Viele Proxmox-Aufrufe (clone, create, download-url, start) antworten sofort mit einer UPID und
|
||||
* laufen im Hintergrund weiter. Fertig ist erst, wenn der Task `stopped` mit `exitstatus: OK` meldet.
|
||||
*/
|
||||
type TaskStatus = { status: "running" | "stopped"; exitstatus?: string };
|
||||
type LogZeile = { n: number; t: string };
|
||||
|
||||
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
||||
export function nodeAusUpid(upid: string): string {
|
||||
const teile = upid.split(":");
|
||||
if (teile[0] !== "UPID" || !teile[1]) {
|
||||
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
||||
}
|
||||
return teile[1];
|
||||
}
|
||||
|
||||
export type AbwartenOptionen = {
|
||||
intervallMs?: number;
|
||||
zeitlimitMs?: number;
|
||||
/** Wird mit neuen Log-Zeilen des Tasks aufgerufen (z. B. Download-Fortschritt). */
|
||||
beiLog?: (zeilen: string[]) => void;
|
||||
warten?: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||
|
||||
export async function aufgabeAbwarten(
|
||||
client: ProxmoxClient,
|
||||
upid: string,
|
||||
optionen: AbwartenOptionen = {},
|
||||
): Promise<void> {
|
||||
const node = nodeAusUpid(upid);
|
||||
const intervall = optionen.intervallMs ?? 1000;
|
||||
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
||||
const warten = optionen.warten ?? schlafen;
|
||||
const beginn = Date.now();
|
||||
let gelesen = 0;
|
||||
|
||||
const logLesen = async (): Promise<string[]> => {
|
||||
const zeilen = await client.get<LogZeile[]>(pfad`/nodes/${node}/tasks/${upid}/log`, {
|
||||
start: gelesen,
|
||||
limit: 500,
|
||||
});
|
||||
// Proxmox antwortet ohne neue Zeilen mit [{n:…, t:"no content"}].
|
||||
const neu = zeilen.filter((zeile) => zeile.n > gelesen && zeile.t !== "no content");
|
||||
if (neu.length > 0) gelesen = neu[neu.length - 1]!.n;
|
||||
return neu.map((zeile) => zeile.t);
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const status = await client.get<TaskStatus>(pfad`/nodes/${node}/tasks/${upid}/status`);
|
||||
if (optionen.beiLog) {
|
||||
const neu = await logLesen();
|
||||
if (neu.length > 0) optionen.beiLog(neu);
|
||||
}
|
||||
if (status.status === "stopped") {
|
||||
if (status.exitstatus === "OK" || status.exitstatus?.startsWith("WARNINGS")) return;
|
||||
const letzte = optionen.beiLog ? [] : (await logLesen()).slice(-5);
|
||||
throw new ProxmoxFehler(
|
||||
"aufgabe",
|
||||
`Proxmox-Task fehlgeschlagen: ${status.exitstatus ?? "unbekannt"}`,
|
||||
letzte.length > 0
|
||||
? `Letzte Zeilen: ${letzte.join(" | ")}`
|
||||
: "Task-Log im Detail-Log oder in Proxmox prüfen.",
|
||||
{ pfad: upid },
|
||||
);
|
||||
}
|
||||
if (Date.now() - beginn > zeitlimit) {
|
||||
throw new ProxmoxFehler(
|
||||
"zeitueberschreitung",
|
||||
`Proxmox-Task läuft länger als ${Math.round(zeitlimit / 60000)} min.`,
|
||||
"In Proxmox nachsehen, ob er hängt; ProxDeploy bricht ihn nicht selbst ab.",
|
||||
{ pfad: upid },
|
||||
);
|
||||
}
|
||||
await warten(intervall);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Agent, fetch } from "undici";
|
||||
import { TOKEN_ID_MUSTER } from "@/lib/schemas/server";
|
||||
import { antwortFehler, ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||
import { tlsConnector, type TlsEinstellung } from "./tls";
|
||||
|
||||
/**
|
||||
* Zugang zur Proxmox-VE-API mit einem API-Token. Bewusst schlank: Pfade und Parameter sind die
|
||||
* der Proxmox-API (siehe pve.proxmox.com/pve-docs/api-viewer), damit Dry-Run und Log genau das
|
||||
* zeigen, was Proxmox bekommt.
|
||||
*/
|
||||
export type ProxmoxVerbindung = TlsEinstellung & {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tokenGeheimnis: string;
|
||||
};
|
||||
|
||||
export type Parameter = Record<string, string | number | boolean | readonly string[] | undefined>;
|
||||
|
||||
export type Methode = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
/** Ein Aufruf, wie er im Dry-Run und im Detail-Log erscheint. */
|
||||
export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter };
|
||||
|
||||
export type AufrufBeobachter = (
|
||||
aufruf: Aufruf,
|
||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||
) => void;
|
||||
|
||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||
|
||||
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
||||
const kodiert = new URLSearchParams();
|
||||
for (const [name, wert] of Object.entries(parameter ?? {})) {
|
||||
if (wert === undefined) continue;
|
||||
if (Array.isArray(wert)) {
|
||||
for (const eintrag of wert) kodiert.append(name, eintrag);
|
||||
} else if (typeof wert === "boolean") {
|
||||
kodiert.append(name, wert ? "1" : "0");
|
||||
} else {
|
||||
kodiert.append(name, String(wert));
|
||||
}
|
||||
}
|
||||
return kodiert;
|
||||
}
|
||||
|
||||
/** Pfadteile wie Node-Namen oder Storage-IDs sicher einsetzen: `pfad\`/nodes/${n}\``. */
|
||||
export function pfad(teile: TemplateStringsArray, ...werte: Array<string | number>): string {
|
||||
return teile.reduce(
|
||||
(ergebnis, teil, index) =>
|
||||
ergebnis + teil + (index < werte.length ? encodeURIComponent(String(werte[index])) : ""),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
export class ProxmoxClient {
|
||||
readonly basis: string;
|
||||
private readonly agent: Agent;
|
||||
private readonly kopf: string;
|
||||
|
||||
constructor(
|
||||
private readonly verbindung: ProxmoxVerbindung,
|
||||
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
|
||||
) {
|
||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||
throw new ProxmoxFehler(
|
||||
"anmeldung",
|
||||
`„${verbindung.tokenId}“ ist keine gültige Token-ID.`,
|
||||
"Format: benutzer@realm!tokenname, z. B. proxdeploy@pve!deploy",
|
||||
);
|
||||
}
|
||||
this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json";
|
||||
this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 10_000 });
|
||||
this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`;
|
||||
}
|
||||
|
||||
async get<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "GET", pfad, parameter });
|
||||
}
|
||||
|
||||
async post<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "POST", pfad, parameter });
|
||||
}
|
||||
|
||||
async put<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "PUT", pfad, parameter });
|
||||
}
|
||||
|
||||
async delete<T>(pfad: string, parameter?: Parameter): Promise<T> {
|
||||
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
||||
}
|
||||
|
||||
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
||||
const beginn = Date.now();
|
||||
try {
|
||||
const ergebnis = await this.senden<T>(aufruf);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||
return ergebnis;
|
||||
} catch (fehler) {
|
||||
const proxmoxFehler =
|
||||
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
||||
throw proxmoxFehler;
|
||||
}
|
||||
}
|
||||
|
||||
private async senden<T>(aufruf: Aufruf): Promise<T> {
|
||||
const kodiert = parameterKodieren(aufruf.parameter);
|
||||
const mitRumpf = aufruf.methode === "POST" || aufruf.methode === "PUT";
|
||||
const ziel = this.basis + aufruf.pfad + (!mitRumpf && kodiert.size > 0 ? `?${kodiert}` : "");
|
||||
|
||||
const antwort = await fetch(ziel, {
|
||||
method: aufruf.methode,
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
authorization: this.kopf,
|
||||
accept: "application/json",
|
||||
...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
||||
},
|
||||
body: mitRumpf ? kodiert.toString() : undefined,
|
||||
signal: AbortSignal.timeout(this.optionen.zeitlimitMs ?? STANDARD_ZEITLIMIT_MS),
|
||||
});
|
||||
|
||||
const text = await antwort.text();
|
||||
let koerper: unknown = null;
|
||||
try {
|
||||
koerper = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// Manche Fehlerantworten von pveproxy sind kein JSON; dann zählt die Statuszeile.
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
throw antwortFehler(antwort.status, antwort.statusText, koerper, aufruf.pfad);
|
||||
}
|
||||
return (koerper as { data: T } | null)?.data as T;
|
||||
}
|
||||
|
||||
async schliessen(): Promise<void> {
|
||||
await this.agent.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Fehler aus dem Gespräch mit Proxmox – mit Ursache und Lösungshinweis, so wie sie in Web und
|
||||
* CLI unter einer fehlgeschlagenen Phase stehen. Die Art bestimmt den Hinweis; der Text von
|
||||
* Proxmox selbst bleibt in `ursache` erhalten, weil er oft das Entscheidende enthält.
|
||||
*/
|
||||
export type ProxmoxFehlerArt =
|
||||
| "verbindung"
|
||||
| "tls"
|
||||
| "fingerabdruck"
|
||||
| "anmeldung"
|
||||
| "recht"
|
||||
| "nicht_gefunden"
|
||||
| "parameter"
|
||||
| "aufgabe"
|
||||
| "zeitueberschreitung"
|
||||
| "api";
|
||||
|
||||
export class ProxmoxFehler extends Error {
|
||||
constructor(
|
||||
readonly art: ProxmoxFehlerArt,
|
||||
readonly ursache: string,
|
||||
readonly hinweis: string,
|
||||
readonly details: { pfad?: string; status?: number; parameter?: Record<string, string> } = {},
|
||||
) {
|
||||
super(ursache);
|
||||
this.name = "ProxmoxFehler";
|
||||
}
|
||||
}
|
||||
|
||||
const TLS_CODES_SELBST_SIGNIERT = new Set([
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
]);
|
||||
|
||||
/** Übersetzt einen Netzwerk- oder TLS-Fehler von undici in einen ProxmoxFehler. */
|
||||
export function verbindungsFehler(fehler: unknown, url: string): ProxmoxFehler {
|
||||
if (fehler instanceof ProxmoxFehler) return fehler;
|
||||
// undici verpackt die eigentliche Ursache in `cause`, manchmal zweifach.
|
||||
let ursache: unknown = fehler;
|
||||
for (let i = 0; i < 3 && ursache instanceof Error && ursache.cause; i += 1) {
|
||||
if (ursache.cause instanceof ProxmoxFehler) return ursache.cause;
|
||||
ursache = ursache.cause;
|
||||
}
|
||||
const code = (ursache as { code?: string } | undefined)?.code ?? "";
|
||||
const text = ursache instanceof Error ? ursache.message : String(ursache);
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
if (TLS_CODES_SELBST_SIGNIERT.has(code)) {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat von ${host} ist nicht vertrauenswürdig (${code}).`,
|
||||
"Bei einem selbst signierten Proxmox-Zertifikat „Fingerprint holen“ und den Fingerprint bestätigen.",
|
||||
);
|
||||
}
|
||||
if (code === "ERR_TLS_CERT_ALTNAME_INVALID") {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat passt nicht zum Namen ${host}.`,
|
||||
"Die URL mit dem Namen aus dem Zertifikat angeben oder den Fingerprint pinnen.",
|
||||
);
|
||||
}
|
||||
if (code === "CERT_HAS_EXPIRED") {
|
||||
return new ProxmoxFehler(
|
||||
"tls",
|
||||
`Das Zertifikat von ${host} ist abgelaufen.`,
|
||||
"Zertifikat auf dem Node erneuern.",
|
||||
);
|
||||
}
|
||||
if (code === "ECONNREFUSED") {
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`${host} lehnt die Verbindung ab.`,
|
||||
"Läuft pveproxy? Stimmt der Port (Standard 8006)?",
|
||||
);
|
||||
}
|
||||
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`Der Name ${host} lässt sich nicht auflösen.`,
|
||||
"Hostnamen oder DNS prüfen.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "UND_ERR_CONNECT_TIMEOUT" ||
|
||||
code === "EHOSTUNREACH" ||
|
||||
code === "ENETUNREACH" ||
|
||||
(ursache instanceof Error && ursache.name === "TimeoutError")
|
||||
) {
|
||||
return new ProxmoxFehler(
|
||||
"zeitueberschreitung",
|
||||
`${host} antwortet nicht.`,
|
||||
"Ist der Host aus dem Netz von ProxDeploy erreichbar? Firewall und Routing prüfen.",
|
||||
);
|
||||
}
|
||||
return new ProxmoxFehler(
|
||||
"verbindung",
|
||||
`Verbindung zu ${host} fehlgeschlagen: ${text}`,
|
||||
"Adresse und Netzwerk prüfen.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxmox meldet fehlende Rechte als „Permission check failed (/vms/120, VM.Allocate)“. Daraus
|
||||
* wird ein Hinweis, der Pfad und Recht nennt – das spart den Blick in die Doku.
|
||||
*/
|
||||
export function rechtHinweis(meldung: string): string {
|
||||
const treffer = /Permission check failed \(([^,]+), ([^)]+)\)/.exec(meldung);
|
||||
if (treffer) {
|
||||
return (
|
||||
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
|
||||
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
|
||||
);
|
||||
}
|
||||
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
|
||||
}
|
||||
|
||||
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
|
||||
export function antwortFehler(
|
||||
status: number,
|
||||
statusText: string,
|
||||
koerper: unknown,
|
||||
pfad: string,
|
||||
): ProxmoxFehler {
|
||||
const parameter =
|
||||
koerper &&
|
||||
typeof koerper === "object" &&
|
||||
"errors" in koerper &&
|
||||
koerper.errors &&
|
||||
typeof koerper.errors === "object"
|
||||
? (koerper.errors as Record<string, string>)
|
||||
: undefined;
|
||||
// Proxmox schreibt die eigentliche Meldung in die Statuszeile, nicht in den Rumpf.
|
||||
const meldung = (statusText || "").trim() || `HTTP ${status}`;
|
||||
|
||||
if (status === 401) {
|
||||
return new ProxmoxFehler(
|
||||
"anmeldung",
|
||||
"Proxmox lehnt das API-Token ab.",
|
||||
"Token-ID (user@realm!tokenid) und Secret prüfen; ist das Token abgelaufen oder gelöscht?",
|
||||
{ pfad, status },
|
||||
);
|
||||
}
|
||||
if (status === 403) {
|
||||
return new ProxmoxFehler("recht", meldung, rechtHinweis(meldung), { pfad, status });
|
||||
}
|
||||
if (status === 400 && parameter) {
|
||||
const liste = Object.entries(parameter)
|
||||
.map(([name, text]) => `${name}: ${String(text).trim()}`)
|
||||
.join("; ");
|
||||
return new ProxmoxFehler("parameter", `Proxmox lehnt Parameter ab – ${liste}`, "Eingaben prüfen.", {
|
||||
pfad,
|
||||
status,
|
||||
parameter,
|
||||
});
|
||||
}
|
||||
if (status === 404 || /does not exist|not found/i.test(meldung)) {
|
||||
return new ProxmoxFehler("nicht_gefunden", meldung, "Gibt es Node, VM oder Storage noch?", {
|
||||
pfad,
|
||||
status,
|
||||
});
|
||||
}
|
||||
if (status === 501) {
|
||||
return new ProxmoxFehler(
|
||||
"nicht_gefunden",
|
||||
`Proxmox kennt ${pfad} nicht (${meldung}).`,
|
||||
"Die PVE-Version unterstützt diesen Aufruf nicht; ProxDeploy weicht dann auf SSH aus, wenn eingerichtet.",
|
||||
{ pfad, status },
|
||||
);
|
||||
}
|
||||
return new ProxmoxFehler("api", meldung, "Meldung von Proxmox prüfen; Details stehen im Log.", {
|
||||
pfad,
|
||||
status,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { connect, type PeerCertificate } from "node:tls";
|
||||
import { buildConnector } from "undici";
|
||||
import { ProxmoxFehler, verbindungsFehler } from "./fehler";
|
||||
|
||||
/**
|
||||
* TLS zu Proxmox. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; dann zählt nur der
|
||||
* gepinnte SHA-256-Fingerprint, den ein Admin beim Anlegen bestätigt hat (TOFU **mit**
|
||||
* Bestätigung). Ein globales `NODE_TLS_REJECT_UNAUTHORIZED=0` gibt es nicht.
|
||||
*/
|
||||
|
||||
/** „ab:cd:…“, „ABCD…“ oder „SHA256:…“-ähnliche Eingaben → „AB:CD:…“. */
|
||||
export function fingerabdruckNormalisieren(eingabe: string): string | null {
|
||||
const hex = eingabe
|
||||
.trim()
|
||||
.replace(/^sha-?256[:= ]*/i, "")
|
||||
.replace(/[^0-9a-f]/gi, "")
|
||||
.toUpperCase();
|
||||
if (hex.length !== 64) return null;
|
||||
return hex.match(/.{2}/g)!.join(":");
|
||||
}
|
||||
|
||||
export type ZertifikatInfo = {
|
||||
fingerabdruck: string;
|
||||
subjekt: string;
|
||||
aussteller: string;
|
||||
gueltigBis: string;
|
||||
alternativeNamen: string[];
|
||||
/** Ob das System dem Zertifikat ohnehin vertraut (z. B. ACME/Let's Encrypt). */
|
||||
vertraut: boolean;
|
||||
fehlerGrund: string | null;
|
||||
};
|
||||
|
||||
function namen(teil: PeerCertificate["subject"] | undefined): string {
|
||||
if (!teil) return "";
|
||||
return Object.entries(teil)
|
||||
.map(([schluessel, wert]) => `${schluessel}=${Array.isArray(wert) ? wert.join("+") : wert}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Holt das Zertifikat ohne es zu prüfen – nur zum Anzeigen und Bestätigen. */
|
||||
export function zertifikatHolen(url: string, zeitlimitMs = 10_000): Promise<ZertifikatInfo> {
|
||||
const ziel = new URL(url);
|
||||
const port = Number(ziel.port || 443);
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
const socket = connect({
|
||||
host: ziel.hostname,
|
||||
port,
|
||||
servername: /^[\d.]+$|:/.test(ziel.hostname) ? undefined : ziel.hostname,
|
||||
rejectUnauthorized: false,
|
||||
timeout: zeitlimitMs,
|
||||
});
|
||||
socket.once("secureConnect", () => {
|
||||
const zertifikat = socket.getPeerCertificate();
|
||||
socket.end();
|
||||
if (!zertifikat?.fingerprint256) {
|
||||
ablehnen(
|
||||
new ProxmoxFehler(
|
||||
"tls",
|
||||
"Der Server hat kein Zertifikat geschickt.",
|
||||
"Ist das wirklich Proxmox auf HTTPS?",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
erfuellen({
|
||||
fingerabdruck: fingerabdruckNormalisieren(zertifikat.fingerprint256)!,
|
||||
subjekt: namen(zertifikat.subject),
|
||||
aussteller: namen(zertifikat.issuer),
|
||||
gueltigBis: new Date(zertifikat.valid_to).toISOString(),
|
||||
alternativeNamen: (zertifikat.subjectaltname ?? "")
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean),
|
||||
vertraut: socket.authorized,
|
||||
fehlerGrund: socket.authorizationError ? String(socket.authorizationError) : null,
|
||||
});
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
ablehnen(verbindungsFehler(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), url));
|
||||
});
|
||||
socket.once("error", (fehler) => ablehnen(verbindungsFehler(fehler, url)));
|
||||
});
|
||||
}
|
||||
|
||||
export type TlsEinstellung = { tlsPruefen: boolean; fingerabdruck: string | null };
|
||||
|
||||
/**
|
||||
* Connector für undici.
|
||||
* - `tlsPruefen` ohne Pin: normale Prüfung gegen die Systemzertifikate.
|
||||
* - Pin gesetzt: die Kette wird nicht geprüft (selbst signiert), aber der Fingerprint muss
|
||||
* **exakt** stimmen – sonst wird die Verbindung vor dem ersten Byte getrennt.
|
||||
* - `tlsPruefen` **und** Pin: beides muss stimmen.
|
||||
*/
|
||||
export function tlsConnector(einstellung: TlsEinstellung): buildConnector.connector {
|
||||
const pin = einstellung.fingerabdruck ? fingerabdruckNormalisieren(einstellung.fingerabdruck) : null;
|
||||
if (!pin && !einstellung.tlsPruefen) {
|
||||
// Die Datenbank verbietet diesen Zustand (server_tls_oder_pin); hier zur Sicherheit noch einmal.
|
||||
throw new ProxmoxFehler(
|
||||
"tls",
|
||||
"Weder TLS-Prüfung noch Fingerprint – die Verbindung wäre blind.",
|
||||
"Fingerprint holen und bestätigen oder TLS-Prüfung einschalten.",
|
||||
);
|
||||
}
|
||||
// Ohne Sitzungs-Cache: eine wiederaufgenommene TLS-Sitzung schickt kein Zertifikat mit,
|
||||
// und der Pin ließe sich dann nicht prüfen.
|
||||
const basis = buildConnector({ rejectUnauthorized: einstellung.tlsPruefen, maxCachedSessions: 0 });
|
||||
if (!pin) return basis;
|
||||
|
||||
return (optionen, rueckruf) => {
|
||||
basis(optionen, (fehler, socket) => {
|
||||
if (fehler || !socket) {
|
||||
rueckruf(fehler ?? new Error("Keine Verbindung"), null);
|
||||
return;
|
||||
}
|
||||
const zertifikat = "getPeerCertificate" in socket ? socket.getPeerCertificate() : undefined;
|
||||
const gesehen = zertifikat?.fingerprint256
|
||||
? fingerabdruckNormalisieren(zertifikat.fingerprint256)
|
||||
: null;
|
||||
if (gesehen !== pin) {
|
||||
socket.destroy();
|
||||
rueckruf(
|
||||
new ProxmoxFehler(
|
||||
"fingerabdruck",
|
||||
`Der Fingerprint des Zertifikats hat sich geändert (erwartet ${pin.slice(0, 11)}…, gesehen ${gesehen?.slice(0, 11) ?? "keiner"}…).`,
|
||||
"Wurde das Zertifikat erneuert? Dann in den Server-Einstellungen neu holen und bestätigen. Sonst nicht verbinden – jemand könnte dazwischen sitzen.",
|
||||
),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
rueckruf(null, socket);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user