syncova-backup/apps/web/src/features/infrastructure/ProxmoxPage.tsx
Jerrit Fritzsche ccfff87d3f
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: vollstaendige Verwaltungskonsole
Von 95 fachlichen Endpunkten erreicht die Oberflaeche jetzt 89 statt 23.
Schreibend waren es neun (vier davon An- und Abmeldung) — jetzt ist jede
Handlung der Anlage bedienbar.

Neu bedienbar:

- **Wiederherstellung** als vierstufiger Assistent. Die Vorabpruefung ist ein
  eigener Schritt, weil sie den Unterschied zwischen Hoffnung und Nachweis
  macht: Sie schreibt nichts und stellt fest, ob **jeder benoetigte Block noch
  da ist**. Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert
  hat. Die drei Huerden vor dem Ueberschreiben sind sichtbar umgesetzt; laeuft
  die dritte ins Leere, entfaellt sie — ein Ritual ohne Anlass gewoehnt das
  Wegklicken an.
- **Wiederherstellungspunkte** mit Bewertung, Schutz, Ransomware-Einschaetzung,
  Legal Hold, Fristverlaengerung und Loeschung. Eine unbelastbare Prozentzahl
  wird als Vermutung gekennzeichnet, ungemessene Eingangsgroessen erscheinen als
  "ungemessen" statt als null Punkte.
- **Pruefung** mit allen fuenf Pruefarten. Zustand und Ergebnis stehen
  nebeneinander: Eine gescheiterte Pruefung ist kein Befund am Backup.
- **Repositories** mit Integritaetslauf, Katalog-Neuaufbau, Gesundheitspruefung
  und gemessener Durchsetzungsstufe. Die Uebernahme sagt ausdruecklich, dass
  hier nichts angelegt wird.
- **Aufbewahrung** mit Regeln und Vorschau vor dem Loeschen.
- **Proxmox** (neun Endpunkte, bisher ohne jede Oberflaeche) und **Agenten**
  samt einmaliger Anzeige des Aufnahme-Tokens.
- **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.**

Zwei Funde beim Nachweis gegen den laufenden Dienst:

- **Die Einstufung heisst `successful`, nicht `unverified`.** Das Vokabular
  lautet failed/corrupted/successful/verified/recoverable. `successful` ist die
  Falle: Es bedeutet "der Lauf ist durchgelaufen" — nicht "wiederherstellbar".
  Es ist deshalb **neutral**, nicht gruen; ein gruenes Abzeichen laese sich als
  "geprueft und in Ordnung", und genau diese Verwechslung soll die Anlage
  verhindern. Regressionstest, durch Mutation als fangend bestaetigt.
- **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des
  SSH-Schluesselfelds. Gekennzeichnet statt das Muster aufzuweichen.

Die Statuszuordnung steht an genau einer Stelle und faellt fuer unbekannte
Werte auf neutral zurueck, nie auf gruen: Ein neuer Serverzustand darf nicht
als "in Ordnung" durchgehen.

Nachgewiesen im Container gegen Debian 12 mit echtem PostgreSQL und nginx,
ueber genau die Aufrufe, die die Konsole macht: Repository uebernommen,
Durchsetzungsstufe gemessen (advisory — korrekt auf overlayfs), Auftrag
angelegt, Lauf 202, zweiter Anstoss 409, Sicherung erfolgreich (2 Objekte,
3.000.006 Byte, 0 uebergangen), Blockpruefung clean (5 Bloecke), Vorabpruefung
"wiederherstellbar: 2 Dateien, 2,9 MiB", Wiederherstellung nach /etc
abgewiesen.

