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>
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
/**
|
|
* Agenten — Aufnahme und Verwaltung.
|
|
*
|
|
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
|
|
* und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das
|
|
* ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu
|
|
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
|
|
*
|
|
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
|
|
* koennte er sich als ein anderes System ausgeben.
|
|
*/
|
|
|
|
import { Ban, Copy, KeyRound, Plus, RefreshCw } 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,
|
|
ConfirmDialog,
|
|
DataTable,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogRoot,
|
|
ErrorState,
|
|
StatusBadge,
|
|
TextInput,
|
|
useToast,
|
|
type TableColumn,
|
|
} from '@/components/ui';
|
|
import { formatRelativeTime } from '@/lib/utils';
|
|
import {
|
|
createEnrollmentToken,
|
|
listAgents,
|
|
revokeAgent,
|
|
rotateAgentCredentials,
|
|
type Agent,
|
|
type EnrollmentToken,
|
|
} from './infrastructureApi';
|
|
|
|
export function AgentsPage({
|
|
grantedPermissions,
|
|
}: {
|
|
readonly grantedPermissions: readonly string[];
|
|
}) {
|
|
const toast = useToast();
|
|
const [isEnrollDialogOpen, setIsEnrollDialogOpen] = useState(false);
|
|
const [issuedToken, setIssuedToken] = useState<EnrollmentToken | null>(null);
|
|
const [agentPendingRevoke, setAgentPendingRevoke] = useState<Agent | null>(null);
|
|
const [draftAgentName, setDraftAgentName] = useState('');
|
|
|
|
const agentsResource = useApiResource(
|
|
useCallback((abortSignal) => listAgents(abortSignal), []),
|
|
);
|
|
|
|
const mayEnroll =
|
|
grantedPermissions.includes('*') || grantedPermissions.includes('agents.enroll');
|
|
const mayWrite =
|
|
grantedPermissions.includes('*') || grantedPermissions.includes('agents.write');
|
|
|
|
const enrollMutation = useMutation(createEnrollmentToken, {
|
|
onSuccess: (token) => {
|
|
setIssuedToken(token);
|
|
setIsEnrollDialogOpen(false);
|
|
setDraftAgentName('');
|
|
agentsResource.reload();
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError(
|
|
'Das Aufnahme-Token liess sich nicht erzeugen',
|
|
describeApiError(apiError),
|
|
apiError.requestId,
|
|
),
|
|
});
|
|
|
|
const revokeMutation = useMutation(revokeAgent, {
|
|
onSuccess: () => {
|
|
toast.showSuccess(
|
|
'Agent gesperrt',
|
|
'Sein Betriebstoken gilt sofort nicht mehr — nicht erst nach Ablauf.',
|
|
);
|
|
setAgentPendingRevoke(null);
|
|
agentsResource.reload();
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError('Die Sperre schlug fehl', describeApiError(apiError), apiError.requestId),
|
|
});
|
|
|
|
const rotateMutation = useMutation(rotateAgentCredentials, {
|
|
onSuccess: () => {
|
|
toast.showSuccess(
|
|
'Betriebstoken erneuert',
|
|
'Der Agent muss mit dem neuen Token versorgt werden, sonst meldet er sich nicht mehr.',
|
|
);
|
|
agentsResource.reload();
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
|
});
|
|
|
|
const agents = agentsResource.data ?? [];
|
|
|
|
const tableColumns: ReadonlyArray<TableColumn<Agent>> = [
|
|
{
|
|
key: 'name',
|
|
header: 'Agent',
|
|
render: (agent) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-fg">{agent.name}</p>
|
|
<p className="truncate text-xs text-fg-muted">
|
|
{agent.hostname ?? '—'}
|
|
{agent.platform ? ` · ${agent.platform}` : ''}
|
|
{agent.architecture ? `/${agent.architecture}` : ''}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Zustand',
|
|
render: (agent) => <StatusBadge status={agent.status} />,
|
|
},
|
|
{
|
|
key: 'version',
|
|
header: 'Fassung',
|
|
render: (agent) => (
|
|
<span className="font-mono text-xs text-fg-muted">{agent.version ?? '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'heartbeat',
|
|
header: 'Letzte Meldung',
|
|
render: (agent) =>
|
|
agent.last_heartbeat_at ? (
|
|
<span className="text-sm text-fg-muted">
|
|
{formatRelativeTime(agent.last_heartbeat_at)}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-warning">Noch nie</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
className: 'w-px',
|
|
render: (agent) =>
|
|
mayWrite ? (
|
|
<div
|
|
className="flex items-center justify-end gap-1"
|
|
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
role="presentation"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void rotateMutation.run(agent.id)}
|
|
title="Betriebstoken erneuern"
|
|
>
|
|
<RefreshCw />
|
|
</Button>
|
|
{agent.status !== 'revoked' ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setAgentPendingRevoke(agent)}
|
|
title="Agent sperren"
|
|
>
|
|
<Ban />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null,
|
|
},
|
|
];
|
|
|
|
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
|
|
return (
|
|
<>
|
|
<PageHeader title="Agenten" />
|
|
<Card>
|
|
<ErrorState
|
|
message={describeApiError(agentsResource.loadError)}
|
|
requestId={agentsResource.loadError.requestId}
|
|
onRetry={agentsResource.reload}
|
|
/>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Agenten"
|
|
description="Systeme, die sich selbst sichern."
|
|
actions={
|
|
mayEnroll ? (
|
|
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
|
|
<Plus />
|
|
Agent aufnehmen
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
<Callout tone="info" className="mb-4">
|
|
Der Agent <strong>holt seine Auftraege ab</strong>; der Server drueckt
|
|
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
|
|
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
|
|
jedem gesicherten System.
|
|
</Callout>
|
|
|
|
<Card>
|
|
<DataTable
|
|
columns={tableColumns}
|
|
rows={agents}
|
|
getRowKey={(agent) => agent.id}
|
|
isLoading={agentsResource.loadState === 'loading'}
|
|
emptyTitle="Kein Agent aufgenommen"
|
|
emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal."
|
|
emptyAction={
|
|
mayEnroll ? (
|
|
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
|
|
<Plus />
|
|
Ersten Agenten aufnehmen
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
</Card>
|
|
|
|
{/* --- Aufnahme --- */}
|
|
<DialogRoot open={isEnrollDialogOpen} onOpenChange={setIsEnrollDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader
|
|
title="Agent aufnehmen"
|
|
description="Erzeugt ein einmalig gueltiges Aufnahme-Token."
|
|
/>
|
|
<DialogBody>
|
|
<TextInput
|
|
label="Name des Agenten"
|
|
required
|
|
value={draftAgentName}
|
|
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
|
|
placeholder="dateiserver-01"
|
|
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst koennte er sich als ein anderes System ausgeben."
|
|
/>
|
|
<Callout tone="info">
|
|
Das Token gilt <strong>eine Stunde</strong> und nur fuer die
|
|
Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
|
|
ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
|
|
ist.
|
|
</Callout>
|
|
</DialogBody>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setIsEnrollDialogOpen(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
isLoading={enrollMutation.isRunning}
|
|
disabled={!draftAgentName.trim()}
|
|
onClick={() => void enrollMutation.run(draftAgentName.trim(), 60)}
|
|
>
|
|
Token erzeugen
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
|
|
{/* --- Einmalige Anzeige des Tokens --- */}
|
|
{issuedToken ? (
|
|
<IssuedTokenDialog token={issuedToken} onClose={() => setIssuedToken(null)} />
|
|
) : null}
|
|
|
|
<ConfirmDialog
|
|
open={agentPendingRevoke !== null}
|
|
onOpenChange={(isOpen) => !isOpen && setAgentPendingRevoke(null)}
|
|
title="Agent sperren"
|
|
description={agentPendingRevoke?.name}
|
|
confirmLabel="Sperren"
|
|
isDestructive
|
|
isLoading={revokeMutation.isRunning}
|
|
onConfirm={() => {
|
|
if (agentPendingRevoke) {
|
|
void revokeMutation.run(agentPendingRevoke.id);
|
|
}
|
|
}}
|
|
>
|
|
<Callout tone="warning">
|
|
Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent
|
|
beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
|
|
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
|
|
danach aus.
|
|
</Callout>
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Zeigt das Aufnahme-Token einmalig.
|
|
*
|
|
* Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine
|
|
* Schaltflaeche, und sie sagt, was sie bewirkt.
|
|
*/
|
|
function IssuedTokenDialog({
|
|
token,
|
|
onClose,
|
|
}: {
|
|
readonly token: EnrollmentToken;
|
|
readonly onClose: () => void;
|
|
}) {
|
|
const toast = useToast();
|
|
const [hasCopied, setHasCopied] = useState(false);
|
|
|
|
const copyToken = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(token.enrollment_token);
|
|
setHasCopied(true);
|
|
window.setTimeout(() => setHasCopied(false), 2000);
|
|
} catch {
|
|
toast.showInfo(
|
|
'Kopieren nicht moeglich',
|
|
'Markieren Sie das Token und kopieren Sie es von Hand.',
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<DialogRoot open onOpenChange={() => undefined}>
|
|
<DialogContent size="lg">
|
|
<DialogHeader
|
|
title="Aufnahme-Token"
|
|
description={`Fuer den Agenten „${token.agent_name}“`}
|
|
/>
|
|
<DialogBody>
|
|
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
|
Es wird nur als Hash gespeichert und laesst sich nicht wieder
|
|
abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher
|
|
hinterlegt haben — sonst muessen Sie ein neues erzeugen.
|
|
</Callout>
|
|
|
|
<div className="rounded-md border border-line bg-sunken p-3">
|
|
<code className="block break-all font-mono text-sm text-fg">
|
|
{token.enrollment_token}
|
|
</code>
|
|
</div>
|
|
|
|
<Button onClick={() => void copyToken()}>
|
|
{hasCopied ? <KeyRound /> : <Copy />}
|
|
{hasCopied ? 'Kopiert' : 'Token kopieren'}
|
|
</Button>
|
|
|
|
<div className="border-t border-line pt-4">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
Auf dem zu sichernden System
|
|
</p>
|
|
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
|
|
syncova-agent enroll --server https://<dieser-server> --token
|
|
<token> --state /var/lib/syncova-agent/state.json
|
|
</code>
|
|
<p className="mt-1.5 text-xs text-fg-muted">
|
|
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
|
|
Mit einem Verzeichnis haelt sich der Agent fuer registriert und
|
|
laeuft ohne Token.
|
|
</p>
|
|
</div>
|
|
</DialogBody>
|
|
<DialogFooter>
|
|
<Button variant="primary" onClick={onClose}>
|
|
Ich habe das Token hinterlegt
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
);
|
|
}
|