Merge branch 'phase/010-bootstrap'

This commit is contained in:
2026-10-12 09:52:00 +02:00
13 changed files with 602 additions and 3 deletions
+2
View File
@@ -27,6 +27,8 @@ SSHKV_MASTER_KEY_FILE=./geheim/master.key
# Betrieb: Datei auf dem Docker-Host, die als Secret nur in den Worker kommt. # Betrieb: Datei auf dem Docker-Host, die als Secret nur in den Worker kommt.
# Rechte: chown 1001:1001, chmod 400 (der Worker verweigert lesbare Schluessel). # Rechte: chown 1001:1001, chmod 400 (der Worker verweigert lesbare Schluessel).
SSHKV_MASTER_KEY_HOST_FILE=./geheim/master.key SSHKV_MASTER_KEY_HOST_FILE=./geheim/master.key
# Benutzer, unter dem der Worker laeuft (muss die Datei lesen duerfen). Standard 1001:1001.
# SSHKV_WORKER_USER=1001:1001
# --- OIDC (authentik) ---------------------------------------------------------- # --- OIDC (authentik) ----------------------------------------------------------
SSHKV_OIDC_ISSUER= SSHKV_OIDC_ISSUER=
+12
View File
@@ -91,6 +91,18 @@ pnpm worker # zweites Terminal
Oberflächen immer gegen den **gebauten Stand** prüfen (`pnpm build && pnpm start`) – siehe Oberflächen immer gegen den **gebauten Stand** prüfen (`pnpm build && pnpm start`) – siehe
Laufzettel: `next dev` hydriert in dieser Umgebung nicht zuverlässig. Laufzettel: `next dev` hydriert in dieser Umgebung nicht zuverlässig.
## Tests brauchen die Testdatenbank für sich allein
`tests/hilfen/db.ts` leert vor jedem Test alle Tabellen. Zwei gleichzeitige Läufe (z. B. ein
Sub-Agent und die Hauptsitzung) räumen sich gegenseitig die Daten weg – das sieht aus wie ein
echter Fehler („No record was found for an update“). Parallel Prüfende bekommen deshalb eine
**eigene** Datenbank:
```bash
docker exec sshkv-dev-db psql -U sshkv -c "create database sshkv_test_<name>"
TEST_DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv_test_<name>?schema=public pnpm test
```
## Vor jedem Commit ## Vor jedem Commit
```bash ```bash
+4
View File
@@ -4,6 +4,7 @@
# 2. Master-Key erzeugen (nur fuer den Worker lesbar, Container-Benutzer 1001): # 2. Master-Key erzeugen (nur fuer den Worker lesbar, Container-Benutzer 1001):
# mkdir -p geheim && head -c 32 /dev/urandom > geheim/master.key # mkdir -p geheim && head -c 32 /dev/urandom > geheim/master.key
# sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key # sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key
# (oder SSHKV_WORKER_USER auf den Eigentuemer der Datei setzen)
# 3. docker compose up -d # 3. docker compose up -d
# Nur im internen Netz/VPN betreiben und einen Reverse Proxy mit TLS davorsetzen (docs/betrieb). # Nur im internen Netz/VPN betreiben und einen Reverse Proxy mit TLS davorsetzen (docs/betrieb).
@@ -80,6 +81,9 @@ services:
container_name: sshkv-worker container_name: sshkv-worker
restart: unless-stopped restart: unless-stopped
command: ["./start.sh", "worker"] command: ["./start.sh", "worker"]
# Muss die Master-Key-Datei lesen duerfen (0400). Standard ist der Benutzer im Abbild;
# alternativ den Eigentuemer der Datei eintragen, z. B. SSHKV_WORKER_USER=1000:1000.
user: "${SSHKV_WORKER_USER:-1001:1001}"
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3400", "dev": "next dev -p 3400",
"build": "next build", "build": "next build",
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native --external:cpu-features --external:*.node", "build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@node-rs/argon2 --external:pg-native --external:cpu-features --external:*.node",
"start": "next start -p 3400", "start": "next start -p 3400",
"worker": "tsx src/worker/index.ts", "worker": "tsx src/worker/index.ts",
"lint": "eslint", "lint": "eslint",
+11
View File
@@ -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}$/;
+129
View File
@@ -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;
}
+8
View File
@@ -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;
+65
View File
@@ -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;
}
}
+35
View File
@@ -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,
});
}
+113
View File
@@ -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) } };
};
+3 -2
View File
@@ -1,11 +1,12 @@
import type { HandlerTabelle } from "@/worker/ausfuehren"; import type { HandlerTabelle } from "@/worker/ausfuehren";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
/** /**
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010), * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
* Ausrollen (#016), Abgleich (#021) …
*/ */
export const HANDLER: HandlerTabelle = { export const HANDLER: HandlerTabelle = {
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
ERREICHBARKEIT: erreichbarkeitHandler, ERREICHBARKEIT: erreichbarkeitHandler,
BOOTSTRAP: bootstrapHandler,
}; };
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { bootstrapBefehl, bootstrapSkript, sshdBefundLesen } from "@/lib/ssh/bootstrap-skript";
import { shellQuote } from "@/lib/shell";
/** Das Bootstrap-Skript als Text (#010); ausgefuehrt wird es im Integrationstest. */
const KEY =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl sshkv-deployment@x";
describe("Bootstrap-Skript", () => {
it("quotiert Shell-Werte sicher", () => {
expect(shellQuote("a'b")).toBe(`'a'\\''b'`);
expect(shellQuote("$(id)")).toBe(`'$(id)'`);
expect(() => shellQuote("a\0b")).toThrow();
});
it("weist ungueltige Benutzer und kaputte Keys ab", () => {
expect(() =>
bootstrapSkript({ benutzer: "root;id", dienstbenutzerAnlegen: false, deploymentKey: KEY }),
).toThrow();
expect(() =>
bootstrapSkript({
benutzer: "sshkv",
dienstbenutzerAnlegen: true,
deploymentKey: "ssh-ed25519 kaputt\nid",
}),
).toThrow();
});
it("setzt den Key quotiert ein und prueft sudoers vor dem Aktivieren", () => {
const skript = bootstrapSkript({ benutzer: "sshkv", dienstbenutzerAnlegen: true, deploymentKey: KEY });
expect(skript).toContain(`KEY='${KEY}'`);
expect(skript).toContain("visudo -cf");
expect(skript.indexOf("visudo -cf")).toBeLessThan(skript.indexOf('mv "$NEU_SUDO" /etc/sudoers.d/sshkv'));
expect(skript).toContain("runuser -u");
});
it("ergibt einen Befehl mit Here-Document, das nichts expandiert", () => {
const befehl = bootstrapBefehl({ benutzer: "root", dienstbenutzerAnlegen: false, deploymentKey: KEY });
expect(befehl.startsWith("sudo sh <<'SSHKV_BOOTSTRAP'\n")).toBe(true);
expect(befehl.trimEnd().endsWith("SSHKV_BOOTSTRAP")).toBe(true);
});
it("liest die sshd-Einstellungen aus der Ausgabe", () => {
expect(
sshdBefundLesen(
"SSHKV-SSHD:permitrootlogin without-password\nSSHKV-SSHD:passwordauthentication no\nanderes\nSSHKV-SSHD:authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2\n",
),
).toEqual({
permitRootLogin: "without-password",
passwordAuthentication: "no",
authorizedKeysFile: ".ssh/authorized_keys .ssh/authorized_keys2",
});
});
});
+165
View File
@@ -0,0 +1,165 @@
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 { verschluesseln } from "@/lib/geheimnisse";
import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { deploymentKeySicherstellen, masterKeyVergessen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
import { hostVerbinden } from "@/worker/ssh/host";
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
import { datenbankLeeren } from "../hilfen/db";
import { befehlAlsRoot, rootPasswort, testPasswort, zielB, zielBereit } from "./hilfen/ziele";
/** Bootstrap gegen ein echtes sshd (#010). Ziel B, damit #009 auf Ziel A ungestoert bleibt. */
let verzeichnis: string;
beforeAll(async () => {
await zielBereit(zielB);
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();
await deploymentKeySicherstellen();
// Jeder Test beginnt ohne Dienstbenutzer und mit leerer authorized_keys fuer root.
await befehlAlsRoot(
zielB,
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys*; true",
);
});
async function hostMitVertrautemKey(anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER") {
const host = await prisma.host.create({
data: { name: "ziel-b", adresse: zielB.host, port: zielB.port, anmeldeart },
});
const abruf = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId: host.id } });
await vorgangAusfuehren(abruf.id, HANDLER);
await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } });
return host;
}
async function bootstrap(hostId: string, benutzer: string, passwort: string) {
const vorgang = await prisma.vorgang.create({
data: { art: "BOOTSTRAP", hostId, eingabe: { modus: "passwort", benutzer } },
});
await vorgangAusfuehren(vorgang.id, HANDLER, verschluesseln(passwort));
return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } });
}
describe("Bootstrap per Passwort", () => {
it("legt Dienstbenutzer, sudoers und Deployment-Key an und meldet sich danach per Key an", async () => {
const host = await hostMitVertrautemKey();
const vorgang = await bootstrap(host.id, "root", rootPasswort());
expect(vorgang.fehler).toBeNull();
expect(vorgang.status).toBe("ERLEDIGT");
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(aktuell.bootstrapAm).not.toBeNull();
expect(aktuell.sshdBefund).toMatchObject({ permitRootLogin: "yes", passwordAuthentication: "yes" });
const verbindung = await hostVerbinden(aktuell);
expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("sshkv");
expect((await befehlAusfuehren(verbindung, "sudo -n id -un")).stdout.trim()).toBe("root");
const datei = await befehlAusfuehren(
verbindung,
"cat ~/.ssh/authorized_keys; stat -c %a ~/.ssh ~/.ssh/authorized_keys",
);
verbindung.end();
expect(datei.stdout).toMatch(
/^# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nssh-ed25519 \S+ sshkv-deployment@/,
);
expect(datei.stdout).toContain("# END SSHKV MANAGED\n700\n600");
const sudoers = await befehlAlsRoot(zielB, "stat -c %a /etc/sudoers.d/sshkv");
expect(sudoers.stdout.trim()).toBe("440");
});
it("ist wiederholbar, ohne den Key doppelt einzutragen, und behaelt fremde Zeilen", async () => {
const host = await hostMitVertrautemKey("ROOT");
await befehlAlsRoot(
zielB,
"mkdir -p /root/.ssh && echo 'ssh-ed25519 AAAAfremd admin@laptop' > /root/.ssh/authorized_keys",
);
expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT");
expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT");
const datei = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(datei.match(/sshkv-deployment@/g)).toHaveLength(1);
expect(datei.startsWith("ssh-ed25519 AAAAfremd admin@laptop\n")).toBe(true);
const sicherungen = await befehlAlsRoot(zielB, "ls /root/.ssh | grep -c sshkv.bak");
expect(Number(sicherungen.stdout.trim())).toBe(1);
});
it("geht auch ueber einen Benutzer mit sudo-Passwort", async () => {
const host = await hostMitVertrautemKey();
const vorgang = await bootstrap(host.id, "test", testPasswort());
expect(vorgang.fehler).toBeNull();
});
it("speichert das Passwort nirgends", async () => {
const host = await hostMitVertrautemKey();
const passwort = rootPasswort();
await bootstrap(host.id, "root", passwort);
const alles = JSON.stringify(
[await prisma.vorgang.findMany(), await prisma.protokoll.findMany(), await prisma.host.findMany()],
(_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert),
);
expect(alles).not.toContain(passwort);
});
it("scheitert mit falschem Passwort verstaendlich", async () => {
const host = await hostMitVertrautemKey();
const vorgang = await bootstrap(host.id, "root", "falsch");
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
expect(vorgang.fehler).toMatch(/Anmeldung wurde abgelehnt/);
});
it("folgt keinem Symlink im Home des Dienstbenutzers", async () => {
const host = await hostMitVertrautemKey();
await befehlAlsRoot(
zielB,
"useradd -m -s /bin/sh sshkv && mkdir -p /home/sshkv/.ssh && ln -s /etc/shadow /home/sshkv/.ssh/authorized_keys && chown -R sshkv: /home/sshkv/.ssh",
);
const vorher = (await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout;
const vorgang = await bootstrap(host.id, "root", rootPasswort());
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
expect((await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout).toBe(vorher);
const kopie = await befehlAlsRoot(
zielB,
"cat /home/sshkv/.ssh/*.sshkv.* 2>/dev/null | grep -c root: || true",
);
expect(kopie.stdout.trim()).toBe("0");
});
});
describe("Bootstrap per Befehl", () => {
it("funktioniert mit dem angezeigten Befehl und anschliessender Pruefung", async () => {
const host = await hostMitVertrautemKey();
const deployment = await prisma.schluessel.findFirstOrThrow({ where: { kategorie: "DEPLOYMENT" } });
const befehl = bootstrapBefehl({
benutzer: "sshkv",
dienstbenutzerAnlegen: true,
deploymentKey: deployment.publicKey,
});
const ausgabe = await befehlAlsRoot(zielB, befehl);
expect(ausgabe.stdout).toContain("SSHKV-BOOTSTRAP-OK");
const pruefung = await prisma.vorgang.create({
data: { art: "BOOTSTRAP", hostId: host.id, eingabe: { modus: "pruefen" } },
});
await vorgangAusfuehren(pruefung.id, HANDLER);
const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: pruefung.id } });
expect(fertig.fehler).toBeNull();
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).bootstrapAm).not.toBeNull();
});
});