Merge branch 'phase/010-bootstrap'
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Quotierung fuer POSIX-sh (#010). Alles, was in Skripte auf den Hosts eingesetzt wird,
|
||||
* geht hier durch – auch Werte, die die Datenbank schon per CHECK eingeschraenkt hat.
|
||||
*/
|
||||
export function shellQuote(wert: string): string {
|
||||
if (wert.includes("\0")) throw new Error("Nullbyte in einem Shell-Wert.");
|
||||
return `'${wert.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Benutzernamen, wie sie auch die Datenbank erlaubt (CHECK *_zielbenutzer_gueltig). */
|
||||
export const BENUTZERNAME_MUSTER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell";
|
||||
import {
|
||||
BLOCK_ANFANG,
|
||||
BLOCK_ANFANG_PRAEFIX,
|
||||
BLOCK_ENDE,
|
||||
BLOCK_ENDE_PRAEFIX,
|
||||
} from "@/lib/ssh/verwalteter-block";
|
||||
import { publicKeyZeileLesen } from "@/lib/ssh/openssh";
|
||||
|
||||
/**
|
||||
* Das Bootstrap-Skript (#010): bringt den Deployment-Key auf einen neuen Host.
|
||||
*
|
||||
* Ein Skript fuer beide Wege – der Worker fuehrt es nach der einmaligen Passwortanmeldung
|
||||
* aus, und dasselbe Skript steht als Befehl zum Kopieren in der Oberflaeche. So gibt es
|
||||
* keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host veraendert.
|
||||
*
|
||||
* Laeuft als root und ist idempotent:
|
||||
* - Dienstbenutzer `sshkv` anlegen (ohne Passwort, nur Schluessel) und
|
||||
* /etc/sudoers.d/sshkv schreiben – erst nach `visudo -c`, und waehrend der Pruefung unter
|
||||
* einem Namen mit Punkt, den sudo ignoriert.
|
||||
* - Den Deployment-Key in den verwalteten Block der authorized_keys eintragen. Gelesen und
|
||||
* geschrieben wird **als Zielbenutzer** (`runuser`): ein Symlink im Home eines Benutzers
|
||||
* koennte root sonst eine fremde Datei lesen oder ueberschreiben lassen.
|
||||
* - Vorher ein Backup, geschrieben ueber Temp-Datei und `mv` (atomar), Rechte 0700/0600.
|
||||
* - Am Ende die sshd-Einstellungen ausgeben, die die Oberflaeche anzeigt.
|
||||
*/
|
||||
export type BootstrapOptionen = {
|
||||
/** `sshkv` (Dienstbenutzer) oder `root`. */
|
||||
readonly benutzer: string;
|
||||
readonly dienstbenutzerAnlegen: boolean;
|
||||
/** Public-Key-Zeile des Deployment-Keys. */
|
||||
readonly deploymentKey: string;
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_ERFOLG = "SSHKV-BOOTSTRAP-OK";
|
||||
export const SSHD_MARKE = "SSHKV-SSHD:";
|
||||
|
||||
export function bootstrapSkript(optionen: BootstrapOptionen): string {
|
||||
if (!BENUTZERNAME_MUSTER.test(optionen.benutzer)) throw new Error("Ungültiger Benutzername.");
|
||||
// Nur eine einwandfreie Public-Key-Zeile wird eingesetzt; der Parser lehnt Steuerzeichen ab.
|
||||
const key = publicKeyZeileLesen(optionen.deploymentKey);
|
||||
const zeile = `${key.typ} ${key.blob.toString("base64")}${key.kommentar ? ` ${key.kommentar}` : ""}`;
|
||||
|
||||
return `#!/bin/sh
|
||||
# SSHKeyVault – Bootstrap. Fuehrt aus: Dienstbenutzer/sudoers (falls gewaehlt) und
|
||||
# Deployment-Key im verwalteten Block von authorized_keys. Idempotent.
|
||||
set -eu
|
||||
umask 077
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
BENUTZER=${shellQuote(optionen.benutzer)}
|
||||
KEY=${shellQuote(zeile)}
|
||||
DIENSTBENUTZER=${optionen.dienstbenutzerAnlegen ? "1" : "0"}
|
||||
|
||||
if [ "$(id -u)" != 0 ]; then echo "Bitte als root ausfuehren." >&2; exit 1; fi
|
||||
|
||||
if [ "$DIENSTBENUTZER" = 1 ]; then
|
||||
if ! id -u "$BENUTZER" >/dev/null 2>&1; then
|
||||
# Ohne Passwort: useradd setzt ein gesperrtes Passwort, Anmeldung nur per Schluessel.
|
||||
useradd --create-home --shell /bin/sh --comment SSHKeyVault "$BENUTZER"
|
||||
fi
|
||||
NEU_SUDO=$(mktemp /etc/sudoers.d/.sshkv.XXXXXX)
|
||||
printf '%s\\n' '# Verwaltet von SSHKeyVault - nicht manuell bearbeiten' "$BENUTZER ALL=(ALL:ALL) NOPASSWD: ALL" > "$NEU_SUDO"
|
||||
chmod 0440 "$NEU_SUDO"
|
||||
if ! visudo -cf "$NEU_SUDO" >/dev/null; then rm -f "$NEU_SUDO"; echo "sudoers-Pruefung fehlgeschlagen." >&2; exit 1; fi
|
||||
mv "$NEU_SUDO" /etc/sudoers.d/sshkv
|
||||
fi
|
||||
|
||||
HEIM=$(getent passwd "$BENUTZER" | cut -d: -f6)
|
||||
if [ -z "$HEIM" ] || [ ! -d "$HEIM" ]; then echo "Kein Home-Verzeichnis fuer $BENUTZER." >&2; exit 1; fi
|
||||
als() { if [ "$BENUTZER" = root ]; then "$@"; else runuser -u "$BENUTZER" -- "$@"; fi; }
|
||||
|
||||
als mkdir -p "$HEIM/.ssh"
|
||||
als chmod 0700 "$HEIM/.ssh"
|
||||
DATEI="$HEIM/.ssh/authorized_keys"
|
||||
[ -e "$DATEI" ] || als touch "$DATEI"
|
||||
|
||||
if ! als cat "$DATEI" | grep -qxF "$KEY"; then
|
||||
als cp -p "$DATEI" "$DATEI.sshkv.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
NEU=$(als mktemp "$HEIM/.ssh/.sshkv.tmp.XXXXXX")
|
||||
als cat "$DATEI" | tr -d '\\r' | awk -v key="$KEY" \
|
||||
-v anfang=${shellQuote(BLOCK_ANFANG)} -v ende=${shellQuote(BLOCK_ENDE)} \
|
||||
-v pa=${shellQuote(BLOCK_ANFANG_PRAEFIX)} -v pe=${shellQuote(BLOCK_ENDE_PRAEFIX)} '
|
||||
index($0, pa) == 1 { print; drin = 1; next }
|
||||
index($0, pe) == 1 { if (drin && !fertig) { print key; fertig = 1 } drin = 0; print; next }
|
||||
{ print }
|
||||
END { if (!fertig) { print anfang; print key; print ende } }
|
||||
' | als tee "$NEU" >/dev/null
|
||||
als chmod 0600 "$NEU"
|
||||
als mv "$NEU" "$DATEI"
|
||||
fi
|
||||
als chmod 0600 "$DATEI"
|
||||
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authorizedkeysfile) ' | sed 's/^/${SSHD_MARKE}/' || true
|
||||
fi
|
||||
echo ${BOOTSTRAP_ERFOLG}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Fuer die Oberflaeche: zum Einfuegen in eine root-Shell auf dem Host. */
|
||||
export function bootstrapBefehl(optionen: BootstrapOptionen): string {
|
||||
return `sudo sh <<'SSHKV_BOOTSTRAP'\n${bootstrapSkript(optionen)}SSHKV_BOOTSTRAP\n`;
|
||||
}
|
||||
|
||||
export type SshdBefund = {
|
||||
readonly permitRootLogin?: string;
|
||||
readonly passwordAuthentication?: string;
|
||||
readonly pubkeyAuthentication?: string;
|
||||
readonly authorizedKeysFile?: string;
|
||||
};
|
||||
|
||||
/** Liest die sshd-Zeilen aus der Ausgabe des Skripts. */
|
||||
export function sshdBefundLesen(ausgabe: string): SshdBefund {
|
||||
const befund: Record<string, string> = {};
|
||||
const namen: Record<string, keyof SshdBefund> = {
|
||||
permitrootlogin: "permitRootLogin",
|
||||
passwordauthentication: "passwordAuthentication",
|
||||
pubkeyauthentication: "pubkeyAuthentication",
|
||||
authorizedkeysfile: "authorizedKeysFile",
|
||||
};
|
||||
for (const zeile of ausgabe.split("\n")) {
|
||||
if (!zeile.startsWith(SSHD_MARKE)) continue;
|
||||
const [schluessel, ...rest] = zeile.slice(SSHD_MARKE.length).trim().split(/\s+/);
|
||||
const name = schluessel ? namen[schluessel.toLowerCase()] : undefined;
|
||||
if (name) befund[name] = rest.join(" ");
|
||||
}
|
||||
return befund;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Marker des verwalteten Blocks in authorized_keys (Plan, „Zuweisungen & Abgleich“).
|
||||
* Erkannt wird am Praefix, damit ein Block mit verändertem Zusatztext trotzdem gefunden wird.
|
||||
*/
|
||||
export const BLOCK_ANFANG_PRAEFIX = "# BEGIN SSHKV MANAGED";
|
||||
export const BLOCK_ENDE_PRAEFIX = "# END SSHKV MANAGED";
|
||||
export const BLOCK_ANFANG = `${BLOCK_ANFANG_PRAEFIX} – nicht manuell bearbeiten`;
|
||||
export const BLOCK_ENDE = BLOCK_ENDE_PRAEFIX;
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Bootstrap starten (#010). Das Passwort verlaesst diese Funktion nur verschluesselt im Job
|
||||
* und steht nie in `Vorgang.eingabe`, im Protokoll oder in einem Log.
|
||||
*/
|
||||
export type BootstrapAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
|
||||
const passwortSchema = z.object({
|
||||
hostId: z.string().min(1).max(64),
|
||||
benutzer: z.string().regex(BENUTZERNAME_MUSTER, "Ungültiger Benutzername."),
|
||||
passwort: z.string().min(1).max(1024),
|
||||
});
|
||||
|
||||
export async function bootstrapPerPasswortAktion(
|
||||
eingabe: z.input<typeof passwortSchema>,
|
||||
): Promise<BootstrapAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
const daten = passwortSchema.safeParse(eingabe);
|
||||
if (!daten.success) return { fehler: daten.error.issues[0]?.message ?? "Eingaben unvollständig." };
|
||||
const vorgangId = await vorgangEinreihen({
|
||||
art: "BOOTSTRAP",
|
||||
angefordertVonId: benutzer.id,
|
||||
hostId: daten.data.hostId,
|
||||
eingabe: { modus: "passwort", benutzer: daten.data.benutzer },
|
||||
geheim: verschluesseln(daten.data.passwort),
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "host.bootstrap.angefordert",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { hostId: daten.data.hostId, anmeldung: daten.data.benutzer, art: "passwort" },
|
||||
});
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapPruefenAktion(hostId: string): Promise<BootstrapAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
const id = z.string().min(1).max(64).parse(hostId);
|
||||
return {
|
||||
vorgangId: await vorgangEinreihen({
|
||||
art: "BOOTSTRAP",
|
||||
angefordertVonId: benutzer.id,
|
||||
hostId: id,
|
||||
eingabe: { modus: "pruefen" },
|
||||
}),
|
||||
};
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript";
|
||||
|
||||
/**
|
||||
* Der Befehl zum Kopieren (#010). Er enthaelt nur den Public Key des Deployment-Keys und
|
||||
* darf deshalb in die Oberflaeche.
|
||||
*/
|
||||
export async function bootstrapBefehlFuer(hostId: string): Promise<string | undefined> {
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId },
|
||||
select: {
|
||||
anmeldeart: true,
|
||||
deploymentSchluessel: { select: { publicKey: true } },
|
||||
gruppen: {
|
||||
select: { hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!host) return undefined;
|
||||
const standard = await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { publicKey: true },
|
||||
});
|
||||
// Dieselbe Reihenfolge wie im Worker (deploymentKeyIdFuer): Host, Gruppe, Instanz.
|
||||
const publicKey =
|
||||
host.deploymentSchluessel?.publicKey ??
|
||||
host.gruppen.find((g) => g.hostgruppe.deploymentSchluessel)?.hostgruppe.deploymentSchluessel?.publicKey ??
|
||||
standard?.publicKey;
|
||||
if (!publicKey) return undefined;
|
||||
return bootstrapBefehl({
|
||||
benutzer: host.anmeldeart === "ROOT" ? "root" : "sshkv",
|
||||
dienstbenutzerAnlegen: host.anmeldeart === "DIENSTBENUTZER",
|
||||
deploymentKey: publicKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { BOOTSTRAP_ERFOLG, bootstrapSkript, sshdBefundLesen, SSHD_MARKE } from "@/lib/ssh/bootstrap-skript";
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||
import { anmeldebenutzer, deploymentKeyIdFuer, hostVerbinden } from "@/worker/ssh/host";
|
||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* Bootstrap eines Hosts (#010).
|
||||
*
|
||||
* - `passwort`: einmalige Passwortanmeldung, Skript ausfuehren. Das Passwort kommt
|
||||
* verschluesselt im Job (nie in `Vorgang.eingabe`), lebt nur fuer diesen Lauf im Speicher
|
||||
* und wird nirgends geschrieben; der Worker loescht den Job danach.
|
||||
* - `pruefen`: nach dem Copy-Paste-Weg nur nachsehen, ob der Deployment-Key greift.
|
||||
*
|
||||
* In beiden Faellen endet der Vorgang mit einer Anmeldung per Deployment-Key – erst die
|
||||
* beweist, dass sshkv ab jetzt ohne Passwort arbeiten kann.
|
||||
*/
|
||||
type Eingabe = { modus: "passwort" | "pruefen"; benutzer?: string };
|
||||
|
||||
function eingabeLesen(roh: unknown): Eingabe {
|
||||
const wert = (roh ?? {}) as Partial<Eingabe>;
|
||||
if (wert.modus !== "passwort" && wert.modus !== "pruefen")
|
||||
throw new VorgangsFehler("Unbekannte Bootstrap-Art.");
|
||||
if (wert.modus === "passwort" && (!wert.benutzer || !BENUTZERNAME_MUSTER.test(wert.benutzer))) {
|
||||
throw new VorgangsFehler("Ungültiger Benutzer für die Passwortanmeldung.");
|
||||
}
|
||||
return { modus: wert.modus, ...(wert.benutzer ? { benutzer: wert.benutzer } : {}) };
|
||||
}
|
||||
|
||||
async function skriptPerPasswort(host: Host, benutzer: string, passwort: string): Promise<string> {
|
||||
const deploymentKey = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: await deploymentKeyIdFuer(host) },
|
||||
select: { publicKey: true },
|
||||
});
|
||||
const skript = bootstrapSkript({
|
||||
benutzer: anmeldebenutzer(host),
|
||||
dienstbenutzerAnlegen: host.anmeldeart === "DIENSTBENUTZER",
|
||||
deploymentKey: deploymentKey.publicKey,
|
||||
});
|
||||
|
||||
const verbindung = await hostVerbinden(host, { benutzer, passwort });
|
||||
try {
|
||||
// Als root direkt; sonst ueber sudo, das Passwort liest sudo als erste Zeile von stdin.
|
||||
const ergebnis =
|
||||
benutzer === "root"
|
||||
? await befehlAusfuehren(verbindung, "sh -s", skript)
|
||||
: await befehlAusfuehren(verbindung, "sudo -S -p '' sh -s", `${passwort}\n${skript}`);
|
||||
if (ergebnis.code !== 0 || !ergebnis.stdout.includes(BOOTSTRAP_ERFOLG)) {
|
||||
// stderr des Skripts ist von uns formuliert; die erste Zeile genuegt als Hinweis.
|
||||
const grund = ergebnis.stderr.split("\n").find((zeile) => zeile.trim()) ?? `Exit-Code ${ergebnis.code}`;
|
||||
throw new VorgangsFehler(`Bootstrap-Skript fehlgeschlagen: ${grund.slice(0, 200)}`);
|
||||
}
|
||||
return ergebnis.stdout;
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Meldet sich per Deployment-Key an und prueft sudo; liefert die sshd-Ausgabe. */
|
||||
async function mitDeploymentKeyPruefen(host: Host): Promise<string> {
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
const wer = await befehlAusfuehren(verbindung, "id -un");
|
||||
if (wer.stdout.trim() !== anmeldebenutzer(host)) {
|
||||
throw new VorgangsFehler("Anmeldung per Deployment-Key landete beim falschen Benutzer.");
|
||||
}
|
||||
const sudo = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||
if (sudo) {
|
||||
const probe = await befehlAusfuehren(verbindung, "sudo -n true");
|
||||
if (probe.code !== 0)
|
||||
throw new VorgangsFehler("Der Dienstbenutzer darf sudo nicht ohne Passwort ausführen.");
|
||||
}
|
||||
const sshd = await befehlAusfuehren(
|
||||
verbindung,
|
||||
`${sudo}sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authorizedkeysfile) ' | sed 's/^/${SSHD_MARKE}/'`,
|
||||
);
|
||||
return sshd.stdout;
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
}
|
||||
|
||||
export const bootstrapHandler: Handler = async ({ vorgang, geheim }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const eingabe = eingabeLesen(vorgang.eingabe);
|
||||
|
||||
let ausgabe = "";
|
||||
if (eingabe.modus === "passwort") {
|
||||
if (!geheim) throw new VorgangsFehler("Das Passwort fehlt – bitte den Bootstrap erneut starten.");
|
||||
ausgabe = await skriptPerPasswort(host, eingabe.benutzer!, entschluesseln(geheim));
|
||||
}
|
||||
const pruefung = await mitDeploymentKeyPruefen(host);
|
||||
const befund = sshdBefundLesen(`${ausgabe}\n${pruefung}`);
|
||||
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: { bootstrapAm: new Date(), erreichbar: true, zuletztGeprueftAm: new Date(), sshdBefund: befund },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: eingabe.modus === "passwort" ? "host.bootstrap" : "host.bootstrap.geprueft",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: anmeldebenutzer(host),
|
||||
details: { ...befund, anmeldung: eingabe.benutzer ?? null },
|
||||
});
|
||||
return { ergebnis: { sshd: befund, benutzer: anmeldebenutzer(host) } };
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010),
|
||||
* Ausrollen (#016), Abgleich (#021) …
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
|
||||
*/
|
||||
export const HANDLER: HandlerTabelle = {
|
||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||
BOOTSTRAP: bootstrapHandler,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user