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>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|