76 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 460 KB (136 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:35:05 +02:00

619 lines
22 KiB
TypeScript

/**
* Proxmox-Verbuende.
*
* Dieser Bereich hatte bisher gar keine Oberflaeche — neun Endpunkte, die sich
* nur ueber die API bedienen liessen.
*
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausfuehrlich
* beschriftet:
*
* - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschluessel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
* dann das Archiv, das Syncova fuer ein Backup haelt.
*/
import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CardContent,
CardHeader,
CheckboxField,
ConfirmDialog,
DataTable,
DetailItem,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
SelectInput,
StatusBadge,
TextArea,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
import {
createCluster,
deleteCluster,
discoverCluster,
listClusterMachines,
listClusters,
testCluster,
type Cluster,
type TransportKind,
type VirtualMachine,
} from './infrastructureApi';
export function ProxmoxPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [selectedCluster, setSelectedCluster] = useState<Cluster | null>(null);
const [clusterPendingDeletion, setClusterPendingDeletion] = useState<Cluster | null>(null);
// Entwurf des neuen Verbunds.
const [draftName, setDraftName] = useState('');
const [draftEndpoint, setDraftEndpoint] = useState('https://');
const [draftTokenId, setDraftTokenId] = useState('');
const [draftTokenSecret, setDraftTokenSecret] = useState('');
const [draftFingerprint, setDraftFingerprint] = useState('');
const [draftStorageId, setDraftStorageId] = useState('local');
const [draftTransport, setDraftTransport] = useState<TransportKind>('local');
const [draftMountRoot, setDraftMountRoot] = useState('');
const [draftSshUser, setDraftSshUser] = useState('root');
const [draftSshKey, setDraftSshKey] = useState('');
const [draftSshFingerprint, setDraftSshFingerprint] = useState('');
const [draftKeepArchive, setDraftKeepArchive] = useState(false);
const clustersResource = useApiResource(
useCallback((abortSignal) => listClusters(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('providers.write');
const createMutation = useMutation(createCluster, {
onSuccess: (cluster) => {
toast.showSuccess(
'Verbund angelegt',
`${cluster.name} — pruefen Sie als Naechstes die Verbindung.`,
);
setIsCreateDialogOpen(false);
setDraftTokenSecret('');
setDraftSshKey('');
clustersResource.reload();
},
onError: (apiError) =>
toast.showError('Der Verbund liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
});
const testMutation = useMutation(testCluster, {
onSuccess: (result) =>
toast.showSuccess(
'Verbindung steht',
result.version ? `Proxmox VE ${result.version}` : result.message,
),
onError: (apiError) =>
toast.showError(
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig
// verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben.
apiError.statusCode === 503
? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`,
apiError.requestId,
),
});
const discoverMutation = useMutation(discoverCluster, {
onSuccess: (result) => {
toast.showSuccess(
'Bestand erfasst',
result.summary ??
`${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gaeste.`,
);
clustersResource.reload();
},
onError: (apiError) =>
toast.showError('Die Erfassung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteCluster, {
onSuccess: () => {
toast.showSuccess('Verbund entfernt');
setClusterPendingDeletion(null);
setSelectedCluster(null);
clustersResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Verbund liess sich nicht entfernen',
apiError.statusCode === 409
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
: describeApiError(apiError),
apiError.requestId,
),
});
const clusters = clustersResource.data ?? [];
const endpointIsSecure = draftEndpoint.trim().toLowerCase().startsWith('https://');
const sshTransportComplete =
draftTransport !== 'ssh' || (draftSshKey.trim() !== '' && draftSshFingerprint.trim() !== '');
const tableColumns: ReadonlyArray<TableColumn<Cluster>> = [
{
key: 'name',
header: 'Verbund',
render: (cluster) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{cluster.name}</p>
<p className="truncate font-mono text-xs text-fg-muted">{cluster.api_endpoint}</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (cluster) => <StatusBadge status={cluster.status} />,
},
{
key: 'transport',
header: 'Archivzugriff',
render: (cluster) => (
<span className="text-sm">
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
</span>
),
},
{
key: 'last_seen',
header: 'Zuletzt erreicht',
render: (cluster) =>
cluster.last_seen_at ? (
<span className="text-sm text-fg-muted">
{formatRelativeTime(cluster.last_seen_at)}
</span>
) : (
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
// seit Wochen toter Verbund saehe frisch aus.
<span className="text-xs text-warning">Nie erreicht</span>
),
},
];
if (clustersResource.loadState === 'failed' && clustersResource.loadError) {
return (
<>
<PageHeader title="Proxmox" />
<Card>
<ErrorState
message={describeApiError(clustersResource.loadError)}
requestId={clustersResource.loadError.requestId}
onRetry={clustersResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Proxmox"
description="Virtualisierungsverbuende und ihre Gaeste."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Verbund anlegen
</Button>
) : null
}
/>
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprueft</strong> — dafuer stand kein Verbund zur
Verfuegung.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={clusters}
getRowKey={(cluster) => cluster.id}
onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'}
emptyTitle="Kein Verbund eingerichtet"
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten — kein Ticket, denn ein Token ist dauerhaft gueltig und einzeln widerrufbar."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Verbund anlegen
</Button>
) : null
}
/>
</Card>
{selectedCluster ? (
<ClusterDetailCard
cluster={selectedCluster}
mayWrite={mayWrite}
isTesting={testMutation.isRunning}
isDiscovering={discoverMutation.isRunning}
onTest={() => void testMutation.run(selectedCluster.id)}
onDiscover={() => void discoverMutation.run(selectedCluster.id)}
onDelete={() => setClusterPendingDeletion(selectedCluster)}
onClose={() => setSelectedCluster(null)}
/>
) : null}
{/* --- Verbund anlegen --- */}
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent size="lg">
<DialogHeader
title="Proxmox-Verbund anlegen"
description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert."
/>
<DialogBody>
<TextInput
label="Bezeichnung"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Rechenzentrum Nord"
/>
<TextInput
label="API-Adresse"
required
className="font-mono"
value={draftEndpoint}
onChange={(changeEvent) => setDraftEndpoint(changeEvent.target.value)}
placeholder="https://pve.example:8006"
error={
draftEndpoint.trim().length > 8 && !endpointIsSecure
? 'Nur https. Ueber diese Verbindung geht ein API-Token.'
: undefined
}
/>
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
label="Token-Kennung"
required
className="font-mono"
value={draftTokenId}
onChange={(changeEvent) => setDraftTokenId(changeEvent.target.value)}
placeholder="syncova@pve!backup"
/>
<TextInput
label="Token-Geheimnis"
required
type="password"
autoComplete="new-password"
value={draftTokenSecret}
onChange={(changeEvent) => setDraftTokenSecret(changeEvent.target.value)}
hint="Wird nach dem Anlegen nie wieder angezeigt."
/>
</div>
<TextInput
label="TLS-Fingerabdruck (bei selbstsigniertem Zertifikat)"
className="font-mono text-xs"
value={draftFingerprint}
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
placeholder="AB:CD:EF:…"
hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat."
/>
<TextInput
label="Speicherkennung fuer Sicherungen"
required
className="font-mono"
value={draftStorageId}
onChange={(changeEvent) => setDraftStorageId(changeEvent.target.value)}
placeholder="local"
/>
<div className="border-t border-line pt-4">
<SelectInput
label="Zugriff auf die Sicherungsarchive"
value={draftTransport}
onChange={(changeEvent) =>
setDraftTransport(changeEvent.target.value as TransportKind)
}
hint="Proxmox kann eine Sicherung anstossen, die entstandene Datei aber nicht ueber die API herausgeben. Deshalb braucht es einen zweiten Weg."
>
<option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option>
<option value="ssh">SSH zum Knoten</option>
</SelectInput>
</div>
{draftTransport === 'local' ? (
<TextInput
label="Pfad der Ablage auf diesem Server"
className="font-mono"
value={draftMountRoot}
onChange={(changeEvent) => setDraftMountRoot(changeEvent.target.value)}
placeholder="/mnt/pve-dump"
/>
) : (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
label="SSH-Benutzer"
value={draftSshUser}
onChange={(changeEvent) => setDraftSshUser(changeEvent.target.value)}
/>
</div>
<TextArea
label="Privater Schluessel (PEM)"
required
className="font-mono text-xs"
value={draftSshKey}
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schluessel */
/>
<TextInput
label="Wirtsschluessel-Fingerabdruck"
required
className="font-mono text-xs"
value={draftSshFingerprint}
onChange={(changeEvent) => setDraftSshFingerprint(changeEvent.target.value)}
placeholder="SHA256:…"
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/>
<Callout tone="warning">
Einen Schalter „Wirtsschluessel egal" gibt es nicht. Ein
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova fuer ein Backup haelt.
</Callout>
</div>
)}
<CheckboxField
checked={draftKeepArchive}
onCheckedChange={setDraftKeepArchive}
label="Archiv auf dem Knoten behalten"
hint="Normalerweise wird es nach dem Einlesen entfernt. Behaelt man es, fuellt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, fuer die keine Aufbewahrungsregel gilt."
/>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createMutation.isRunning}
disabled={
!draftName.trim() ||
!endpointIsSecure ||
!draftTokenId.trim() ||
!draftTokenSecret ||
!draftStorageId.trim() ||
!sshTransportComplete
}
onClick={() =>
void createMutation.run({
name: draftName.trim(),
api_endpoint: draftEndpoint.trim(),
api_token_id: draftTokenId.trim(),
api_token_secret: draftTokenSecret,
backup_storage_id: draftStorageId.trim(),
archive_transport: draftTransport,
...(draftFingerprint.trim()
? { tls_fingerprint: draftFingerprint.trim() }
: {}),
...(draftTransport === 'local' && draftMountRoot.trim()
? { archive_mount_roots: { [draftStorageId.trim()]: draftMountRoot.trim() } }
: {}),
...(draftTransport === 'ssh'
? {
ssh_username: draftSshUser.trim(),
ssh_private_key_pem: draftSshKey,
ssh_host_fingerprints: { '*': draftSshFingerprint.trim() },
}
: {}),
keep_archive_on_node: draftKeepArchive,
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={clusterPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setClusterPendingDeletion(null)}
title="Verbund entfernen"
description={clusterPendingDeletion?.name}
confirmLabel="Entfernen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={clusterPendingDeletion?.name}
onConfirm={() => {
if (clusterPendingDeletion) {
void deleteMutation.run(clusterPendingDeletion.id);
}
}}
>
<Callout tone="info">
Die vorhandenen Sicherungen der Gaeste bleiben im Repository. Verweisen
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
</Callout>
</ConfirmDialog>
</>
);
}
/** Einzelheiten eines Verbunds samt Gastliste. */
function ClusterDetailCard({
cluster,
mayWrite,
isTesting,
isDiscovering,
onTest,
onDiscover,
onDelete,
onClose,
}: {
readonly cluster: Cluster;
readonly mayWrite: boolean;
readonly isTesting: boolean;
readonly isDiscovering: boolean;
readonly onTest: () => void;
readonly onDiscover: () => void;
readonly onDelete: () => void;
readonly onClose: () => void;
}) {
const machinesResource = useApiResource(
useCallback((abortSignal) => listClusterMachines(cluster.id, abortSignal), [cluster.id]),
cluster.id,
);
const machines = machinesResource.data ?? [];
const machineColumns: ReadonlyArray<TableColumn<VirtualMachine>> = [
{
key: 'name',
header: 'Gast',
render: (machine) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{machine.name}</p>
<p className="text-xs text-fg-muted">
{machine.guest_kind} · {machine.provider_vm_id}
{machine.node_name ? ` · ${machine.node_name}` : ''}
</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (machine) =>
machine.missing_since ? (
// Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte
// Zeile naehme die Zuordnung zu vorhandenen Backups mit.
<span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)}
</span>
) : (
<StatusBadge status={machine.status} />
),
},
{
key: 'resources',
header: 'Ausstattung',
render: (machine) => (
<span className="text-sm text-fg-muted">
{machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
</span>
),
},
{
key: 'disks',
header: 'Platten',
numeric: true,
render: (machine) => (
<span>
{machine.disk_count}
{machine.excluded_disk_count > 0 ? (
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
// Wiederherstellung liefert sonst eine unvollstaendige Maschine,
// die jemand fuer vollstaendig haelt.
<span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen)
</span>
) : null}
</span>
),
},
];
return (
<Card className="mt-4">
<CardHeader
title={cluster.name}
description={cluster.api_endpoint}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
</Button>
}
/>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Zustand">
<StatusBadge status={cluster.status} />
</DetailItem>
<DetailItem label="Token-Kennung">
<code className="font-mono text-xs">{cluster.api_token_id}</code>
</DetailItem>
<DetailItem label="Archivzugriff">
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
</DetailItem>
<DetailItem label="Speicher">{cluster.backup_storage_id}</DetailItem>
</dl>
{mayWrite ? (
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button size="sm" isLoading={isTesting} onClick={onTest}>
<Network />
Verbindung pruefen
</Button>
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
<Search />
Bestand erfassen
</Button>
<Button size="sm" variant="ghost" onClick={machinesResource.reload}>
<RefreshCw />
Liste aktualisieren
</Button>
<Button size="sm" variant="destructive" className="ml-auto" onClick={onDelete}>
<Trash2 />
Entfernen
</Button>
</div>
) : null}
<div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Gaeste
</p>
<DataTable
columns={machineColumns}
rows={machines}
getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'}
emptyTitle="Kein Gast erfasst"
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gaeste dieses Verbunds zu sehen."
/>
</div>
</CardContent>
</Card>
);
}