diff --git a/docs/issues/005-adapter-profile.md b/docs/issues/005-adapter-profile.md index 0a03805..3f7914d 100644 --- a/docs/issues/005-adapter-profile.md +++ b/docs/issues/005-adapter-profile.md @@ -1,23 +1,31 @@ # #005 Adapter debian und Profil-Engine -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** feature, engine -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min ## Ziel -Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript. +Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript. ## Aufgaben -- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d) -- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting) -- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional) -- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe +- [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung) +- [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung) +- [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet +- [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional) +- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen +- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz +- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil ## Akzeptanzkriterien -- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code +- [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code +- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12) ## Abhängigkeiten #004 ## Notizen / Entscheidungen -– +- **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“. +- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck). +- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus. +- **Marken `::proxdeploy:: profil `** im Skript zeigen im Live-Log, welches Profil gerade läuft. +- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an. diff --git a/docs/issues/README.md b/docs/issues/README.md index cc1efdc..bf12b2b 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -8,7 +8,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min | | [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min | | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min | -| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – | +| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min | | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – | | [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | diff --git a/docs/katalog.md b/docs/katalog.md new file mode 100644 index 0000000..9c19d51 --- /dev/null +++ b/docs/katalog.md @@ -0,0 +1,123 @@ +# Katalog: Distributionen und Profile + +Distributionen und Profile sind Daten, kein Code. Jede liegt als YAML-Datei unter `catalog/` und wird +beim Start von ProxDeploy in die Datenbank abgeglichen. Die Datei ist maßgeblich für den Inhalt; in der +Oberfläche (*Katalog*) lassen sich Einträge nur aktivieren und deaktivieren. Eine fehlerhafte Datei +wird mit Dateiname und Fehlerstelle im Log gemeldet und übersprungen. + +Regeln für beide Arten: + +- Dateiname = `id` + `.yaml` (`catalog/distros/debian-13.yaml` → `id: debian-13`) +- unbekannte Schlüssel sind ein Fehler – ein Tippfehler fällt so sofort auf +- nur HTTPS-Quellen + +## Neue Distribution hinzufügen + +Beispiel Debian 13: + +```yaml +id: debian-13 +name: Debian 13 „Trixie“ +family: debian # wählt den Provisionierungs-Adapter +os: + id: debian # für Platzhalter in Profilen: {{ os.id }} + version_id: "13" # {{ os.version_id }} – in Anführungszeichen, sonst wird 22.04 zur Zahl + codename: trixie # {{ os.codename }} +arch: amd64 +vm: + image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 + checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS + checksum_type: sha512 # sha256 | sha512 + format: qcow2 # qcow2 | raw | vmdk + default_user: debian + guest_agent_preinstalled: false # sonst baut ProxDeploy ihn beim Template-Bau ein + bios: ovmf # ovmf (UEFI, Standard) | seabios + min_disk_gb: 4 +lxc: + template_pattern: "debian-13-standard_*_amd64.tar.zst" # wie in `pveam available` + ostype: debian +``` + +`vm` oder `lxc` darf fehlen, wenn die Distribution nur eins davon kann. Bei mehreren passenden +LXC-Templates gewinnt die höchste Version. Die Prüfsummen-Datei darf im GNU-Format (` ` +oder ` *`) oder im BSD-Format (`SHA256 () = `) vorliegen. + +Gehört die Distribution zu einer Familie, für die es noch keinen Adapter gibt (etwa `rhel` für Rocky +oder Alma, `alpine`), braucht es zusätzlich einen Adapter: + +1. `src/engine/adapter/.ts` anlegen, das Interface `FamilienAdapter` aus + `src/engine/adapter/schnittstelle.ts` umsetzen (Pakete, Dienste, Paketquellen, Zeitzone, Locale, + Tastatur, Benutzer, SSH-Härtung, Guest-Agent, automatische Updates) +2. in `src/engine/adapter/index.ts` eintragen +3. Tests nach dem Muster von `tests/engine/profile.test.ts`, dazu ein Image in + `tests/container/profile-pruefen.sh` +4. in den Profilen unter `families:` eine Umsetzung für die neue Familie ergänzen – Profile ohne + Umsetzung erscheinen im Assistenten ausgegraut + +## Eigenes Profil anlegen + +Ein Profil ist eine Liste von Schritten je Distributionsfamilie. Der Adapter übersetzt sie in ein +Shell-Skript, das bei VMs über den Guest-Agent und bei Containern per SSH läuft. + +```yaml +id: nginx +name: nginx +description: Webserver mit Startseite +requires: [base] # wird automatisch davor ausgeführt +variables: + - { name: titel, label: Seitentitel, default: Willkommen } + - { name: tls, label: Zertifikat per certbot, type: boolean, default: false } +lxc: + features: { nesting: false } # Container-Features, die das Profil braucht +families: + debian: + - packages: [nginx] + - file: + path: /var/www/html/index.html + content: | +

{{ var.titel }}

+

{{ guest.name }} · {{ os.id }} {{ os.version_id }}

