Merge branch 'phase/009-host-key-pruefung'
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorgangLesen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status eines Vorgangs fuer die Fortschrittsanzeige (#009). Liefert nur, was
|
||||||
|
* `vorgangLesen` freigibt – keine Job-Interna, keine Geheimnisse.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const vorgang = await vorgangLesen((await params).id);
|
||||||
|
if (!vorgang) return NextResponse.json({ fehler: "Unbekannter Vorgang." }, { status: 404 });
|
||||||
|
return NextResponse.json(vorgang, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState, useTransition, type ReactNode } from "react";
|
||||||
|
import { Button, type ButtonVariante } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet einen Vorgang im Worker und zeigt seinen Fortschritt (#009). Fragt den Status
|
||||||
|
* jede Sekunde ab und laedt nach dem Ende die Seite neu, damit Server Components die neuen
|
||||||
|
* Daten zeigen.
|
||||||
|
*/
|
||||||
|
type Antwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||||
|
type Status = { status: string; fehler: string | null };
|
||||||
|
|
||||||
|
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||||
|
|
||||||
|
export function VorgangsKnopf({
|
||||||
|
aktion,
|
||||||
|
beschriftung,
|
||||||
|
variante = "sekundaer",
|
||||||
|
icon,
|
||||||
|
klein,
|
||||||
|
}: {
|
||||||
|
aktion: () => Promise<Antwort>;
|
||||||
|
beschriftung: string;
|
||||||
|
variante?: ButtonVariante;
|
||||||
|
icon?: ReactNode;
|
||||||
|
klein?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [vorgangId, setVorgangId] = useState<string>();
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const abbrechen = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorgangId) return;
|
||||||
|
abbrechen.current = false;
|
||||||
|
const abfragen = async () => {
|
||||||
|
while (!abbrechen.current) {
|
||||||
|
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||||
|
if (!antwort.ok) {
|
||||||
|
setFehler("Status des Vorgangs nicht abrufbar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const neu = (await antwort.json()) as Status;
|
||||||
|
setStatus(neu);
|
||||||
|
if (!LAUFEND.has(neu.status)) {
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 1000));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void abfragen();
|
||||||
|
return () => {
|
||||||
|
abbrechen.current = true;
|
||||||
|
};
|
||||||
|
}, [vorgangId, router]);
|
||||||
|
|
||||||
|
const laeuft =
|
||||||
|
startet || (status !== undefined && LAUFEND.has(status.status)) || (vorgangId !== undefined && !status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<Button
|
||||||
|
variante={variante}
|
||||||
|
icon={icon}
|
||||||
|
klein={klein}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
setStatus(undefined);
|
||||||
|
const antwort = await aktion();
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setVorgangId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Läuft …" : beschriftung}
|
||||||
|
</Button>
|
||||||
|
<p role="status" className="text-[13px]">
|
||||||
|
{fehler && <span className="text-danger">{fehler}</span>}
|
||||||
|
{!fehler && status?.status === "FEHLGESCHLAGEN" && (
|
||||||
|
<span className="text-danger">{status.fehler}</span>
|
||||||
|
)}
|
||||||
|
{!fehler && status?.status === "ERLEDIGT" && <span className="text-ok">Erledigt.</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { bestaetigungFrisch, rechtErforderlich, RechteFehler } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { HostKeyEntscheidungFehler, hostKeyEntscheiden } from "@/server/services/hostkeys";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/** Aktionen rund um Host-Keys und Erreichbarkeit (#009). */
|
||||||
|
|
||||||
|
export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||||
|
export type EntscheidungsAntwort = {
|
||||||
|
readonly fehler?: string;
|
||||||
|
readonly bestaetigen?: string;
|
||||||
|
readonly erledigt?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostIdSchema = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
async function einreihen(
|
||||||
|
art: "HOSTKEY_ABRUFEN" | "ERREICHBARKEIT",
|
||||||
|
hostId: string,
|
||||||
|
): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
const id = hostIdSchema.parse(hostId);
|
||||||
|
return { vorgangId: await vorgangEinreihen({ art, angefordertVonId: benutzer.id, hostId: id }) };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostKeyAbrufenAktion(hostId: string): Promise<VorgangsAntwort> {
|
||||||
|
return einreihen("HOSTKEY_ABRUFEN", hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function erreichbarkeitPruefenAktion(hostId: string): Promise<VorgangsAntwort> {
|
||||||
|
return einreihen("ERREICHBARKEIT", hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entscheidungSchema = z.object({
|
||||||
|
hostId: hostIdSchema,
|
||||||
|
hostSchluesselId: z.string().min(1).max(64),
|
||||||
|
vertrauen: z.boolean(),
|
||||||
|
ersetzen: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function hostKeyEntscheidenAktion(
|
||||||
|
eingabe: z.input<typeof entscheidungSchema>,
|
||||||
|
): Promise<EntscheidungsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostKeyEntscheiden");
|
||||||
|
const daten = entscheidungSchema.parse(eingabe);
|
||||||
|
// Einen vertrauten Key zu ersetzen ist der Moment eines moeglichen Angriffs – nur mit
|
||||||
|
// frischer Bestaetigung der eigenen Anmeldung.
|
||||||
|
if (daten.ersetzen && !bestaetigungFrisch(benutzer.bestaetigtAm)) {
|
||||||
|
return { bestaetigen: `/bestaetigen?ziel=${encodeURIComponent(`/hosts/${daten.hostId}`)}` };
|
||||||
|
}
|
||||||
|
await hostKeyEntscheiden(daten, { id: benutzer.id, name: benutzer.name });
|
||||||
|
revalidatePath(`/hosts/${daten.hostId}`);
|
||||||
|
return { erledigt: true };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof HostKeyEntscheidungFehler)
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entscheidung eines Menschen ueber einen Host-Key (#009, TOFU).
|
||||||
|
*
|
||||||
|
* Einen vertrauten Key zu ersetzen ist der Moment, in dem ein Angreifer mit einer
|
||||||
|
* abgefangenen Verbindung gewinnen wuerde. Deshalb verlangt das ein ausdrueckliches
|
||||||
|
* `ersetzen` (die Oberflaeche fragt nach und verlangt eine frische Bestaetigung).
|
||||||
|
*/
|
||||||
|
export class HostKeyEntscheidungFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "HostKeyEntscheidungFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Entscheidung = {
|
||||||
|
readonly hostSchluesselId: string;
|
||||||
|
readonly vertrauen: boolean;
|
||||||
|
readonly ersetzen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function hostKeyEntscheiden(
|
||||||
|
eingabe: Entscheidung,
|
||||||
|
akteur: { readonly id: string; readonly name: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const key = await tx.hostSchluessel.findUnique({
|
||||||
|
where: { id: eingabe.hostSchluesselId },
|
||||||
|
include: { host: true },
|
||||||
|
});
|
||||||
|
if (!key) throw new HostKeyEntscheidungFehler("Diesen Host-Key gibt es nicht.");
|
||||||
|
if (key.status !== "AUSSTEHEND") {
|
||||||
|
throw new HostKeyEntscheidungFehler("Über diesen Host-Key wurde bereits entschieden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eingabe.vertrauen) {
|
||||||
|
await tx.hostSchluessel.update({
|
||||||
|
where: { id: key.id },
|
||||||
|
data: { status: "ABGELEHNT", entschiedenVon: akteur.name, entschiedenAm: new Date() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "hostkey.abgelehnt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: key.host.name,
|
||||||
|
fingerprint: key.fingerprint,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bisher = await tx.hostSchluessel.findFirst({
|
||||||
|
where: { hostId: key.hostId, algorithmus: key.algorithmus, status: "VERTRAUT" },
|
||||||
|
});
|
||||||
|
if (bisher && !eingabe.ersetzen) {
|
||||||
|
throw new HostKeyEntscheidungFehler(
|
||||||
|
`Für ${key.algorithmus} ist bereits ein anderer Host-Key vertraut. Ersetzen muss ausdrücklich bestätigt werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bisher) {
|
||||||
|
await tx.hostSchluessel.update({ where: { id: bisher.id }, data: { status: "ERSETZT" } });
|
||||||
|
}
|
||||||
|
await tx.hostSchluessel.update({
|
||||||
|
where: { id: key.id },
|
||||||
|
data: { status: "VERTRAUT", entschiedenVon: akteur.name, entschiedenAm: new Date() },
|
||||||
|
});
|
||||||
|
// Eine offene Abweichung zu genau diesem Key ist damit erledigt.
|
||||||
|
await tx.abweichung.updateMany({
|
||||||
|
where: { hostId: key.hostId, art: "HOSTKEY_GEAENDERT", fingerprint: key.fingerprint, behobenAm: null },
|
||||||
|
data: { behobenAm: new Date() },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: bisher ? "hostkey.ersetzt" : "hostkey.bestaetigt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: key.host.name,
|
||||||
|
fingerprint: key.fingerprint,
|
||||||
|
details: bisher
|
||||||
|
? { bisher: bisher.fingerprint, algorithmus: key.algorithmus }
|
||||||
|
: { algorithmus: key.algorithmus },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { hostKeyFesthalten, hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { befehlAusfuehren, hostKeyAbrufen } from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/** Vorgaenge HOSTKEY_ABRUFEN und ERREICHBARKEIT (#009). */
|
||||||
|
|
||||||
|
export async function hostLaden(hostId: string | null) {
|
||||||
|
if (!hostId) throw new VorgangsFehler("Der Vorgang nennt keinen Host.");
|
||||||
|
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
||||||
|
if (!host || host.archiviertAm) throw new VorgangsFehler("Der Host existiert nicht mehr.");
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hostKeyAbrufenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const host = await hostLaden(vorgang.hostId);
|
||||||
|
const gesehen = await hostKeyAbrufen(host);
|
||||||
|
const ergebnis = await hostKeyFesthalten(host.id, gesehen);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "hostkey.abgerufen",
|
||||||
|
ergebnis: ergebnis === "geaendert" ? "FEHLER" : "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
fingerprint: gesehen.fingerprint,
|
||||||
|
details: { algorithmus: gesehen.algorithmus, ergebnis },
|
||||||
|
});
|
||||||
|
return { ergebnis: { ...gesehen, ergebnis } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const erreichbarkeitHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const host = await hostLaden(vorgang.hostId);
|
||||||
|
try {
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
try {
|
||||||
|
const antwort = await befehlAusfuehren(verbindung, "id -un");
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: host.id },
|
||||||
|
data: { erreichbar: true, zuletztGeprueftAm: new Date() },
|
||||||
|
});
|
||||||
|
return { ergebnis: { erreichbar: true, benutzer: antwort.stdout.trim() } };
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: host.id },
|
||||||
|
data: { erreichbar: false, zuletztGeprueftAm: new Date() },
|
||||||
|
});
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Host-Key (#009), Bootstrap
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010),
|
||||||
* (#010), Ausrollen (#016), Abgleich (#021) …
|
* Ausrollen (#016), Abgleich (#021) …
|
||||||
*/
|
*/
|
||||||
export const HANDLER: HandlerTabelle = {};
|
export const HANDLER: HandlerTabelle = {
|
||||||
|
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||||
|
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { Host } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||||
|
import { privateKeyLaden } from "@/worker/geheimnisse";
|
||||||
|
import {
|
||||||
|
HostKeyGeaendertFehler,
|
||||||
|
verbinden,
|
||||||
|
type Anmeldung,
|
||||||
|
type GesehenerHostKey,
|
||||||
|
} from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungen zu verwalteten Hosts (#009): vertraute Host-Keys aus der Datenbank,
|
||||||
|
* Deployment-Key und Anmeldebenutzer nach `anmeldeart`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Dienstbenutzer auf den Hosts (Freigabe Frage 1). */
|
||||||
|
export const DIENSTBENUTZER = "sshkv";
|
||||||
|
|
||||||
|
export function anmeldebenutzer(host: Pick<Host, "anmeldeart">): string {
|
||||||
|
return host.anmeldeart === "ROOT" ? "root" : DIENSTBENUTZER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host-eigener Key, sonst der einer Gruppe, sonst der Instanz-Standard. */
|
||||||
|
export async function deploymentKeyIdFuer(
|
||||||
|
host: Pick<Host, "id" | "deploymentSchluesselId">,
|
||||||
|
): Promise<string> {
|
||||||
|
if (host.deploymentSchluesselId) return host.deploymentSchluesselId;
|
||||||
|
const gruppe = await prisma.hostgruppeMitglied.findFirst({
|
||||||
|
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||||
|
select: { hostgruppe: { select: { deploymentSchluesselId: true } } },
|
||||||
|
});
|
||||||
|
if (gruppe?.hostgruppe.deploymentSchluesselId) return gruppe.hostgruppe.deploymentSchluesselId;
|
||||||
|
const standard = await prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!standard)
|
||||||
|
throw new VorgangsFehler("Es gibt noch keinen Deployment-Key. Läuft der Worker vollständig?");
|
||||||
|
return standard.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt einen gesehenen Host-Key fest. Ein abweichender Key wird nie vertraut, sondern als
|
||||||
|
* AUSSTEHEND angelegt, und eine Abweichung HOSTKEY_GEAENDERT geht auf.
|
||||||
|
*/
|
||||||
|
export async function hostKeyFesthalten(
|
||||||
|
hostId: string,
|
||||||
|
gesehen: GesehenerHostKey,
|
||||||
|
): Promise<"unveraendert" | "neu" | "geaendert"> {
|
||||||
|
const vertraut = await prisma.hostSchluessel.findFirst({
|
||||||
|
where: { hostId, algorithmus: gesehen.algorithmus, status: "VERTRAUT" },
|
||||||
|
});
|
||||||
|
if (vertraut?.fingerprint === gesehen.fingerprint) {
|
||||||
|
await prisma.hostSchluessel.update({ where: { id: vertraut.id }, data: { zuletztGesehen: new Date() } });
|
||||||
|
return "unveraendert";
|
||||||
|
}
|
||||||
|
await prisma.hostSchluessel.upsert({
|
||||||
|
where: { hostId_fingerprint: { hostId, fingerprint: gesehen.fingerprint } },
|
||||||
|
create: { hostId, ...gesehen, status: "AUSSTEHEND" },
|
||||||
|
update: { zuletztGesehen: new Date() },
|
||||||
|
});
|
||||||
|
if (!vertraut) return "neu";
|
||||||
|
|
||||||
|
const offen = await prisma.abweichung.findFirst({
|
||||||
|
where: { hostId, art: "HOSTKEY_GEAENDERT", behobenAm: null, fingerprint: gesehen.fingerprint },
|
||||||
|
});
|
||||||
|
if (!offen) {
|
||||||
|
await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId,
|
||||||
|
art: "HOSTKEY_GEAENDERT",
|
||||||
|
fingerprint: gesehen.fingerprint,
|
||||||
|
details: { erwartet: vertraut.fingerprint, algorithmus: gesehen.algorithmus },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.host.update({ where: { id: hostId }, data: { driftZustand: "DRIFT" } });
|
||||||
|
}
|
||||||
|
return "geaendert";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindet sich mit einem Host. Ein geaenderter Host-Key wird festgehalten und protokolliert,
|
||||||
|
* dann weitergeworfen – der Vorgang scheitert.
|
||||||
|
*/
|
||||||
|
export async function hostVerbinden(host: Host, anmeldung?: Anmeldung): Promise<Client> {
|
||||||
|
const vertraut = await prisma.hostSchluessel.findMany({
|
||||||
|
where: { hostId: host.id, status: "VERTRAUT" },
|
||||||
|
select: { algorithmus: true, fingerprint: true },
|
||||||
|
});
|
||||||
|
let zugang = anmeldung;
|
||||||
|
let privateKey: Buffer | undefined;
|
||||||
|
if (!zugang) {
|
||||||
|
privateKey = await privateKeyLaden(await deploymentKeyIdFuer(host));
|
||||||
|
zugang = { benutzer: anmeldebenutzer(host), privateKey };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await verbinden(host, vertraut, zugang);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof HostKeyGeaendertFehler) {
|
||||||
|
await hostKeyFesthalten(host.id, fehler.gesehen);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "hostkey.abweichung",
|
||||||
|
ergebnis: "VERWEIGERT",
|
||||||
|
hostName: host.name,
|
||||||
|
fingerprint: fehler.gesehen.fingerprint,
|
||||||
|
details: { erwartet: fehler.erwartet },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
} finally {
|
||||||
|
privateKey?.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { Client, type ConnectConfig } from "ssh2";
|
||||||
|
import type { Host, HostSchluessel } from "@/generated/prisma/client";
|
||||||
|
import { fingerprintSha256 } from "@/lib/ssh/openssh";
|
||||||
|
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH-Verbindungen des Workers (#009) – ausschliesslich mit strikter Host-Key-Pruefung.
|
||||||
|
*
|
||||||
|
* Es gibt genau zwei Arten, einen Host-Key zu behandeln:
|
||||||
|
* - `abrufen`: Key nur ansehen und die Verbindung abbrechen, bevor irgendetwas
|
||||||
|
* uebertragen wird (TOFU, der Mensch entscheidet danach).
|
||||||
|
* - `pruefen`: nur ein vertrauter Key ist gut. Ein unbekannter oder geaenderter Key bricht
|
||||||
|
* die Verbindung ab, bevor Zugangsdaten gesendet werden. Es gibt keinen Codepfad, der
|
||||||
|
* einen Key automatisch annimmt (CLAUDE.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie lange ein Verbindungsaufbau dauern darf. */
|
||||||
|
const VERBINDUNG_TIMEOUT_MS = 15_000;
|
||||||
|
/** Wie lange ein einzelner Befehl laufen darf. */
|
||||||
|
const BEFEHL_TIMEOUT_MS = 120_000;
|
||||||
|
/** Mehr Ausgabe nehmen wir nicht an – authorized_keys-Dateien sind klein. */
|
||||||
|
const AUSGABE_HOECHSTENS = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bevorzugte Reihenfolge beim Abrufen. ed25519 zuerst: kurz, modern und auf jedem
|
||||||
|
* aktuellen Debian vorhanden.
|
||||||
|
*/
|
||||||
|
const ALGORITHMEN_REIHENFOLGE = [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SignaturAlgorithmus = (typeof ALGORITHMEN_REIHENFOLGE)[number];
|
||||||
|
|
||||||
|
/** Der Typ im Key-Blob (`ssh-rsa`) und die Signaturverfahren dazu (`rsa-sha2-*`). */
|
||||||
|
export function signaturAlgorithmenFuer(keyTyp: string): SignaturAlgorithmus[] {
|
||||||
|
if (keyTyp === "ssh-rsa") return ["rsa-sha2-512", "rsa-sha2-256"];
|
||||||
|
return ALGORITHMEN_REIHENFOLGE.filter((algorithmus) => algorithmus === keyTyp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den Typ (erstes SSH-String-Feld) aus einem Key-Blob. */
|
||||||
|
export function keyTypAusBlob(blob: Buffer): string {
|
||||||
|
if (blob.length < 4) return "";
|
||||||
|
const laenge = blob.readUInt32BE(0);
|
||||||
|
if (laenge <= 0 || laenge > 64 || blob.length < 4 + laenge) return "";
|
||||||
|
return blob.subarray(4, 4 + laenge).toString("ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GesehenerHostKey = {
|
||||||
|
readonly algorithmus: string;
|
||||||
|
readonly publicKey: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hostKeyBeschreiben(blob: Buffer): GesehenerHostKey {
|
||||||
|
return {
|
||||||
|
algorithmus: keyTypAusBlob(blob),
|
||||||
|
publicKey: blob.toString("base64"),
|
||||||
|
fingerprint: fingerprintSha256(blob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Host praesentierte einen anderen Key als den vertrauten. */
|
||||||
|
export class HostKeyGeaendertFehler extends VorgangsFehler {
|
||||||
|
constructor(
|
||||||
|
readonly gesehen: GesehenerHostKey,
|
||||||
|
readonly erwartet: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
`ACHTUNG: Der Host-Key hat sich geändert (erwartet ${erwartet}, gesehen ${gesehen.fingerprint}). ` +
|
||||||
|
"Die Verbindung wurde abgebrochen. Erst prüfen, ob der Host neu installiert wurde – sonst könnte " +
|
||||||
|
"jemand die Verbindung abfangen.",
|
||||||
|
);
|
||||||
|
this.name = "HostKeyGeaendertFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HostKeyUnbekanntFehler extends VorgangsFehler {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"Für diesen Host ist noch kein Host-Key bestätigt. Bitte zuerst den Host-Key abrufen und bestätigen.",
|
||||||
|
);
|
||||||
|
this.name = "HostKeyUnbekanntFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ziel = Pick<Host, "adresse" | "port">;
|
||||||
|
|
||||||
|
export type Anmeldung =
|
||||||
|
| { readonly benutzer: string; readonly privateKey: Buffer }
|
||||||
|
| { readonly benutzer: string; readonly passwort: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt den Host-Key, ohne sich anzumelden. Die Verbindung wird im hostVerifier abgebrochen –
|
||||||
|
* vor dem Austausch von Zugangsdaten.
|
||||||
|
*/
|
||||||
|
export function hostKeyAbrufen(ziel: Ziel): Promise<GesehenerHostKey> {
|
||||||
|
return new Promise((erfuellen, ablehnen) => {
|
||||||
|
const client = new Client();
|
||||||
|
let gesehen: GesehenerHostKey | undefined;
|
||||||
|
const fertig = (fehler?: unknown) => {
|
||||||
|
client.end();
|
||||||
|
if (gesehen) erfuellen(gesehen);
|
||||||
|
else ablehnen(fehler instanceof VorgangsFehler ? fehler : verbindungsFehler(fehler, ziel));
|
||||||
|
};
|
||||||
|
client.on("error", fertig);
|
||||||
|
client.on("close", () => fertig());
|
||||||
|
client.connect({
|
||||||
|
host: ziel.adresse,
|
||||||
|
port: ziel.port,
|
||||||
|
username: "sshkv-hostkey-abruf",
|
||||||
|
readyTimeout: VERBINDUNG_TIMEOUT_MS,
|
||||||
|
algorithms: { serverHostKey: [...ALGORITHMEN_REIHENFOLGE] },
|
||||||
|
hostVerifier: (blob: Buffer) => {
|
||||||
|
gesehen = hostKeyBeschreiben(blob);
|
||||||
|
// false bricht ab; mehr als den Key wollten wir nicht.
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut eine Verbindung mit strikter Pruefung gegen die vertrauten Host-Keys auf. Nur die
|
||||||
|
* Algorithmen der vertrauten Keys werden angeboten – sonst koennte der Server einen Key eines
|
||||||
|
* anderen Typs zeigen, den wir nie gesehen haben.
|
||||||
|
*/
|
||||||
|
export function verbinden(
|
||||||
|
ziel: Ziel,
|
||||||
|
vertraut: readonly Pick<HostSchluessel, "algorithmus" | "fingerprint">[],
|
||||||
|
anmeldung: Anmeldung,
|
||||||
|
): Promise<Client> {
|
||||||
|
if (vertraut.length === 0) return Promise.reject(new HostKeyUnbekanntFehler());
|
||||||
|
const angeboten = [...new Set(vertraut.flatMap((key) => signaturAlgorithmenFuer(key.algorithmus)))];
|
||||||
|
|
||||||
|
return new Promise((erfuellen, ablehnen) => {
|
||||||
|
const client = new Client();
|
||||||
|
let abweichung: HostKeyGeaendertFehler | undefined;
|
||||||
|
client.on("ready", () => erfuellen(client));
|
||||||
|
client.on("error", (fehler) => {
|
||||||
|
client.end();
|
||||||
|
ablehnen(abweichung ?? verbindungsFehler(fehler, ziel));
|
||||||
|
});
|
||||||
|
|
||||||
|
const konfiguration: ConnectConfig = {
|
||||||
|
host: ziel.adresse,
|
||||||
|
port: ziel.port,
|
||||||
|
username: anmeldung.benutzer,
|
||||||
|
readyTimeout: VERBINDUNG_TIMEOUT_MS,
|
||||||
|
algorithms: { serverHostKey: angeboten },
|
||||||
|
hostVerifier: (blob: Buffer) => {
|
||||||
|
const gesehen = hostKeyBeschreiben(blob);
|
||||||
|
const passend = vertraut.find((key) => key.algorithmus === gesehen.algorithmus);
|
||||||
|
if (passend && passend.fingerprint === gesehen.fingerprint) return true;
|
||||||
|
abweichung = new HostKeyGeaendertFehler(gesehen, passend?.fingerprint ?? vertraut[0]!.fingerprint);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
// Nichts ausprobieren, was nicht ausdruecklich gewollt ist (kein Agent, keine Tastatur).
|
||||||
|
tryKeyboard: false,
|
||||||
|
...("privateKey" in anmeldung
|
||||||
|
? { privateKey: anmeldung.privateKey }
|
||||||
|
: { password: anmeldung.passwort }),
|
||||||
|
};
|
||||||
|
client.connect(konfiguration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler {
|
||||||
|
const meldung = fehler instanceof Error ? fehler.message : String(fehler);
|
||||||
|
const code = (fehler as { code?: string } | undefined)?.code;
|
||||||
|
if (code === "ECONNREFUSED")
|
||||||
|
return new VorgangsFehler(`${ziel.adresse}:${ziel.port} lehnt die Verbindung ab.`);
|
||||||
|
if (code === "ENOTFOUND" || code === "EAI_AGAIN")
|
||||||
|
return new VorgangsFehler(`${ziel.adresse} ist nicht auflösbar.`);
|
||||||
|
if (code === "EHOSTUNREACH" || code === "ENETUNREACH")
|
||||||
|
return new VorgangsFehler(`${ziel.adresse} ist nicht erreichbar.`);
|
||||||
|
if (/Timed out/i.test(meldung))
|
||||||
|
return new VorgangsFehler(`Zeitüberschreitung beim Verbinden mit ${ziel.adresse}.`);
|
||||||
|
if (/All configured authentication methods failed/i.test(meldung))
|
||||||
|
return new VorgangsFehler("Die Anmeldung wurde abgelehnt (Schlüssel bzw. Passwort nicht akzeptiert).");
|
||||||
|
if (/no matching|handshake failed/i.test(meldung))
|
||||||
|
return new VorgangsFehler(
|
||||||
|
"Kein gemeinsames Verfahren für den Host-Key – der Host bietet den vertrauten Key-Typ nicht an.",
|
||||||
|
);
|
||||||
|
return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Befehlsergebnis = { readonly stdout: string; readonly stderr: string; readonly code: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte
|
||||||
|
* (und Private Keys, #017) nur durch die SSH-Verbindung und nie als Datei auf dem sshkv-Server.
|
||||||
|
*/
|
||||||
|
export function befehlAusfuehren(
|
||||||
|
client: Client,
|
||||||
|
befehl: string,
|
||||||
|
stdin?: string | Buffer,
|
||||||
|
): Promise<Befehlsergebnis> {
|
||||||
|
return new Promise((erfuellen, ablehnen) => {
|
||||||
|
client.exec(befehl, (fehler, strom) => {
|
||||||
|
if (fehler)
|
||||||
|
return ablehnen(new VorgangsFehler("Befehl konnte nicht gestartet werden.", { cause: fehler }));
|
||||||
|
const ausgabe: Buffer[] = [];
|
||||||
|
const fehlerAusgabe: Buffer[] = [];
|
||||||
|
let laenge = 0;
|
||||||
|
const zeitgeber = setTimeout(() => {
|
||||||
|
strom.close();
|
||||||
|
ablehnen(new VorgangsFehler("Zeitüberschreitung bei einem Befehl auf dem Host."));
|
||||||
|
}, BEFEHL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const sammeln = (ziel: Buffer[]) => (daten: Buffer) => {
|
||||||
|
laenge += daten.length;
|
||||||
|
if (laenge > AUSGABE_HOECHSTENS) {
|
||||||
|
strom.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ziel.push(daten);
|
||||||
|
};
|
||||||
|
strom.on("data", sammeln(ausgabe));
|
||||||
|
strom.stderr.on("data", sammeln(fehlerAusgabe));
|
||||||
|
strom.on("close", (code: number | null) => {
|
||||||
|
clearTimeout(zeitgeber);
|
||||||
|
if (laenge > AUSGABE_HOECHSTENS) {
|
||||||
|
return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet."));
|
||||||
|
}
|
||||||
|
erfuellen({
|
||||||
|
stdout: Buffer.concat(ausgabe).toString("utf8"),
|
||||||
|
stderr: Buffer.concat(fehlerAusgabe).toString("utf8"),
|
||||||
|
code: code ?? -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (stdin !== undefined) strom.end(stdin);
|
||||||
|
else strom.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { hostKeyEntscheiden } from "@/server/services/hostkeys";
|
||||||
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
|
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||||
|
import { HANDLER } from "@/worker/handler";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { HostKeyGeaendertFehler, befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||||
|
import { datenbankLeeren } from "../hilfen/db";
|
||||||
|
import { hostKeyFingerprint, hostKeysTauschen, rootPasswort, zielA, zielBereit } from "./hilfen/ziele";
|
||||||
|
|
||||||
|
/** Host-Key-Pruefung gegen ein echtes sshd (#009). */
|
||||||
|
|
||||||
|
const AKTEUR = { id: "test", name: "Test" };
|
||||||
|
let verzeichnis: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await zielBereit(zielA);
|
||||||
|
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||||
|
const datei = path.join(verzeichnis, "master.key");
|
||||||
|
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||||
|
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||||
|
masterKeyVergessen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => rmSync(verzeichnis, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function hostAnlegen() {
|
||||||
|
return prisma.host.create({
|
||||||
|
data: { name: "ziel-a", adresse: zielA.host, port: zielA.port, anmeldeart: "ROOT" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abrufen(hostId: string) {
|
||||||
|
const vorgang = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId } });
|
||||||
|
await vorgangAusfuehren(vorgang.id, HANDLER);
|
||||||
|
return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwort = () => ({ benutzer: "root", passwort: rootPasswort() });
|
||||||
|
|
||||||
|
describe("Host-Key (TOFU)", () => {
|
||||||
|
it("ruft den Key ab, ohne ihm zu vertrauen", async () => {
|
||||||
|
const host = await hostAnlegen();
|
||||||
|
const vorgang = await abrufen(host.id);
|
||||||
|
expect(vorgang.status).toBe("ERLEDIGT");
|
||||||
|
const keys = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } });
|
||||||
|
expect(keys).toHaveLength(1);
|
||||||
|
expect(keys[0]).toMatchObject({ status: "AUSSTEHEND", algorithmus: "ssh-ed25519" });
|
||||||
|
expect(keys[0]?.fingerprint).toBe(await hostKeyFingerprint(zielA));
|
||||||
|
// Ohne Bestaetigung keine Verbindung.
|
||||||
|
await expect(hostVerbinden(host, passwort())).rejects.toThrow(/noch kein Host-Key bestätigt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verbindet nach Bestaetigung und verweigert nach einem Host-Key-Wechsel", async () => {
|
||||||
|
const host = await hostAnlegen();
|
||||||
|
await abrufen(host.id);
|
||||||
|
const [key] = await prisma.hostSchluessel.findMany({ where: { hostId: host.id } });
|
||||||
|
await hostKeyEntscheiden({ hostSchluesselId: key!.id, vertrauen: true }, AKTEUR);
|
||||||
|
|
||||||
|
const verbindung = await hostVerbinden(host, passwort());
|
||||||
|
expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("root");
|
||||||
|
verbindung.end();
|
||||||
|
|
||||||
|
await hostKeysTauschen(zielA);
|
||||||
|
await expect(hostVerbinden(host, passwort())).rejects.toBeInstanceOf(HostKeyGeaendertFehler);
|
||||||
|
|
||||||
|
const abweichung = await prisma.abweichung.findFirst({
|
||||||
|
where: { hostId: host.id, art: "HOSTKEY_GEAENDERT" },
|
||||||
|
});
|
||||||
|
expect(abweichung?.behobenAm).toBeNull();
|
||||||
|
const neu = await prisma.hostSchluessel.findFirst({ where: { hostId: host.id, status: "AUSSTEHEND" } });
|
||||||
|
expect(neu?.fingerprint).toBe(await hostKeyFingerprint(zielA));
|
||||||
|
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT");
|
||||||
|
|
||||||
|
// Ersetzen nur ausdruecklich.
|
||||||
|
await expect(hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true }, AKTEUR)).rejects.toThrow(
|
||||||
|
/ausdrücklich/,
|
||||||
|
);
|
||||||
|
await hostKeyEntscheiden({ hostSchluesselId: neu!.id, vertrauen: true, ersetzen: true }, AKTEUR);
|
||||||
|
const wieder = await hostVerbinden(host, passwort());
|
||||||
|
wieder.end();
|
||||||
|
expect(
|
||||||
|
await prisma.abweichung.count({
|
||||||
|
where: { hostId: host.id, art: "HOSTKEY_GEAENDERT", behobenAm: null },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
const aktionen = (await prisma.protokoll.findMany()).map((e) => e.aktion);
|
||||||
|
expect(aktionen).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"hostkey.abgerufen",
|
||||||
|
"hostkey.bestaetigt",
|
||||||
|
"hostkey.abweichung",
|
||||||
|
"hostkey.ersetzt",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen nicht erreichbaren Host verstaendlich", async () => {
|
||||||
|
const host = await prisma.host.create({ data: { name: "weg", adresse: "127.0.0.1", port: 1 } });
|
||||||
|
const vorgang = await abrufen(host.id);
|
||||||
|
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
|
||||||
|
expect(vorgang.fehler).toMatch(/lehnt die Verbindung ab/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["tests/integration/**/*.test.ts"],
|
include: ["tests/integration/**/*.test.ts"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
globalSetup: ["tests/global-setup.ts"],
|
||||||
setupFiles: ["tests/setup-env.ts"],
|
setupFiles: ["tests/setup-env.ts"],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
testTimeout: 60_000,
|
testTimeout: 60_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user