Files
florian.wildeandClaude Opus 5.5 263143134f feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:52 +02:00

223 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { PrismaPg } from "@prisma/adapter-pg";
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma/client";
import { PASSWORT_MINDESTLAENGE, passwortHashen } from "../src/lib/passwort-hash";
import { EINGEBAUTE_VORLAGEN } from "./vorlagen";
/**
* Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen; optional Demo-Hosts (#008).
*
* Laeuft bei jedem Start des migrate-Dienstes und ist deshalb idempotent. Ein bestehender
* Notfall-Admin wird nie angefasst – sonst setzte jeder Neustart ein in der Oberflaeche
* geaendertes Passwort zurueck.
*/
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }) });
async function notfallAdmin(): Promise<void> {
const anmeldename = process.env["SSHKV_BREAKGLASS_USER"]?.trim() || "notfall";
const passwort = process.env["SSHKV_BREAKGLASS_PASSWORD"] ?? "";
if (await prisma.benutzer.findFirst({ where: { istNotfall: true } })) {
console.log("Notfall-Admin vorhanden – unverändert.");
return;
}
if (passwort.length < PASSWORT_MINDESTLAENGE) {
console.warn(
`Kein Notfall-Admin angelegt: SSHKV_BREAKGLASS_PASSWORD fehlt oder ist kürzer als ${PASSWORT_MINDESTLAENGE} Zeichen.`,
);
return;
}
await prisma.benutzer.create({
data: {
anmeldename,
name: "Notfall-Admin",
rolle: "ADMIN",
istNotfall: true,
passwortHash: await passwortHashen(passwort),
},
});
console.log(`Notfall-Admin „${anmeldename}“ angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.`);
}
async function vorlagen(): Promise<void> {
for (const vorlage of EINGEBAUTE_VORLAGEN) {
await prisma.vorlage.upsert({ where: { name: vorlage.name }, create: vorlage, update: {} });
}
console.log(`${EINGEBAUTE_VORLAGEN.length} eingebaute Vorlagen geprüft.`);
}
/**
* Demo-Hosts fuer Entwicklung, Screenshots und Handbuch (#008) – nur mit SSHKV_SEED_DEMO=1,
* damit eine echte Instanz nie Phantom-Hosts bekommt. Upsert nach Name: ein zweiter Lauf
* aendert nichts, und in der Oberflaeche geaenderte Demo-Hosts bleiben, wie sie sind.
* Bewusst ohne Protokolleintraege: Seed-Daten sind keine Handlung eines Menschen.
*/
const DEMO_GRUPPEN = [
{ name: "Proxmox", beschreibung: "Virtualisierungs-Cluster im Rechenzentrum" },
{ name: "DMZ", beschreibung: "Von außen erreichbare Systeme" },
{ name: "Kunde X", beschreibung: "Systeme, die wir für Kunde X betreiben" },
] as const;
const DEMO_HOSTS: {
name: string;
adresse: string;
port?: number;
tags: string[];
beschreibung?: string;
anmeldeart?: "DIENSTBENUTZER" | "ROOT";
kunde?: string;
gruppen: string[];
}[] = [
{ name: "srv-pve01", adresse: "10.0.10.11", tags: ["pve", "produktion"], gruppen: ["Proxmox"] },
{ name: "srv-pve02", adresse: "10.0.10.12", tags: ["pve", "produktion"], gruppen: ["Proxmox"] },
{
name: "srv-backup01",
adresse: "10.0.10.30",
tags: ["backup"],
beschreibung: "Proxmox Backup Server, zieht nachts von pve01/pve02",
gruppen: ["Proxmox"],
},
{
name: "srv-app01",
adresse: "app01.kunde-x.example",
port: 2222,
tags: ["web"],
kunde: "Kunde X",
gruppen: ["Kunde X"],
},
{
name: "fw-edge01",
adresse: "192.168.1.1",
tags: ["firewall"],
anmeldeart: "ROOT",
beschreibung: "Appliance ohne sudo – Anmeldung direkt als root",
gruppen: ["DMZ"],
},
{ name: "srv-rz-claude-linux", adresse: "fd00:10::42", tags: ["test"], gruppen: [] },
];
async function demoDaten(): Promise<void> {
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
const gruppen = new Map<string, string>();
for (const g of DEMO_GRUPPEN) {
const gruppe = await prisma.hostgruppe.upsert({ where: { name: g.name }, create: g, update: {} });
gruppen.set(g.name, gruppe.id);
}
for (const h of DEMO_HOSTS) {
const kunde = h.kunde
? await prisma.kunde.upsert({ where: { name: h.kunde }, create: { name: h.kunde }, update: {} })
: null;
await prisma.host.upsert({
where: { name: h.name },
update: {},
create: {
name: h.name,
adresse: h.adresse,
port: h.port ?? 22,
tags: h.tags,
beschreibung: h.beschreibung ?? null,
anmeldeart: h.anmeldeart ?? "DIENSTBENUTZER",
kundeId: kunde?.id ?? null,
gruppen: { create: h.gruppen.map((name) => ({ hostgruppeId: gruppen.get(name) ?? "" })) },
},
});
}
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
}
/**
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
* verworfen – es sind Attrappen, die nirgends Zugang geben.
*/
const DEMO_PERSONEN = [
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
] as const;
const TAG_MS = 24 * 60 * 60 * 1000;
const DEMO_SCHLUESSEL: {
name: string;
slug: string;
kategorie: "PERSON" | "DIENST";
person?: string;
tags: string[];
ablaufInTagen?: number;
kommentar: string;
}[] = [
{
name: "Laptop Florian",
slug: "laptop-florian",
kategorie: "PERSON",
person: "Florian Wilde",
tags: ["laptop"],
kommentar: "florian@laptop",
},
{
name: "Wartungszugang Netzwerk GmbH",
slug: "wartung-netzwerk-gmbh",
kategorie: "PERSON",
person: "Anna Berger",
tags: ["dienstleister"],
ablaufInTagen: 18,
kommentar: "anna@netzwerk-gmbh",
},
{
name: "Workstation Mehmet",
slug: "workstation-mehmet",
kategorie: "PERSON",
person: "Mehmet Yılmaz",
tags: [],
ablaufInTagen: 200,
kommentar: "mehmet@ws-07",
},
{
name: "Monitoring zabbix → alle Hosts",
slug: "monitoring-zabbix",
kategorie: "DIENST",
tags: ["monitoring"],
kommentar: "zabbix@srv-mon01",
},
];
async function demoSchluessel(): Promise<void> {
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
const personen = new Map<string, string>();
for (const p of DEMO_PERSONEN) {
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
personen.set(p.name, person.id);
}
for (const s of DEMO_SCHLUESSEL) {
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
await prisma.schluessel.create({
data: {
name: s.name,
slug: s.slug,
typ: "ED25519",
kategorie: s.kategorie,
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: s.kommentar,
tags: s.tags,
personId: s.person ? (personen.get(s.person) ?? null) : null,
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
},
select: { id: true },
});
}
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
}
try {
await notfallAdmin();
await vorlagen();
await demoDaten();
await demoSchluessel();
} finally {
await prisma.$disconnect();
}