**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —
- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").
Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.
Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.
**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.
**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.
**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.
84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
521 lines
17 KiB
TypeScript
521 lines
17 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 Oberfläche sagt das
|
|
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
|
|
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
|
|
*
|
|
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
|
|
* könnte er sich als ein anderes System ausgeben.
|
|
*/
|
|
|
|
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } 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 { formatDateTime, 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 ließ 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 holt seine Aufträge ab; der Server drückt sie nicht.
|
|
</Callout>
|
|
|
|
<Card>
|
|
<DataTable
|
|
columns={tableColumns}
|
|
rows={agents}
|
|
getRowKey={(agent) => agent.id}
|
|
isLoading={agentsResource.loadState === 'loading'}
|
|
emptyTitle="Kein Agent aufgenommen"
|
|
emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde."
|
|
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 gültiges 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 könnte er sich als ein anderes System ausgeben."
|
|
/>
|
|
<Callout tone="info">
|
|
Gilt eine Stunde und nur zur Registrierung.
|
|
</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 sofort nicht mehr. Geplante Sicherungen fallen aus.
|
|
</Callout>
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Zeigt das Aufnahme-Token einmalig.
|
|
*
|
|
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
|
* Schaltfläche, und sie sagt, was sie bewirkt.
|
|
*/
|
|
/**
|
|
* Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme.
|
|
*
|
|
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
|
* Schaltfläche, und sie sagt, was sie bewirkt.
|
|
*
|
|
* Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token
|
|
* eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
|
|
* jemand `<token>` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die
|
|
* nichts mit seinem Problem zu tun hat.
|
|
*/
|
|
function IssuedTokenDialog({
|
|
token,
|
|
onClose,
|
|
}: {
|
|
readonly token: EnrollmentToken;
|
|
readonly onClose: () => void;
|
|
}) {
|
|
const toast = useToast();
|
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
|
|
|
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
|
|
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
|
|
const serverAddress = window.location.origin;
|
|
|
|
const copyText = async (textToCopy: string, entryKey: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
setCopiedKey(entryKey);
|
|
window.setTimeout(() => setCopiedKey(null), 2000);
|
|
} catch {
|
|
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
|
|
}
|
|
};
|
|
|
|
const linuxSteps = [
|
|
{
|
|
key: 'linux-paket',
|
|
title: '1. Paket auspacken',
|
|
command: `sudo mkdir -p /opt/syncova-agent
|
|
sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp
|
|
sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`,
|
|
},
|
|
{
|
|
key: 'linux-konto',
|
|
title: '2. Dienstkonto und Verzeichnisse',
|
|
command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent
|
|
sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`,
|
|
},
|
|
{
|
|
key: 'linux-enroll',
|
|
title: '3. Aufnehmen',
|
|
command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\
|
|
--server ${serverAddress} \\
|
|
--token ${token.token} \\
|
|
--state /var/lib/syncova-agent/state.json`,
|
|
},
|
|
{
|
|
key: 'linux-dienst',
|
|
title: '4. Als Dienst einrichten',
|
|
command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF'
|
|
[Unit]
|
|
Description=Syncova Agent
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=syncova-agent
|
|
ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
NoNewPrivileges=yes
|
|
ProtectSystem=strict
|
|
ReadWritePaths=/var/lib/syncova-agent
|
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
EOF
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now syncova-agent`,
|
|
},
|
|
];
|
|
|
|
const windowsSteps = [
|
|
{
|
|
key: 'win-paket',
|
|
title: '1. Paket auspacken',
|
|
command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent"
|
|
Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova
|
|
Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`,
|
|
},
|
|
{
|
|
key: 'win-enroll',
|
|
title: '2. Aufnehmen',
|
|
command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova"
|
|
& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \`
|
|
--server ${serverAddress} \`
|
|
--token ${token.token} \`
|
|
--state "C:\\ProgramData\\Syncova\\state.json"`,
|
|
},
|
|
{
|
|
key: 'win-dienst',
|
|
title: '3. Als Dienst einrichten',
|
|
command: `New-Service -Name SyncovaAgent \`
|
|
-DisplayName "Syncova Agent" \`
|
|
-BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \`
|
|
-StartupType Automatic
|
|
Start-Service SyncovaAgent`,
|
|
},
|
|
];
|
|
|
|
const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps;
|
|
|
|
return (
|
|
<DialogRoot open onOpenChange={() => undefined}>
|
|
<DialogContent size="xl">
|
|
<DialogHeader
|
|
title="Agent aufnehmen"
|
|
description={`Für „${token.agent_name}"`}
|
|
/>
|
|
<DialogBody>
|
|
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
|
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
|
wenn der Agent aufgenommen ist.
|
|
</Callout>
|
|
|
|
<div className="border border-line bg-sunken p-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<code className="min-w-0 break-all font-mono text-sm text-fg">
|
|
{token.token}
|
|
</code>
|
|
<Button
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => void copyText(token.token, 'token')}
|
|
>
|
|
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
|
|
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
|
|
</Button>
|
|
</div>
|
|
{token.expires_at ? (
|
|
<p className="mt-2 text-xs text-fg-muted">
|
|
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Systemwahl */}
|
|
<div className="flex gap-2 border-b border-line pb-3">
|
|
<Button
|
|
size="sm"
|
|
variant={platform === 'linux' ? 'primary' : 'ghost'}
|
|
onClick={() => setPlatform('linux')}
|
|
>
|
|
<Terminal />
|
|
Linux
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={platform === 'windows' ? 'primary' : 'ghost'}
|
|
onClick={() => setPlatform('windows')}
|
|
>
|
|
<MonitorCog />
|
|
Windows (PowerShell)
|
|
</Button>
|
|
</div>
|
|
|
|
{platform === 'windows' ? (
|
|
<Callout tone="warning">
|
|
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
|
|
<strong>nie auf echter Hardware gefahren</strong>. Der
|
|
Kommandozeilenweg ist nachgewiesen.
|
|
</Callout>
|
|
) : null}
|
|
|
|
{activeSteps.map((step) => (
|
|
<div key={step.key}>
|
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-fg">{step.title}</p>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => void copyText(step.command, step.key)}
|
|
>
|
|
{copiedKey === step.key ? <KeyRound /> : <Copy />}
|
|
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
|
|
</Button>
|
|
</div>
|
|
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
|
|
{step.command}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
|
|
<Callout tone="info" title="Zwei Stolperstellen">
|
|
<ul className="mt-1 space-y-1 text-xs">
|
|
<li>
|
|
<code className="font-mono">--state</code> erwartet eine{' '}
|
|
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
|
|
hält sich der Agent für registriert und läuft ohne Token.
|
|
</li>
|
|
<li>
|
|
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
|
|
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
|
|
getrennten Maschinen eine Freigabe.
|
|
</li>
|
|
</ul>
|
|
</Callout>
|
|
</DialogBody>
|
|
<DialogFooter>
|
|
<Button variant="primary" onClick={onClose}>
|
|
Der Agent ist aufgenommen
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
);
|
|
}
|