/** * 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(null); const [agentPendingRevoke, setAgentPendingRevoke] = useState(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> = [ { key: 'name', header: 'Agent', render: (agent) => (

{agent.name}

{agent.hostname ?? '—'} {agent.platform ? ` · ${agent.platform}` : ''} {agent.architecture ? `/${agent.architecture}` : ''}

), }, { key: 'status', header: 'Zustand', render: (agent) => , }, { key: 'version', header: 'Fassung', render: (agent) => ( {agent.version ?? '—'} ), }, { key: 'heartbeat', header: 'Letzte Meldung', render: (agent) => agent.last_heartbeat_at ? ( {formatRelativeTime(agent.last_heartbeat_at)} ) : ( Noch nie ), }, { key: 'actions', header: '', className: 'w-px', render: (agent) => mayWrite ? (
clickEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} role="presentation" > {agent.status !== 'revoked' ? ( ) : null}
) : null, }, ]; if (agentsResource.loadState === 'failed' && agentsResource.loadError) { return ( <> ); } return ( <> setIsEnrollDialogOpen(true)}> Agent aufnehmen ) : null } /> Der Agent holt seine Auftraege ab; 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. 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 ? ( ) : null } /> {/* --- Aufnahme --- */} 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." /> Das Token gilt eine Stunde und nur fuer die Registrierung. Danach erhaelt der Agent ein Betriebstoken mit ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar ist. {/* --- Einmalige Anzeige des Tokens --- */} {issuedToken ? ( setIssuedToken(null)} /> ) : null} !isOpen && setAgentPendingRevoke(null)} title="Agent sperren" description={agentPendingRevoke?.name} confirmLabel="Sperren" isDestructive isLoading={revokeMutation.isRunning} onConfirm={() => { if (agentPendingRevoke) { void revokeMutation.run(agentPendingRevoke.id); } }} > Das Betriebstoken gilt sofort nicht mehr. Der Agent beendet sich beim naechsten Versuch — ein abgelehntes Token behebt sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen danach aus. ); } /** * 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 ( undefined}> 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.
{token.enrollment_token}

Auf dem zu sichernden System

syncova-agent enroll --server https://<dieser-server> --token <token> --state /var/lib/syncova-agent/state.json

`--state` erwartet eine Datei, kein Verzeichnis. Mit einem Verzeichnis haelt sich der Agent fuer registriert und laeuft ohne Token.

); }