+ - service: { name: nginx, enable: true, start: true } + - packages: [certbot, python3-certbot-nginx] + if: tls +``` + +### Schritte + +| Schritt | Wirkung | +|---|---| +| `update: true` | Paketlisten aktualisieren und alle Pakete aktualisieren | +| `packages: [a, b]` | Pakete installieren | +| `service: { name, enable, start, restart }` | Dienst aktivieren, starten oder neu starten | +| `repo: { name, key_url, uris, suites, components }` | Paketquelle mit Schlüssel einrichten (deb822) | +| `repo_package: ` | ein Paket installieren, das selbst eine Quelle einrichtet (z. B. zabbix-release) | +| `file: { path, content, mode }` | Datei schreiben (Inhalt wird unverändert übertragen) | +| `timezone`, `locale`, `keyboard` | Zeitzone, Sprache, Tastaturbelegung | +| `ssh_harden: { password_auth, root_login }` | SSH absichern – läuft immer ganz am Ende | +| `guest_agent: true` | QEMU-Guest-Agent (in Containern übersprungen) | +| `unattended_upgrades: true` | automatische Sicherheitsupdates | +| `run: ` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt | + +Jeder Schritt kann `if: ` (oder `if: "!"`) tragen und läuft dann nur, wenn die +Variable wahr bzw. falsch ist. + +### Platzhalter + +`{{ var. }}` (Profil-Variable), `{{ os.id }}`, `{{ os.version_id }}`, `{{ os.codename }}`, +`{{ guest.name }}`, `{{ guest.user }}`. `{{ var.a ?? var.b }}` nimmt `b`, wenn `a` leer ist. Ein +unbekannter Platzhalter ist ein Fehler, kein leerer Text. + +In `run`-Schritten wird jeder eingesetzte Wert für die Shell gequotet – eine Eingabe im Formular kann +so nie zu Code werden. Variablen dürfen keine Zeilenumbrüche enthalten. + +### Variablentypen + +`string` (Standard), `number`, `boolean`, `select` (mit `options: [...]`). `required: true` macht +das Feld im Assistenten zur Pflicht; `default` füllt es vor; `help` erscheint darunter. + +### Prüfen + +```bash +pnpm exec tsx scripts/profil-rendern.ts debian-13 nginx titel=Hallo # Skript ansehen +pnpm test:container debian-13 # wirklich ausführen +``` diff --git a/scripts/profil-rendern.ts b/scripts/profil-rendern.ts new file mode 100644 index 0000000..af62f96 --- /dev/null +++ b/scripts/profil-rendern.ts @@ -0,0 +1,42 @@ +/** + * Rendert Profile für eine Distribution auf stdout – zum Ansehen und für die Integrationsprüfung + * in einem Container: `pnpm exec tsx scripts/profil-rendern.ts debian-13 base,docker server=zbx.local` + */ +import { adapterFuer } from "@/engine/adapter"; +import { katalogLaden } from "@/engine/katalog/laden"; +import { profileAufloesen } from "@/engine/profile/aufloesen"; +import { profileRendern } from "@/engine/profile/rendern"; + +const [distroId = "debian-13", auswahl = "base", ...zuweisungen] = process.argv.slice(2); +const katalog = await katalogLaden("catalog"); +const distro = katalog.distributionen.find((d) => d.eintrag.id === distroId)?.eintrag; +if (!distro) throw new Error(`Unbekannte Distribution ${distroId}`); +const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])); +const eingaben: Record> = {}; +for (const z of zuweisungen) { + const [name, wert] = z.split("="); + for (const p of profile.values()) + if (p.variables.some((v) => v.name === name)) (eingaben[p.id] ??= {})[name!] = wert!; +} +const aufloesung = profileAufloesen(auswahl.split(","), profile, distro.family, eingaben); +if (!aufloesung.ok) { + console.error(aufloesung.fehler); + process.exit(1); +} +const adapter = adapterFuer(distro.family)!; +const { skript } = profileRendern( + aufloesung.profile, + adapter, + { + os: distro.os, + gast: { name: "testgast", benutzer: "florian" }, + }, + [ + adapter.benutzerAnlegen({ + name: "florian", + sshSchluessel: ["ssh-ed25519 AAAAtest florian@test"], + sudo: true, + }), + ], +); +process.stdout.write(skript); diff --git a/src/engine/adapter/debian.ts b/src/engine/adapter/debian.ts new file mode 100644 index 0000000..888c448 --- /dev/null +++ b/src/engine/adapter/debian.ts @@ -0,0 +1,172 @@ +import { base64, q } from "@/engine/shell"; +import type { Benutzerangaben, FamilienAdapter, PaketQuelle, SshHaertung } from "./schnittstelle"; + +/** + * Debian und Ubuntu: apt, deb822-Quellen, systemd. Geprüft gegen Debian 12/13 und Ubuntu + * 22.04/24.04/26.04 (Cloud-Images und Proxmox-LXC-Templates). + */ +const APT = "apt_get"; + +/** Ungültige Eingaben fallen beim Rendern auf, nicht erst mitten im Gast. */ +export class AdapterFehler extends Error { + constructor(meldung: string) { + super(meldung); + this.name = "AdapterFehler"; + } +} + +function pruefen(wert: string, muster: RegExp, was: string): string { + if (!muster.test(wert)) throw new AdapterFehler(`${was} „${wert}“ ist ungültig.`); + return wert; +} + +const ZEITZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/; +const LOCALE = /^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9-]+)?(@[a-z]+)?$/; +const LAYOUT = /^[a-z]{2,8}(,[a-z]{2,8})*$/; + +export const debianAdapter: FamilienAdapter = { + familie: "debian", + + kopf: () => `set -euo pipefail +export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8 +# apt wartet selbst bis zu 10 min auf die Paketsperre – cloud-init und unattended-upgrades laufen +# beim ersten Start oft noch. +${APT}() { apt-get -y -q -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"; } +if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi +# Frische Images und Container haben oft keine Paketlisten – ohne dieses update findet apt nichts. +${APT} update +`, + + aktualisieren: () => `${APT} update +${APT} dist-upgrade +`, + + paketeInstallieren: (pakete) => `${APT} install ${pakete.map(q).join(" ")} +`, + + dienstAktivieren: (name, { starten, neuStarten }) => { + // In Containern ohne systemd (selten, aber möglich) nicht scheitern. + const zeilen = [`systemctl enable ${q(name)}`]; + if (neuStarten) zeilen.push(`systemctl restart ${q(name)}`); + else if (starten) zeilen.push(`systemctl start ${q(name)}`); + return zeilen.join("\n") + "\n"; + }, + + repoHinzufuegen: (quelle: PaketQuelle) => { + const schluessel = `/etc/apt/keyrings/${quelle.name}.asc`; + const quellen = `/etc/apt/sources.list.d/${quelle.name}.sources`; + return `${APT} install ca-certificates curl +install -m 0755 -d /etc/apt/keyrings +curl -fsSL ${q(quelle.schluesselUrl)} -o ${q(schluessel)} +chmod a+r ${q(schluessel)} +cat > ${q(quellen)} < `${APT} install ca-certificates curl +pd_deb="$(mktemp --suffix=.deb)" +curl -fsSL ${q(url)} -o "$pd_deb" +${APT} install "$pd_deb" +rm -f "$pd_deb" +${APT} update +`, + + dateiSchreiben: (pfad, inhalt, modus) => `install -d -m 0755 "$(dirname ${q(pfad)})" +printf '%s' ${q(base64(inhalt))} | base64 -d > ${q(pfad)} +chmod ${q(modus)} ${q(pfad)} +`, + + zeitzoneSetzen: (eingabe) => { + const zeitzone = pruefen(eingabe, ZEITZONE, "Zeitzone"); + return `${APT} install tzdata +test -f /usr/share/zoneinfo/${q(zeitzone)} || { echo "Unbekannte Zeitzone: "${q(zeitzone)} >&2; exit 1; } +ln -sf /usr/share/zoneinfo/${q(zeitzone)} /etc/localtime +echo ${q(zeitzone)} > /etc/timezone +`; + }, + + localeSetzen: (eingabe) => { + const locale = pruefen(eingabe, LOCALE, "Locale"); + const zeichensatz = locale.includes(".") ? locale.split(".")[1]! : "UTF-8"; + const zeile = `${locale} ${zeichensatz}`; + // Debian erzeugt aus /etc/locale.gen; Ubuntus locale-gen nimmt die Locale zusätzlich als Argument. + return `${APT} install locales +touch /etc/locale.gen +grep -qxF ${q(zeile)} /etc/locale.gen || echo ${q(zeile)} >> /etc/locale.gen +if grep -qi ubuntu /etc/os-release; then locale-gen ${q(locale)}; else locale-gen; fi +update-locale LANG=${q(locale)} +`; + }, + + tastaturSetzen: (eingabe) => { + const layout = pruefen(eingabe, LAYOUT, "Tastaturbelegung"); + return `echo "keyboard-configuration keyboard-configuration/layoutcode string ${layout}" | debconf-set-selections +${APT} install keyboard-configuration console-setup +sed -i 's/^XKBLAYOUT=.*/XKBLAYOUT="${layout}"/' /etc/default/keyboard +setupcon --save-only >/dev/null 2>&1 || true +`; + }, + + benutzerAnlegen: (b: Benutzerangaben) => { + const name = q(b.name); + const schluessel = b.sshSchluessel.join("\n") + "\n"; + const zeilen = [ + `id -u ${name} >/dev/null 2>&1 || useradd -m -s /bin/bash ${name}`, + `pd_home="$(getent passwd ${name} | cut -d: -f6)"`, + `install -d -m 0700 -o ${name} -g ${name} "$pd_home/.ssh"`, + // Schlüssel ergänzen statt ersetzen: cloud-init hat sie bei VMs vielleicht schon eingetragen. + `printf '%s' ${q(base64(schluessel))} | base64 -d | while IFS= read -r pd_key; do [ -n "$pd_key" ] && { grep -qxF "$pd_key" "$pd_home/.ssh/authorized_keys" 2>/dev/null || echo "$pd_key" >> "$pd_home/.ssh/authorized_keys"; }; done`, + `touch "$pd_home/.ssh/authorized_keys" && chown ${name}:${name} "$pd_home/.ssh/authorized_keys" && chmod 0600 "$pd_home/.ssh/authorized_keys"`, + ]; + if (b.sudo) { + const datei = `/etc/sudoers.d/90-proxdeploy-${b.name}`; + zeilen.push( + `${APT} install sudo`, + `usermod -aG sudo ${name}`, + // Ohne Passwort, weil der Benutzer keins hat (nur SSH-Schlüssel). visudo prüft vor dem Aktivieren. + `echo ${q(`${b.name} ALL=(ALL) NOPASSWD:ALL`)} > ${q(datei)}.neu`, + `visudo -cf ${q(datei)}.neu >/dev/null && mv ${q(datei)}.neu ${q(datei)} && chmod 0440 ${q(datei)}`, + ); + } + return zeilen.join("\n") + "\n"; + }, + + sshHaerten: (h: SshHaertung) => `install -d /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/10-proxdeploy.conf <<'PROXDEPLOY_SSH' +# Von ProxDeploy angelegt +PasswordAuthentication ${h.passwortAnmeldung ? "yes" : "no"} +KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"} +PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"} +PROXDEPLOY_SSH +if command -v sshd >/dev/null 2>&1; then + sshd -t + systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true +fi +`, + + gastAgentInstallieren: () => `# Im Container gibt es keinen Guest-Agent. +if ! systemd-detect-virt --container --quiet; then + ${APT} install qemu-guest-agent + systemctl enable --now qemu-guest-agent 2>/dev/null || systemctl start qemu-guest-agent || true +fi +`, + + automatischeUpdates: (an) => `${APT} install unattended-upgrades +cat > /etc/apt/apt.conf.d/20auto-upgrades <<'PROXDEPLOY_UPGRADES' +APT::Periodic::Update-Package-Lists "${an ? 1 : 0}"; +APT::Periodic::Unattended-Upgrade "${an ? 1 : 0}"; +PROXDEPLOY_UPGRADES +`, + + fakten: () => `. /etc/os-release +printf '{"id":"%s","version_id":"%s","codename":"%s"}\\n' "$ID" "$VERSION_ID" "\${VERSION_CODENAME:-}" +`, +}; diff --git a/src/engine/adapter/index.ts b/src/engine/adapter/index.ts new file mode 100644 index 0000000..4309167 --- /dev/null +++ b/src/engine/adapter/index.ts @@ -0,0 +1,15 @@ +import { debianAdapter } from "./debian"; +import type { FamilienAdapter } from "./schnittstelle"; + +/** Alle bekannten Adapter. Eine neue Familie (rhel, alpine, suse) ist eine Zeile mehr. */ +const ADAPTER: Record = { + [debianAdapter.familie]: debianAdapter, +}; + +export function adapterFuer(familie: string): FamilienAdapter | null { + return ADAPTER[familie] ?? null; +} + +export function bekannteFamilien(): string[] { + return Object.keys(ADAPTER); +} diff --git a/src/engine/adapter/schnittstelle.ts b/src/engine/adapter/schnittstelle.ts new file mode 100644 index 0000000..0f2f1bd --- /dev/null +++ b/src/engine/adapter/schnittstelle.ts @@ -0,0 +1,47 @@ +/** + * Ein Provisionierungs-Adapter übersetzt die deklarativen Schritte eines Profils in Shell für eine + * Distributionsfamilie (debian: apt/systemd, später rhel: dnf, alpine: apk/openrc …). + * + * Jede Methode liefert ein **idempotentes** Skriptfragment und führt selbst nichts aus – so lässt + * sich jeder Adapter ohne Gast testen und im Dry-Run anzeigen. Ein neuer Adapter ist eine neue + * Datei, die dieses Interface umsetzt, und ein Eintrag in `adapter/index.ts`. + */ +export type Skript = string; + +export type PaketQuelle = { + name: string; + schluesselUrl: string; + uris: string; + suites: string; + komponenten: string; +}; + +export type Benutzerangaben = { + name: string; + sshSchluessel: string[]; + sudo: boolean; +}; + +export type SshHaertung = { passwortAnmeldung: boolean; rootAnmeldung: boolean }; + +export interface FamilienAdapter { + readonly familie: string; + /** Vorspann: strikte Shell, nicht interaktiv, Warten auf Paketsperren. */ + kopf(): Skript; + aktualisieren(): Skript; + paketeInstallieren(pakete: string[]): Skript; + dienstAktivieren(name: string, optionen: { starten: boolean; neuStarten: boolean }): Skript; + repoHinzufuegen(quelle: PaketQuelle): Skript; + /** Ein Paket von einer URL, das selbst eine Paketquelle einrichtet (z. B. zabbix-release). */ + repoPaketInstallieren(url: string): Skript; + dateiSchreiben(pfad: string, inhalt: string, modus: string): Skript; + zeitzoneSetzen(zeitzone: string): Skript; + localeSetzen(locale: string): Skript; + tastaturSetzen(layout: string): Skript; + benutzerAnlegen(benutzer: Benutzerangaben): Skript; + sshHaerten(haertung: SshHaertung): Skript; + gastAgentInstallieren(): Skript; + automatischeUpdates(an: boolean): Skript; + /** Gibt OS-Fakten als eine Zeile JSON aus: {"id","version_id","codename"}. */ + fakten(): Skript; +} diff --git a/src/engine/profile/aufloesen.ts b/src/engine/profile/aufloesen.ts new file mode 100644 index 0000000..c857241 --- /dev/null +++ b/src/engine/profile/aufloesen.ts @@ -0,0 +1,101 @@ +import type { Profil, ProfilVariable } from "@/engine/katalog/schema"; + +/** + * Aus der Auswahl im Assistenten wird eine geordnete Liste: Voraussetzungen zuerst (`requires`, + * transitiv), sonst die Reihenfolge der Auswahl. Dazu die Variablen mit Standardwerten und + * Typprüfung. Fehler werden gesammelt, damit das Formular alle auf einmal zeigen kann. + */ +export type AufgeloestesProfil = { profil: Profil; werte: Record }; + +export type ProfilFehler = { profil: string; variable?: string; meldung: string }; + +export type Aufloesung = + { ok: true; profile: AufgeloestesProfil[]; automatisch: string[] } | { ok: false; fehler: ProfilFehler[] }; + +export function profileAufloesen( + auswahl: string[], + verfuegbar: ReadonlyMap, + familie: string, + eingaben: Record> = {}, +): Aufloesung { + const fehler: ProfilFehler[] = []; + const reihenfolge: string[] = []; + const besucht = new Set(); + const inArbeit = new Set(); + + const besuchen = (id: string, von: string | null) => { + if (besucht.has(id)) return; + if (inArbeit.has(id)) { + fehler.push({ profil: id, meldung: `Zyklische Voraussetzung über „${von}“.` }); + return; + } + const profil = verfuegbar.get(id); + if (!profil) { + fehler.push({ + profil: id, + meldung: von + ? `Wird von „${von}“ vorausgesetzt, ist aber nicht verfügbar.` + : "Unbekanntes oder deaktiviertes Profil.", + }); + return; + } + inArbeit.add(id); + for (const voraussetzung of profil.requires) besuchen(voraussetzung, id); + inArbeit.delete(id); + besucht.add(id); + reihenfolge.push(id); + }; + for (const id of auswahl) besuchen(id, null); + + const profile: AufgeloestesProfil[] = []; + for (const id of reihenfolge) { + const profil = verfuegbar.get(id)!; + if (!profil.families[familie]) { + fehler.push({ profil: id, meldung: `Keine Umsetzung für die Familie „${familie}“.` }); + continue; + } + const werte: Record = {}; + for (const variable of profil.variables) { + const ergebnis = wertPruefen(variable, eingaben[id]?.[variable.name]); + if ("fehler" in ergebnis) + fehler.push({ profil: id, variable: variable.name, meldung: ergebnis.fehler }); + else werte[variable.name] = ergebnis.wert; + } + profile.push({ profil, werte }); + } + + if (fehler.length > 0) return { ok: false, fehler }; + return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) }; +} + +function wertPruefen( + variable: ProfilVariable, + eingabe: unknown, +): { wert: string | number | boolean } | { fehler: string } { + const roh = eingabe === undefined || eingabe === null || eingabe === "" ? variable.default : eingabe; + if (roh === undefined || roh === "") { + if (variable.required) return { fehler: `${variable.label} fehlt.` }; + return { wert: variable.type === "boolean" ? false : "" }; + } + switch (variable.type) { + case "boolean": + if (typeof roh === "boolean") return { wert: roh }; + if (roh === "true" || roh === "1") return { wert: true }; + if (roh === "false" || roh === "0") return { wert: false }; + return { fehler: `${variable.label}: ja oder nein erwartet.` }; + case "number": { + const zahl = typeof roh === "number" ? roh : Number(roh); + return Number.isFinite(zahl) ? { wert: zahl } : { fehler: `${variable.label}: Zahl erwartet.` }; + } + case "select": + return variable.options!.includes(String(roh)) + ? { wert: String(roh) } + : { fehler: `${variable.label}: erlaubt sind ${variable.options!.join(", ")}.` }; + default: { + const text = String(roh); + // Ein Zeilenumbruch in einer Variable ist fast immer ein Kopierfehler – und in Konfigurationsdateien gefährlich. + if (/[\r\n\0]/.test(text)) return { fehler: `${variable.label}: keine Zeilenumbrüche.` }; + return { wert: text }; + } + } +} diff --git a/src/engine/profile/rendern.ts b/src/engine/profile/rendern.ts new file mode 100644 index 0000000..0f307b0 --- /dev/null +++ b/src/engine/profile/rendern.ts @@ -0,0 +1,109 @@ +import type { FamilienAdapter } from "@/engine/adapter/schnittstelle"; +import type { ProfilSchritt } from "@/engine/katalog/schema"; +import type { AufgeloestesProfil } from "./aufloesen"; +import { einsetzen, tiefEinsetzen, type VorlagenKontext } from "./vorlage"; + +/** + * Baut aus aufgelösten Profilen ein Provisionierungsskript. + * + * Zwischen den Profilen steht eine Markenzeile (`::proxdeploy:: profil `), an der die Engine + * im Live-Log erkennt, welches Profil gerade läuft. SSH-Härtung wird ans Ende verschoben: Läuft die + * Provisionierung selbst über SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst aus. + */ +export const MARKE = "::proxdeploy::"; + +export type RenderKontext = { + os: { id: string; version_id: string; codename?: string }; + gast: { name: string; benutzer: string }; +}; + +export type Abschnitt = { profil: string; skript: string }; + +export type Gerendert = { skript: string; abschnitte: Abschnitt[] }; + +function bedingungErfuellt(schritt: ProfilSchritt, werte: Record): boolean { + if (!schritt.if) return true; + const verneint = schritt.if.startsWith("!"); + const wert = Boolean(werte[schritt.if.replace(/^!/, "")]); + return verneint ? !wert : wert; +} + +function schrittRendern(schritt: ProfilSchritt, adapter: FamilienAdapter, kontext: VorlagenKontext): string { + if ("update" in schritt) return adapter.aktualisieren(); + if ("packages" in schritt) + return adapter.paketeInstallieren(schritt.packages.map((p) => einsetzen(p, kontext))); + if ("service" in schritt) { + return adapter.dienstAktivieren(einsetzen(schritt.service.name, kontext), { + starten: schritt.service.start, + neuStarten: schritt.service.restart, + }); + } + if ("repo" in schritt) { + const r = tiefEinsetzen(schritt.repo, kontext); + return adapter.repoHinzufuegen({ + name: r.name, + schluesselUrl: r.key_url, + uris: r.uris, + suites: r.suites, + komponenten: r.components, + }); + } + if ("repo_package" in schritt) + return adapter.repoPaketInstallieren(einsetzen(schritt.repo_package, kontext)); + if ("file" in schritt) { + const f = tiefEinsetzen(schritt.file, kontext); + return adapter.dateiSchreiben(f.path, f.content, f.mode); + } + if ("timezone" in schritt) return adapter.zeitzoneSetzen(einsetzen(schritt.timezone, kontext)); + if ("locale" in schritt) return adapter.localeSetzen(einsetzen(schritt.locale, kontext)); + if ("keyboard" in schritt) return adapter.tastaturSetzen(einsetzen(schritt.keyboard, kontext)); + if ("guest_agent" in schritt) return adapter.gastAgentInstallieren(); + if ("unattended_upgrades" in schritt) return adapter.automatischeUpdates(schritt.unattended_upgrades); + if ("run" in schritt) return einsetzen(schritt.run, kontext, "shell") + "\n"; + if ("ssh_harden" in schritt) { + return adapter.sshHaerten({ + passwortAnmeldung: schritt.ssh_harden.password_auth, + rootAnmeldung: schritt.ssh_harden.root_login, + }); + } + const unbekannt: never = schritt; + throw new Error(`Unbekannter Schritt: ${JSON.stringify(unbekannt)}`); +} + +export function profileRendern( + profile: AufgeloestesProfil[], + adapter: FamilienAdapter, + kontext: RenderKontext, + vorspann: string[] = [], +): Gerendert { + const abschnitte: Abschnitt[] = []; + const zumSchluss: string[] = []; + + for (const { profil, werte } of profile) { + const vorlagenKontext: VorlagenKontext = { + var: werte, + os: { id: kontext.os.id, version_id: kontext.os.version_id, codename: kontext.os.codename ?? "" }, + guest: { name: kontext.gast.name, user: kontext.gast.benutzer }, + }; + const teile: string[] = []; + for (const schritt of profil.families[adapter.familie] ?? []) { + if (!bedingungErfuellt(schritt, werte)) continue; + const text = schrittRendern(schritt, adapter, vorlagenKontext); + if ("ssh_harden" in schritt) zumSchluss.push(text); + else teile.push(text); + } + abschnitte.push({ profil: profil.id, skript: teile.join("") }); + } + + const skript = [ + "#!/bin/bash", + "# Von ProxDeploy erzeugt", + adapter.kopf(), + ...vorspann, + ...abschnitte.map((a) => `echo '${MARKE} profil ${a.profil}'\n${a.skript}`), + ...(zumSchluss.length > 0 ? [`echo '${MARKE} abschluss'\n${zumSchluss.join("")}`] : []), + `echo '${MARKE} fertig'\n`, + ].join("\n"); + + return { skript, abschnitte }; +} diff --git a/src/engine/profile/vorlage.ts b/src/engine/profile/vorlage.ts new file mode 100644 index 0000000..7c4da9c --- /dev/null +++ b/src/engine/profile/vorlage.ts @@ -0,0 +1,53 @@ +import { q } from "@/engine/shell"; + +/** + * Platzhalter in Profilen: `{{ var.name }}`, `{{ os.id }}`, `{{ guest.user }}` und die Ausweichform + * `{{ var.a ?? var.b }}` (b, wenn a leer ist). Bewusst keine Logik, keine Filter, keine Schleifen – + * ein Profil soll lesbar bleiben. + * + * Im Modus `shell` (für `run`-Schritte) wird jeder eingesetzte Wert gequotet: eine Variable aus dem + * Formular kann so nie Shell-Code werden. Alle anderen Felder gehen an den Adapter, der selbst quotet. + */ +export type VorlagenKontext = Record< + "var" | "os" | "guest", + Record +>; + +export class VorlagenFehler extends Error { + constructor(meldung: string) { + super(meldung); + this.name = "VorlagenFehler"; + } +} + +const PLATZHALTER = /\{\{\s*([^}]+?)\s*\}\}/g; +const VERWEIS = /^(var|os|guest)\.([a-z][a-z0-9_]*)$/; + +function aufloesen(ausdruck: string, kontext: VorlagenKontext): string { + for (const teil of ausdruck.split("??").map((t) => t.trim())) { + const treffer = VERWEIS.exec(teil); + if (!treffer) throw new VorlagenFehler(`Unbekannter Platzhalter „{{ ${ausdruck} }}“.`); + const bereich = kontext[treffer[1] as keyof VorlagenKontext]; + if (!(treffer[2]! in bereich)) throw new VorlagenFehler(`„${teil}“ ist nicht definiert.`); + const wert = bereich[treffer[2]!]; + if (wert !== undefined && wert !== "") return String(wert); + } + return ""; +} + +export function einsetzen(text: string, kontext: VorlagenKontext, modus: "roh" | "shell" = "roh"): string { + return text.replace(PLATZHALTER, (_, ausdruck: string) => { + const wert = aufloesen(ausdruck, kontext); + return modus === "shell" ? q(wert) : wert; + }); +} + +/** Ersetzt in allen Strings eines Objekts (rekursiv) – für Schritte wie `repo` oder `file`. */ +export function tiefEinsetzen(wert: T, kontext: VorlagenKontext): T { + if (typeof wert === "string") return einsetzen(wert, kontext) as T; + if (Array.isArray(wert)) return wert.map((eintrag) => tiefEinsetzen(eintrag, kontext)) as T; + if (wert && typeof wert === "object") { + return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tiefEinsetzen(v, kontext)])) as T; + } + return wert; +} diff --git a/src/engine/shell.ts b/src/engine/shell.ts new file mode 100644 index 0000000..374aacf --- /dev/null +++ b/src/engine/shell.ts @@ -0,0 +1,14 @@ +/** + * Shell-Quoting für alles, was in ein Provisionierungsskript wandert. Einfache Anführungszeichen + * sind in POSIX-sh die einzige Form, in der nichts mehr ausgewertet wird – kein $, kein `, kein \. + */ +export function q(wert: string): string { + if (wert === "") return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(wert)) return wert; + return `'${wert.replace(/'/g, `'"'"'`)}'`; +} + +/** Inhalt als base64, damit beliebige Bytes (auch Zeilenumbrüche, $, ') unverändert ankommen. */ +export function base64(text: string): string { + return Buffer.from(text, "utf8").toString("base64"); +} diff --git a/tests/container/im-gast-pruefen.sh b/tests/container/im-gast-pruefen.sh new file mode 100755 index 0000000..455672b --- /dev/null +++ b/tests/container/im-gast-pruefen.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Läuft im Container: systemctl-Stub, Skript ausführen, Ergebnis prüfen. +printf '#!/bin/sh\necho "[systemctl] $*" >> /tmp/systemctl.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl +bash "$1" > /tmp/lauf.log 2>&1; code=$? +grep '::proxdeploy::' /tmp/lauf.log +echo "exit=$code" +[ $code -ne 0 ] && tail -25 /tmp/lauf.log +echo "--- Prüfung" +docker --version; docker compose version +zabbix_agent2 -V | head -1; cat /etc/zabbix/zabbix_agent2.d/proxdeploy.conf +cat /etc/timezone; readlink /etc/localtime; grep LANG /etc/default/locale; locale -a | grep -i de_de +grep XKBLAYOUT /etc/default/keyboard +id florian; cat /etc/sudoers.d/90-proxdeploy-florian; cat ~florian/.ssh/authorized_keys +cat /etc/apt/apt.conf.d/20auto-upgrades; dpkg -l fail2ban chrony qemu-guest-agent | grep ^ii | awk '{print $2}' +cat /tmp/systemctl.log | sort -u | tr '\n' ' '; echo +echo "--- Zweiter Lauf (Idempotenz)"; bash "$1" > /tmp/lauf2.log 2>&1; echo "exit2=$?"; grep -c florian ~florian/.ssh/authorized_keys diff --git a/tests/container/profile-pruefen.sh b/tests/container/profile-pruefen.sh new file mode 100755 index 0000000..922c0bb --- /dev/null +++ b/tests/container/profile-pruefen.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Führt die Profile base, docker und zabbix-agent wirklich aus: je ein frischer Container pro +# Distribution, danach Prüfung des Ergebnisses und ein zweiter Lauf (Idempotenz). +# Dauert einige Minuten und braucht Docker und Netz – deshalb nicht im Commit-Tor. +# pnpm test:container # alle +# pnpm test:container debian-13 # eine +set -euo pipefail +cd "$(dirname "$0")/../.." +export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +declare -A IMAGES=( + [debian-12]=debian:bookworm-slim [debian-13]=debian:trixie-slim + [ubuntu-22.04]=ubuntu:22.04 [ubuntu-24.04]=ubuntu:24.04 [ubuntu-26.04]=ubuntu:26.04 +) +ziele=("${@:-debian-12 debian-13 ubuntu-22.04 ubuntu-24.04 ubuntu-26.04}") +arbeit="$(mktemp -d)"; trap 'rm -rf "$arbeit"' EXIT +cp tests/container/im-gast-pruefen.sh "$arbeit/" +fehler=0 +for distro in ${ziele[*]}; do + pnpm -s exec tsx scripts/profil-rendern.ts "$distro" docker,zabbix-agent server=zbx.example fail2ban=true > "$arbeit/$distro.sh" + echo "=== $distro (${IMAGES[$distro]})" + if docker run --rm -v "$arbeit:/s:ro" "${IMAGES[$distro]}" bash /s/im-gast-pruefen.sh "/s/$distro.sh" > "$arbeit/$distro.log" 2>&1 \ + && grep -q '^exit=0$' "$arbeit/$distro.log" && grep -q '^exit2=0$' "$arbeit/$distro.log"; then + echo "✓ $distro" + else + echo "✗ $distro"; tail -30 "$arbeit/$distro.log"; fehler=1 + fi +done +exit $fehler diff --git a/tests/engine/profile.test.ts b/tests/engine/profile.test.ts new file mode 100644 index 0000000..fc5ab6c --- /dev/null +++ b/tests/engine/profile.test.ts @@ -0,0 +1,206 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { adapterFuer, bekannteFamilien } from "@/engine/adapter"; +import { AdapterFehler, debianAdapter } from "@/engine/adapter/debian"; +import { katalogLaden } from "@/engine/katalog/laden"; +import { ProfilSchema, type Profil } from "@/engine/katalog/schema"; +import { profileAufloesen } from "@/engine/profile/aufloesen"; +import { MARKE, profileRendern } from "@/engine/profile/rendern"; +import { einsetzen, VorlagenFehler } from "@/engine/profile/vorlage"; +import { q } from "@/engine/shell"; + +const kontext = { + var: { a: "eins", leer: "" }, + os: { id: "debian", version_id: "13", codename: "trixie" }, + guest: { name: "web01", user: "florian" }, +}; + +async function katalogProfile(): Promise> { + const k = await katalogLaden("catalog"); + return new Map(k.profile.map((p) => [p.eintrag.id, p.eintrag])); +} + +const profil = (roh: unknown) => ProfilSchema.parse(roh); + +describe("Shell-Quoting", () => { + it("lässt Harmloses stehen und packt alles andere in einfache Anführungszeichen", () => { + expect(q("docker-ce")).toBe("docker-ce"); + expect(q("a b")).toBe("'a b'"); + expect(q("$(rm -rf /)")).toBe("'$(rm -rf /)'"); + expect(q("it's")).toBe(`'it'"'"'s'`); + expect(q("")).toBe("''"); + }); + + it("ein gequoteter Wert kommt in bash unverändert an", () => { + for (const wert of ["$(id)", "`id`", "a'b\"c", "x; rm -rf /", "ä ö ü", "\\n"]) { + expect(execFileSync("bash", ["-c", `printf '%s' ${q(wert)}`]).toString()).toBe(wert); + } + }); +}); + +describe("Platzhalter", () => { + it("setzt var, os und guest ein; ?? nimmt den ersten nicht leeren Wert", () => { + expect(einsetzen("{{ os.id }}{{ os.version_id }}-{{ guest.name }}", kontext)).toBe("debian13-web01"); + expect(einsetzen("{{ var.leer ?? var.a }}", kontext)).toBe("eins"); + }); + + it("wirft bei unbekannten Platzhaltern statt sie leer zu lassen", () => { + expect(() => einsetzen("{{ var.gibtsnicht }}", kontext)).toThrow(VorlagenFehler); + expect(() => einsetzen("{{ env.HOME }}", kontext)).toThrow(/Unbekannter Platzhalter/); + }); + + it("im Shell-Modus kann eine Variable nie Code werden", () => { + const boese = { ...kontext, var: { name: "x; touch /tmp/gehackt" } }; + const befehl = einsetzen("echo {{ var.name }}", boese, "shell"); + expect(befehl).toBe("echo 'x; touch /tmp/gehackt'"); + expect(execFileSync("bash", ["-c", befehl]).toString().trim()).toBe("x; touch /tmp/gehackt"); + }); +}); + +describe("Profile auflösen", () => { + it("zieht Voraussetzungen nach vorne und meldet sie als automatisch", async () => { + const ergebnis = profileAufloesen(["docker", "zabbix-agent"], await katalogProfile(), "debian", { + "zabbix-agent": { server: "zbx" }, + }); + expect(ergebnis.ok).toBe(true); + if (!ergebnis.ok) return; + expect(ergebnis.profile.map((p) => p.profil.id)).toEqual(["base", "docker", "zabbix-agent"]); + expect(ergebnis.automatisch).toEqual(["base"]); + }); + + it("setzt Standardwerte und prüft Pflichtvariablen", async () => { + const fehlt = profileAufloesen(["zabbix-agent"], await katalogProfile(), "debian"); + expect(fehlt).toEqual({ + ok: false, + fehler: [{ profil: "zabbix-agent", variable: "server", meldung: "Zabbix-Server fehlt." }], + }); + + const ok = profileAufloesen(["base"], await katalogProfile(), "debian"); + expect(ok.ok && ok.profile[0]!.werte).toEqual({ + zeitzone: "Europe/Berlin", + locale: "de_DE.UTF-8", + tastatur: "de", + fail2ban: false, + }); + }); + + it("lehnt Zeilenumbrüche, falsche Typen, Zyklen und fehlende Familien ab", () => { + const verfuegbar = new Map([ + ["a", profil({ id: "a", name: "A", requires: ["b"], families: { debian: [{ update: true }] } })], + ["b", profil({ id: "b", name: "B", requires: ["a"], families: { debian: [{ update: true }] } })], + ["nur-rhel", profil({ id: "nur-rhel", name: "R", families: { rhel: [{ update: true }] } })], + [ + "v", + profil({ + id: "v", + name: "V", + variables: [ + { name: "text", label: "Text" }, + { name: "zahl", label: "Zahl", type: "number" }, + { name: "wahl", label: "Wahl", type: "select", options: ["x", "y"] }, + ], + families: { debian: [{ update: true }] }, + }), + ], + ]); + const ergebnis = profileAufloesen(["a", "nur-rhel", "v"], verfuegbar, "debian", { + v: { text: "a\nb", zahl: "abc", wahl: "z" }, + }); + expect(ergebnis.ok).toBe(false); + if (ergebnis.ok) return; + const meldungen = ergebnis.fehler.map( + (f) => `${f.profil}${f.variable ? "." + f.variable : ""}: ${f.meldung}`, + ); + expect(meldungen).toEqual( + expect.arrayContaining([ + expect.stringMatching(/Zyklische Voraussetzung/), + "nur-rhel: Keine Umsetzung für die Familie „debian“.", + "v.text: Text: keine Zeilenumbrüche.", + "v.zahl: Zahl: Zahl erwartet.", + "v.wahl: Wahl: erlaubt sind x, y.", + ]), + ); + }); +}); + +describe("Rendern", () => { + const renderKontext = { + os: { id: "debian", version_id: "13", codename: "trixie" }, + gast: { name: "web01", benutzer: "florian" }, + }; + + async function rendern(auswahl: string[], eingaben = {}) { + const ergebnis = profileAufloesen(auswahl, await katalogProfile(), "debian", eingaben); + if (!ergebnis.ok) throw new Error(JSON.stringify(ergebnis.fehler)); + return profileRendern(ergebnis.profile, debianAdapter, renderKontext); + } + + it("setzt Marken je Profil und die SSH-Härtung ganz ans Ende", async () => { + const { skript } = await rendern(["docker"]); + const marken = skript.split("\n").filter((z) => z.includes(MARKE)); + expect(marken).toEqual([ + `echo '${MARKE} profil base'`, + `echo '${MARKE} profil docker'`, + `echo '${MARKE} abschluss'`, + `echo '${MARKE} fertig'`, + ]); + expect(skript.indexOf("10-proxdeploy.conf")).toBeGreaterThan(skript.indexOf("docker-compose-plugin")); + }); + + it("setzt Distribution und Gast ein (Docker-Repo, Zabbix-Paket, Hostname)", async () => { + const { skript } = await rendern(["docker", "zabbix-agent"], { + "zabbix-agent": { server: "zbx.example" }, + }); + expect(skript).toContain("https://download.docker.com/linux/debian/gpg"); + expect(skript).toContain("Suites: trixie"); + expect(skript).toContain("zabbix-release_latest_7.0+debian13_all.deb"); + expect(skript).toContain("usermod -aG docker florian"); + // Der Inhalt der Konfigurationsdatei steht base64-kodiert im Skript. + const b64 = /printf '%s' (\S+) \| base64 -d > \/etc\/zabbix/.exec(skript)![1]!; + expect(Buffer.from(b64, "base64").toString()).toContain("Hostname=web01\n"); + }); + + it("überspringt Schritte, deren Bedingung nicht erfüllt ist", async () => { + expect((await rendern(["base"])).skript).not.toContain("fail2ban"); + expect((await rendern(["base"], { base: { fail2ban: true } })).skript).toContain("fail2ban"); + }); + + it("weist ungültige Zeitzone, Locale und Tastatur schon beim Rendern ab", async () => { + await expect(rendern(["base"], { base: { zeitzone: "Europe/Berlin; rm" } })).rejects.toThrow( + AdapterFehler, + ); + await expect(rendern(["base"], { base: { locale: "de" } })).rejects.toThrow(/Locale/); + await expect(rendern(["base"], { base: { tastatur: "de'" } })).rejects.toThrow(/Tastatur/); + }); + + it("ist syntaktisch gültiges bash", async () => { + const { skript } = await rendern(["docker", "zabbix-agent"], { + "zabbix-agent": { server: "zbx" }, + base: { fail2ban: true }, + }); + const datei = join(mkdtempSync(join(tmpdir(), "pd-skript-")), "s.sh"); + writeFileSync(datei, skript); + expect(() => execFileSync("bash", ["-n", datei])).not.toThrow(); + }); +}); + +describe("Adapter-Registry", () => { + it("kennt debian; eine unbekannte Familie ergibt null statt Absturz", () => { + expect(bekannteFamilien()).toEqual(["debian"]); + expect(adapterFuer("debian")).toBe(debianAdapter); + expect(adapterFuer("rhel")).toBeNull(); + }); + + it("Benutzer: sudo-Datei wird vor dem Aktivieren mit visudo geprüft, Schlüssel ergänzt statt ersetzt", () => { + const skript = debianAdapter.benutzerAnlegen({ + name: "florian", + sshSchluessel: ["ssh-ed25519 AAAA x"], + sudo: true, + }); + expect(skript).toMatch(/visudo -cf .*\.neu >\/dev\/null && mv/); + expect(skript).toContain('>> "$pd_home/.ssh/authorized_keys"'); + }); +}); diff --git a/tests/engine/shellcheck.test.ts b/tests/engine/shellcheck.test.ts new file mode 100644 index 0000000..ffe62c4 --- /dev/null +++ b/tests/engine/shellcheck.test.ts @@ -0,0 +1,55 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { debianAdapter } from "@/engine/adapter/debian"; +import { katalogLaden } from "@/engine/katalog/laden"; +import { profileAufloesen } from "@/engine/profile/aufloesen"; +import { profileRendern } from "@/engine/profile/rendern"; + +/** shellcheck über Docker – läuft nur, wenn Docker und das Image da sind, und sagt das sonst. */ +function shellcheckVerfuegbar(): boolean { + try { + execFileSync("docker", ["image", "inspect", "koalaman/shellcheck:stable"], { stdio: "ignore" }); + return true; + } catch { + console.warn("shellcheck übersprungen: docker oder koalaman/shellcheck:stable fehlt"); + return false; + } +} + +describe.runIf(shellcheckVerfuegbar())("shellcheck", () => { + it("findet in den gerenderten Katalogprofilen nichts", async () => { + const katalog = await katalogLaden("catalog"); + const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])); + const ordner = mkdtempSync(join(tmpdir(), "pd-sc-")); + for (const { eintrag: distro } of katalog.distributionen) { + const aufgeloest = profileAufloesen(["docker", "zabbix-agent"], profile, "debian", { + base: { fail2ban: true }, + "zabbix-agent": { server: "zbx" }, + }); + if (!aufgeloest.ok) throw new Error("Auflösung fehlgeschlagen"); + const { skript } = profileRendern( + aufgeloest.profile, + debianAdapter, + { os: distro.os, gast: { name: "g", benutzer: "u" } }, + [debianAdapter.benutzerAnlegen({ name: "u", sshSchluessel: ["ssh-ed25519 AAAA u"], sudo: true })], + ); + writeFileSync(join(ordner, `${distro.id}.sh`), skript); + } + const ausgabe = (() => { + try { + execFileSync( + "bash", + ["-c", `docker run --rm -v ${ordner}:/s:ro koalaman/shellcheck:stable -s bash /s/*.sh`], + { stdio: "pipe" }, + ); + return ""; + } catch (fehler) { + return String((fehler as { stdout?: Buffer }).stdout ?? fehler); + } + })(); + expect(ausgabe).toBe(""); + }, 60_000); +});