Files
sshkeyvault/src/app/(app)/hosts/[id]/page.tsx
T
florian.wildeandClaude Opus 5.5 e518e75022 feat(abgleich): Drift-Erkennung, Zeitplan und Uebernahme (#021, #022)
Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf,
als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende,
von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte
Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und
wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen.
Auf dem Host aendert der Abgleich nichts.

Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern
nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand
je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt
machen oder mit Dry-Run in den verwalteten Block holen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:11 +02:00

324 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { KeyRound, Pencil, ShieldQuestion } from "lucide-react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { ArchivierenDialog } from "@/components/hosts/ArchivierenDialog";
import { adresseMitPort, AnmeldeartBadge, HostSchluesselStatusBadge } from "@/components/hosts/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, relativ } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise";
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { istZustand } from "@/server/queries/abgleich";
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
import { verwendungenAufHost } from "@/server/queries/ziele";
import { HostKeyAktionen } from "./HostKeyAktionen";
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
export const metadata: Metadata = { title: "Host" };
function Zeitpunkt({ wert, jetzt, sonst }: { wert: Date | null; jetzt: Date; sonst: string }) {
if (!wert) return <span className="text-muted">{sonst}</span>;
return (
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
{relativ(wert, jetzt)}
</time>
);
}
function Info({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
</div>
);
}
function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) {
const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : "";
if (host.erreichbar === true) return <Statuspunkt status="synchron" text={`erreichbar${pruefung}`} />;
if (host.erreichbar === false)
return <Statuspunkt status="unerreichbar" text={`nicht erreichbar${pruefung}`} />;
return <Statuspunkt status="unbekannt" text="noch nicht geprüft" />;
}
/** `sshd -T`-Auszug (#010 fuellt ihn); hier nur flach als Schluessel/Wert gezeigt. */
function SshdBefund({ befund }: { befund: unknown }) {
if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null;
const eintraege = Object.entries(befund as Record<string, unknown>);
if (eintraege.length === 0) return null;
const hinweise = sshdHinweise(befund as Record<string, unknown>);
return (
<div className="border-border mt-5 border-t pt-4">
<p className="text-muted text-[13px] font-medium">sshd-Befund</p>
<dl className="mt-2 grid gap-x-6 gap-y-1 font-mono text-[13px] sm:grid-cols-2">
{eintraege.map(([k, v]) => (
<div key={k} className="flex min-w-0 gap-2">
<dt className="text-muted shrink-0">{k}</dt>
<dd className="text-text min-w-0 break-all">{String(v)}</dd>
</div>
))}
</dl>
{hinweise.length > 0 && (
<ul className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
{hinweise.map((h) => (
<li
key={h.text}
className={
h.stufe === "gefahr" ? "text-danger" : h.stufe === "warnung" ? "text-accent" : "text-muted"
}
>
{h.stufe === "info" ? "ℹ" : "▲"} {h.text}
</li>
))}
</ul>
)}
</div>
);
}
/** Hostdetail (#008): Gerüst, in das #009 (Host-Key), #010 (Bootstrap) und #016 einziehen. */
export default async function HostSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/hosts/${id}`);
const host = await hostDetail(id);
if (!host) notFound();
const archiviert = host.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
const verwendungen = await verwendungenAufHost(host.id);
const ist = await istZustand(host.id);
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: host.name }]} />}
titel={<span className="font-mono">{host.name}</span>}
untertitel={
<>
<span className="text-text font-mono text-[13.5px] break-all">
{adresseMitPort(host.adresse, host.port)}
</span>
{host.beschreibung && <span className="mt-1.5 block">{host.beschreibung}</span>}
</>
}
badges={
<>
{archiviert && host.archiviertAm && (
<Badge variante="gefahr">archiviert am {datum(host.archiviertAm)}</Badge>
)}
<AnmeldeartBadge anmeldeart={host.anmeldeart} />
{host.gruppen.map((g) => (
<Badge key={g.id}>{g.name}</Badge>
))}
{host.tags.map((t) => (
<Badge key={t} mono className="text-muted">
#{t}
</Badge>
))}
</>
}
aktionen={
verwalten && (
<Button href={`/hosts/${host.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)
}
/>
<Karte as="div">
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-4">
<Info titel="Erreichbar">
<Erreichbarkeit host={host} jetzt={jetzt} />
</Info>
<Info titel="Bootstrap">
{host.bootstrapAm ? (
<time dateTime={host.bootstrapAm.toISOString()}>{datumZeit(host.bootstrapAm)}</time>
) : (
<span className="text-accent">noch nicht eingerichtet</span>
)}
</Info>
<Info titel="Letzter Abgleich">
<span className="flex flex-col gap-0.5">
<Zeitpunkt wert={host.letzterAbgleichAm} jetzt={jetzt} sonst="noch nie" />
{host.letzterAbgleichAm && <Statuspunkt status={host.status} className="text-[13px]" />}
</span>
</Info>
<Info titel="Kunde">{host.kunde ? host.kunde.name : <span className="text-muted">–</span>}</Info>
</dl>
<SshdBefund befund={host.sshdBefund} />
<BootstrapAktionen
hostId={host.id}
darfVerwalten={verwalten}
eingerichtet={host.bootstrapAm !== null}
hostKeyVertraut={host.hostKey.vertraut > 0}
befehl={verwalten && !host.bootstrapAm ? await bootstrapBefehlFuer(host.id) : undefined}
/>
</Karte>
<Karte
titel="Host-Key"
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
aktionen={
<HostKeyAktionen hostId={host.id} darfEntscheiden={darfEntscheiden} status={host.hostKey} />
}
>
{host.hostSchluessel.length === 0 ? (
<Leerzustand
icon={<ShieldQuestion className="size-5" />}
titel="Noch kein Host-Key abgerufen"
text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host."
/>
) : (
<ul className="flex flex-col">
{host.hostSchluessel.map((k) => (
<li
key={k.id}
className="border-border grid gap-x-4 gap-y-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0 md:grid-cols-[12rem_minmax(0,1fr)_auto] md:items-center"
>
<span className="flex items-center justify-between gap-3 md:block">
<span className="text-text font-mono text-[13px]">{k.algorithmus}</span>
<span className="md:hidden">
<HostSchluesselStatusBadge status={k.status} />
</span>
</span>
<span className="min-w-0">
<MonoKopieren wert={k.fingerprint} beschriftung="Fingerprint" umbrechen />
<span className="text-muted block text-[12.5px]">
zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)}
</span>
</span>
<span className="hidden md:block">
<HostSchluesselStatusBadge status={k.status} />
</span>
{k.status === "AUSSTEHEND" && darfEntscheiden && (
<HostKeyEntscheidung
hostId={host.id}
hostSchluesselId={k.id}
ersetztVertrauten={host.hostSchluessel.some(
(v) => v.status === "VERTRAUT" && v.algorithmus === k.algorithmus,
)}
/>
)}
</li>
))}
</ul>
)}
</Karte>
<Karte
titel="Schlüssel auf diesem Host"
untertitel="Public Keys in authorized_keys und Private Keys, die sshkv hier verwaltet"
>
{host.zielkonten.length > 0 && (
<p className="text-muted mb-4 text-[14px]">
Zielkonten:{" "}
{host.zielkonten.map((z, i) => (
<span key={z.id}>
{i > 0 && ", "}
<code className="text-text font-mono text-[13px]">{z.benutzername}</code>
</span>
))}
</p>
)}
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Noch keine Schlüssel zugewiesen"
text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)."
/>
) : (
<ul className="flex flex-col">
{verwendungen.oeffentlich.map((v) => (
<li
key={v.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<span className="min-w-0">
<a
href={`/schluessel/${v.schluessel.id}`}
className="text-text hover:text-accent font-medium"
>
{v.schluessel.name}
</a>
<span className="text-muted block font-mono text-[12.5px] break-all">
Public · {v.zielbenutzer}@{host.name}
{v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""}
{v.optionen ? ` · ${v.optionen}` : ""}
</span>
</span>
<VerwendungsZustand zustand={v.zustand} />
</li>
))}
{verwendungen.privat.map((v) => (
<li
key={v.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<span className="min-w-0">
<a
href={`/schluessel/${v.schluessel.id}`}
className="text-text hover:text-accent font-medium"
>
{v.schluessel.name}
</a>
<span className="text-muted block font-mono text-[12.5px] break-all">
Private · {v.zielbenutzer}@{host.name}:{v.pfad}
</span>
</span>
<VerwendungsZustand zustand={v.zustand} />
</li>
))}
</ul>
)}
{darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && (
<div className="border-border mt-5 border-t pt-5">
<AusrollenPanel
auswahl={{ hostId: host.id }}
offen={verwendungen.oeffentlich.filter((v) => v.zustand !== "AUSGEROLLT").length}
/>
</div>
)}
</Karte>
{host.bootstrapAm && (
<AbgleichKarten
hostId={host.id}
zustand={ist}
darfAbgleichen={verwalten}
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
darfVerwalten={darfAusrollen}
/>
)}
{verwalten && (
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten.
</p>
<ArchivierenDialog hostId={host.id} hostName={host.name} />
</div>
</Karte>
)}
</div>
);
}