Merge branch 'phase/018-020-assistent'
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestätigt" };
|
||||
|
||||
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
|
||||
export default async function BestaetigtSeite() {
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Anmeldung bestätigt"
|
||||
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Assistent } from "@/components/assistent/Assistent";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { assistentDaten } from "@/server/queries/assistent";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
|
||||
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
|
||||
*/
|
||||
export default async function AssistentSeite() {
|
||||
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
|
||||
const daten = await assistentDaten();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
|
||||
titel="Host-zu-Host-Assistent"
|
||||
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
|
||||
/>
|
||||
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default async function Uebersicht() {
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
|
||||
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||
import {
|
||||
assistentErzeugenAktion,
|
||||
assistentZielkontoAktion,
|
||||
assistentZuweisenAktion,
|
||||
type SchrittAntwort,
|
||||
} from "@/server/actions/assistent";
|
||||
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||
import type { AssistentDaten } from "@/server/queries/assistent";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
|
||||
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
|
||||
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
|
||||
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
|
||||
*/
|
||||
type Werte = {
|
||||
vorlageId: string;
|
||||
zweck: string;
|
||||
ablaufAm: string;
|
||||
quelleHostId: string;
|
||||
quelleBenutzer: string;
|
||||
pfad: string;
|
||||
zielHostId: string;
|
||||
zielBenutzer: string;
|
||||
anlegen: boolean;
|
||||
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
sudoBefehle: string;
|
||||
optionen: string;
|
||||
quellIp: string;
|
||||
alias: string;
|
||||
hostName: string;
|
||||
port: string;
|
||||
knownHosts: boolean;
|
||||
fingerprintGeprueft: boolean;
|
||||
};
|
||||
|
||||
const LEER: Werte = {
|
||||
vorlageId: "",
|
||||
zweck: "",
|
||||
ablaufAm: "",
|
||||
quelleHostId: "",
|
||||
quelleBenutzer: "root",
|
||||
pfad: "",
|
||||
zielHostId: "",
|
||||
zielBenutzer: "root",
|
||||
anlegen: false,
|
||||
sudoModus: "KEINER",
|
||||
sudoBefehle: "",
|
||||
optionen: "",
|
||||
quellIp: "",
|
||||
alias: "",
|
||||
hostName: "",
|
||||
port: "22",
|
||||
knownHosts: true,
|
||||
fingerprintGeprueft: false,
|
||||
};
|
||||
|
||||
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
|
||||
|
||||
type Ablauf = {
|
||||
schluesselId?: string;
|
||||
zielkontoFertig?: boolean;
|
||||
privatVerwendungId?: string;
|
||||
laeuft?: string;
|
||||
fehler?: string;
|
||||
bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function datumIn(tage: number): string {
|
||||
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function kurz(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 40) || "zugang"
|
||||
);
|
||||
}
|
||||
|
||||
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
|
||||
async function warten(
|
||||
vorgangId: string,
|
||||
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
|
||||
for (;;) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
|
||||
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
}
|
||||
|
||||
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [w, setW] = useState<Werte>(LEER);
|
||||
const [ablauf, setAblauf] = useState<Ablauf>({});
|
||||
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
|
||||
|
||||
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
|
||||
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
|
||||
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
|
||||
|
||||
function vorlageWaehlen(id: string) {
|
||||
const v = daten.vorlagen.find((x) => x.id === id);
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
vorlageId: id,
|
||||
...(v
|
||||
? {
|
||||
zielBenutzer: v.zielbenutzer,
|
||||
anlegen: v.zielbenutzerAnlegen,
|
||||
sudoModus: v.sudoModus,
|
||||
sudoBefehle: v.sudoBefehle.join("\n"),
|
||||
knownHosts: v.knownHostsEintragen,
|
||||
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
|
||||
function zielVorbelegen(zielId: string) {
|
||||
const b = daten.hosts.find((h) => h.id === zielId);
|
||||
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
|
||||
const werte = {
|
||||
quellHost: quelle?.name,
|
||||
quellHostIp: quellIp,
|
||||
zielHost: b?.name,
|
||||
zielHostIp: b?.adresse,
|
||||
};
|
||||
const aufloesen = (muster: string | null | undefined) => {
|
||||
if (!muster) return "";
|
||||
try {
|
||||
return platzhalterErsetzen(muster, werte);
|
||||
} catch {
|
||||
return muster;
|
||||
}
|
||||
};
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
zielHostId: zielId,
|
||||
quellIp,
|
||||
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
|
||||
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
|
||||
hostName: b ? b.adresse : "",
|
||||
port: b ? String(b.port) : "22",
|
||||
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
|
||||
fingerprintGeprueft: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
|
||||
let sudoVorschau: string | null = null;
|
||||
let sudoFehler: string | undefined;
|
||||
if (w.anlegen) {
|
||||
try {
|
||||
sudoVorschau = sudoersInhalt(
|
||||
w.zielBenutzer,
|
||||
w.sudoModus,
|
||||
w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
);
|
||||
} catch (fehler) {
|
||||
sudoFehler = (fehler as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const schrittOk = [
|
||||
w.zweck.trim().length > 0,
|
||||
Boolean(w.quelleHostId && w.quelleBenutzer),
|
||||
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
|
||||
!offenePlatzhalter &&
|
||||
!sudoFehler &&
|
||||
(!w.knownHosts || w.fingerprintGeprueft),
|
||||
true,
|
||||
];
|
||||
|
||||
async function ausfuehren() {
|
||||
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
|
||||
const melden = (neu: Partial<Ablauf>) => {
|
||||
stand = { ...stand, ...neu };
|
||||
setAblauf(stand);
|
||||
};
|
||||
const pruefen = (antwort: SchrittAntwort) => {
|
||||
if (antwort.fehler || antwort.bestaetigen) {
|
||||
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
if (!stand.schluesselId) {
|
||||
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
|
||||
const antwort = await assistentErzeugenAktion({
|
||||
name: w.zweck.trim(),
|
||||
vorlageId: w.vorlageId || null,
|
||||
ablaufAm: w.ablaufAm || null,
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
|
||||
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
|
||||
}
|
||||
if (w.anlegen && !stand.zielkontoFertig) {
|
||||
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
|
||||
const antwort = await assistentZielkontoAktion({
|
||||
hostId: w.zielHostId,
|
||||
benutzer: w.zielBenutzer,
|
||||
modus: w.sudoModus,
|
||||
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
|
||||
melden({ zielkontoFertig: true });
|
||||
}
|
||||
if (!stand.privatVerwendungId) {
|
||||
melden({ laeuft: "Zuweisungen werden angelegt …" });
|
||||
const antwort = await assistentZuweisenAktion({
|
||||
schluesselId: stand.schluesselId!,
|
||||
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
|
||||
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
|
||||
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
melden({ privatVerwendungId: antwort.privatVerwendungId });
|
||||
}
|
||||
melden({ laeuft: undefined });
|
||||
setSchritt(4);
|
||||
} catch (fehler) {
|
||||
melden({ laeuft: undefined, fehler: (fehler as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
|
||||
{SCHRITTE.map((titel, index) => (
|
||||
<li
|
||||
key={titel}
|
||||
aria-current={index === schritt ? "step" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||
index === schritt
|
||||
? "border-accent text-accent bg-raised font-medium"
|
||||
: index < schritt
|
||||
? "border-border text-text"
|
||||
: "border-border text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||
</span>
|
||||
{titel}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{schritt === 0 && (
|
||||
<Karte
|
||||
titel="Vorlage & Zweck"
|
||||
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Vorlage"
|
||||
name="vorlageId"
|
||||
value={w.vorlageId}
|
||||
onChange={(e) => vorlageWaehlen(e.target.value)}
|
||||
>
|
||||
<option value="">– ohne Vorlage –</option>
|
||||
{daten.vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
|
||||
<Feld
|
||||
beschriftung="Zweck"
|
||||
name="zweck"
|
||||
value={w.zweck}
|
||||
onChange={(e) => setze("zweck", e.target.value)}
|
||||
placeholder="Backup srv-backup01 → srv-pve01"
|
||||
required
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Ablaufdatum (optional)"
|
||||
name="ablaufAm"
|
||||
type="date"
|
||||
value={w.ablaufAm}
|
||||
onChange={(e) => setze("ablaufAm", e.target.value)}
|
||||
hinweis="Projektbezogene Zugänge sollten ablaufen."
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 1 && (
|
||||
<Karte
|
||||
titel="Quelle – Host A"
|
||||
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host A"
|
||||
name="quelleHostId"
|
||||
value={w.quelleHostId}
|
||||
onChange={(e) => setze("quelleHostId", e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Benutzer auf A"
|
||||
name="quelleBenutzer"
|
||||
value={w.quelleBenutzer}
|
||||
onChange={(e) => setze("quelleBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Pfad des Private Keys"
|
||||
name="pfad"
|
||||
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
|
||||
onChange={(e) => setze("pfad", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 2 && (
|
||||
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host B"
|
||||
name="zielHostId"
|
||||
value={w.zielHostId}
|
||||
onChange={(e) => zielVorbelegen(e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts
|
||||
.filter((h) => h.id !== w.quelleHostId)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld
|
||||
beschriftung="Benutzer auf B"
|
||||
name="zielBenutzer"
|
||||
value={w.zielBenutzer}
|
||||
onChange={(e) => setze("zielBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
{w.zielBenutzer === "root" && (
|
||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
|
||||
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
|
||||
</p>
|
||||
)}
|
||||
<Ankreuzfeld
|
||||
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
|
||||
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
|
||||
checked={w.anlegen}
|
||||
onChange={(e) => setze("anlegen", e.target.checked)}
|
||||
/>
|
||||
{w.anlegen && (
|
||||
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
|
||||
<Auswahlliste
|
||||
beschriftung="sudo"
|
||||
name="sudoModus"
|
||||
value={w.sudoModus}
|
||||
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
|
||||
>
|
||||
<option value="KEINER">keine sudo-Rechte</option>
|
||||
<option value="BEFEHLE">bestimmte Befehle</option>
|
||||
<option value="VOLL">voller sudo-Zugriff</option>
|
||||
</Auswahlliste>
|
||||
{w.sudoModus === "BEFEHLE" && (
|
||||
<Textbereich
|
||||
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
|
||||
name="sudoBefehle"
|
||||
value={w.sudoBefehle}
|
||||
onChange={(e) => setze("sudoBefehle", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
|
||||
{sudoVorschau && (
|
||||
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
|
||||
{sudoVorschau}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{quelle && !istIpAdresse(quelle.adresse) && (
|
||||
<Feld
|
||||
beschriftung={`IP von ${quelle.name} (für from="…")`}
|
||||
name="quellIp"
|
||||
value={w.quellIp}
|
||||
onChange={(e) => setze("quellIp", e.target.value)}
|
||||
mono
|
||||
hinweis="Die Adresse, unter der B den Host A sieht."
|
||||
/>
|
||||
)}
|
||||
<Feld
|
||||
beschriftung="Einschränkungen in authorized_keys"
|
||||
name="optionen"
|
||||
value={w.optionen}
|
||||
onChange={(e) => setze("optionen", e.target.value)}
|
||||
mono
|
||||
placeholder={
|
||||
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
|
||||
}
|
||||
hinweis={
|
||||
offenePlatzhalter
|
||||
? "Es sind noch Platzhalter offen."
|
||||
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld
|
||||
beschriftung="Alias auf A"
|
||||
name="alias"
|
||||
value={w.alias}
|
||||
onChange={(e) => setze("alias", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="HostName (aus Sicht von A)"
|
||||
name="hostName"
|
||||
value={w.hostName}
|
||||
onChange={(e) => setze("hostName", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
value={w.port}
|
||||
onChange={(e) => setze("port", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<Ankreuzfeld
|
||||
beschriftung="Host-Key von B in known_hosts von A eintragen"
|
||||
checked={w.knownHosts}
|
||||
onChange={(e) => setze("knownHosts", e.target.checked)}
|
||||
/>
|
||||
{w.knownHosts && ziel && (
|
||||
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
|
||||
{ziel.hostKeys.map((k) => (
|
||||
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
|
||||
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
|
||||
</p>
|
||||
))}
|
||||
<Ankreuzfeld
|
||||
beschriftung="Fingerprint von B ist bestätigt"
|
||||
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
|
||||
checked={w.fingerprintGeprueft}
|
||||
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<Karte
|
||||
titel="Zusammenfassung"
|
||||
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
|
||||
>
|
||||
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<dt className="text-muted">Schlüssel</dt>
|
||||
<dd>
|
||||
ed25519 · {w.zweck}
|
||||
{w.ablaufAm && (
|
||||
<span className="text-accent">
|
||||
{" "}
|
||||
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted">Private Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
|
||||
</dd>
|
||||
<dt className="text-muted">~/.ssh/config auf A</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
|
||||
</dd>
|
||||
<dt className="text-muted">Public Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.optionen && `${w.optionen} · `}
|
||||
{w.zielBenutzer}@{ziel?.name}
|
||||
</dd>
|
||||
{w.anlegen && (
|
||||
<>
|
||||
<dt className="text-muted">Zielbenutzer</dt>
|
||||
<dd>
|
||||
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
|
||||
{w.sudoModus === "KEINER" ? (
|
||||
"ohne sudo"
|
||||
) : w.sudoModus === "VOLL" ? (
|
||||
<span className="text-danger">voller sudo-Zugriff</span>
|
||||
) : (
|
||||
"sudo für bestimmte Befehle"
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
|
||||
<p className="text-accent mt-4 text-[13.5px]">
|
||||
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||
Ausführen danach.
|
||||
</p>
|
||||
)}
|
||||
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
|
||||
<Formularfehler text={ablauf.fehler} />
|
||||
{ablauf.bestaetigen && (
|
||||
<a
|
||||
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
|
||||
erneut ausführen
|
||||
</a>
|
||||
)}
|
||||
{ablauf.laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{ablauf.laeuft}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Play className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => void ausfuehren()}
|
||||
>
|
||||
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
|
||||
</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && ablauf.schluesselId && (
|
||||
<>
|
||||
<Karte
|
||||
titel="Dry-Run und Ausrollen"
|
||||
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
|
||||
</Karte>
|
||||
<Karte
|
||||
titel="Verbindung testen"
|
||||
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
|
||||
>
|
||||
{ablauf.privatVerwendungId && (
|
||||
<VorgangsKnopf
|
||||
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
|
||||
beschriftung="Verbindung von A nach B testen"
|
||||
variante="primaer"
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted mt-3 text-[13.5px]">
|
||||
Zum Schlüssel:{" "}
|
||||
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
|
||||
{w.zweck}
|
||||
</a>
|
||||
</p>
|
||||
</Karte>
|
||||
</>
|
||||
)}
|
||||
|
||||
{schritt < 3 && (
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={schritt === 0}
|
||||
onClick={() => setSchritt(schritt - 1)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
disabled={!schrittOk[schritt]}
|
||||
onClick={() => {
|
||||
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
|
||||
setSchritt(schritt + 1);
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{schritt === 3 && !ablauf.schluesselId && (
|
||||
<div>
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => setSchritt(2)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import { erzeugenAnfordern } from "@/server/services/schluessel";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
privateVerwendungAnlegen,
|
||||
publicVerwendungAnlegen,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { zielkontoAnfordern } from "@/server/services/zielkonten";
|
||||
|
||||
/**
|
||||
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
|
||||
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
|
||||
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
|
||||
*/
|
||||
export type SchrittAntwort = {
|
||||
readonly vorgangId?: string;
|
||||
readonly privatVerwendungId?: string;
|
||||
readonly fehler?: string;
|
||||
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
|
||||
readonly bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function alsAntwort(fehler: unknown): SchrittAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||
if (
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof ZielkontoFehler ||
|
||||
fehler instanceof HostFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
export async function assistentErzeugenAktion(eingabe: {
|
||||
name: string;
|
||||
vorlageId: string | null;
|
||||
ablaufAm: string | null;
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
return {
|
||||
vorgangId: await erzeugenAnfordern(
|
||||
{
|
||||
name: eingabe.name,
|
||||
vorlageId: eingabe.vorlageId,
|
||||
ablaufAm: eingabe.ablaufAm,
|
||||
tags: ["host-zu-host"],
|
||||
},
|
||||
undefined,
|
||||
akteurVon(benutzer),
|
||||
),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZielkontoAktion(eingabe: {
|
||||
hostId: string;
|
||||
benutzer: string;
|
||||
modus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
befehle: string[];
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
|
||||
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||
return {
|
||||
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZuweisenAktion(eingabe: {
|
||||
schluesselId: string;
|
||||
quelle: { hostId: string; benutzer: string; pfad: string };
|
||||
ziel: { hostId: string; benutzer: string; optionen: string | null };
|
||||
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
const akteur = akteurVon(benutzer);
|
||||
const privatVerwendungId = await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: id.parse(eingabe.schluesselId),
|
||||
hostId: id.parse(eingabe.quelle.hostId),
|
||||
zielbenutzer: eingabe.quelle.benutzer,
|
||||
pfad: eingabe.quelle.pfad,
|
||||
sshConfig: {
|
||||
alias: eingabe.ssh.alias,
|
||||
hostName: eingabe.ssh.hostName,
|
||||
benutzer: eingabe.ziel.benutzer,
|
||||
port: eingabe.ssh.port,
|
||||
},
|
||||
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: id.parse(eingabe.ziel.hostId),
|
||||
zielbenutzer: eingabe.ziel.benutzer,
|
||||
optionen: eingabe.ziel.optionen,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
return { privatVerwendungId };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
|
||||
export async function assistentDaten() {
|
||||
const [hosts, vorlagen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
adresse: true,
|
||||
port: true,
|
||||
bootstrapAm: true,
|
||||
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.vorlage.findMany({
|
||||
where: { archiviertAm: null, kategorie: "DIENST" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
standardLaufzeitTage: true,
|
||||
zielbenutzer: true,
|
||||
zielbenutzerAnlegen: true,
|
||||
sudoModus: true,
|
||||
sudoBefehle: true,
|
||||
authorizedKeysOptionen: true,
|
||||
sshConfigAlias: true,
|
||||
knownHostsEintragen: true,
|
||||
},
|
||||
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
hosts: hosts
|
||||
.filter((h) => h.bootstrapAm !== null)
|
||||
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
|
||||
vorlagen,
|
||||
};
|
||||
}
|
||||
|
||||
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
|
||||
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
|
||||
*/
|
||||
export function zielkontoEingabePruefen(
|
||||
benutzer: string,
|
||||
modus: SudoModus,
|
||||
befehle: readonly string[],
|
||||
): void {
|
||||
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
|
||||
if (istGeschuetzt(benutzer))
|
||||
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||
sudoersInhalt(benutzer, modus, befehle);
|
||||
}
|
||||
|
||||
export async function zielkontoAnfordern(
|
||||
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
|
||||
akteur: { id: string },
|
||||
): Promise<string> {
|
||||
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
|
||||
return vorgangEinreihen({
|
||||
art: "ZIELKONTO_ANLEGEN",
|
||||
angefordertVonId: akteur.id,
|
||||
hostId: eingabe.hostId,
|
||||
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto";
|
||||
|
||||
/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */
|
||||
type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown };
|
||||
|
||||
function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } {
|
||||
const e = (roh ?? {}) as Eingabe;
|
||||
if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben.");
|
||||
const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER";
|
||||
const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : [];
|
||||
return { benutzer: e.benutzer, modus, befehle };
|
||||
}
|
||||
|
||||
async function akteurVon(id: string | null) {
|
||||
const benutzer = id
|
||||
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
|
||||
: null;
|
||||
return benutzer ?? ("System" as const);
|
||||
}
|
||||
|
||||
export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const konto = eingabeLesen(vorgang.eingabe);
|
||||
const verbindung = await hostVerbinden(host);
|
||||
let angelegt: boolean;
|
||||
try {
|
||||
({ angelegt } = await zielkontoAnwenden(verbindung, host, konto));
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle);
|
||||
const vorher = await prisma.zielkonto.findUnique({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||
});
|
||||
await prisma.zielkonto.upsert({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||
create: {
|
||||
hostId: host.id,
|
||||
benutzername: konto.benutzer,
|
||||
vonSshkvAngelegt: angelegt,
|
||||
sudoModus: konto.modus,
|
||||
sudoBefehle: konto.befehle,
|
||||
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||
sudoersInhalt: inhalt,
|
||||
},
|
||||
update: {
|
||||
// Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie.
|
||||
vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt,
|
||||
sudoModus: konto.modus,
|
||||
sudoBefehle: konto.befehle,
|
||||
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||
sudoersInhalt: inhalt,
|
||||
entferntAm: null,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||
aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: konto.benutzer,
|
||||
diff: inhalt ?? "(keine sudoers-Datei)",
|
||||
details: { sudoModus: konto.modus },
|
||||
});
|
||||
return { ergebnis: { angelegt, benutzer: konto.benutzer } };
|
||||
};
|
||||
|
||||
export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const { benutzer } = eingabeLesen(vorgang.eingabe);
|
||||
const konto = await prisma.zielkonto.findUnique({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } },
|
||||
});
|
||||
if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr).");
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
// Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat.
|
||||
await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt);
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } });
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||
aktion: "zielkonto.entfernt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: benutzer,
|
||||
details: { benutzerGeloescht: konto.vonSshkvAngelegt },
|
||||
});
|
||||
return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } };
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/
|
||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||
@@ -20,4 +21,6 @@ export const HANDLER: HandlerTabelle = {
|
||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||
VERBINDUNGSTEST: verbindungstestHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user