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>
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
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();
|
||
}
|