**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:
- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.
Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).
**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.
**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.
Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.
82 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
/**
|
|
* API-Anbindung von Virtualisierungsverbünden und Agenten.
|
|
*
|
|
* Beide teilen eine Eigenschaft, die die Masken prägt: **Geheimnisse gehen nur
|
|
* hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das
|
|
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen —
|
|
* die API gibt sie nicht zurück, und das ist kein Mangel, sondern der Grund,
|
|
* warum ein Lesezugriff auf die Konfiguration ungefährlich ist.
|
|
*
|
|
* Die Oberfläche muss das sagen, sonst schließt jemand das Fenster mit dem
|
|
* einmalig angezeigten Aufnahme-Token.
|
|
*/
|
|
|
|
import { requestApi } from '../../api/client';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Proxmox-Verbünde
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Zugriffsweg auf die Sicherungsarchive. */
|
|
export type TransportKind = 'local' | 'ssh';
|
|
|
|
/** Ein eingerichteter Verbund. */
|
|
export interface Cluster {
|
|
id: string;
|
|
name: string;
|
|
api_endpoint: string;
|
|
api_token_id: string;
|
|
/** Fingerabdruck bei selbstsigniertem Zertifikat. */
|
|
tls_fingerprint?: string;
|
|
backup_storage_id: string;
|
|
archive_transport: TransportKind;
|
|
archive_mount_roots?: Record<string, string>;
|
|
ssh_username?: string;
|
|
ssh_port?: number;
|
|
ssh_host_fingerprints?: Record<string, string>;
|
|
keep_archive_on_node: boolean;
|
|
status: string;
|
|
last_seen_at?: string;
|
|
}
|
|
|
|
/** Anfrage zum Anlegen eines Verbunds. */
|
|
export interface ClusterInput {
|
|
name: string;
|
|
api_endpoint: string;
|
|
api_token_id: string;
|
|
/** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */
|
|
api_token_secret: string;
|
|
tls_fingerprint?: string;
|
|
backup_storage_id: string;
|
|
archive_transport: TransportKind;
|
|
archive_mount_roots?: Record<string, string>;
|
|
ssh_username?: string;
|
|
ssh_port?: number;
|
|
ssh_private_key_pem?: string;
|
|
/** Ohne hinterlegten Wirtsschlüssel kommt keine SSH-Verbindung zustande. */
|
|
ssh_host_fingerprints?: Record<string, string>;
|
|
keep_archive_on_node?: boolean;
|
|
}
|
|
|
|
/** Ein Knoten des Verbunds. */
|
|
export interface ClusterHost {
|
|
id: string;
|
|
cluster_id: string;
|
|
node_name: string;
|
|
status?: string;
|
|
cpu_count?: number;
|
|
memory_bytes?: number;
|
|
}
|
|
|
|
/** Ein Gast. */
|
|
export interface VirtualMachine {
|
|
id: string;
|
|
cluster_id: string;
|
|
provider_vm_id: string;
|
|
name: string;
|
|
guest_kind: string;
|
|
status?: string;
|
|
cpu_count?: number;
|
|
memory_bytes?: number;
|
|
node_name?: string;
|
|
disk_count: number;
|
|
/**
|
|
* Platten mit `backup=0`.
|
|
*
|
|
* Größer als null macht einen Sicherungslauf zum Teilfehler — nicht weil
|
|
* etwas schiefging, sondern weil die Wiederherstellung sonst eine
|
|
* unvollständige Maschine liefert, die jemand für vollständig hält.
|
|
*/
|
|
excluded_disk_count: number;
|
|
/**
|
|
* Zeitpunkt, seit dem der Gast im Bestand fehlt.
|
|
*
|
|
* Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder
|
|
* verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu
|
|
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
|
|
* weg ist.
|
|
*/
|
|
missing_since?: string;
|
|
}
|
|
|
|
/** Lädt die Verbünde. */
|
|
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
|
|
return requestApi<Cluster[]>(
|
|
'/proxmox/clusters',
|
|
abortSignal ? { signal: abortSignal } : {},
|
|
);
|
|
}
|
|
|
|
/** Legt einen Verbund an. */
|
|
export async function createCluster(clusterInput: ClusterInput): Promise<Cluster> {
|
|
return requestApi<Cluster>('/proxmox/clusters', {
|
|
method: 'POST',
|
|
body: clusterInput,
|
|
idempotencyKey: true,
|
|
});
|
|
}
|
|
|
|
/** Löscht einen Verbund. */
|
|
export async function deleteCluster(clusterIdentifier: string): Promise<void> {
|
|
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
|
|
method: 'DELETE',
|
|
idempotencyKey: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prüft die Verbindung.
|
|
*
|
|
* Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist
|
|
* von `unreachable` getrennt, weil die Abhilfe eine völlig andere ist.
|
|
*/
|
|
export async function testCluster(
|
|
clusterIdentifier: string,
|
|
): Promise<{ status: string; message?: string; version?: string }> {
|
|
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/test`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
/** Erfasst den Bestand neu. */
|
|
export async function discoverCluster(
|
|
clusterIdentifier: string,
|
|
): Promise<{ hosts_found?: number; guests_found?: number; summary?: string }> {
|
|
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/discover`, {
|
|
method: 'POST',
|
|
idempotencyKey: true,
|
|
});
|
|
}
|
|
|
|
/** Lädt die Knoten eines Verbunds. */
|
|
export async function listClusterHosts(
|
|
clusterIdentifier: string,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<ClusterHost[]> {
|
|
return requestApi<ClusterHost[]>(
|
|
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/hosts`,
|
|
abortSignal ? { signal: abortSignal } : {},
|
|
);
|
|
}
|
|
|
|
/** Lädt die Gäste eines Verbunds. */
|
|
export async function listClusterMachines(
|
|
clusterIdentifier: string,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<VirtualMachine[]> {
|
|
return requestApi<VirtualMachine[]>(
|
|
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/vms`,
|
|
abortSignal ? { signal: abortSignal } : {},
|
|
);
|
|
}
|
|
|
|
/** Lädt alle bekannten Gäste, verbundsübergreifend. */
|
|
export async function listVirtualMachines(
|
|
abortSignal?: AbortSignal,
|
|
): Promise<VirtualMachine[]> {
|
|
return requestApi<VirtualMachine[]>(
|
|
'/virtual-machines',
|
|
abortSignal ? { signal: abortSignal } : {},
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agenten
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Ein aufgenommener Agent. */
|
|
export interface Agent {
|
|
id: string;
|
|
name: string;
|
|
hostname?: string;
|
|
platform?: string;
|
|
architecture?: string;
|
|
version?: string;
|
|
status: string;
|
|
last_heartbeat_at?: string;
|
|
enrolled_at?: string;
|
|
}
|
|
|
|
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
|
|
export interface EnrollmentToken {
|
|
/**
|
|
* Das Token im Klartext — **einmalig**.
|
|
*
|
|
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die
|
|
* Oberfläche muss das sagen, sonst schließt jemand das Fenster.
|
|
*/
|
|
enrollment_token: string;
|
|
agent_name: string;
|
|
expires_at?: string;
|
|
}
|
|
|
|
/** Lädt die Agenten. */
|
|
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
|
|
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
|
|
}
|
|
|
|
/**
|
|
* Erzeugt ein Aufnahme-Token.
|
|
*
|
|
* Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst könnte er
|
|
* sich als ein anderes System ausgeben.
|
|
*/
|
|
export async function createEnrollmentToken(
|
|
agentName: string,
|
|
validityMinutes = 60,
|
|
): Promise<EnrollmentToken> {
|
|
return requestApi<EnrollmentToken>('/agents/enrollment-tokens', {
|
|
method: 'POST',
|
|
body: { agent_name: agentName, validity_minutes: validityMinutes },
|
|
idempotencyKey: true,
|
|
});
|
|
}
|
|
|
|
/** Sperrt einen Agenten. Sein Betriebstoken gilt sofort nicht mehr. */
|
|
export async function revokeAgent(agentIdentifier: string): Promise<Agent> {
|
|
return requestApi<Agent>(`/agents/${encodeURIComponent(agentIdentifier)}/revoke`, {
|
|
method: 'POST',
|
|
idempotencyKey: true,
|
|
});
|
|
}
|
|
|
|
/** Erneuert das Betriebstoken eines Agenten. */
|
|
export async function rotateAgentCredentials(
|
|
agentIdentifier: string,
|
|
): Promise<{ operational_token?: string }> {
|
|
return requestApi(
|
|
`/agents/${encodeURIComponent(agentIdentifier)}/rotate-credentials`,
|
|
{ method: 'POST', idempotencyKey: true },
|
|
);
|
|
}
|
|
|
|
/** Lädt den Gesundheitszustand eines Agenten. */
|
|
export async function getAgentHealth(
|
|
agentIdentifier: string,
|
|
abortSignal?: AbortSignal,
|
|
): Promise<{ status: string; message?: string; last_heartbeat_at?: string }> {
|
|
return requestApi(
|
|
`/agents/${encodeURIComponent(agentIdentifier)}/health`,
|
|
abortSignal ? { signal: abortSignal } : {},
|
|
);
|
|
}
|