Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/**
|
|
* HTTP-Client fuer die Syncova-API.
|
|
*
|
|
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
|
|
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
|
|
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
|
|
* (PROMPT.md §140: keine stillen Fehler).
|
|
*/
|
|
|
|
import type { ErrorResponse, SuccessResponse } from '../types/api';
|
|
|
|
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
|
|
const API_BASE_PATH = '/api/v1';
|
|
|
|
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
|
|
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
|
|
|
|
/**
|
|
* Fehler einer API-Anfrage.
|
|
*
|
|
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
|
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
|
|
*/
|
|
export class ApiError extends Error {
|
|
/** Stabiler maschinenlesbarer Fehlercode. */
|
|
public readonly code: string;
|
|
/** HTTP-Statuscode der Antwort. */
|
|
public readonly statusCode: number;
|
|
/** Kennung des Requests zur Zuordnung im Serverlog. */
|
|
public readonly requestId: string;
|
|
/** Optionale unbedenkliche Zusatzinformationen. */
|
|
public readonly details: Record<string, unknown> | undefined;
|
|
|
|
constructor(parameters: {
|
|
code: string;
|
|
message: string;
|
|
statusCode: number;
|
|
requestId: string;
|
|
details?: Record<string, unknown>;
|
|
}) {
|
|
super(parameters.message);
|
|
this.name = 'ApiError';
|
|
this.code = parameters.code;
|
|
this.statusCode = parameters.statusCode;
|
|
this.requestId = parameters.requestId;
|
|
this.details = parameters.details;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
|
|
*
|
|
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
|
|
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
|
|
* dem Anmeldemodul, das seinerseits den Client verwendet.
|
|
*/
|
|
let accessTokenProvider: () => string | null = () => null;
|
|
|
|
/** Hinterlegt die Funktion, die das aktuelle Zugriffstoken liefert. */
|
|
export function setAccessTokenProvider(tokenProvider: () => string | null): void {
|
|
accessTokenProvider = tokenProvider;
|
|
}
|
|
|
|
/** Fehlercode fuer eine nicht erreichbare API. */
|
|
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
|
|
|
|
/** Fehlercode fuer eine unverstaendliche Antwort. */
|
|
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
|
|
|
|
/**
|
|
* Erzeugt eine Correlation ID fuer einen Request.
|
|
*
|
|
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
|
|
* Rueckfall deckt aeltere Testumgebungen ab.
|
|
*/
|
|
function createCorrelationId(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
|
|
// der Nachvollziehbarkeit, nicht der Sicherheit.
|
|
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
|
|
}
|
|
|
|
/** Optionen einer API-Anfrage. */
|
|
export interface RequestOptions {
|
|
/** HTTP-Methode; Standard ist GET. */
|
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
|
|
body?: unknown;
|
|
/** Signal zum Abbrechen der Anfrage. */
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* Fuehrt eine Anfrage gegen die Syncova-API aus.
|
|
*
|
|
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
|
|
* @returns Die Nutzlast der Antwort.
|
|
* @throws ApiError bei jedem Fehlerfall.
|
|
*/
|
|
export async function requestApi<TPayload>(
|
|
endpointPath: string,
|
|
requestOptions: RequestOptions = {},
|
|
): Promise<TPayload> {
|
|
const correlationId = createCorrelationId();
|
|
const requestMethod = requestOptions.method ?? 'GET';
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
Accept: 'application/json',
|
|
[CORRELATION_ID_HEADER]: correlationId,
|
|
};
|
|
|
|
if (requestOptions.body !== undefined) {
|
|
requestHeaders['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
|
|
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
|
|
const accessToken = accessTokenProvider();
|
|
if (accessToken !== null) {
|
|
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
let httpResponse: Response;
|
|
try {
|
|
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
|
|
method: requestMethod,
|
|
headers: requestHeaders,
|
|
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
|
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
|
|
credentials: 'same-origin',
|
|
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
|
});
|
|
} catch (networkError) {
|
|
// Ein Abbruch durch den Aufrufer ist kein Fehlerfall und wird durchgereicht.
|
|
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
|
|
throw networkError;
|
|
}
|
|
|
|
throw new ApiError({
|
|
code: NETWORK_ERROR_CODE,
|
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
|
statusCode: 0,
|
|
requestId: correlationId,
|
|
});
|
|
}
|
|
|
|
// 204 traegt per Definition keinen Koerper.
|
|
if (httpResponse.status === 204) {
|
|
return undefined as TPayload;
|
|
}
|
|
|
|
let parsedBody: unknown;
|
|
try {
|
|
parsedBody = await httpResponse.json();
|
|
} catch {
|
|
throw new ApiError({
|
|
code: MALFORMED_RESPONSE_CODE,
|
|
message: 'Die Antwort des Servers war unverstaendlich.',
|
|
statusCode: httpResponse.status,
|
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
|
});
|
|
}
|
|
|
|
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
|
|
//
|
|
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
|
|
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
|
|
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
|
|
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
|
|
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
|
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
|
|
const errorResponse = parsedBody as Partial<ErrorResponse>;
|
|
if (errorResponse.error) {
|
|
throw new ApiError({
|
|
code: errorResponse.error.code,
|
|
message: errorResponse.error.message,
|
|
statusCode: httpResponse.status,
|
|
requestId: errorResponse.error.request_id,
|
|
...(errorResponse.error.details ? { details: errorResponse.error.details } : {}),
|
|
});
|
|
}
|
|
|
|
const successResponse = parsedBody as Partial<SuccessResponse<TPayload>>;
|
|
if (successResponse.data === undefined) {
|
|
throw new ApiError({
|
|
code: MALFORMED_RESPONSE_CODE,
|
|
message: httpResponse.ok
|
|
? 'Die Antwort des Servers enthielt keine Daten.'
|
|
: 'Der Server meldete einen Fehler ohne verwertbare Beschreibung.',
|
|
statusCode: httpResponse.status,
|
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
|
});
|
|
}
|
|
|
|
return successResponse.data;
|
|
}
|
|
|
|
/**
|
|
* Laedt eine Datei von der API herunter.
|
|
*
|
|
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
|
|
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
|
|
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
|
|
* unverstaendliche Antwort — obwohl alles in Ordnung ist.
|
|
*
|
|
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
|
|
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
|
|
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
|
|
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
|
|
* eine kaputte Datei.
|
|
*/
|
|
export async function downloadApiFile(
|
|
endpointPath: string,
|
|
requestOptions: RequestOptions = {},
|
|
): Promise<{ blob: Blob; fileName: string }> {
|
|
const correlationId = createCorrelationId();
|
|
|
|
const requestHeaders: Record<string, string> = {
|
|
[CORRELATION_ID_HEADER]: correlationId,
|
|
};
|
|
|
|
if (requestOptions.body !== undefined) {
|
|
requestHeaders['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const accessToken = accessTokenProvider();
|
|
if (accessToken !== null) {
|
|
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
|
|
let httpResponse: Response;
|
|
try {
|
|
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
|
|
method: requestOptions.method ?? 'POST',
|
|
headers: requestHeaders,
|
|
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
|
credentials: 'same-origin',
|
|
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
|
});
|
|
} catch (networkError) {
|
|
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
|
|
throw networkError;
|
|
}
|
|
|
|
throw new ApiError({
|
|
code: NETWORK_ERROR_CODE,
|
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
|
statusCode: 0,
|
|
requestId: correlationId,
|
|
});
|
|
}
|
|
|
|
const responseContentType = httpResponse.headers.get('Content-Type') ?? '';
|
|
|
|
// Eine JSON-Antwort auf eine Dateianfrage ist immer ein Fehler.
|
|
if (responseContentType.includes('application/json')) {
|
|
const parsedBody = (await httpResponse.json()) as Partial<ErrorResponse>;
|
|
|
|
throw new ApiError({
|
|
code: parsedBody.error?.code ?? MALFORMED_RESPONSE_CODE,
|
|
message: parsedBody.error?.message ?? 'Die Datei konnte nicht erzeugt werden.',
|
|
statusCode: httpResponse.status,
|
|
requestId: parsedBody.error?.request_id ?? correlationId,
|
|
});
|
|
}
|
|
|
|
if (!httpResponse.ok) {
|
|
throw new ApiError({
|
|
code: MALFORMED_RESPONSE_CODE,
|
|
message: 'Die Datei konnte nicht erzeugt werden.',
|
|
statusCode: httpResponse.status,
|
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
|
});
|
|
}
|
|
|
|
return {
|
|
blob: await httpResponse.blob(),
|
|
fileName: parseFileNameFromDisposition(httpResponse.headers.get('Content-Disposition')),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
|
|
*
|
|
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
|
|
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
|
|
*/
|
|
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
|
|
if (dispositionHeader === null) {
|
|
return '';
|
|
}
|
|
|
|
const fileNameMatch = /filename="([^"]+)"/.exec(dispositionHeader);
|
|
|
|
return fileNameMatch === null ? '' : (fileNameMatch[1] ?? '');
|
|
}
|