commit 06c34746367cbfe38987a10e7f8549df49916098 Author: BartelLuis Date: Mon Sep 14 20:09:12 2026 +0200 feat: add Proxmox provisioning service with CI and deployment tooling diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1e30a79 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.venv +.venv-ci-* +venv +__pycache__ +*.py[cod] +.pytest_cache +.cache +reports +build.env +deploy.env +*.egg-info +build +dist +data +secrets +backups +.env +*.key +*.pem +*.iso +*.sqlite3* +*.log diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..62154d8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +*.py text eol=lf +*.sh text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +*.toml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.md text eol=lf +Dockerfile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ffe94d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# Protected runs get their own group; publication is queued at job level. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name != 'pull_request' && github.ref_protected && github.run_id || 'checks' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || !github.ref_protected }} + +defaults: + run: + shell: bash + +env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + +jobs: + python-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: pyproject.toml + - name: Run Python tests + run: sh ci/python-tests.sh + - name: Upload JUnit report + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-test-results + path: reports/pytest.xml + retention-days: 7 + if-no-files-found: warn + + javascript-check: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + package-manager-cache: false + - name: Check JavaScript syntax + run: node --check provisioner/static/app.js + + container-policy: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + publish: ${{ steps.policy.outputs.publish }} + steps: + # Ref names are read from the runner environment, never inserted into shell code. + # The container script independently enforces these publication rules. + - name: Select verification or publication + id: policy + run: | + publish=false + if [[ "$GITHUB_REF_PROTECTED" == true ]] && + [[ "$GITHUB_EVENT_NAME" == push || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + if [[ "$GITHUB_REF_TYPE" == branch && -n "$DEFAULT_BRANCH" && "$GITHUB_REF_NAME" == "$DEFAULT_BRANCH" ]] || + [[ "$GITHUB_REF_TYPE" == tag && "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + publish=true + fi + fi + printf 'publish=%s\n' "$publish" >> "$GITHUB_OUTPUT" + + container-verify: + needs: [python-tests, javascript-check, container-policy] + if: ${{ needs.container-policy.outputs.publish == 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + DOCKER_HOST: unix:///var/run/docker.sock + DOCKER_BUILDKIT: "1" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Check Docker tools + run: | + docker info + docker buildx version + - name: Build and smoke-test image + run: sh ci/container.sh verify + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: container-build + path: build.env + retention-days: 7 + if-no-files-found: error + + container-publish: + needs: [python-tests, javascript-check, container-policy] + if: ${{ needs.container-policy.outputs.publish == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + concurrency: + group: ghcr-container-publish + cancel-in-progress: false + queue: max + env: + DOCKER_HOST: unix:///var/run/docker.sock + DOCKER_BUILDKIT: "1" + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Check Docker tools + run: | + docker info + docker buildx version + - name: Build, smoke-test and publish image + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: sh ci/container.sh publish + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: container-deploy + path: | + build.env + deploy.env + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a832f0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +.venv-ci-*/ +venv/ +*.egg-info/ +build/ +dist/ +.coverage +htmlcov/ +.cache/ +reports/ +build.env +deploy.env +.env +data/ +secrets/ +backups/ +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm +*.key +*.iso +*.pem +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be06918 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + DATA_DIR=/var/lib/proxmox-ais \ + MASTER_KEY_FILE=/run/secrets/master.key + +WORKDIR /app +RUN groupadd --gid 10001 provisioner \ + && useradd --uid 10001 --gid provisioner --no-create-home provisioner \ + && mkdir -p /var/lib/proxmox-ais /run/secrets \ + && chown -R provisioner:provisioner /var/lib/proxmox-ais /run/secrets +COPY pyproject.toml README.md ./ +COPY provisioner ./provisioner +RUN pip install . + +USER 10001:10001 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)" +ENTRYPOINT ["proxmox-ais"] +CMD ["serve", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Feinkonzept_Proxmox_Provisionierung.md b/Feinkonzept_Proxmox_Provisionierung.md new file mode 100644 index 0000000..4f5b949 --- /dev/null +++ b/Feinkonzept_Proxmox_Provisionierung.md @@ -0,0 +1,487 @@ +# Feinkonzept für ein Webtool zur Proxmox Provisionierung + +**Automatische Installation und gesteuerte Postinstallation** + +Version 1.0 \| 13. September 2026 \| Technische Umsetzungsvorlage + +### Ziel und Architekturentscheidung + +Das Tool verwaltet Proxmox VE Server, Installationsprofile und versionierte Postinstallationsskripte über eine Weboberfläche. Eine vorbereitete Proxmox ISO kontaktiert das Tool, erhält die für den Server freigegebene Installationskonfiguration und installiert Proxmox. Beim ersten Start lädt ein kleiner Starthelfer die zugewiesene Nachkonfiguration und führt sie kontrolliert aus. + +Empfohlen wird ein zentraler Dienst in einem Docker Container mit integrierter Weboberfläche, API und persistenter SQLite Datenbank. Die Server bauen die Verbindungen selbst per HTTPS auf. Ein eigener, zeitlich begrenzt aktiver Runner auf jedem Server übernimmt Skriptausführung, Statusmeldungen und Wiederaufnahme nach Unterbrechungen. + +### Leitentscheidungen + +| Bereich | Festlegung | +| --- | --- | +| Installationsmedium | Eine gemeinsame ISO je Standort oder Bereitstellungsgruppe; hostbezogene Antworten kommen vom Tool. | +| Freigabe | Nur eindeutig zugeordnete und ausdrücklich für Installation freigegebene Hosts erhalten eine Antwortdatei. | +| Reproduzierbarkeit | Jeder Lauf bindet unveränderliche Versionen von Profil, Skripten und aufgelösten Parametern. | +| Postinstallation | Wiederaufnehmbarer Ablauf mit einzelnen Schritten, Zustandsprüfung und abschließender Funktionsprüfung. | +| Betrieb | Ein Anwendungscontainer; TLS direkt oder über vorhandenen Reverse Proxy. ISO Vorbereitung zunächst als separater Arbeitsschritt. | + +### Planungsannahmen und Umfang + +Ausgelegt wird zunächst eine interne Umgebung mit bis zu 100 verwalteten Hosts und 10 parallelen Installationen. Diese Werte sind Abnahmeziele, keine gemessenen Leistungsangaben. DHCP, DNS, Zeitsynchronisation und Netzwerkrouting werden bereitgestellt. Unterstützte ISO Builds werden einzeln freigegeben. + +Die erste Version umfasst Neuinstallation, Basiskonfiguration, Profile, Skripte, Freigaben, Live Status und Auditprotokoll. Automatisches Einschalten, BMC Steuerung, PXE, Clusterbeitritt, Ceph Aufbau und dauerhafte Konfigurationsverwaltung sind spätere Erweiterungen. Die eigentliche Löschung der Installationsdatenträger bleibt Aufgabe des Proxmox Installers. + +## 1 Anbindung an den Proxmox Installer + +### Installationskonfiguration und erster Start + +Der Proxmox Automated Installer kann seine Antwortdatei per HTTP beziehungsweise HTTPS abrufen. Dabei sendet er einen POST Request mit Systeminformationen. Aktuelle Dokumentation beschreibt zusätzlich --answer-auth-token; daraus entsteht ein Authorization Header im Format Bearer <name>:<secret>. Das Tool liefert im Erfolgsfall TOML als Antwortinhalt. [1] + +Der First Boot Mechanismus unterstützt ein ausführbares Skript von der ISO oder einer URL. Bei from-url wird es bereits im Installer heruntergeladen und auf dem Zielsystem hinterlegt; die Ausführung erfolgt beim ersten Boot. Der hier vorgesehene Starthelfer verwendet ordering = "network-online" und prüft danach selbst die Erreichbarkeit des Tools. Der dokumentierte Implementierungsansatz zeigt diese Trennung von Download und Ausführung. [2] + +Das optionale Proxmox Postinstallation Webhook meldet Installationsinformationen. Es ersetzt keine lokale Skriptausführung. Im Konzept dient es ausschließlich als zusätzliches Ereignis zwischen Antwortauslieferung und erstem Start. [3] + +### Vorbereitung der gemeinsamen ISO + +Der folgende Befehl ist ein Konfigurationsmuster. SOURCE.iso, Token und Fingerprint sind vor Verwendung zu ersetzen. Es werden keine produktiven Zugangsdaten vorgegeben. Die unterstützte Kombination aus ISO und Assistant muss die verwendeten Optionen tatsächlich enthalten. + +```bash +proxmox-auto-install-assistant prepare-iso SOURCE.iso \ + --fetch-from http \ + --url "https://provision.example.net/installer/v1/answer" \ + --cert-fingerprint "" \ + --answer-auth-token ":" +``` + +Das Grundmuster mit URL, Zertifikatsfingerprint und Token ist offiziell dokumentiert. [3] Der eigene API Pfad ist eine Festlegung dieses Konzepts. Das erzeugte Medium wird zusammen mit ISO Prüfsumme, Assistant Paketversion, Gültigkeitsbereich und Prüfergebnis registriert. Skriptänderungen erfordern danach keinen neuen ISO Build. + +### Abschnitt der dynamischen Antwortdatei + +```toml +[first-boot] +source = "from-url" +ordering = "network-online" +url = "https://provision.example.net/bootstrap/v1/" +cert-fingerprint = "" +``` + +Dies ist nur der First Boot Abschnitt. Die vollständige Antwort enthält zusätzlich global, network und disk-setup mit hostbezogenen Werten. Vor Auslieferung wird sie für die freigegebene Zielversion validiert. Der Download Token gewährt ausschließlich Zugriff auf den Starthelfer dieses Laufs. + +### Kompatibilität als Freigabebedingung + +Keine pauschale Unterstützung aller Proxmox Versionen zusagen. Pro ISO werden Antwortschema, Token Header, First Boot Verhalten, Webhook und Bootverfahren getestet. Fehlt native Token Unterstützung, lehnt Version 1 dieses Medium ab. Ein neuer Assistant allein aktualisiert nicht die Komponenten innerhalb einer älteren ISO. + +## 2 Komponenten und Bereitstellung + +Die Anwendung ist ein modularer Monolith. Fachliche Komponenten bleiben im Code getrennt, werden jedoch gemeinsam ausgeliefert. Dadurch erfüllt der Regelbetrieb die Vorgabe eines Docker Containers ohne zusätzliche Datenbank oder Nachrichtenwarteschlange. + +| Komponente | Aufgabe | Vorschlag | +| --- | --- | --- | +| Weboberfläche | Inventar, Profile, Skripte, Freigaben, Laufdetails | Serverseitiges HTML mit kleinen JavaScript Komponenten | +| API | Installer Adapter, Runner API, Verwaltungsfunktionen | Python mit FastAPI und typisierten Datenmodellen | +| Konfigurationsdienst | Werte zusammenführen, validieren, Versionen fixieren | TOML und JSON Serializer; restriktive Vorlagen | +| Laufsteuerung | Freigaben, Status, Timeout, Sperren, Ereignisse | Persistente Zustände; keine Aufgaben nur im Arbeitsspeicher | +| Datenhaltung | Inventar, Versionen, Läufe, Ereignisse, Audit | SQLite im lokalen Volume; Schema Migrationen | +| Artefaktablage | Unveränderliche Skriptpakete und Run Manifeste | Dateien nach Inhaltsdigest; Referenzen in Datenbank | +| Starthelfer und Runner | Start, Abruf, Ausführung und Rückmeldungen | Kleiner Bash Starthelfer; Python Runner und Bash Module | + +FastAPI unterstützt OpenAPI und JSON Schema; daraus wird die technische API Dokumentation erzeugt. [4] Die Oberfläche verwendet dieselben fachlichen Dienste, aber eigene Benutzerrechte. Geheimnisse und freigegebene Skripte dürfen nicht über allgemeine Dateipfade ausgeliefert werden. + +### Verbindungen + +Administratoren greifen aus dem Verwaltungsnetz per HTTPS auf die Oberfläche zu. Installer und installierte Hosts erreichen nur die vorgesehenen Maschinenendpunkte. Es gibt im Grundumfang keine eingehende SSH Verbindung vom Tool zu den Hosts. Paketquellen werden direkt oder über einen internen Paketproxy erreicht. + +TLS endet entweder im vorhandenen Reverse Proxy oder direkt am Anwendungsserver auf einem unprivilegierten Port. Im Proxybetrieb ist der Backend Port ausschließlich für den Proxy erreichbar. Vertrauenswürdige Proxy Header werden auf konkrete Proxy Adressen beschränkt. + +### ISO Erstellung als eigener Vorgang + +Version 1 stellt den vorbereiteten Befehl, die erforderlichen Parameter und eine Registrierung der erzeugten ISO bereit. Der Administrator erstellt das Medium auf einer kontrollierten Build Maschine mit dem offiziellen Assistant. Das Webtool selbst benötigt dafür weder privilegierte Rechte noch einen Docker Socket. + +Ein späterer ISO Builder kann denselben Auftrag in einem kurzlebigen, separat gestarteten Build Container ausführen. Dabei gelten feste Eingabepfade, Ressourcenlimits und Prüfsummen. Das Ausführen eines vom Benutzer eingegebenen Shell Befehls im Anwendungscontainer ist nicht Teil der Architektur. + +## 3 Ablauf einer Installation + +| Schritt | Aktion und Ergebnis | +| --- | --- | +| 1 Vorbereiten | Host anhand Seriennummer, System UUID und MAC Adressen erfassen; FQDN, Netzwerk, Datenträgerprofil und Skriptprofil zuweisen. | +| 2 Prüfen | Aufgelöste Konfiguration anzeigen, Pflichtfelder validieren, Kollisionen bei IP und FQDN prüfen; Skriptversionen und Modulreihenfolge festschreiben. | +| 3 Freigeben | Operator erteilt eine zeitlich begrenzte Installationsfreigabe. Neuinstallationen enthalten eine gesonderte Bestätigung der zu überschreibenden Zielgeräte. | +| 4 Booten | Administrator startet den Server mit der vorbereiteten ISO über USB oder vorhandene virtuelle Medien. | +| 5 Zuordnen | Installer sendet Systemdaten. API prüft Gruppentoken, Hostzuordnung, Freigabefenster, Versionsprofil und laufende Vorgänge. | +| 6 Antworten | API reserviert atomar den Installationslauf und liefert dieselbe validierte TOML Antwort bei erlaubten Wiederholungen. | +| 7 Installieren | Installer lädt den runbezogenen Starthelfer und installiert Proxmox. Ein konfiguriertes Webhook kann das Ende der Basisinstallation melden. | +| 8 Registrieren | Nach dem Reboot legt der Starthelfer lokale Zustandsdateien und einen systemd Dienst an. Der Runner registriert sich für genau diesen Lauf. | +| 9 Konfigurieren | Runner lädt Manifest und Skriptpaket, prüft Integrität, führt Schritte aus und meldet Ereignisse, Protokolle und Neustartbedarf. | +| 10 Abschließen | Pflichtprüfungen sind erfolgreich. Der Lauf wird abgeschlossen, Ausführungsrechte erlöschen und der Runner wird deaktiviert. | + +### Unbekannte und widersprüchliche Hosts + +Ein unbekannter Host kann mit gültigem Gruppentoken als entdeckt gespeichert werden, erhält jedoch keine ausführbare Installationskonfiguration. Mehrdeutige Identitäten oder widersprüchliche UUID und Seriennummern führen ebenfalls zur Sperre. Die Oberfläche zeigt den Grund und die nötige Zuordnung. + +Das Konzept setzt kein beliebig langes Warten des Proxmox Installers voraus. Nach einer Ablehnung wird die Zuordnung im Tool korrigiert und der Installationsversuch neu gestartet. Für unbeaufsichtigten Betrieb sind Hosts und Freigaben deshalb vorher anzulegen. + +### Grenze der Installationssperre + +Ein HTTP Abruf und eine tatsächliche Installation sind nicht gleichzusetzen. Wiederholungsabrufe werden nur in einem kurzen, getesteten Auslieferungsfenster zugelassen. Ab gemeldetem Installationsende, Runner Registrierung oder Ablauf dieses Fensters ist eine neue Antwort gesperrt. Das verhindert weitere Freigaben, kann eine bereits ausgelieferte und lokal gespeicherte Antwort aber nicht zurückziehen. Die Bootreihenfolge muss nach Installation auf das lokale System wechseln. + +## 4 Bedienoberfläche und Berechtigungen + +### Zentrale Ansichten + +| Ansicht | Inhalte und Aktionen | +| --- | --- | +| Übersicht | Anzahl bereiter, laufender, gestörter und abgeschlossener Hosts; ausstehende Freigaben; letzte Ereignisse; Filter nach Standort und Profil. | +| Serverinventar | Identitäten, FQDN, IP, Standort, Tags, Profil und letzter Kontakt. Anlegen, Datenimport, Zuordnen und Sperren. | +| Serverdetail | Sollkonfiguration, erkannte Daten, Historie und aktueller Lauf. Ansichten für Schritte, redigierte Logs, Prüfungen und Fehlerursache. | +| Installationsprofile | Basisparameter und Hardwareprofil getrennt pflegen. Vorschau der vollständigen Antwort mit markierten Geheimnisreferenzen. | +| Postinstallationsprofile | Module auswählen und ordnen, Parameter setzen, Abhängigkeiten prüfen, Rebootstrategie festlegen. | +| Skriptverwaltung | Quelltext, Versionsvergleich, Syntaxprüfung, Freigabestatus, Testnachweis, unterstützte Proxmox Builds und Änderungsgrund. | +| Installationsmedien | URL, Gültigkeitsbereich, Tokenstatus, Zertifikat, Assistant Version, ISO Prüfsumme und vorbereiteter Build Befehl. | +| Audit und Einstellungen | Änderungen, Freigaben, Tokenzugriffe, Aufbewahrung, Benutzer, Zertifikate und Sicherungsstatus. | + +### Benutzerrollen + +| Rolle | Berechtigung | +| --- | --- | +| Leser | Inventar und redigierte Laufdaten ansehen; keine Änderungen oder Geheimnisse. | +| Operator | Hosts zuweisen, freigegebene Profile nutzen, Installationen freigeben und sichere Wiederaufnahme anfordern. | +| Skriptautor | Skripte und Profile als Entwurf bearbeiten; keine eigene Veröffentlichung im Vieraugenmodus. | +| Administrator | Benutzer, Geheimnisse, Vertrauensanker und Skriptveröffentlichungen verwalten; privilegierte Vorgänge auditieren. | +| Entwickler | Hat alle Berechtigungen. | + +### Bedienregeln + +Die Aktion Installation freigeben zeigt Host, Zielversion, ausgewählte Datenträger, aufgelöste Netzwerkeinstellungen und Skriptversionen zusammen an. Profiländerungen wirken nur auf neue Läufe. Ein laufender Vorgang wird niemals durch Bearbeiten eines Profils stillschweigend verändert. + +Wiederaufnehmen und Neu installieren sind getrennte Aktionen. Abbrechen stoppt einen Runner am nächsten sicheren Übergang. Ein bereits laufender Paketmanager oder eine bereits gestartete Datenträgeroperation wird nicht als sicher rückgängig darstellbar behandelt. Unbekannt bezeichnet fehlende Rückmeldung, nicht nachgewiesenen Erfolg oder Fehlschlag. + +## 5 Profile und Postinstallationsmodule + +### Auflösung der Konfiguration + +Werte werden in fester Reihenfolge zusammengeführt: globale Vorgaben, Standort, Profilversion, Hostwerte und freigegebene Laufparameter. Spezifischere Werte überschreiben allgemeinere. Sicherheitsregeln, freigegebene Proxmox Builds und gesperrte Variablen dürfen durch Hostwerte nicht gelockert werden. Ergebnis und Herkunft jedes Werts sind in der Vorschau sichtbar. + +Installationsprofil und Postinstallationsprofil bleiben getrennte Objekte. Das erste enthält Sprache, Zeitzone, Root Zugang, FQDN, Managementnetz und Systemdatenträger. Das zweite enthält Module und deren Parameter. Ein Lauf speichert die vollständig aufgelöste Kombination mit Versionsnummern und Digest. + +### Empfohlene Module für die erste Version + +| Modul | Verhalten | Erfolgskriterium | +| --- | --- | --- | +| Voraussetzungen | Zielversion, Zeit, DNS, Konnektivität und freien Speicher prüfen | Alle Pflichtbedingungen erfüllt | +| Paketquellen | Freigegebenes Repositoryprofil anwenden; Subscription berücksichtigen | Erwartete Quellen aktiv und erreichbar | +| Basispakete | Definierte Paketliste installieren; Paketmanager Sperre beachten | Erwartete Pakete vorhanden | +| Zugang und SSH | Freigegebene Schlüssel und Benutzer konfigurieren | Konfiguration validiert; definierter Zugang vorhanden | +| Zeit und Monitoring | Zeitsynchronisation und gewählten Monitoring Agent konfigurieren | Dienste aktiv; lokale Funktionsprüfung erfolgreich | +| Zusätzlicher Storage | Freigegebene, nicht destruktive Storage Anbindung setzen | Sollressource vorhanden und lokal nutzbar | +| Abschlussprüfung | Netzwerk, PVE Dienste, Storage und Versionsstand prüfen | Alle als Pflicht markierten Prüfungen erfolgreich | + +### Schnittstelle eines Moduls + +Jedes Modul hat eine unveränderliche ID und Version, eine unterstützte Zielmatrix, ein Parameterschema, Abhängigkeiten, Timeout, Wiederholungsregeln und eine Prüffunktion. check ermittelt den Istzustand; apply ändert nur erforderliche Werte; verify prüft das Ergebnis. Ein Custom Bash Skript muss dieselbe Schnittstelle einhalten. + +Parameter werden in einer JSON Datei übergeben. Es gibt kein eval und keine ungeprüfte Textersetzung in Shell Befehlen. Eine festgelegte Funktion liest erlaubte Parameter typisiert aus. Geheimnisse werden nur für den aktuellen Schritt als Datei mit Modus 0600 bereitgestellt, anschließend entfernt und niemals in die Profilvorschau oder Logs geschrieben. + +Eine syntaktische Prüfung, beispielsweise bash -n und ein Linter, bewertet keine fachliche Sicherheit. Die Veröffentlichung verlangt zusätzlich einen Test auf einem passenden Testhost. Kernelupdates, Netzwerkumbau und destruktive Storage Schritte sind eigenständige Erweiterungen mit zusätzlicher Wiederherstellungsplanung. + +## 6 Starthelfer und wiederaufnehmbare Ausführung + +### Verantwortung des Starthelfers + +Der Starthelfer enthält den kleinen, fest versionierten Runner, run_id, eine begrenzte Enrollment Berechtigung, die API Adresse und den erwarteten Hostbezug. Er enthält keine allgemeinen Administratorzugänge und kein vollständiges Postinstallationsskript. Das Paket bleibt unter dem Limit des freigegebenen Installers. + +Vor dem ersten Netzwerkabruf schreibt er seine Konfiguration atomar nach /etc/pve-provisioner, installiert den mitgelieferten Runner und aktiviert pve-provisioner.service nach network-online.target. Bash, Python und TLS Unterstützung werden je Zielimage geprüft. Der Runner führt Enrollment und Skriptabruf aus; fehlende Voraussetzungen erzeugen einen sichtbaren Fehler. + +### Lokaler Zustand und Ausführungsregeln + +Der Runner verwendet /var/lib/pve-provisioner für run_id, Manifestdigest, Gerätebindung, Ereigniszähler und Schrittzustände. Er hält eine lokale flock Sperre. Der Server erlaubt gleichzeitig höchstens einen aktiven Ausführungslauf pro Host. Pakete werden erst vollständig heruntergeladen, validiert und atomar in das Laufverzeichnis übernommen. + +Vor jedem Schritt prüft der Runner die Laufberechtigung und den lokalen Checkpoint. Er schreibt applying dauerhaft vor der Änderung, führt apply aus und schreibt succeeded erst nach erfolgreichem verify. Unvollständige Schritte werden nach einem Absturz erneut geprüft. Ein bloßer Rückgabecode 0 ersetzt keine Zustandsprüfung. + +| Situation | Festgelegtes Verhalten | +| --- | --- | +| Netzausfall | Abrufe mit exponentiellem Abstand und Zufallsanteil wiederholen; nach konfigurierter Gesamtdauer auf manuelle Prüfung wechseln. | +| API nicht erreichbar | Bereits laufenden sicheren Schritt beenden, Ereignisse lokal puffern; keine weiteren Änderungen nach Ablauf der Laufberechtigung. | +| Geplanter Reboot | Checkpoint und Rebootabsicht speichern; Nachricht zustellen oder puffern; Neustartbudget prüfen; nach Boot über denselben Lauf fortsetzen. | +| Unklarer Schrittzustand | check und verify ausführen. Nur ausdrücklich wiederholbare Schritte erneut anwenden; sonst manuelle Prüfung. | +| Doppelte Rückmeldung | Ereignis anhand run_id und Sequenznummer deduplizieren; Quittierung wiederholt zurückgeben. | +| Erfolgreicher Abschluss | Terminalen Checkpoint speichern, finalen Status quittieren lassen, Ausführungsrechte widerrufen und Dienst deaktivieren. | + +### Systemd und Fehlergrenzen + +Der eigene Dienst verwendet Type=simple und Restart=on-failure mit begrenzter Startfrequenz. Er benötigt keine Proxmox Hook Wiederholung. Permanente Modulfehler führen zu einem persistenten Haltzustand; ein Neustart des Dienstes darf sie nicht automatisch erneut ausführen. Zulässige Wiederholungen sind Teil der Moduldefinition, nicht einer pauschalen Shell Schleife. + +Beliebige Shell Aktionen haben keine Exactly once Garantie. Zustandsprüfungen und idempotente Schritte ermöglichen sichere Wiederaufnahme; unklare destruktive Aktionen werden nicht automatisch wiederholt. + +## 7 Zustände und Ereignisse + +Hostzustand, Installationsfreigabe und Ausführungslauf sind getrennte Zustandsautomaten. Ein Host kann bereits installiert sein, während ein neuer Postinstallationslauf wartet. Eine solche Nachkonfiguration erteilt niemals automatisch eine neue Erlaubnis zum Löschen und Installieren. + +| Laufzustand | Bedeutung und erlaubter Übergang | +| --- | --- | +| prepared | Konfiguration fixiert; für Installation zeitlich begrenzt freigegeben. Übergang zu answer_served oder expired. | +| answer_served | Antwort ausgeliefert. Installation läuft möglicherweise; ihr Beginn ist ohne weitere Meldung nicht bewiesen. | +| installed_reported | Optionales Installer Webhook empfangen. Runner Registrierung wird erwartet; dieser Zustand darf übersprungen werden. | +| runner_ready | Enrollment abgeschlossen, Hostbindung geprüft. Manifest und Ausführungsberechtigung können ausgegeben werden. | +| running | Ein bestimmter Schritt läuft; Heartbeats und geordnete Ereignisse werden angenommen. | +| reboot_pending | Checkpoint vorhanden. Gleicher Lauf wartet auf Rückkehr mit geänderter boot_id. | +| waiting_retry | Temporärer Fehler bei einem ausdrücklich wiederholbaren Vorgang; nächster Versuch ist terminiert. | +| needs_review | Unklarer oder nicht automatisch behebbarer Zustand. Weiterarbeit erfordert eine dokumentierte Operatorentscheidung. | +| succeeded | Alle Pflichtschritte und Abschlussprüfungen erfolgreich, Abschlussmeldung gespeichert. | +| failed oder cancelled | Permanenter Fehler oder Abbruch bestätigt. Weitere Änderungen sind gesperrt. | +| expired | Freigabe oder Startfrist abgelaufen, ohne rechtzeitige Aufnahme des Laufs. | + +### Ereignisformat + +```json +{ + "run_id": "run-2026-001", + "sequence": 42, + "boot_id": "", + "step_id": "base-packages", + "type": "step.succeeded", + "occurred_at": "2026-09-13T10:15:00Z", + "exit_code": 0, + "verification": {"packages_present": true} +} +``` + +Der Server ergänzt Empfangszeit und authentifizierte Geräteidentität. Ereignisse dürfen nur zulässige Übergänge auslösen. Veraltete Meldungen können einen terminalen Erfolg nicht auf running zurücksetzen. Der Server bestätigt die höchste lückenlos gespeicherte Sequenznummer; der Runner behält nicht quittierte Ereignisse. + +Ein fehlender Heartbeat setzt den Kontaktstatus auf unbekannt und erzeugt nach einer konfigurierten Frist eine Betriebswarnung. Er beweist weder einen Modulfehler noch einen erfolgreichen Abschluss. Die Übersicht verwendet deshalb keine künstliche Prozentanzeige für nicht beobachtbare Installerphasen. + +## 8 Schnittstellen des Tools + +### Maschinenendpunkte + +| Methode und Pfad | Vertrag | +| --- | --- | +| POST /installer/v1/answer | Native Proxmox Systemdaten und Gruppentoken; 200 mit roher TOML Antwort bei passender Freigabe. | +| GET /bootstrap/v1/{token} | Runbezogener Starthelfer; ausschließlich über zeitlich begrenzte Download Capability. | +| POST /installer/v1/report/{token} | Optionales Proxmox Webhook; eigener Token, nur Ereignisannahme für diesen Lauf. | +| POST /agent/v1/enroll | Enrollment Secret und lokal erzeugter öffentlicher Geräteschlüssel; registriert den Runner für einen Lauf. | +| POST /agent/v1/lease | Authentifiziertes Gerät erhält begrenzte Laufberechtigung oder Status wait, stop, revoked. | +| GET /agent/v1/runs/{id}/manifest | Fixierte Schrittfolge, Parameterreferenzen, Digests, Fristen und freigegebene Artefakte. | +| GET /agent/v1/artifacts/{digest} | Nur für den eigenen Lauf autorisierte, unveränderliche Skriptpakete. | +| POST /agent/v1/runs/{id}/events | Batch mit Sequenznummern; transaktionale Speicherung und Quittierung. | +| POST /agent/v1/runs/{id}/logs | Begrenzte, redigierte Logblöcke mit Sequenz und Größenlimit. | +| POST /agent/v1/runs/{id}/complete | Abschluss mit Prüfergebnissen; serverseitige Prüfung aller Pflichtschritte. | + +### Verwaltungsschnittstellen + +Unter /api/v1 liegen CRUD Ressourcen für hosts, profiles, modules, releases und iso-records sowie Aktionen approve-install, resume, cancel und publish. Änderungen benötigen Benutzeranmeldung, rollenbasierte Prüfung und bei Browser Sessions CSRF Schutz. Versionierte Objekte werden mit ETag beziehungsweise Versionsnummer gegen gleichzeitiges Überschreiben geschützt. + +### Fehlersemantik und Validierung + +401 bedeutet ungültige Authentifizierung, 403 fehlende Freigabe, 409 mehrdeutige Zuordnung oder Zustandskonflikt, 410 abgelaufene Berechtigung, 422 ungültige Konfiguration und 503 temporäre Nichtverfügbarkeit. Für unbekannte Hosts wird niemals eine Standardantwort mit Datenträgerauswahl zurückgegeben. Fehlertexte enthalten keine Geheimnisse. + +Nur der Installer Adapter akzeptiert das native Proxmox Payload. Er speichert dessen Schemahinweis und normalisiert verfügbare Identitätsfelder; nicht vorhandene Felder werden nicht erfunden. Die eigene Runner API verwendet ein separat dokumentiertes Schema. Die Anwendung garantiert keine zusätzlichen HTTP Header oder eigenen Felder seitens des unveränderten Proxmox Installers. + +Antworten und Starthelfer verwenden Cache-Control: no-store. Keine Weiterleitung auf fremde Domains. GET /agent/v1/runs/{id}/secrets/{step} liefert nur Geheimnisse des autorisierten aktuellen Schritts. Größen und Ratenlimits gelten je Endpunkt; nach dem Starthelfer stehen Zugangsdaten nur in authentifizierten Requests. + +## 9 Vertrauensmodell und Geheimnisse + +### Hostzuordnung ist keine Geräteauthentifizierung + +UUID, Seriennummer und MAC Adressen helfen bei der Zuordnung, sind aber vom anfragenden System behauptete und grundsätzlich kopierbare Werte. Auch ein in einer gemeinsamen ISO gespeicherter Token ist auslesbar. Das Standardmodell setzt daher ein kontrolliertes Provisionierungsnetz und Schutz der Installationsmedien voraus. + +Bei höherem Schutzbedarf erhält jeder Host ein individuelles Installationsmedium mit eigenem Token oder ein vorab unabhängig bereitgestelltes Gerätegeheimnis. Optional ist später hardwaregestützte Attestierung möglich. Ein gemeinsamer ISO Token plus MAC Abgleich darf in der Oberfläche nicht als starker Identitätsnachweis bezeichnet werden. + +| Berechtigung | Begrenzung und Lebenszyklus | +| --- | --- | +| ISO Gruppentoken | Nur Antwortabruf für einen Standort und freigegebene Hosts; Ablaufdatum, Sperrung und Ratenlimit. Keine Verwaltungsrechte. | +| Bootstrap Download Token | Nur ein Starthelfer eines Laufs. Kurzes Abruffenster mit begrenzten Wiederholungen; danach gesperrt. | +| Enrollment Secret | Nur erstmalige Bindung eines Geräteschlüssels an den bereits reservierten Lauf; Ablauf nach geplantem Installationsfenster. | +| Gerätebindung | Privater Schlüssel lokal mit Modus 0600. Requests signiert mit Nonce und Request Digest; kein Gerätezugriff auf andere Hosts. | +| Laufberechtigung | Kurzlebig und erneuerbar, etwa 15 Minuten; nur zugewiesene Module, Artefakte und Ereignisse. Terminale Läufe entziehen Änderungsrechte. | +| Betriebsgeheimnisse | Verschlüsselt gespeichert; Schlüssel außerhalb des Datenvolumes. Ausgabe nur an berechtigten Schritt und niemals in allgemeine Logs. | + +### Enrollment mit sicherer Wiederholung + +Der erste gültige Enrollment Request bindet das Secret atomar an einen lokal generierten Geräteschlüssel. Geht die Antwort verloren, darf derselbe Schlüssel den Vorgang erneut anfragen; ein anderer wird abgewiesen. Spätere Authentifizierung verlangt Schlüsselbesitz. Eine Umbindung ist ein Auditvorgang. Signaturen und Replay Schutz werden mit einem etablierten Verfahren umgesetzt. Nach Abschluss bleibt kurzzeitig nur die idempotente Abschlussquittierung möglich. + +### TLS und Integrität + +Der ISO Fingerprint schützt die Verbindung zum Antwortdienst. Starthelfer und Runner verwenden passende Vertrauensanker; Zertifikatsrotation wird mit neuen Medien und Übergangsfristen geplant. Zertifikatsprüfungen werden nicht deaktiviert. Skriptpakete werden anhand eines authentifiziert bezogenen Manifests und Inhaltsdigests geprüft. + +Der Bootstrap URL Token kann in Installerprotokollen erscheinen. Deshalb ist er eine begrenzte Capability und wird in Proxy und Anwendungslogs maskiert. Reproduzierbare Starthelfer mit enthaltenem Enrollment Secret werden verschlüsselt aufbewahrt. Token Verifizierer werden gehasht gespeichert. Root Passwörter sind individuell; bevorzugt wird ein für die Zielversion zulässiger Passwort Hash übertragen. + +## 10 Datenmodell und Konsistenz + +| Entität | Wesentliche Felder und Beziehungen | +| --- | --- | +| Host | id, Standort, FQDN, Soll IP, Status, Profilzuordnung, gesperrt, letzter Kontakt. | +| HostIdentity | host_id, Typ, Wert, Herkunft, Prüfdatum; mehrere Identitäten je Host. | +| ProfileVersion | id, profile_id, Version, Werte, Module, Schema, Zielmatrix, Status, Digest. | +| ModuleVersion | id, module_id, Version, Artefaktdigest, check/apply/verify Vertrag, Timeout, Wiederholungsregel, Freigabenachweis. | +| InstallApproval | host_id, freigegebene ProfileVersion, Gültigkeitsfenster, Genehmiger, Neuinstallationsgrund, Verbrauchsstatus. | +| ProvisioningRun | id, host_id, approval_id, Zustand, Antwortdigest, Manifestdigest, fixierte Parameter, Beginn, Ende, Versionszähler. | +| RunStep | run_id, step_id, Position, Modulversion, Versuch, Zustand, Checkpoint, Exitcode, Prüfergebnis. | +| Credential | id, Typ, Scope, Verifizierer oder Public Key, Fristen, Sperre, Lauf oder Gruppe. | +| Artifact und Secret | Digest, Größe, Ablagepfad, Typ; Geheimnisse separat verschlüsselt mit Schlüsselreferenz. Keine Secrets im Artefaktnamen. | +| Event und LogChunk | run_id, sequence, Zeit, Gerät, Nutzdaten beziehungsweise begrenzter Logblock; zusammengesetzter eindeutiger Schlüssel. | +| AuditEvent | Akteur, Aktion, Objekt, Zeitpunkt, Änderungsgrund und redigierte Vorher Nachher Werte. | +| IsoRecord | ISO Digests, Assistant Version, Zielbuild, URL, Fingerprint, Token, Teststatus. | + +### Transaktionsregeln + +Freigabeprüfung, Reservierung eines Laufs und Verbrauch der Freigabe erfolgen in einer Transaktion. Ein eindeutiger Index verhindert mehrere aktive Installationsläufe je Host. Wiederholte POST Requests liefern innerhalb des zugelassenen Fensters dieselbe Antwort und vergeben weder neue IP Adressen noch neue Hostnamen. + +Zustandsübergänge prüfen die gespeicherte Versionsnummer. Ereignisse werden dedupliziert gespeichert, bevor die API sie quittiert. Ein Lauf referenziert nur bereits vollständig geschriebene Artefakte. Die Veröffentlichung erfolgt durch atomare Dateiumbenennung und anschließende Datenbankreferenz; verwaiste Dateien dürfen später bereinigt werden. + +### SQLite im Eincontainerbetrieb + +SQLite wird auf einem lokalen Docker Volume im WAL Modus betrieben. WAL erlaubt parallele Leser und einen schreibenden Vorgang; die Datenbank gehört nicht auf ein Netzwerkdateisystem. [5] Transaktionen bleiben kurz, Logdaten werden gebündelt und Schreibkonflikte kontrolliert wiederholt. Zunächst läuft ein API Prozess mit begrenzter Hintergrundarbeit. + +Für mehrere aktive Anwendungsinstanzen oder höhere Schreiblast ist eine Migration auf PostgreSQL vorgesehen. Das ist eine Änderung des Betriebsmodells und erfordert zusätzliche Infrastruktur. Eine zweite Instanz darf nicht einfach dasselbe SQLite Volume parallel verwenden. + +## 11 Docker Betrieb und Wiederherstellung + +### Containervertrag + +Das Image enthält Weboberfläche, API, Migrationswerkzeug und feste Runner Versionen. Es läuft als nicht privilegierter Benutzer ohne Hostnetz, ohne Docker Socket und ohne Zugriff auf Hostgeräte. Das Root Dateisystem ist schreibgeschützt; nur Datenvolume und temporäre Verzeichnisse sind beschreibbar. Docker Volumes entkoppeln persistente Daten vom Containerlebenszyklus. [6] + +```yaml +# Zielkonfiguration; Image und Dateien entstehen in der Umsetzung +services: + provisioner: + image: ${PROVISIONER_IMAGE} + user: "10001:10001" + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + ports: ["127.0.0.1:8080:8080"] + volumes: + - provisioner-data:/data + - ./config:/config:ro + - ./secrets:/run/secrets:ro + tmpfs: ["/tmp:rw,noexec,nosuid,size=128m"] + environment: + APP_CONFIG: /config/app.toml + DATA_DIR: /data + MASTER_KEY_FILE: /run/secrets/master.key +volumes: + provisioner-data: +``` + +Dieses Beispiel setzt einen TLS Proxy auf demselben Docker Host voraus. Bei direkter TLS Terminierung werden Zertifikat und Schlüssel eingebunden und der TLS Port passend veröffentlicht. Image Digest, Dateirechte und Eigentümer des Volumes werden im Installationspaket festgelegt. Das Beispiel ist keine bereits verfügbare Software. + +### Betriebsziele und Beobachtung + +Startdimensionierung: 2 vCPU, 2 GB RAM und 20 GB Datenkapazität ohne ISO Archiv; unter Last zu überprüfen. /health/live prüft den Prozess, /health/ready Datenbank und notwendige Konfiguration. Gemessen werden API Fehler, Antwortzeiten, aktive Läufe, Alter des letzten Heartbeats, Speicherplatz und Backupalter. Secrets und Logtexte werden nicht zu Metriklabels. + +Als Startwerte gelten 30 Sekunden Heartbeat, 3 Minuten bis Kontakt unbekannt, 30 Tage technische Logs und 180 Tage Auditdaten. Fristen sind konfigurierbar. Ein fertiger Host darf nicht allein wegen Ablaufs einer Logaufbewahrung erneut freigegeben werden. + +### Sicherung und Updates + +Tägliche konsistente Sicherung über die SQLite Backup API oder einen kontrollierten Anwendungsstopp; keine isolierte Kopie nur der laufenden DB Datei. Artefakte, Konfiguration und verschlüsselte Geheimnisse gehören zur Sicherung. Der Entschlüsselungsschlüssel wird separat geschützt gesichert. Planungsziel: RPO 24 Stunden, RTO 2 Stunden. + +Updates stoppen neue Freigaben, sichern den Datenstand und führen versionierte Migrationen aus. Wiederherstellung und Kompatibilität mit bereits gestarteten Runnern werden vor Produktionsfreigabe geprüft. Nach Restore werden unsichere aktive Berechtigungen gesperrt und Läufe abgeglichen, damit zurückgesetzte Zustände keine Aktion erneut auslösen. + +## 12 Netzwerk und Datenträgersicherheit + +### Erreichbarkeit in zwei Umgebungen + +Der Installer benötigt sein eigenes erreichbares Startnetz, bevor er die Antwortdatei abrufen kann. Die darin enthaltene spätere Management IP löst dieses Problem nicht. Standard ist ein dediziertes Provisionierungs VLAN mit DHCP, DNS und korrekter Route zum Tool. Nach dem Neustart muss das Zielnetz ebenfalls HTTPS zum Tool und zu den benötigten Paketquellen erlauben. + +| Verbindung | Zweck und Freigabe | +| --- | --- | +| Browser zum Tool | HTTPS aus dem Verwaltungsnetz; rollenbasierte Anmeldung. | +| Installer zum Tool | HTTPS für Antwort, Starthelfer und optionales Report Webhook; maschinelle Authentifizierung. | +| Installierter Host zum Tool | HTTPS für Enrollment, Laufberechtigung, Artefakte und Status. | +| Installer und Host zur Infrastruktur | DNS, DHCP im Startnetz, Zeitdienst und freigegebene Paketquellen gemäß Standortkonzept. | +| Tool zu externen Diensten | Nur bewusst eingerichtete Ziele, etwa Sicherungsziel oder spätere Identitätsanbieter. | + +Ein erfolgreicher network-online.target garantiert nicht die Erreichbarkeit eines bestimmten Dienstes. Der Runner testet Namensauflösung, TLS Verbindung und API Antwort ausdrücklich. Wechsel von IP, Bridge, Bond oder VLAN wird in der ersten Version möglichst vollständig in der Installationskonfiguration abgebildet. + +### Auswahl der Systemdatenträger + +Datenträger werden anhand eines geprüften Hardwareprofils und stabiler, vom freigegebenen Installer unterstützter Eigenschaften ausgewählt. Pauschale Auswahl des ersten Geräts oder eine feste Annahme über /dev/sda ist unzulässig. Anzahl und erwartete Eigenschaften der Systemdatenträger müssen zum freigegebenen Profil passen. + +Die Systeminformationen des Antwortabrufs werden nicht pauschal als vollständiges Storageinventar behandelt. Fehlen Datenträgermerkmale im nativen Payload, ist vor der Freigabe eine separate Inventarisierung beziehungsweise ein geprüfter Hardwaretyp erforderlich. Ein Postinstallationsskript kann einen bereits überschriebenen falschen Datenträger nicht nachträglich schützen. + +### Risikoreiche Erweiterungen + +Netzwerkänderungen nach dem ersten Start benötigen einen separaten Rückfallmechanismus mit lokaler Konfigurationssicherung, Zeitfenster und Out of Band Zugang. Vollständige Root Storage Änderungen sind Neuinstallationsvorgänge. Zusätzliche destruktive Storage Module dürfen nur ausdrücklich ausgewählte Geräte verwenden und verlangen eine eigene Freigabe. + +Clusterbeitritt und Ceph werden später als koordinierte Gruppenabläufe entwickelt. Dazu gehören versionsbezogene Kompatibilitätsprüfungen, Quorum, serielle Operationen und eigene Geheimnisverwaltung. Sie werden nicht als beliebige zusätzliche Bash Zeile in ein allgemeines Basisskript aufgenommen. + +## 13 Abnahmekriterien und Teststrategie + +Diese Prüfaufträge definieren die Fertigstellung; Installationen wurden noch nicht ausgeführt. Unit Tests prüfen Regeln und Zustände, Vertragstests echte Installer Payloads und Integrationstests Persistenz und Fehlerfälle. Vollständige Installationen werden zunächst virtuell, dann auf einem physischen Gerät je Hardwareprofil geprüft. Ein Bash Syntaxcheck allein genügt nicht. + +| Nr | Testfall | Erwartetes Ergebnis | +| --- | --- | --- | +| A01 | Bekannter und freigegebener Host | Basisinstallation und alle Pflichtmodule laufen mit fixierten Versionen bis succeeded durch. | +| A02 | Unbekannter oder gesperrter Host | Keine verwendbare Antwortdatei; keine Freigabe destruktiver Installation durch das Tool. | +| A03 | Doppelte oder widersprüchliche Identität | Zuordnung abgelehnt, Konflikt sichtbar, keine automatische Auswahl eines Hosts. | +| A04 | Parallele und wiederholte Antwortabrufe | Ein Lauf und dieselbe Konfiguration; keine doppelte Vergabe von IP oder Hostname. | +| A05 | Falsches oder abgelaufenes Zertifikat | Abruf scheitert geschlossen; kein Ausweichen auf unverschlüsselten oder ungeprüften Zugriff. | +| A06 | Falscher, gesperrter oder fremder Token | 401 beziehungsweise 403; kein Zugriff auf fremde Antwort, Artefakte oder Logs. | +| A07 | Manipuliertes Skriptpaket | Digestprüfung schlägt fehl; keine Ausführung und nachvollziehbarer Fehler. | +| A08 | Verlust der Enrollment Antwort | Wiederholung mit demselben Schlüssel funktioniert; anderer Schlüssel bleibt gesperrt. | +| A09 | Netzausfall und Containerneustart | Runner puffert Ereignisse; Zustand bleibt erhalten; keine unkontrollierte Modulwiederholung. | +| A10 | Stromausfall während eines Schritts | Istzustand wird geprüft; sichere Wiederaufnahme oder needs_review, keine falsche Erfolgsmeldung. | +| A11 | Geplanter Neustart | Rückkehr in denselben Lauf; abgeschlossene Schritte werden nicht erneut angewendet. | +| A12 | Erneutes Booten der Installations ISO | Nach Sperre beziehungsweise Abschluss keine neue Antwort ohne neue ausdrückliche Freigabe. | +| A13 | Gleichzeitige Profiländerung | Aktiver Lauf behält Manifest und Versionen; neuer Lauf nutzt erst die neue Veröffentlichung. | +| A14 | Zehn parallele Läufe | Keine Zuordnungsfehler oder verlorenen Ereignisse; p95 Antwortzeit der Steuer API unter 2 s im definierten Testnetz. | +| A15 | Backup und Restore | Vollständige Wiederherstellung einschließlich Schlüssel; RPO und RTO nachgewiesen; aktive Läufe sicher abgeglichen. | +| A16 | Jeder freigegebene ISO Build | Antwortschema, Token, First Boot, Startnetz, UEFI und gegebenenfalls Secure Boot praktisch geprüft. | + +## 14 Umsetzung und offene Festlegungen + +### Umsetzung in fünf Arbeitspaketen + +| Paket | Ergebnis | Abschlussbedingung | +| --- | --- | --- | +| 1 Technischer Nachweis | Ein Ziel ISO Build, Antwortendpoint, sicherer Starthelfer und ein Testmodul | Installation bis zu einer verifizierten Rückmeldung demonstriert | +| 2 Fachlicher Kern | Inventar, Profile, Modulversionen, Freigaben und Datenmodell | Zuordnung und Konfigurationsauflösung mit Fehlerfällen geprüft | +| 3 Robuste Ausführung | Runner, Enrollment, Checkpoints, Reboots, Ereignisse und Statusanzeige | Unterbrechungs und Wiederaufnahmetests bestehen | +| 4 Betriebsreife | Rollen, Audit, Geheimnisse, Containerpaket, Backup und Upgradepfad | Sicherheits und Wiederherstellungskriterien erfüllt | +| 5 Pilotbetrieb | Test auf realer Zielhardware, Betriebsanleitung und Supportmatrix | Alle zutreffenden Abnahmekriterien erfüllt und Betreiberfreigabe erteilt | + +Liefergegenstände der Umsetzung sind Quellcode, versioniertes Containerimage, Compose Beispiel, Datenbankschema mit Migrationen, OpenAPI Spezifikation, Starthelfer und Runner, Basismodule, ISO Vorbereitungsanleitung, getestete Kompatibilitätsmatrix sowie Betriebs und Wiederherstellungsanleitung. Eine belastbare Aufwandsschätzung folgt nach dem technischen Nachweis und der Festlegung der Module. + +### Vor Entwicklungsbeginn festzulegen + +| Entscheidung | Vorgesehener Ausgangspunkt | +| --- | --- | +| Proxmox Zielversionen | Ein konkret getesteter aktueller ISO Build zum Start; weitere Builds nur mit Nachweis. | +| Start und Zielnetz | Dediziertes DHCP Provisionierungsnetz, erreichbare feste HTTPS Adresse des Tools. | +| Hardware und Storage | Freigegebene Servertypen; je Typ explizite Systemdatenträger und Dateisystemkonfiguration. | +| Identitätsniveau | Gruppen ISO im kontrollierten Netz; für höhere Anforderungen individuelles Medium oder unabhängiges Gerätegeheimnis. | +| Authentifizierung der Benutzer | Lokale Konten und Rollen zum Start; OIDC als optionale Erweiterung. | +| Konkrete Skriptmodule | Repositories, Pakete, SSH, Zeit, Monitoring und Abschlussprüfung; genaue Produkte und Werte festlegen. | +| TLS und Aufbewahrung | Vorhandenen Proxy nutzen, sofern verfügbar; Fristen und Backupziele an Betriebsvorgaben anpassen. | + +### Abgleich mit vorhandenen Proxmox Funktionen + +Proxmox Datacenter Manager dokumentiert inzwischen vorbereitete Antworten, Zielfilter, Installationsübersicht und eigene Installationstokens. [3] Falls diese Lösung bereits vorhanden ist, sollte vor Eigenentwicklung geprüft werden, welche Funktionen sie abdeckt. Der in diesem Konzept ausgearbeitete Eigenbau erfüllt die Docker Vorgabe und legt zusätzlich die hostbezogene Postinstallation mit Checkpoints und Laufsteuerung fest. + +## 15 Quellen und technische Nachweise + +Die Quellen belegen die verwendeten Produktmechanismen. Datenmodell, eigene API Pfade, Rollen, Fristen, Betriebsziele und Wiederaufnahmelogik sind Festlegungen dieses Feinkonzepts. Vor einer Implementierungsfreigabe werden die Mechanismen mit den tatsächlich eingesetzten ISO und Paketversionen geprüft. + +[\[1\] Proxmox VE Wiki Automated Installation](https://pve.proxmox.com/wiki/Automated_Installation) + +Dokumentierter Antwortabruf, Authentifizierung und First Boot Optionen. Stand des recherchierten Wiki Eintrags: 14. Juli 2026. Referenz für den versionsbezogenen Adapter und die vollständige Antwortdatei. + +[\[2\] Proxmox Entwicklerarchiv First Boot Hook Implementierung](https://lists.proxmox.com/pipermail/pve-devel/2024-November/066649.html) + +Primärer Implementierungsnachweis vom 18. November 2024: Abruf des First Boot Executables im Installer, Quellenwahl und Ausführungsreihenfolge. Der Beitrag ist ein historischer Patch; die Produktfreigabe stützt sich zusätzlich auf den praktischen Test des gewählten Builds. + +[\[3\] Proxmox Datacenter Manager Automated Installations](https://pdm.proxmox.com/docs/automated-installations.html) + +Offizielle Dokumentation zu vorbereiteten Antworten, Installationsinformationen, Tokenverwaltung und ISO Vorbereitung. Recherchierte Dokumentationsversion: 1.1.7. + +[\[4\] FastAPI Features](https://fastapi.tiangolo.com/features/) + +Offizielle Funktionsbeschreibung zu OpenAPI, JSON Schema und API Dokumentation. + +[\[5\] SQLite Write Ahead Logging](https://www.sqlite.org/wal.html) + +Offizielle Beschreibung des WAL Modus und seiner Einschränkungen, insbesondere bei gemeinsam genutzten Netzwerkdateisystemen und Schreibzugriffen. + +[\[6\] Docker Volumes](https://docs.docker.com/engine/storage/volumes/) + +Offizielle Beschreibung persistenter Volumes und ihrer Unabhängigkeit vom Containerlebenszyklus. + +### Wichtige Nachweise aus dem Pilotbetrieb + +Für jede unterstützte Kombination werden ISO Digest, Assistant Paketversion, Installer Payload Muster, validierte Antwortdatei ohne Geheimnisse, First Boot Protokoll, Runner Version und Ergebnis der Abnahmetests archiviert. Diese Nachweise bilden die Supportmatrix und verhindern, dass eine ungetestete Versionsänderung automatisch für produktive Installationen verwendet wird. diff --git a/README.md b/README.md new file mode 100644 index 0000000..62b8c2d --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Proxmox AIS + +Webtool für kontrollierte Proxmox-Neuinstallationen und wiederaufnehmbare +Postinstallation auf Basis des [Feinkonzepts](Feinkonzept_Proxmox_Provisionierung.md). +Ein FastAPI-Dienst bündelt Inventar, versionierte Profile und Bash-Module, +Installationsfreigaben, ISO-Registrierung, Laufprotokolle und Auditdaten. +SQLite und unveränderliche Artefakte liegen in einem persistenten Datenverzeichnis; +der Schlüssel für verschlüsselte Geheimnisse liegt separat. + +**Unabhängigkeitshinweis:** Proxmox AIS ist ein unabhängiges Projekt und steht in keiner Verbindung zur Proxmox Server Solutions GmbH oder den Entwicklern von Proxmox Virtual Environment. + +**Status: erste Implementierung für die Laborabnahme.** +Es ist noch kein echter Proxmox-ISO-Build auf Hardware oder in einer VM abgenommen. +Die [Kompatibilitätsmatrix](docs/compatibility.md) trennt Softwaretests von noch +offenen Installationstests. Freigaben können Datenträger überschreiben lassen; +für die erste Abnahme ausschließlich dedizierte Testsysteme verwenden. + +## Lokal starten + +Python 3.12 oder neuer wird benötigt. Die Anwendung selbst läuft unter Windows +und Linux; der Host-Runner benötigt Proxmox/Linux mit systemd, Python und Bash. + +PowerShell: + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e ".[dev]" +$env:PUBLIC_URL = "http://127.0.0.1:8080" +$env:SECURE_COOKIES = "false" +.\.venv\Scripts\proxmox-ais.exe init --username admin +.\.venv\Scripts\proxmox-ais.exe serve +``` + +Linux/macOS: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -e '.[dev]' +export PUBLIC_URL=http://127.0.0.1:8080 +export SECURE_COOKIES=false +.venv/bin/proxmox-ais init --username admin +.venv/bin/proxmox-ais serve +``` + +`init` fragt das Passwort zweimal verdeckt ab, speichert einen scrypt-Hash und +legt `secrets/master.key` mit restriktiven Rechten an. Anschließend + öffnen. Es gibt keine mitgelieferten Zugangsdaten. +Die HTTP-Einstellungen sind ausschließlich für die lokale Entwicklung gedacht. + +Optional `config/app.example.toml` kopieren und dessen Pfad über `APP_CONFIG` +setzen. Umgebungsvariablen überschreiben TOML-Werte. Weitere Einstellungen +und TLS-Konfiguration stehen in der [Betriebsanleitung](docs/operations.md). + +## Registry-Container deployen + +Auf dem Zielserver werden Docker Engine, das Compose-Plugin und ein +HTTPS-Reverse-Proxy benötigt. Die Anwendung wird als fertiges Image aus der +GitHub Container Registry (GHCR) heruntergeladen. Quellcode und Build-Werkzeuge werden +für das Deployment nicht benötigt. + +[compose.yaml](compose.yaml) und die [Umgebungsvorlage](config/deployment.env.example) +in dasselbe Verzeichnis auf dem Zielserver kopieren; die Vorlage dort als `.env` +speichern. `PUBLIC_URL` auf die erreichbare HTTPS-URL der Anwendung setzen. +Die vollständige `PROVISIONER_IMAGE=…@sha256:…`-Zeile aus `deploy.env` im Artefakt +`container-deploy` eines erfolgreichen `container-publish`-Jobs in `.env` übernehmen. Alternativ +einen bereits veröffentlichten Versionstag aus der Projektregistry verwenden. +Compose benötigt ausdrücklich `PROVISIONER_IMAGE`; ein lokaler Build ist nicht +Teil dieser Deployment-Konfiguration. + +Im Deployment-Verzeichnis ausführen. Für ein privates GHCR-Paket beim Login den +eigenen GitHub-Benutzernamen und einen Personal Access Token (classic) mit +`read:packages` als Passwort verwenden. Bei einem öffentlichen Paket entfällt +der Login. Details stehen unter [Registry-Anmeldung](docs/deployment.md#an-der-registry-anmelden-und-image-laden). + +```bash +# Nur für ein privates Paket; GITHUB_USERNAME ersetzen: +docker login ghcr.io --username GITHUB_USERNAME +docker compose config --quiet +docker compose pull provisioner +# Nur bei der ersten Inbetriebnahme: +docker compose run --rm --no-deps --volume proxmox-ais-keys:/run/secrets:rw provisioner init --username admin +docker compose up -d --no-build --wait +docker compose ps +``` + +Der HTTPS-Reverse-Proxy leitet auf `127.0.0.1:8080` weiter. Nur `init` bindet das +Schlüsselvolume schreibbar ein; im Regelbetrieb läuft das Image als UID/GID 10001 +mit schreibgeschütztem Dateisystem. Die Daten liegen in `proxmox-ais-data`, der +Master-Key getrennt in `proxmox-ais-keys`. + +Die [Deployment-Anleitung](docs/deployment.md) beschreibt Image-Auswahl, +Erstinitialisierung, Updates, Rollback und die Prüfung des laufenden Containers. + +## GitHub Actions CI/CD + +Der [GitHub-Actions-Workflow](.github/workflows/ci.yml) prüft Python und JavaScript, baut das Image +und testet HTTPS, Anmeldung und Datenerhalt beim Neustart im gehärteten Container. +Geschützte Standardbranch-Pushes veröffentlichen `sha-` und `edge` in +`ghcr.io/netivra/proxmox-ais`; geschützte Release-Tags wie `v0.1.0` zusätzlich +die passende Versionsnummer. Feature-Branches und Pull Requests werden ohne Push geprüft. + +Die Jobs verwenden GitHub-gehostete Runner mit `ubuntu-24.04`, Python 3.13, +Node.js 24 und Docker/Buildx. Ein eigener CI-Runner ist nicht erforderlich. +Für die Veröffentlichung müssen der Standardbranch und die Release-Tags durch +aktive GitHub-Regeln geschützt sein. GHCR erhält das automatisch bereitgestellte +`GITHUB_TOKEN`; ein eigenes CI-Secret wird nicht benötigt. Einrichtung, +Schutzregeln und Artefakte stehen in der [GitHub-Anleitung](docs/github-actions.md). + +Die Pipeline liefert das getestete Image und dessen Digest in `deploy.env`. +Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deployment.md). + +## Erster Installationslauf + +1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`, + `author`, `admin` und `developer`. +2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten + Token sicher speichern. Er ist Bestandteil des Installationsmediums. +3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen. + Das Medium erst nach bestandenem Labortest freigeben. +4. Root-Zugang als Geheimnis speichern, Installationsprofil und + Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und + `verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig + muss eine andere Person als der Autor veröffentlichen. +5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen + und Medium erfassen. Vorschau und geprüfte Zielgeräte kontrollieren. +6. Eine zeitlich begrenzte Freigabe mit FQDN-Bestätigung und ausdrücklicher + Datenträgerbestätigung erteilen. Erst anschließend vom vorbereiteten Medium booten. +7. Laufstatus und redigierte Logs verfolgen. Der Lauf gilt erst nach erfolgreichen + Pflichtprüfungen als abgeschlossen. + +Die [ISO-Anleitung](docs/iso-preparation.md) beschreibt den offiziellen Assistant; +der [API-Ablauf](docs/api-workflow.md) zeigt konkrete Requests. Die vollständigen +Schemas stehen nach Anmeldung am laufenden Dienst unter `/openapi.json`; ein +generierter Stand liegt in [docs/openapi.json](docs/openapi.json). + +## Prüfen und sichern + +```bash +.venv/bin/python -m pytest +.venv/bin/proxmox-ais backup ./backups/first-snapshot +``` + +Unter PowerShell entsprechend `.\.venv\Scripts\python.exe -m pytest` und +`.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden. +Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen. + +Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte +Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den +Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich +offline in ein leeres Datenverzeichnis und sperrt alte Maschinenberechtigungen. +Details stehen in [Betrieb und Wiederherstellung](docs/operations.md). + +## Umfang und Grenzen + +ISO-Erstellung erfolgt auf einer getrennten Build-Maschine. BMC-Steuerung, PXE, +Clusterbeitritt, Ceph, dauerhafte Konfigurationsverwaltung und zusätzliche +destruktive Storage-Module gehören nicht zu dieser Version. Die Kapazitäten +von 100 Hosts und zehn parallelen Installationen sind Planungsziele, +keine bereits gemessenen Leistungswerte. Gemeinsame ISO-Tokens plus gemeldete +Hardwaremerkmale setzen ein kontrolliertes Provisionierungsnetz voraus. diff --git a/ci/container.sh b/ci/container.sh new file mode 100644 index 0000000..69f3b6d --- /dev/null +++ b/ci/container.sh @@ -0,0 +1,147 @@ +#!/bin/sh +# Build and smoke-test the same image that is optionally published to GHCR. +set -eu + +fail() { + printf '%s\n' "ERROR: $*" >&2 + exit 1 +} + +mode=${1:-} +case "$mode" in + verify|publish) ;; + *) fail 'Usage: sh ci/container.sh verify|publish' ;; +esac + +: "${GITHUB_SHA:?GITHUB_SHA is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" +: "${GITHUB_JOB:?GITHUB_JOB is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || fail 'Expected a full Git commit SHA' +for run_identifier in "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"; do + case "$run_identifier" in + ''|*[!0-9]*) fail 'GitHub run ID and attempt must be numeric' ;; + esac +done +case "$GITHUB_JOB" in + ''|[!A-Za-z_]*|*[!A-Za-z0-9_-]*) fail 'GITHUB_JOB must be a safe job identifier' ;; +esac +printf '%s\n' "$GITHUB_REPOSITORY" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*$' || fail 'Expected GITHUB_REPOSITORY in owner/repository form' +registry_image="ghcr.io/$(printf '%s' "$GITHUB_REPOSITORY" | LC_ALL=C tr '[:upper:]' '[:lower:]')" +project_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}" +job_identifier="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}" + +# Read the literal PEP 621 version without needing the test job's virtual +# environment, installing dependencies, or evaluating project code. +project_version=$(awk ' + /^\[project\][[:space:]]*$/ { in_project = 1; next } + /^\[/ { in_project = 0 } + in_project && /^[[:space:]]*version[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*"/, "", value) + sub(/"[[:space:]]*(#.*)?$/, "", value) + print value + exit + } +' pyproject.toml) +[ -n "$project_version" ] || fail 'Cannot read [project].version from pyproject.toml' + +publish_tag= +if [ "$mode" = publish ]; then + [ "${GITHUB_REF_PROTECTED:-}" = true ] || fail 'Publication requires a protected branch or tag' + case "${GITHUB_EVENT_NAME:-}" in + push|workflow_dispatch) ;; + *) fail 'Publication is allowed only from push or workflow_dispatch events' ;; + esac + case "${GITHUB_REF_TYPE:-}" in + tag) + printf '%s\n' "${GITHUB_REF_NAME:-}" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' || fail 'Release tags must have the form vX.Y.Z' + publish_tag=${GITHUB_REF_NAME#v} + [ "$publish_tag" = "$project_version" ] || fail "Release tag $GITHUB_REF_NAME does not match project version $project_version" + ;; + branch) + [ -n "${DEFAULT_BRANCH:-}" ] && [ "${GITHUB_REF_NAME:-}" = "$DEFAULT_BRANCH" ] || fail 'Only the default branch or a version tag may publish' + publish_tag=edge + ;; + *) fail 'Publication requires a branch or tag ref' ;; + esac + : "${GITHUB_ACTOR:?GITHUB_ACTOR is required for publication}" + : "${GHCR_TOKEN:?GHCR_TOKEN is required for publication}" +fi + +BUILD_IMAGE="${registry_image}:ci-${job_identifier}" +smoke_container="ais-smoke-${job_identifier}" +smoke_started=false +build_started=false +docker_config_dir= +cleanup() { + if [ "$smoke_started" = true ]; then + docker rm --force "$smoke_container" >/dev/null 2>&1 || true + fi + if [ "$build_started" = true ]; then + # A self-hosted runner may use a persistent daemon. Remove only this job's + # unique tag; published SHA/channel tags may be used by other jobs. + docker image rm "$BUILD_IMAGE" >/dev/null 2>&1 || true + fi + if [ -n "$docker_config_dir" ]; then + rm -f "$docker_config_dir/config.json" + rmdir "$docker_config_dir" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +rm -f build.env deploy.env + +build_started=true +# Load one single-platform image locally so the smoke test and both published +# tags all use the exact image built here, without separate manifest artifacts. +docker build --pull --platform linux/amd64 \ + --provenance=false --sbom=false \ + --label "org.opencontainers.image.source=$project_url" \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" \ + --label "org.opencontainers.image.version=$project_version" \ + --tag "$BUILD_IMAGE" . + +# Stream the smoke script through stdin, so no checkout bind mount is needed. +smoke_started=true +docker run --rm --interactive --name "$smoke_container" \ + --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --network none --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + --entrypoint python "$BUILD_IMAGE" - < tests/container_smoke.py + +printf 'BUILD_IMAGE=%s\nIMAGE_REVISION=%s\nIMAGE_VERSION=%s\n' \ + "$BUILD_IMAGE" "$GITHUB_SHA" "$project_version" > build.env + +if [ "$mode" = publish ]; then + docker_config_dir=$(mktemp -d "${TMPDIR:-/tmp}/ais-ci-docker.XXXXXXXX") + DOCKER_CONFIG=$docker_config_dir + export DOCKER_CONFIG + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io \ + --username "$GITHUB_ACTOR" --password-stdin + + commit_image="${registry_image}:sha-${GITHUB_SHA}" + channel_image="${registry_image}:${publish_tag}" + docker tag "$BUILD_IMAGE" "$commit_image" + docker push "$commit_image" + docker tag "$BUILD_IMAGE" "$channel_image" + docker push "$channel_image" + + # Tags can be reassigned by a later rebuild; use the registry digest for + # a deployment reference that keeps identifying precisely this image. + repo_digests=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$BUILD_IMAGE") + image_digest= + while IFS= read -r candidate; do + case "$candidate" in + "$registry_image"@sha256:*) image_digest=$candidate; break ;; + esac + done < deploy.env + printf 'Published %s and %s\nDeployment image: %s\n' "$commit_image" "$channel_image" "$image_digest" +fi diff --git a/ci/python-tests.sh b/ci/python-tests.sh new file mode 100644 index 0000000..8d0ea89 --- /dev/null +++ b/ci/python-tests.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Run tests in an isolated, disposable environment on a Linux Actions runner. +set -eu + +fail() { + printf '%s\n' "ERROR: $*" >&2 + exit 1 +} + +: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}" +: "${GITHUB_JOB:?GITHUB_JOB is required}" +for run_identifier in "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"; do + case "$run_identifier" in + ''|*[!0-9]*) fail 'GitHub run ID and attempt must be numeric' ;; + esac +done +case "$GITHUB_JOB" in + ''|[!A-Za-z_]*|*[!A-Za-z0-9_-]*) fail 'GITHUB_JOB must be a safe job identifier' ;; +esac +job_identifier="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}" + +for required_tool in python3 bash openssl ssh-keygen; do + command -v "$required_tool" >/dev/null 2>&1 || fail "Required runner tool is missing: $required_tool" +done +python3 -c 'import sys; sys.version_info >= (3, 12) or sys.exit("Python 3.12 or newer is required")' + +cd "$GITHUB_WORKSPACE" +project_root=$(pwd -P) +[ -f pyproject.toml ] || fail 'GITHUB_WORKSPACE does not contain pyproject.toml' +# Resolve the checkout first and allocate a fresh directory inside it. The +# cleanup target is never taken from an arbitrary environment-provided path. +venv_dir=$(mktemp -d "$project_root/.venv-ci-${job_identifier}.XXXXXXXX") +cleanup() { + cleanup_status=$? + trap - EXIT + case "$venv_dir" in + "$project_root"/.venv-ci-"$job_identifier".*) rm -rf -- "$venv_dir" ;; + *) printf '%s\n' 'ERROR: Refusing to clean an unexpected virtual environment path' >&2 ;; + esac + exit "$cleanup_status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +python3 -m venv "$venv_dir" +"$venv_dir/bin/python" -m pip install '.[dev]' +mkdir -p reports +"$venv_dir/bin/python" -m pytest --junitxml=reports/pytest.xml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..0c37867 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + provisioner: + image: ${PROVISIONER_IMAGE:?PROVISIONER_IMAGE in .env auf das Image aus der GitHub Container Registry setzen} + restart: unless-stopped + init: true + ports: + - "127.0.0.1:8080:8080" + environment: + DATA_DIR: /var/lib/proxmox-ais + MASTER_KEY_FILE: /run/secrets/master.key + PUBLIC_URL: ${PUBLIC_URL:-https://provision.example.net} + SECURE_COOKIES: "true" + MAINTENANCE: ${MAINTENANCE:-false} + volumes: + - ais-data:/var/lib/proxmox-ais + - ais-keys:/run/secrets:ro + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +volumes: + ais-data: + name: proxmox-ais-data + ais-keys: + name: proxmox-ais-keys diff --git a/config/app.example.toml b/config/app.example.toml new file mode 100644 index 0000000..c976a76 --- /dev/null +++ b/config/app.example.toml @@ -0,0 +1,8 @@ +# Optional configuration: set APP_CONFIG to this file's absolute path. +# Environment variables override these values. +data_dir = "./data" +master_key_file = "./secrets/master.key" +public_url = "https://provision.example.net" +secure_cookies = true +# Supply the initial administrator interactively with `proxmox-ais init`. +# Keep the encryption key outside data_dir and back it up separately. diff --git a/config/deployment.env.example b/config/deployment.env.example new file mode 100644 index 0000000..6f9def8 --- /dev/null +++ b/config/deployment.env.example @@ -0,0 +1,8 @@ +# Extern erreichbare HTTPS-URL der Anwendung (ohne Unterpfad). +PUBLIC_URL=https://provision.example.net +MAINTENANCE=false + +# Durch die vollstaendige PROVISIONER_IMAGE-Zeile aus deploy.env im GitHub- +# Actions-Artefakt container-deploy ersetzen. Alternativ einen vorhandenen +# Versionstag aus GHCR verwenden. Der Beispieltag muss veroeffentlicht sein. +PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais:0.1.0 diff --git a/docs/api-workflow.md b/docs/api-workflow.md new file mode 100644 index 0000000..4856e77 --- /dev/null +++ b/docs/api-workflow.md @@ -0,0 +1,166 @@ +# API-Ablauf im Labor + +Die API verwendet Sessioncookies und für alle Verwaltungsänderungen +`X-CSRF-Token`. Die Beispiele benötigen Bash, curl und jq. Für PowerShell steht +die gleiche API über `Invoke-RestMethod` zur Verfügung. Das vollständige Schema +ist nach Anmeldung unter `/openapi.json` und als generierter Repositorystand in +[openapi.json](openapi.json) verfügbar. + +## Anmelden und Daten lesen + +```bash +export AIS_URL=https://provision.example.net +umask 077 +read -rsp 'Administrator-Passwort: ' AIS_PASSWORD +printf '\n' +printf '%s' "$AIS_PASSWORD" | curl --fail --silent --show-error \ + --cookie-jar session.cookies --data-urlencode username=admin \ + --data-urlencode password@- "$AIS_URL/auth/login" --output /dev/null +unset AIS_PASSWORD +AIS_CSRF=$(curl --fail --silent --show-error --cookie session.cookies \ + "$AIS_URL/api/v1/me" | jq -r .csrf_token) +curl --fail --silent --show-error --cookie session.cookies "$AIS_URL/api/v1/hosts" +``` + +Cookies wie Zugangsdaten behandeln und nach Ende der Sitzung löschen. +Der Server liefert bei nicht angemeldeten API-Aufrufen HTTP 401 und bei +unzulässigen Rollen oder fehlendem CSRF-Token HTTP 403. + +## Profilversion anlegen + +Zuerst einen Root-Passwort-Hash als Geheimnis in der Weboberfläche speichern. +Unter Linux kann ein SHA-512-crypt-Hash etwa mit `openssl passwd -6` interaktiv +erzeugt werden. Den Hash nicht als Klartextfeld in ein Profil aufnehmen. + +[sample-profile.json](sample-profile.json) kopieren und alle Platzhalter anpassen: +Secret-ID, konkret geprüfter Build, Management-Interface, Datenträgerseriennummer +und Inventarisierungsnachweis. `9.1-1` dient lediglich als Formatbeispiel und ist +keine Kompatibilitätsfreigabe. FQDN und Management-IP kommen aus dem Hostinventar. + +```bash +curl --fail --silent --show-error --cookie session.cookies \ + --header "X-CSRF-Token: $AIS_CSRF" \ + --json @my-installation-profile.json "$AIS_URL/api/v1/profiles" +``` + +Die Antwort enthält `id`, `version`, `digest` und `status=draft`. Ein weiterer +POST mit demselben Namen und Typ erstellt eine neue unveränderliche Version. +Bereits vorbereitete Läufe behalten die zuvor aufgelösten Werte und Versionen. + +Veröffentlichung erfolgt in einer Sitzung einer anderen berechtigten Person: + +```bash +curl --fail --silent --show-error --cookie reviewer.cookies \ + --header "X-CSRF-Token: $REVIEWER_CSRF" \ + --json '{"test_evidence":"lab-report-2026-09-13","reason":"Labortest erfolgreich geprüft"}' \ + "$AIS_URL/api/v1/profiles/$PROFILE_ID/publish" +``` + +Module werden analog über `POST /api/v1/modules` und +`POST /api/v1/modules/{id}/publish` versioniert und veröffentlicht. Ausgangsvorlagen +stehen unter `GET /api/v1/modules/builtin`. Sie enthalten keinen ausgefüllten +Testnachweis und benötigen Anpassung sowie Prüfung auf dem Zielhost. + +Ein Postinstallationsprofil verweist auf unveränderliche Modul-IDs: + +```json +{ + "name": "lab-postinstall", + "kind": "postinstall", + "target_builds": ["9.1-1"], + "steps": [ + { + "id": "final-check", + "module_id": "REPLACE_WITH_PUBLISHED_MODULE_ID", + "parameters": {}, + "required": true + } + ], + "reboot_budget": 1 +} +``` + +Modulabhängigkeiten müssen bereits in vorherigen Schritten enthalten sein. +Mindestens eine Pflichtprüfung wird verlangt. Modulparameter werden gegen das +JSON-Schema der gewählten Modulversion validiert. + +## Host prüfen und Installation freigeben + +Gruppentoken, geprüftes Medium und beide veröffentlichten Profile müssen bereits +existieren. Der Host verweist über `installation_profile_id`, +`postinstall_profile_id` und `iso_id` auf diese Objekte. + +```bash +curl --fail --silent --show-error --cookie session.cookies \ + "$AIS_URL/api/v1/hosts/$HOST_ID/preview" +curl --fail --silent --show-error --cookie session.cookies \ + --header "X-CSRF-Token: $AIS_CSRF" \ + --json '{"expected_version":1,"valid_minutes":30,"confirmation":"pve01.lab.example.net","disks_confirmed":true,"reason":"Freigegebener dedizierter Labortest"}' \ + "$AIS_URL/api/v1/hosts/$HOST_ID/approve-install" +``` + +Vor dem zweiten Aufruf tatsächlichen FQDN, aktuelle Hostversion und dargestellte +Zielgeräte prüfen. Die Aktion erzeugt einen vorbereiteten Lauf und bindet die +Konfiguration. Ein zweiter gleichzeitiger aktiver Lauf für denselben Host ist gesperrt. + +Der Proxmox-Installer sendet seinen nativen Request selbst. Für einen isolierten +API-Test zeigt dieses Beispiel die verwendete Payload-Struktur: + +```json +{ + "$schema": {"version": "1.0"}, + "product": {"product": "pve"}, + "iso": {"release": "9.1", "build": "1"}, + "dmi": { + "system": { + "uuid": "d2e59b03-13cf-4ac9-a390-78c55f6a36d3", + "serial": "LAB-HOST-001" + } + }, + "network-interfaces": [{"mac": "02:00:00:00:00:01"}] +} +``` + +Der echte Installer darf weitere Hardwarefelder senden. Registrierung und +Antwortschema stammen aus den +[offiziellen Installer-Typen](https://github.com/proxmox/proxmox-rs/blob/master/proxmox-installer-types/src/answer.rs). +Ein unbekannter Host erhält keine TOML-Antwort. Der Gruppentoken muss zum Standort +und zum freigegebenen Medium des Hosts passen. + +```bash +curl --fail --silent --show-error \ + --header "Authorization: Bearer $INSTALLER_GROUP_TOKEN" \ + --json @installer-info.json "$AIS_URL/installer/v1/answer" \ + --output answer.toml +``` + +Dieser Abruf verbraucht eine Freigabe. `answer.toml` enthält den Root-Hash und +runbezogene Downloadberechtigungen; wie Zugangsdaten schützen. Innerhalb des +kurzen Auslieferungsfensters liefern Wiederholungen dieselbe Antwort. + +## Runner und Status + +Der Starthelfer richtet den Runner automatisch ein. Das Gerät erzeugt einen +eigenen Ed25519-Schlüssel, registriert dessen öffentlichen Anteil und signiert +weitere Requests einschließlich Methode, Pfad, Zeitstempel, Nonce und Body-Digest. +Ein gewöhnlicher curl-Aufruf mit Installer-Gruppentoken berechtigt nicht zum +Manifest-, Artefakt- oder Secret-Abruf. + +```bash +curl --fail --silent --show-error --cookie session.cookies \ + "$AIS_URL/api/v1/runs/$RUN_ID" +``` + +Die Laufansicht enthält Schrittzustände, redigierte Logs und die fixierte +Konfiguration. Fortsetzung und Abbruch benötigen `expected_version` und einen +Begründungstext über `/api/v1/runs/{id}/resume` bzw. `/cancel`. Eine Fortsetzung +erst nach Prüfung des lokalen Zustands anfordern. Ein bereits laufender +Paketmanager wird beim Abbruch am nächsten sicheren Übergang angehalten. + +Für einen dauerhaft verlassenen Lauf steht `/api/v1/runs/{id}/reconcile` zur +Verfügung. Zuerst den Abbruch anfordern, ausgestellte Leases und das +Antwortauslieferungsfenster ablaufen lassen sowie Installer und Runner lokal +stoppen und prüfen. Danach `expected_version`, `reason`, `confirmation` mit dem +Host-FQDN und `execution_stopped: true` senden. Diese gesonderte Bestätigung beendet +den Lauf als `cancelled`; eine neue Installation benötigt anschließend eine +eigene Freigabe. Der Ablauf ist auch für `needs_review` nach Restore vorgesehen. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..b068f54 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,44 @@ +# Kompatibilität und Abnahme + +Es gibt zum Auslieferungszeitpunkt **keinen auf Hardware oder in einer VM +freigegebenen Proxmox-ISO-Build**. Die Registrierung `test_status=passed` ist eine +vom Administrator dokumentierte Freigabe; sie ersetzt keinen tatsächlichen Test. + +| Kombination | Status | Nachweis | +| --- | --- | --- | +| API/SQLite/Sicherheitslogik, Python 3.14 unter Windows | Automatisierte lokale Softwaretests | `python -m pytest` | +| Python 3.13 im bereitgestellten Dockerfile | Image gebaut; Tests, TLS-Start und Dienstneustart bestanden | Ergebnisse im [Testbericht](test-report.md) | +| Python 3.12+ | Deklarierte Python-Untergrenze; keine vollständige CI-Matrix | Auf gewählter Version testen | +| Proxmox VE ISO mit nativem Answer-Token und First-Boot-URL | Pro konkretem ISO-Build zu testen | Noch offen | +| UEFI, BIOS und Secure Boot | Je verwendetem Bootmodus zu testen | Noch offen | +| Netzunterbrechung/Neustart auf echtem Proxmox-Host | Runner-Softwaretests plus erforderlicher Integrationstest | Hardware-/VM-Test offen | +| 100 Hosts, zehn gleichzeitige Installationen | Planungsziel | Lasttest offen | +| RPO 24 Stunden / RTO zwei Stunden | Planungsziel | Betrieblicher Restore-Test offen | + +Ein ISO ohne native Unterstützung für `--answer-auth-token` wird nicht freigegeben. +Die Assistant-Version allein belegt nicht die Funktionen der Komponenten in einer +älteren ISO. Alle Beispieldaten sind Platzhalter und stellen keine Freigabe dar. + +## Freigabe eines Builds + +Für jeden Build ein eigenes Prüfprotokoll außerhalb der Anwendung archivieren: + +- Original-ISO und SHA-256, Assistant-Paketversion, Build-Befehl, Ergebnisdatei + und Prüfsumme; Tokens im Protokoll redigieren. +- Installer-Request als redigiertes JSON; UUID-, Seriennummer- und MAC-Zuordnung + sowie Ablehnung unbekannter und widersprüchlicher Identitäten. +- Antwortdatei ohne Geheimnisse, Prüfung mit dem zugehörigen Assistant, korrekte + Netzwerkwerte und Auswahl ausschließlich der vorgesehenen Testdatenträger. +- Bearer-Header, TLS-Verifikation, First-Boot-Download, Zeitpunkt des ersten + Starts und Vertrauen in die Server-CA. +- Modulabläufe `check`, `apply`, `verify`, Verlust einer Serverantwort, + Netzausfall, erneuter Start und kontrollierter Reboot. Unterbrochene nicht sichere + Änderungen müssen einen prüfbedürftigen Zustand ergeben. +- Abschluss nur nach erfolgreichen Pflichtprüfungen; danach keine erneute + Installationsantwort oder weitere Skriptausführung mit alten Berechtigungen. +- Hardware-/VM-Konfiguration und Bootverfahren. Secure Boot nur nennen, + wenn genau dieser Modus tatsächlich geprüft wurde. + +Die Akzeptanzszenarien A01–A16 des Feinkonzepts sind die vollständige fachliche +Abnahmeliste. Repositorytests decken die simulierbaren Server- und Runner-Teile ab, +nicht die Betriebssysteminstallation oder reale Ausfälle. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a226987 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,241 @@ +# Deployment mit einem Image aus der Container Registry + +Proxmox AIS wird auf dem Zielserver aus einem bereits veröffentlichten +Containerimage gestartet. Der [GitHub-Actions-Workflow](github-actions.md) erstellt dieses Image; auf dem +Zielserver werden nur Docker Compose und die Betriebskonfiguration benötigt. +Diese Anleitung setzt einen erfolgreichen `container-publish`-Job und ein +weiterhin abrufbares Image voraus. + +## Voraussetzungen und Dateien + +Der Zielserver benötigt Linux auf amd64, Docker Engine, das Docker-Compose-Plugin +und einen HTTPS-Reverse-Proxy. Die Docker-Befehle unter einem Deploymentkonto +mit Docker-Zugriff ausführen. Dasselbe Konto für `docker login` und +`docker compose` verwenden, damit die Registry-Anmeldung verfügbar ist. +Die [Docker-Installationsanleitung](https://docs.docker.com/engine/install/debian/) +beschreibt beispielsweise die Installation auf Debian. + +Python, Node.js, ein CI-Runner und Build-Werkzeuge gehören zur Build-Umgebung; +die Anwendung samt Laufzeit ist im veröffentlichten Image enthalten. Ein +Checkout des Anwendungsquellcodes ist auf dem Zielserver nicht erforderlich. + +Ein beschreibbares Deploymentverzeichnis bereitstellen, beispielsweise +`/opt/proxmox-ais`, und diese beiden Dateien aus dem gewünschten Projektstand +herunterladen beziehungsweise dorthin kopieren: + +| Datei aus dem Repository | Dateiname auf dem Zielserver | +| --- | --- | +| [compose.yaml](../compose.yaml) | `/opt/proxmox-ais/compose.yaml` | +| [config/deployment.env.example](../config/deployment.env.example) | `/opt/proxmox-ais/.env` | + +Im weiteren Verlauf alle Compose-Befehle in diesem Verzeichnis ausführen: + +```bash +cd /opt/proxmox-ais +chmod 600 .env +docker version +docker compose version +``` + +Die Compose-Datei verwendet das Image aus `PROVISIONER_IMAGE`. Ein fehlender +Wert führt bereits bei der Konfigurationsprüfung zu einem Fehler. + +## Image und öffentliche Adresse festlegen + +In GitHub unter **Actions** den erfolgreichen Workflow-Lauf mit +`container-publish` öffnen, unter **Artifacts** das Archiv `container-deploy` +herunterladen und entpacken. Es enthält `build.env` und `deploy.env`. +Die vollständige Zeile `PROVISIONER_IMAGE=…@sha256:…` aus `deploy.env` in die +`.env` auf dem Zielserver übernehmen. Der Digest legt genau das veröffentlichte +Image fest. Compose liest `deploy.env` nicht automatisch ein. +[GitHub-Artefakte herunterladen](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts). + +Alternativ auf GitHub das zugehörige **Packages**-Paket öffnen und den vollständigen +Imagepfad mit einem tatsächlich vorhandenen Versions-Tag kopieren. Der aktuelle +Imagepfad der Beispieldatei sieht so aus: + +```dotenv +PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais:0.1.0 +PUBLIC_URL=https://provision.example.net +MAINTENANCE=false +``` + +`PROVISIONER_IMAGE` und `PUBLIC_URL` für die Installation festlegen. Der +Imagepfad entspricht dem aktuellen Wert der Vorlage; die Verfügbarkeit des +Tags `0.1.0` wird damit nicht vorausgesetzt. Maßgeblich sind das +Pipeline-Artefakt oder die Registry-Anzeige deines Projekts. +Der Registry-Endpunkt ist `ghcr.io`. Für reproduzierbare Deployments den Digest +bevorzugen und die bisher verwendeten Digests für spätere Updates aufbewahren. + +`PUBLIC_URL` ist die vollständige HTTPS-Basisadresse ohne zusätzlichen Pfad, +unter der Browser, Installer und installierte Hosts den Dienst erreichen. +DNS und Zertifikatskette müssen für diese Systeme gültig sein. Der +Reverse-Proxy auf dem Zielserver leitet diese Adresse an +`http://127.0.0.1:8080` weiter. Der Compose-Port ist nur auf Loopback gebunden. +Bei einem Proxy auf einem anderen Host oder in einem anderen Container dessen +Verbindung gezielt nach der [Betriebsanleitung](operations.md#tls-und-reverse-proxy) +einrichten; `127.0.0.1` bezeichnet dort jeweils das eigene System. + +## An der Registry anmelden und Image laden + +Öffentliche GHCR-Pakete lassen sich ohne Anmeldung herunterladen. Für ein +privates Paket einen GitHub Personal Access Token **(classic)** mit dem Scope +`read:packages` verwenden. Das zugehörige GitHub-Konto benötigt Lesezugriff auf +das Paket; bei Organisations-SSO den Token dafür autorisieren. Der Token wird +bei der Anmeldung als Passwort verdeckt eingegeben und gehört nicht in `.env`. +Das kurzlebige `GITHUB_TOKEN` des CI-Jobs ist kein Deployment-Zugang. +[GHCR-Authentifizierung](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-with-a-personal-access-token-classic). + +Nur für private Pakete anmelden und `GITHUB_USERNAME` durch den GitHub-Namen +des Token-Inhabers ersetzen: + +```bash +docker login ghcr.io --username GITHUB_USERNAME +``` + +Konfiguration prüfen und das gewählte Image herunterladen: + +```bash +docker compose config --quiet +docker compose pull provisioner +``` + +Erst nach erfolgreichem Pull fortfahren. `unauthorized` oder `denied` weist +auf Anmeldung beziehungsweise Zugriffsrechte hin; bei `manifest unknown` den +Imagepfad und den verfügbaren Tag/Digest prüfen. Bei internen Zertifikaten +den CA-Vertrauensanker im Docker-Dienst des Zielservers einrichten. + +## Einmalige Initialisierung und Start + +Nur bei der ersten Inbetriebnahme mit neuen Daten- und Schlüsselvolumes den +Administrator und den Master-Key anlegen: + +```bash +docker compose run --rm --no-deps \ + --volume proxmox-ais-keys:/run/secrets:rw provisioner init --username admin +``` + +Das Passwort wird zweimal interaktiv abgefragt. Es gibt kein Standardpasswort. +Das Schlüsselvolume ist für diesen einen Aufruf beschreibbar. Im Regelbetrieb +wird es schreibgeschützt eingebunden. Bei einer bestehenden Installation oder +einem Update diesen Initialisierungsbefehl nicht erneut ausführen. + +Anschließend den Dienst starten: + +```bash +docker compose up -d --no-build --wait +docker compose ps +docker compose logs --tail=100 provisioner +``` + +`--wait` wartet auf einen laufenden, gesunden Dienst gemäß dem Healthcheck im +Image. Der Proxyzugriff wird separat geprüft. +[Compose-Startoptionen](https://docs.docker.com/reference/cli/docker/compose/up/). +Mit `curl`, sofern auf dem Zielserver verfügbar, den lokalen Dienst und die +öffentliche HTTPS-Adresse prüfen; die Beispieladresse ersetzen: + +```bash +curl --fail http://127.0.0.1:8080/health/ready +curl --fail https://provision.example.net/health/ready +``` + +Beide Aufrufe sollen `"status":"ready"` liefern. Danach die öffentliche +Adresse im Browser öffnen und mit dem angelegten Administrator anmelden. + +## Daten und Schlüssel + +Die Anwendung läuft im Container als UID/GID `10001:10001`. Die Compose-Datei +verwendet diese dauerhaften, von Docker verwalteten Volumes: + +| Volume | Inhalt | Containerpfad | +| --- | --- | --- | +| `proxmox-ais-data` | SQLite-Datenbank und Artefakte | `/var/lib/proxmox-ais` | +| `proxmox-ais-keys` | Master-Key für verschlüsselte Geheimnisse | `/run/secrets` | + +Für neue Volumes übernimmt Docker die vorbereiteten Verzeichnisse aus dem +Image; sie sind für den Dienstbenutzer angelegt. Beim Wiederverwenden oder +Wiederherstellen bestehender Volumes müssen Eigentümer und Rechte weiterhin +zu UID/GID 10001 passen. Lokale Bind-Mount-Verzeichnisse benötigen dieselben +passenden Rechte, falls die Compose-Datei später entsprechend angepasst wird. +[Docker-Volumes](https://docs.docker.com/engine/storage/volumes/). + +Containerneustarts und Updates erhalten diese Volumes. +`docker compose down --volumes` würde sie löschen. Für mehrere getrennte Instanzen auf demselben +Host die Volume-Namen in Compose und beim Initialisierungsaufruf je Instanz +anpassen. Daten und Master-Key nach der +[Backup-Anleitung](operations.md#backup) getrennt sichern. + +## Updates + +Ein Wartungsfenster vorsehen. Vor dem ersten Neustart den Digest des +tatsächlich laufenden Images ermitteln, insbesondere bei Verwendung eines +beweglichen Tags: + +```bash +AIS_RUNNING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$(docker compose ps -q provisioner)") +docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$AIS_RUNNING_IMAGE_ID" +``` + +Den zum Projekt passenden Registry-Digest zusammen mit dem Backup aufbewahren +und in `.env` als `PROVISIONER_IMAGE` eintragen. Damit verwendet auch der +Wartungsneustart genau die bisherige Version. Falls kein Registry-Digest +angezeigt wird, die bisherige Version vor dem Update eindeutig zuordnen. + +Jetzt in `.env` `MAINTENANCE=true` setzen und die Einstellung mit der +bisherigen Version übernehmen: + +```bash +docker compose up -d --no-build --wait +``` + +Der Wartungsmodus sperrt neue Installationsfreigaben. Bereits gestartete +Installer und Host-Runner werden dadurch nicht beendet. Ihre laufenden +Phasen vor dem eigentlichen Versionswechsel prüfen und einen geeigneten +Zeitpunkt für den Dienststopp abwarten. + +Jetzt die Daten mit der bisher eingesetzten Anwendungsversion sichern und +den Master-Key separat verwahren. Die [Backup-Anleitung](operations.md#backup) +enthält dafür die Containerbefehle. Zusätzlich die aktuelle `.env`, den +bisherigen Image-Digest und die Proxykonfiguration sichern. + +Danach die neue `PROVISIONER_IMAGE`-Zeile aus dem erfolgreichen +`container-publish`-Job in `.env` übernehmen. `PUBLIC_URL` und die +Volume-Namen beibehalten, `MAINTENANCE=true` gesetzt lassen. Folgender Ablauf +prüft und lädt das neue Image, bevor er den bisherigen Dienst stoppt: + +```bash +( +set -eu +docker compose config --quiet +docker compose pull provisioner +docker compose stop provisioner +docker compose up -d --no-build --wait +docker compose ps +) +``` + +Bei einem fehlgeschlagenen Pull läuft der bisherige Dienst weiter. Nach dem +Update Readiness über HTTPS, Anmeldung und Inventar prüfen. Erst nach +erfolgreicher Prüfung in `.env` `MAINTENANCE=false` setzen und übernehmen: + +```bash +docker compose up -d --no-build --wait +``` + +Der Initialisierungsbefehl gehört ausschließlich zur ersten Inbetriebnahme. + +## Rückkehr zum vorherigen Stand + +Falls der neue Dienst nicht gesund startet, zunächst +`docker compose logs --tail=100 provisioner` prüfen. Das vorherige Image darf +nur auf eine damit kompatible Datenbank zugreifen. Bei bestätigter +Kompatibilität die gesicherte `.env` beziehungsweise den bisherigen exakten +Image-Digest wiederherstellen und den Updateablauf erneut ausführen; den +Wartungsmodus erst nach erfolgreicher Prüfung beenden. + +Hat die neue Version das Schema bereits verändert, den Dienst stoppen und +mit der zum Backup passenden Anwendungsversion nach der +[Restore-Anleitung](operations.md#restore) in ein neues leeres Datenvolume +wiederherstellen. Die vorhandenen Daten erhalten, bis die Wiederherstellung +geprüft ist. Der Restore setzt Maschinenberechtigungen zurück und erfordert +den dort beschriebenen Abgleich laufender Installationen. diff --git a/docs/github-actions.md b/docs/github-actions.md new file mode 100644 index 0000000..fa731d1 --- /dev/null +++ b/docs/github-actions.md @@ -0,0 +1,203 @@ +# GitHub Actions und Container Registry + +Der [Workflow](../.github/workflows/ci.yml) führt die Python-Tests aus, prüft die +JavaScript-Syntax und baut und testet das Anwendungsimage. Freigegebene Builds +werden in die GitHub Container Registry (GHCR) veröffentlicht. Der Workflow +endet beim Image-Push und aktualisiert keinen Zielserver. Die anschließenden +Betriebsschritte stehen in der [Deployment-Anleitung](deployment.md). + +## Auslöser und Image-Tags + +Der Workflow läuft bei Pushes auf alle Branches und Tags, bei Pull Requests +und beim manuellen Start mit `workflow_dispatch`. + +| Ref und Ereignis | Container-Job | Veröffentlichung | +| --- | --- | --- | +| Pull Request, auch aus einem Fork | `container-verify` | Keine | +| Feature-Branch, ungeschützter Ref oder sonstiger Tag | `container-verify` | Keine | +| Geschützter Standardbranch bei Push oder manuellem Start | `container-publish` | `sha-` und `edge` | +| Geschützter Tag `vX.Y.Z` bei Push oder manuellem Start | `container-publish` | `sha-` und `X.Y.Z` | + +Release-Tags müssen dem Muster `v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)` +vollständig entsprechen. Führende Nullen, Vorabversionen wie `v1.0.0-rc.1` und +Build-Zusätze veröffentlichen daher kein Image. Bei einem gültigen geschützten +Release-Tag muss dessen Version außerdem exakt `[project].version` in +[pyproject.toml](../pyproject.toml) entsprechen; eine Abweichung bricht den Lauf ab. + +Das Image liegt für dieses Repository unter `ghcr.io/netivra/proxmox-ais`. +Der Workflow leitet den Pfad aus dem kleingeschriebenen GitHub-Repositorynamen +ab; bei einem Fork oder einer Umbenennung ändert sich deshalb der Imagepfad. +`edge` bezeichnet den zuletzt veröffentlichten Standardbranch-Build. Ein +erneuter Lauf eines älteren Commits kann `edge` zurücksetzen. SHA-Tags enthalten +alle 40 Zeichen des Commits; erneute Builds desselben Quellstands können durch +externe Build-Abhängigkeiten einen anderen Digest ergeben. Es gibt keinen +`latest`-Tag. Im Betrieb den Digest aus `deploy.env` verwenden und die +zugehörigen Registry-Versionen für Updates und Rollback aufbewahren. + +## Jobs und Artefakte + +`python-tests` und `javascript-check` prüfen den Quellstand. Das Skript +[ci/python-tests.sh](../ci/python-tests.sh) installiert `.[dev]` in einer +eigenen temporären virtuellen Umgebung, führt Pytest aus und entfernt die +Umgebung anschließend. Die JavaScript-Prüfung verwendet +`node --check provisioner/static/app.js`; sie ist kein Browsertest. +`container-policy` bestimmt anhand des GitHub-Ereignisses, des Ref-Schutzes +und des Branch- beziehungsweise Tag-Namens, welcher Container-Job laufen darf. + +Nach erfolgreichen Python- und JavaScript-Prüfungen läuft genau einer der +Jobs `container-verify` und `container-publish`. Beide bauen über +[ci/container.sh](../ci/container.sh) ein `linux/amd64`-Image und prüfen es +mit dem [Container-Smoke-Test](../tests/container_smoke.py). Der Publish-Job +lädt genau das bereits getestete Image hoch, ohne einen zweiten Build. +Die Veröffentlichung wird im Skript erneut auf Ereignis und Ref geprüft; +dort erfolgt auch der Abgleich mit der Projektversion. + +Publish-Jobs teilen eine Warteschlange mit `queue: max` und laufen einzeln. +Bis zu 100 Jobs können warten; ein laufender Publish-Job wird durch neue +Commits nicht abgebrochen. Die Reihenfolge richtet sich nach dem Eintritt in +die Warteschlange, nicht zwingend nach dem Alter der Commits. Bei Pull Requests +und ungeschützten Refs ersetzt ein neuer Lauf den vorherigen Lauf desselben Refs. +[GitHub-Parallelität und Warteschlangen](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). + +Der Smoke-Test prüft TLS, Anmeldung, CSRF, ausgelieferte Webdateien und +Datenerhalt über einen Dienstneustart. Er läuft als UID 10001 mit +schreibgeschütztem Dateisystem, entfernten Linux-Capabilities und temporären +Testdaten. Eine Proxmox-Installation ist kein Bestandteil dieses Tests. + +Der Build verwendet `--provenance=false --sbom=false` und veröffentlicht +keine Provenance- oder SBOM-Attestierungen. Die OCI-Labels für Quellrepository, +Commit und Anwendungsversion bleiben erhalten. + +| Artefakt in GitHub Actions | Inhalt | Aufbewahrung | +| --- | --- | --- | +| `python-test-results` | `reports/pytest.xml` als JUnit-Datei, auch nach fehlgeschlagenen Tests, sofern erzeugt | 7 Tage | +| `container-build` | `build.env` nach erfolgreichem Verify-Job | 7 Tage | +| `container-deploy` | `build.env` und `deploy.env` nach erfolgreichem Publish-Job | 30 Tage | + +Die Aufbewahrung wird im Workflow mit `retention-days` gesetzt und ist durch +die übergeordneten GitHub-Einstellungen begrenzt. Die JUnit-Datei wird als +Download bereitgestellt; der Workflow installiert keinen zusätzlichen +Testberichtsdienst. Auf der Übersicht des jeweiligen Laufs unter **Artifacts** +das gewünschte Archiv herunterladen und entpacken. +[Workflow-Artefakte](https://docs.github.com/en/actions/tutorials/store-and-share-data), +[Artefakte herunterladen](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts). + +`build.env` enthält den temporären lokalen Image-Tag, Commit und +Anwendungsversion. Dieser `ci-…`-Tag wird nicht veröffentlicht und beim +Jobende lokal entfernt. `deploy.env` enthält ausschließlich die +Deploymentreferenz `PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais@sha256:…`. +Die Dateien enthalten keine Zugangsdaten. `deploy.env` wird auf dem Zielserver +nicht automatisch eingelesen; seine Image-Zeile in die dortige `.env` übernehmen. + +## GitHub einrichten + +Ein eigener CI-Runner ist nicht erforderlich. Alle Jobs verwenden +GitHub-gehostete Linux-amd64-Runner mit `ubuntu-24.04`. Der Workflow richtet +Python 3.13 über `actions/setup-python` und Node.js 24 über `actions/setup-node` +ein. Docker und Buildx werden vom Runner bereitgestellt und vor dem Build +geprüft. Bash, OpenSSL und der OpenSSH-Client werden dort ebenfalls verwendet. +Diese Build-Umgebung ist +unabhängig vom AIS-Host-Runner für die Postinstallation auf Proxmox-Hosts. +[GitHub-gehostete Runner](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). + +Im Repository unter **Settings → Actions → General** GitHub Actions aktivieren +und die im Workflow verwendeten Actions von `actions/*` zulassen. Diese sind +im Workflow auf vollständige Commit-SHAs festgelegt. Es werden +keine selbst angelegten Repository-Secrets oder CI-Variablen benötigt. +Der Workflow verwendet standardmäßig `contents: read`; nur +`container-publish` erhält zusätzlich `packages: write` und verwendet das +automatisch bereitgestellte `GITHUB_TOKEN` für die Anmeldung an `ghcr.io`. +[Actions-Einstellungen](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository). + +GHCR-Pakete sind bei der ersten Veröffentlichung standardmäßig privat. Ein +bereits existierendes Paket muss mit diesem Repository verbunden sein und +dessen Actions Schreibzugriff haben. In den Paketeinstellungen unter +**Manage Actions access** gegebenenfalls `Netivra/Proxmox-AIS` mit Schreibrecht +hinzufügen. Dort auch die gewünschte Paketsichtbarkeit festlegen. +[GHCR und GITHUB_TOKEN](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-in-a-github-actions-workflow), +[Paketzugriff und Sichtbarkeit](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility). + +## Standardbranch und Release-Tags schützen + +**Vor der ersten Veröffentlichung müssen aktive Schutzregeln für die +veröffentlichten Refs eingerichtet sein.** Der Workflow und das Skript +verlangen `github.ref_protected == true`. GitHub setzt dieses Merkmal, wenn +Branchschutz oder ein passendes Ruleset für den auslösenden Ref konfiguriert +ist. Ohne Schutz wird nur geprüft. +[GitHub-Ref-Kontext](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context). + +Unter den Repository-Einstellungen für Rulesets zwei Regeln anlegen und +jeweils **Enforcement status: Active** wählen: + +| Ruleset | Ziel | Vorgesehene Regeln | +| --- | --- | --- | +| Standardbranch | **Include default branch**, etwa `main` | Pull Request und Review verlangen; Löschen und Force-Push verhindern; erfolgreiche Statuschecks verlangen | +| Releases | Tags mit dem Muster `v*` | Erstellung, Aktualisierung und Löschung beschränken; nur Release-Verantwortliche in die Bypass-Liste aufnehmen | + +Für Pull Requests die Statuschecks `python-tests`, `javascript-check`, +`container-policy` und `container-verify` nach ihrem ersten Lauf als +verpflichtend auswählen. `container-publish` ist bei Pull Requests absichtlich +übersprungen und wird deshalb nicht als Pflichtprüfung eingerichtet. +Das Tag-Ruleset schützt alle `v*`-Tags; die engere Versionsprüfung übernimmt +zusätzlich das CI-Skript. Release-Verantwortliche setzen Tags auf einen +geprüften Commit mit der passenden Projektversion. Ein Tag auf einem Commit +des geschützten Standardbranches ist selbst noch kein geschützter Tag. +[Rulesets anlegen](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository), +[Verfügbare Schutzregeln](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets). + +Die Regeln und Paketberechtigungen im GitHub-Projekt sind Teil der Einrichtung; +sie werden durch den Commit der Workflow-Datei nicht automatisch angelegt. +Welche Rulesets verfügbar sind, hängt von Repository-Sichtbarkeit und +GitHub-Tarif ab. Die CI-Abfragen ersetzen keine restriktiven Schreibrechte +auf Repository und Paket; Änderungen an Workflow und CI-Skripten im Review prüfen. +[Ruleset-Verfügbarkeit](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository). + +## Erster Lauf und Release + +Nach dem Push der Workflow-Datei unter **Actions** den Lauf öffnen. +Python- und JavaScript-Prüfung sowie der passende Container-Job müssen +erfolgreich sein. Für einen geschützten Standardbranch zusätzlich prüfen, +dass das GHCR-Paket `sha-` und `edge` enthält und das Artefakt +`container-deploy` mit `deploy.env` bereitsteht. Ein lokaler Test ersetzt +diesen ersten GitHub-Lauf samt GHCR-Anmeldung und Push nicht. + +Für ein Release zuerst `[project].version` auf die gewünschte Version setzen +und den geprüften Commit übernehmen. Anschließend durch ein für das +Release-Ruleset berechtigtes Konto den passenden Tag erstellen und pushen. +Im vorhandenen Checkout heißt der GitHub-Remote `GH-AIS`; in anderen Checkouts +den Namen mit `git remote -v` prüfen und im Befehl entsprechend ersetzen. +Für Projektversion `0.1.0`, sofern der Tag noch nicht existiert: + +```bash +git tag -a v0.1.0 -m "Release 0.1.0" +git push GH-AIS v0.1.0 +``` + +Der Release-Lauf muss zusätzlich den Image-Tag `0.1.0` erzeugen. Für einen +manuellen Branch-Lauf unter **Actions** den Workflow und **Run workflow** +wählen. Dafür muss die Workflow-Datei bereits auf dem Standardbranch liegen. +Ein manueller Lauf veröffentlicht nur, wenn dieselben Ref-Schutz- und +Versionsbedingungen erfüllt sind. +[Workflow manuell starten](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow). + +Branch-Push und Pull Request können für denselben Quellstand getrennte Läufe +erzeugen. Pull Requests werden im Merge-Kontext geprüft; ihre Tests führen +keinen Registry-Push aus. Die Jobs laden keine produktiven Deployment-Zugänge. + +## Fehlersuche und Deployment + +Bei übersprungenem Publish-Job zuerst Ereignis, Standardbranch, Tagformat und +aktive Schutzregeln prüfen. Bei einer Versionsabweichung müssen Git-Tag und +`pyproject.toml` aufeinander abgestimmt werden. Bei `denied` oder `unauthorized` +im Publish-Job die Organisationsrichtlinien, `packages: write` und den +Actions-Zugriff auf das GHCR-Paket kontrollieren. Fehlt `container-deploy`, +die Logs des Build-, Smoke- und Push-Schritts prüfen; vor erfolgreicher +Veröffentlichung steht kein Deployment-Digest bereit. + +Zum Deployment [compose.yaml](../compose.yaml) und +[config/deployment.env.example](../config/deployment.env.example) auf den +Zielserver kopieren und die Image-Zeile aus `deploy.env` verwenden. +Die [Deployment-Anleitung](deployment.md) beschreibt GHCR-Anmeldung, +Erstinitialisierung, HTTPS-Prüfung, Updates und Rollback. Für private Pakete +benötigt das Deploymentkonto einen Personal Access Token (classic) mit +`read:packages`; öffentliche Pakete lassen sich anonym laden. diff --git a/docs/iso-preparation.md b/docs/iso-preparation.md new file mode 100644 index 0000000..c38416b --- /dev/null +++ b/docs/iso-preparation.md @@ -0,0 +1,50 @@ +# ISO auf einer Build-Maschine vorbereiten + +Die ISO wird außerhalb des Webcontainers mit dem offiziellen +`proxmox-auto-install-assistant` erstellt. Der Container benötigt weder +privilegierte Rechte noch Zugriff auf einen Docker-Socket. + +1. Original-ISO aus vertrauenswürdiger Proxmox-Quelle beziehen und ihre SHA-256 + gegen den veröffentlichten Wert prüfen. Build und Assistant-Version festhalten. +2. In der Anwendung einen standortbezogenen Gruppentoken erzeugen. Den einmal + ausgegebenen vollständigen Wert `:` sicher übernehmen. +3. Zertifikatsfingerprint des verwendeten TLS-Endpunkts über einen + vertrauenswürdigen Weg ermitteln. Bei einem Reverse-Proxy dessen Zertifikat verwenden. +4. Verfügbare Optionen prüfen und die ISO erstellen: + +```bash +proxmox-auto-install-assistant prepare-iso --help +proxmox-auto-install-assistant prepare-iso SOURCE.iso \ + --fetch-from http \ + --url 'https://provision.example.net/installer/v1/answer' \ + --cert-fingerprint '' \ + --answer-auth-token ':' +``` + +URL-, Fingerprint- und Token-Optionen entsprechen der +[offiziellen Proxmox-Dokumentation](https://pdm.proxmox.com/docs/automated-installations.html#preparing-an-iso). +Die URL zeigt hier auf den eigenen Antwortdienst. Den Befehl mit echtem Token +nicht in öffentliche Logs oder gemeinsam genutzte Shell-History schreiben. + +5. Ergebnis-ISO, Ausgangs-ISO, Assistant-Version, Build, Fingerprint und Gruppe + in der Medienverwaltung registrieren. Zunächst `draft` verwenden. +6. Den Build anhand der [Abnahmematrix](compatibility.md) im isolierten Labor + prüfen. Ein neuer Assistant aktualisiert die Komponenten einer alten ISO + nicht automatisch. Erst nach Prüfung `passed` setzen und Testnachweis referenzieren. + +Der Installer sendet Hardwaremerkmale per POST und erhält bei eindeutiger, +freigegebener Zuordnung eine TOML-Antwort. Ein kurzes Wiederholungsfenster liefert +dieselbe Antwort für denselben Lauf. Nach dessen Ende oder Runner-Anmeldung +ist der Abruf gesperrt. Bei abgelehnter Zuordnung die Ursache korrigieren und +den Installationsversuch erneut starten. + +Der dynamische First-Boot-Abschnitt verwendet `source = "from-url"` und +`ordering = "network-online"`. Der Installer lädt den Starthelfer, das installierte +System führt ihn beim ersten Boot aus. Diese Trennung beschreibt der +[offizielle First-Boot-Implementierungsentwurf](https://lists.proxmox.com/pipermail/pve-devel/2024-November/066649.html). +Die Gesamtfunktion muss mit der konkreten ISO praktisch geprüft werden. + +Ein gemeinsamer ISO-Token ist vom Medium auslesbar, Hardwaremerkmale sind kopierbar. +Zugriff auf Medium und Provisionierungsnetz beschränken. Nach erfolgreicher +Installation die Bootreihenfolge auf das lokale System umstellen; eine bereits +ausgelieferte Antwort kann der Server nicht nachträglich ungültig machen. diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..10d3c5f --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,2502 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Proxmox AIS", + "description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.", + "version": "0.1.0" + }, + "paths": { + "/health/live": { + "get": { + "summary": "Health Live", + "operationId": "health_live_health_live_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/health/ready": { + "get": { + "summary": "Health Ready", + "operationId": "health_ready_health_ready_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "summary": "Logout", + "operationId": "logout_auth_logout_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/me": { + "get": { + "summary": "Me", + "operationId": "me_api_v1_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/dashboard": { + "get": { + "summary": "Dashboard", + "operationId": "dashboard_api_v1_dashboard_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/hosts": { + "get": { + "summary": "Hosts", + "operationId": "hosts_api_v1_hosts_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Host", + "operationId": "create_host_api_v1_hosts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/hosts/import": { + "post": { + "summary": "Import Hosts", + "operationId": "import_hosts_api_v1_hosts_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/HostCreate" + }, + "type": "array", + "title": "Payload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/discoveries": { + "get": { + "summary": "Discoveries", + "operationId": "discoveries_api_v1_discoveries_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/hosts/{host_id}": { + "get": { + "summary": "Host Detail", + "operationId": "host_detail_api_v1_hosts__host_id__get", + "parameters": [ + { + "name": "host_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Host Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "summary": "Update Host", + "operationId": "update_host_api_v1_hosts__host_id__patch", + "parameters": [ + { + "name": "host_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Host Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/hosts/{host_id}/preview": { + "get": { + "summary": "Preview", + "operationId": "preview_api_v1_hosts__host_id__preview_get", + "parameters": [ + { + "name": "host_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Host Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/hosts/{host_id}/approve-install": { + "post": { + "summary": "Approve", + "operationId": "approve_api_v1_hosts__host_id__approve_install_post", + "parameters": [ + { + "name": "host_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Host Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Approval" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/profiles": { + "get": { + "summary": "Profiles", + "operationId": "profiles_api_v1_profiles_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Profile", + "operationId": "create_profile_api_v1_profiles_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/modules": { + "get": { + "summary": "Modules", + "operationId": "modules_api_v1_modules_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Module", + "operationId": "create_module_api_v1_modules_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/modules/builtin": { + "get": { + "summary": "Builtin Modules", + "operationId": "builtin_modules_api_v1_modules_builtin_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/modules/{object_id}": { + "get": { + "summary": "Module Detail", + "operationId": "module_detail_api_v1_modules__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/profiles/{object_id}": { + "get": { + "summary": "Profile Detail", + "operationId": "profile_detail_api_v1_profiles__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/modules/{object_id}/publish": { + "post": { + "summary": "Publish Module", + "operationId": "publish_module_api_v1_modules__object_id__publish_post", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Publish" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/profiles/{object_id}/publish": { + "post": { + "summary": "Publish Profile", + "operationId": "publish_profile_api_v1_profiles__object_id__publish_post", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Publish" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups": { + "get": { + "summary": "Groups", + "operationId": "groups_api_v1_groups_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Group", + "operationId": "create_group_api_v1_groups_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/groups/{group_id}/revoke": { + "post": { + "summary": "Revoke Group", + "operationId": "revoke_group_api_v1_groups__group_id__revoke_post", + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Group Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/iso-records": { + "get": { + "summary": "Iso Records", + "operationId": "iso_records_api_v1_iso_records_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Iso", + "operationId": "create_iso_api_v1_iso_records_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IsoCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/secrets": { + "get": { + "summary": "Secrets List", + "operationId": "secrets_list_api_v1_secrets_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create Secret", + "operationId": "create_secret_api_v1_secrets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users": { + "get": { + "summary": "Users", + "operationId": "users_api_v1_users_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Create User", + "operationId": "create_user_api_v1_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/{user_id}/disable": { + "post": { + "summary": "Disable User", + "operationId": "disable_user_api_v1_users__user_id__disable_post", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/audit": { + "get": { + "summary": "Audit List", + "operationId": "audit_list_api_v1_audit_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/runs": { + "get": { + "summary": "Runs", + "operationId": "runs_api_v1_runs_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/runs/{run_id}": { + "get": { + "summary": "Run Detail", + "operationId": "run_detail_api_v1_runs__run_id__get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/cancel": { + "post": { + "summary": "Cancel Run", + "operationId": "cancel_run_api_v1_runs__run_id__cancel_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunAction" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/resume": { + "post": { + "summary": "Resume Run", + "operationId": "resume_run_api_v1_runs__run_id__resume_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunAction" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/runs/{run_id}/reconcile": { + "post": { + "summary": "Reconcile Run", + "description": "Close an externally checked abandoned run; never grants installation.", + "operationId": "reconcile_run_api_v1_runs__run_id__reconcile_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunReconcile" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/installer/v1/answer": { + "post": { + "summary": "Installer Answer", + "operationId": "installer_answer_installer_v1_answer_post", + "responses": { + "200": { + "description": "Successful Response" + } + } + } + }, + "/bootstrap/v1/{download_token}": { + "get": { + "summary": "Bootstrap", + "operationId": "bootstrap_bootstrap_v1__download_token__get", + "parameters": [ + { + "name": "download_token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Download Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/installer/v1/report/{report_token}": { + "post": { + "summary": "Installer Report", + "operationId": "installer_report_installer_v1_report__report_token__post", + "parameters": [ + { + "name": "report_token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Report Token" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/enroll": { + "post": { + "summary": "Enroll", + "operationId": "enroll_agent_v1_enroll_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Enroll" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/lease": { + "post": { + "summary": "Lease", + "operationId": "lease_agent_v1_lease_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaseRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/runs/{run_id}/manifest": { + "get": { + "summary": "Manifest", + "operationId": "manifest_agent_v1_runs__run_id__manifest_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/artifacts/{checksum}": { + "get": { + "summary": "Artifact", + "operationId": "artifact_agent_v1_artifacts__checksum__get", + "parameters": [ + { + "name": "checksum", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Checksum" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/runs/{run_id}/secrets/{step_id}": { + "get": { + "summary": "Step Secrets", + "operationId": "step_secrets_agent_v1_runs__run_id__secrets__step_id__get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + }, + { + "name": "step_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Step Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/runs/{run_id}/events": { + "post": { + "summary": "Events", + "operationId": "events_agent_v1_runs__run_id__events_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventBatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/runs/{run_id}/logs": { + "post": { + "summary": "Logs", + "operationId": "logs_agent_v1_runs__run_id__logs_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogBatch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/agent/v1/runs/{run_id}/complete": { + "post": { + "summary": "Complete", + "operationId": "complete_agent_v1_runs__run_id__complete_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Completion" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Approval": { + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1.0, + "title": "Expected Version" + }, + "valid_minutes": { + "type": "integer", + "maximum": 1440.0, + "minimum": 1.0, + "title": "Valid Minutes", + "default": 30 + }, + "confirmation": { + "type": "string", + "title": "Confirmation" + }, + "disks_confirmed": { + "type": "boolean", + "title": "Disks Confirmed" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "minLength": 5, + "title": "Reason" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "expected_version", + "confirmation", + "disks_confirmed", + "reason" + ], + "title": "Approval" + }, + "Completion": { + "properties": { + "verification": { + "additionalProperties": true, + "type": "object", + "title": "Verification" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "verification" + ], + "title": "Completion" + }, + "Enroll": { + "properties": { + "run_id": { + "type": "string", + "title": "Run Id" + }, + "enrollment_secret": { + "type": "string", + "maxLength": 200, + "minLength": 20, + "title": "Enrollment Secret" + }, + "public_key": { + "type": "string", + "maxLength": 100, + "minLength": 40, + "title": "Public Key" + }, + "identities": { + "items": { + "$ref": "#/components/schemas/Identity" + }, + "type": "array", + "maxItems": 32, + "minItems": 1, + "title": "Identities" + }, + "boot_id": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Boot Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "run_id", + "enrollment_secret", + "public_key", + "identities", + "boot_id" + ], + "title": "Enroll" + }, + "Event": { + "properties": { + "sequence": { + "type": "integer", + "minimum": 1.0, + "title": "Sequence" + }, + "boot_id": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Boot Id" + }, + "step_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Step Id" + }, + "type": { + "type": "string", + "enum": [ + "step.started", + "step.succeeded", + "step.failed", + "run.needs_review", + "run.reboot_pending", + "run.resumed", + "run.cancelled", + "heartbeat" + ], + "title": "Type" + }, + "occurred_at": { + "type": "string", + "maxLength": 80, + "title": "Occurred At" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Exit Code" + }, + "verification": { + "additionalProperties": true, + "type": "object", + "title": "Verification" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "sequence", + "boot_id", + "type", + "occurred_at" + ], + "title": "Event" + }, + "EventBatch": { + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/Event" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Events" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "events" + ], + "title": "EventBatch" + }, + "GroupCreate": { + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", + "title": "Name" + }, + "site": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Site" + }, + "valid_hours": { + "type": "integer", + "maximum": 8760.0, + "minimum": 1.0, + "title": "Valid Hours", + "default": 720 + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "site" + ], + "title": "GroupCreate" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HostCreate": { + "properties": { + "fqdn": { + "type": "string", + "maxLength": 253, + "minLength": 3, + "title": "Fqdn" + }, + "site": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Site" + }, + "management_ip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Management Ip" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 30, + "title": "Tags" + }, + "identities": { + "items": { + "$ref": "#/components/schemas/Identity" + }, + "type": "array", + "maxItems": 32, + "minItems": 1, + "title": "Identities" + }, + "installation_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installation Profile Id" + }, + "postinstall_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Postinstall Profile Id" + }, + "iso_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iso Id" + }, + "overrides": { + "additionalProperties": true, + "type": "object", + "title": "Overrides" + }, + "blocked": { + "type": "boolean", + "title": "Blocked", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "fqdn", + "site", + "identities" + ], + "title": "HostCreate" + }, + "HostUpdate": { + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1.0, + "title": "Expected Version" + }, + "fqdn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fqdn" + }, + "site": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site" + }, + "management_ip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Management Ip" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + }, + "identities": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Identity" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Identities" + }, + "installation_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installation Profile Id" + }, + "postinstall_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Postinstall Profile Id" + }, + "iso_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iso Id" + }, + "overrides": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Overrides" + }, + "blocked": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Blocked" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "expected_version" + ], + "title": "HostUpdate" + }, + "Identity": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "uuid", + "serial", + "mac" + ], + "title": "Kind" + }, + "value": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Value" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "kind", + "value" + ], + "title": "Identity" + }, + "IsoCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "build": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+(?:\\.[0-9]+)?-[0-9]+$", + "title": "Build" + }, + "sha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$", + "title": "Sha256" + }, + "assistant_version": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Assistant Version" + }, + "fingerprint": { + "type": "string", + "pattern": "^(?:[a-fA-F0-9]{64}|(?:[a-fA-F0-9]{2}:){31}[a-fA-F0-9]{2})$", + "title": "Fingerprint" + }, + "group_id": { + "type": "string", + "title": "Group Id" + }, + "test_status": { + "type": "string", + "enum": [ + "draft", + "passed" + ], + "title": "Test Status", + "default": "draft" + }, + "test_evidence": { + "type": "string", + "maxLength": 3000, + "title": "Test Evidence", + "default": "" + }, + "native_token_support": { + "type": "boolean", + "title": "Native Token Support", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "build", + "sha256", + "assistant_version", + "fingerprint", + "group_id" + ], + "title": "IsoCreate" + }, + "LeaseRequest": { + "properties": { + "run_id": { + "type": "string", + "title": "Run Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "run_id" + ], + "title": "LeaseRequest" + }, + "LogBatch": { + "properties": { + "chunks": { + "items": { + "$ref": "#/components/schemas/LogChunk" + }, + "type": "array", + "maxItems": 32, + "minItems": 1, + "title": "Chunks" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "chunks" + ], + "title": "LogBatch" + }, + "LogChunk": { + "properties": { + "sequence": { + "type": "integer", + "minimum": 1.0, + "title": "Sequence" + }, + "step_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Step Id" + }, + "text": { + "type": "string", + "maxLength": 16384, + "title": "Text" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "sequence", + "text" + ], + "title": "LogChunk" + }, + "ModuleCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "source": { + "type": "string", + "maxLength": 262144, + "minLength": 10, + "title": "Source" + }, + "parameters_schema": { + "additionalProperties": true, + "type": "object", + "title": "Parameters Schema" + }, + "dependencies": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 20, + "title": "Dependencies" + }, + "target_builds": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 30, + "title": "Target Builds" + }, + "timeout_seconds": { + "type": "integer", + "maximum": 7200.0, + "minimum": 1.0, + "title": "Timeout Seconds", + "default": 600 + }, + "retry_safe": { + "type": "boolean", + "title": "Retry Safe", + "default": false + }, + "test_evidence": { + "type": "string", + "maxLength": 2000, + "title": "Test Evidence", + "default": "" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "title": "Reason", + "default": "" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "source" + ], + "title": "ModuleCreate" + }, + "ProfileCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "enum": [ + "installation", + "postinstall" + ], + "title": "Kind" + }, + "values": { + "additionalProperties": true, + "type": "object", + "title": "Values" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/StepSpec" + }, + "type": "array", + "maxItems": 50, + "title": "Steps" + }, + "target_builds": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 30, + "title": "Target Builds" + }, + "locked_fields": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Locked Fields" + }, + "reboot_budget": { + "type": "integer", + "maximum": 5.0, + "minimum": 0.0, + "title": "Reboot Budget", + "default": 1 + }, + "reason": { + "type": "string", + "maxLength": 1000, + "title": "Reason", + "default": "" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "kind" + ], + "title": "ProfileCreate" + }, + "Publish": { + "properties": { + "test_evidence": { + "type": "string", + "maxLength": 2000, + "minLength": 5, + "title": "Test Evidence" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "minLength": 3, + "title": "Reason" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "test_evidence", + "reason" + ], + "title": "Publish" + }, + "RunAction": { + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1.0, + "title": "Expected Version" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "minLength": 5, + "title": "Reason" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "expected_version", + "reason" + ], + "title": "RunAction" + }, + "RunReconcile": { + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1.0, + "title": "Expected Version" + }, + "reason": { + "type": "string", + "maxLength": 1000, + "minLength": 5, + "title": "Reason" + }, + "confirmation": { + "type": "string", + "title": "Confirmation" + }, + "execution_stopped": { + "type": "boolean", + "title": "Execution Stopped" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "expected_version", + "reason", + "confirmation", + "execution_stopped" + ], + "title": "RunReconcile" + }, + "SecretCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "value": { + "type": "string", + "maxLength": 16384, + "minLength": 1, + "title": "Value" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "value" + ], + "title": "SecretCreate" + }, + "StepSpec": { + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$", + "title": "Id" + }, + "module_id": { + "type": "string", + "title": "Module Id" + }, + "parameters": { + "additionalProperties": true, + "type": "object", + "title": "Parameters" + }, + "secret_refs": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Secret Refs" + }, + "required": { + "type": "boolean", + "title": "Required", + "default": true + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "module_id" + ], + "title": "StepSpec" + }, + "UserCreate": { + "properties": { + "username": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}$", + "title": "Username" + }, + "password": { + "type": "string", + "maxLength": 1024, + "minLength": 12, + "title": "Password" + }, + "role": { + "type": "string", + "enum": [ + "reader", + "operator", + "author", + "admin", + "developer" + ], + "title": "Role" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "username", + "password", + "role" + ], + "title": "UserCreate" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..585a0e1 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,216 @@ +# Betrieb und Wiederherstellung + +Für die Installation aus der GitHub Container Registry (GHCR) siehe +[Deployment-Anleitung](deployment.md). Alle `docker compose`-Befehle unten +werden im Deployment-Verzeichnis mit `compose.yaml` und `.env` ausgeführt. +`PROVISIONER_IMAGE` verweist auf das veröffentlichte Image, vorzugsweise per Digest. +Die CLI ist im Container enthalten; auf dem Zielserver ist keine eigene +Python-Installation erforderlich. + +## Konfiguration + +`proxmox-ais` liest die optionale TOML-Datei aus `APP_CONFIG`, anschließend +gleichnamige großgeschriebene Umgebungsvariablen. Relative Pfade beziehen sich +auf das Arbeitsverzeichnis. + +Die mitgelieferte Compose-Datei übernimmt `PROVISIONER_IMAGE`, `PUBLIC_URL` +und `MAINTENANCE` aus `.env`. Weitere Anwendungseinstellungen bei Bedarf unter +`services.provisioner.environment` in `compose.yaml` ergänzen. + +| Einstellung | Standard | Bedeutung | +| --- | --- | --- | +| `DATA_DIR` | `./data` | SQLite, Artefakte und Dienstsperre auf lokalem Speicher | +| `MASTER_KEY_FILE` | `./secrets/master.key` | Separat abgelegter Fernet-Schlüssel | +| `PUBLIC_URL` | `https://localhost:8080` | Extern erreichbare Basis-URL ohne Pfad | +| `SECURE_COOKIES` | `true` | Sessioncookies nur über HTTPS | +| `FOUR_EYES` | `true` | Autor darf eigene Versionen nicht veröffentlichen | +| `MAINTENANCE` | `false` | Neue Installationsfreigaben aussetzen | +| `ANSWER_WINDOW_SECONDS` | `300` | Wiederholungsfenster des Antwortabrufs | +| `ENROLLMENT_HOURS` | `4` | Maximale Registrierungsfrist | +| `LEASE_SECONDS` | `900` | Gültigkeit der Runner-Ausführungsberechtigung | +| `RUNNER_CA_FILE` | nicht gesetzt | Vertrauensanker für Runner bei interner CA | + +`TESTING` und Bootstrap-Passwörter nicht im Regelbetrieb setzen. Benutzer werden +interaktiv mit `init` und danach über die Administrationsoberfläche angelegt. +Das Datenverzeichnis darf den Master-Key nicht enthalten. Unter Windows schützt +zusätzlich eine auf das Dienstkonto beschränkte NTFS-ACL das Schlüsselverzeichnis. + +## TLS und Reverse-Proxy + +Der Compose-Port ist nur an Loopback gebunden. Ein bestehender Proxy terminiert +TLS. Bei externem Proxy das private Container-/Verwaltungsnetz gezielt freigeben. +Der Dienst wertet Proxy-Header standardmäßig nicht aus; alle absoluten URLs +entstehen aus `PUBLIC_URL`. Bei einer nativen Installation kann der Dienst +TLS auch direkt terminieren: + +```bash +proxmox-ais serve --host 0.0.0.0 --port 8080 \ + --tls-cert /etc/proxmox-ais/fullchain.pem \ + --tls-key /etc/proxmox-ais/server.key +``` + +Zertifikatskette und DNS müssen im Installer und auf installierten Hosts gültig +sein. Ein ISO-Fingerprint ersetzt nicht automatisch den CA-Vertrauensanker des +Runners. Bei interner CA `RUNNER_CA_FILE` konfigurieren und die gesamte Kette vom +Installer bis zum Runner prüfen. Keine TLS-Prüfung abschalten. Zertifikatsrotation +erfordert die Prüfung vorhandener Medien. + +Capability-Tokens stehen zwangsläufig in Bootstrap-URLs. Der integrierte Server +schreibt daher keine Access-Logs. Im vorgeschalteten Proxy Bootstrap- und +Installer-Report-Pfade redigieren oder deren Access-Logging deaktivieren. Keine +Authorization-Header protokollieren. Skriptausgaben werden zusätzlich redigiert; +Skripte sollten Geheimnisse grundsätzlich nie ausgeben. + +## Betrieb + +Der Dienst verwendet genau einen Anwendungsprozess. Mehrere Uvicorn-Worker oder +gleichzeitige Dienste für dasselbe Datenverzeichnis verhindert die Dienstsperre. +SQLite benötigt lokalen Speicher mit zuverlässigen Dateisperren; kein NFS/SMB-Volume +einsetzen. Als Startwert sind 2 vCPU, 2 GB RAM und 20 GB Speicher ohne ISO-Archiv +vorgesehen; Kapazität muss unter realer Last geprüft werden. + +`/health/live` prüft den Prozess, `/health/ready` Datenbank und Konfiguration. +Readiness, freien Speicher, Backupalter und ausbleibende Runner-Meldungen überwachen. +DHCP, DNS, NTP und Paketquellen sind externe Voraussetzungen. Der Server baut keine +eingehenden SSH-Verbindungen zu Hosts auf. + +## Backup + +Beim Registry-Deployment die Sicherung mit der aktuell eingesetzten Image-Version +erstellen, bevor `PROVISIONER_IMAGE` für ein Update geändert wird. Das +Hostverzeichnis `/srv/ais-backups` muss vorhanden und für UID/GID 10001 +beschreibbar sein. Beispielsweise einmalig vom Administrator anlegen: + +```bash +sudo install -d -o 10001 -g 10001 -m 0700 /srv/ais-backups +docker compose run --rm --no-deps --volume /srv/ais-backups:/backups \ + provisioner backup /backups/2026-09-13 +``` + +Für jede Sicherung einen neuen Zielnamen wählen. Bei einer nativen +Python-Installation lautet der entsprechende Befehl +`proxmox-ais backup /srv/ais-backups/2026-09-13`. + +Das Ziel muss neu sein und außerhalb von `DATA_DIR` liegen. Die Anwendung kann +weiterlaufen: Die SQLite-Backup-API erzeugt einen konsistenten Snapshot, anschließend +werden unveränderliche Artefakte kopiert. Artefakte währenddessen nicht manuell +löschen. Die Sicherung enthält `database.sqlite3`, `artifacts/`, `settings.json` +und `manifest.json`. Der Master-Key wird ausgeschlossen; sein Fingerprint verhindert +die Wiederherstellung mit einem versehentlich falschen Schlüssel. + +`settings.json` enthält die wirksamen Anwendungseinstellungen einschließlich +Standortvorgaben, Fristen und Freigaberegeln. Lokale Daten-/Schlüsselpfade und +Bootstrap-Zugangsdaten sind ausgeschlossen. Individuelle TOML-Datei, +Umgebungsvariablen, Proxykonfiguration, CA-/TLS-Dateien, ursprüngliche +ISOs und Prüfprotokolle separat sichern. Registrierte ISO-Metadaten liegen in der +Datenbank. Backup-Prüfsummen erkennen beschädigte Dateien; sie sind keine Signatur +gegen einen Angreifer mit Schreibzugriff. Backupverzeichnis deshalb schützen. + +Den Master-Key aus `proxmox-ais-keys` über einen getrennten verschlüsselten +Sicherungsweg verwahren. +Eine lokale Kopie in ein bereits vorhandenes Zielverzeichnis ist möglich: + +```bash +docker compose cp provisioner:/run/secrets/master.key /secure/offline/master.key +``` + +Die Schlüsselkopie ist genauso vertraulich wie alle gespeicherten Geheimnisse. + +## Restore + +1. Wartungsmodus aktivieren, neue Freigaben stoppen und Anwendungsdienst + herunterfahren. Die Host-Runner separat berücksichtigen: ein bereits + gestarteter Befehl kann noch laufen. +2. Bisheriges Datenverzeichnis erhalten. Ein neues leeres Datenverzeichnis + wählen und den zum Backup gehörenden Master-Key separat bereitstellen. +3. Die gleiche Anwendungsversion wie beim Backup verwenden. Beim + Registry-Deployment deren Digest in `.env` als `PROVISIONER_IMAGE` setzen + und herunterladen. Das Schlüsselvolume muss bereits den passenden + Master-Key als `master.key`, lesbar für UID/GID 10001, enthalten. In ein + neues, leeres Datenvolume wiederherstellen: + +```bash +docker compose stop provisioner +docker compose pull provisioner +docker volume create proxmox-ais-data-restored +docker compose run --rm --no-deps \ + --volume /srv/ais-backups:/backups:ro \ + --volume proxmox-ais-data-restored:/var/lib/proxmox-ais \ + provisioner restore /backups/2026-09-13 +``` + + `proxmox-ais-data-restored` muss neu sein; existiert es schon, einen anderen + unbenutzten Namen wählen. Nach erfolgreichem Restore in `compose.yaml` unter + `volumes.ais-data.name` diesen neuen Namen eintragen. Das bisherige Datenvolume + bleibt erhalten. Bei einer nativen Python-Installation alternativ: + +```bash +export DATA_DIR=/srv/proxmox-ais-restored +export MASTER_KEY_FILE=/secure/offline/master.key +proxmox-ais restore /srv/ais-backups/2026-09-13 +``` + +4. Individuelle Konfiguration und Zertifikate wiederherstellen. Im Wartungsmodus + starten, anmelden und Inventar sowie aktuelle Hostzustände abgleichen. + Beim Registry-Deployment dafür `MAINTENANCE=true` in `.env` setzen und + `docker compose up -d --no-build --wait` ausführen. +5. Aktive Läufe stehen auf `needs_review`. Alte Sessions, Gerätezugänge, + Bootstrap-/Enrollment-/Report-Capabilities und Gruppentokens gelten nicht mehr; + offene Installationsfreigaben sind gesperrt. Gruppentokens und davon abhängige + Medien neu ausgeben. Zuvor ausgelieferte Installer nicht ungeprüft neu booten. +6. Installer und Runner auf jedem betroffenen Host lokal stoppen und den + tatsächlichen Zustand prüfen. Danach den verlassenen Lauf über + `POST /api/v1/runs/{id}/reconcile` abschließen: aktuelle `expected_version`, + aussagekräftige `reason`, `confirmation` mit dem Host-FQDN und + `execution_stopped: true` übermitteln. Die Aktion beendet den Lauf als + `cancelled`, sperrt seine Berechtigungen und schreibt einen Auditeintrag. + Erst danach ist eine neue ausdrückliche Installationsfreigabe möglich. + Der automatische Wiederanschluss alter Geräteidentitäten nach Restore + ist nicht Teil dieser Version. + +Außerhalb eines Restore zuerst `/cancel` anfordern. Ein offline befindlicher +Runner kann seine bereits erhaltene Lease bis zum Ablauf behalten; eine +Abbruchanforderung zieht diese nicht zurück. `/reconcile` weist den Abgleich +deshalb zurück, solange eine zuvor ausgestellte Lease oder das +Antwortauslieferungsfenster noch gültig ist. Auch nach deren Ablauf ist die +ausdrückliche Bestätigung erforderlich, dass Installer und Runner lokal gestoppt +und kontrolliert wurden. Eine nicht mehr erreichbare Maschine ist kein Nachweis +dafür. Das Verfahren erteilt selbst keine Neuinstallationsfreigabe. + +Restore überschreibt keine bestehende Datenbank. Die Dienstsperre verhindert +Restore während eines laufenden Dienstes im Zielverzeichnis. Ein fehlgeschlagener +Restore hinterlässt `RESTORE_FAILED`; der CLI-Start verweigert dann den Betrieb. +Nach Ursachenklärung erneut in ein neues leeres Ziel restaurieren. + +Bereits heruntergeladene Antwortdateien kann der Server nicht zurückziehen. +Ebenso beendet eine Credential-Sperrung einen lokal laufenden Skriptprozess nicht +unmittelbar. Restore erfordert deshalb den physischen Zustandsabgleich der Hosts. + +RPO 24 Stunden und RTO zwei Stunden sind Ziele aus dem Konzept. Erst wiederholte +Sicherung und zeitlich gemessene Wiederherstellung unter realen Bedingungen +weisen diese Ziele nach. + +## Updates + +Der vollständige Ablauf für Registry-Images steht unter +[Deployment aktualisieren](deployment.md#updates). +Vor dem ersten Neustart `PROVISIONER_IMAGE` auf den Digest des tatsächlich +laufenden Images festlegen, wie dort beschrieben. Anschließend +`MAINTENANCE=true` in `.env` setzen und die laufende Version mit +`docker compose up -d --no-build --wait` neu erstellen. Der Wartungsmodus +sperrt neue Freigaben; bereits gestartete Installations- und Modulabläufe werden +dadurch nicht beendet. Diese vor dem Update kontrolliert abschließen lassen. + +Mit der bisherigen Version Daten und Schlüssel sichern und den alten +Image-Digest festhalten. Erst danach `PROVISIONER_IMAGE` ändern, das neue Image +mit `docker compose pull provisioner` laden, den Dienst mit +`docker compose stop provisioner` stoppen und mit +`docker compose up -d --no-build --wait` neu starten. Nach Prüfung von Anmeldung, +Inventar und Readiness `MAINTENANCE=false` setzen und erneut `up` ausführen. +Bei Updates wird `init` nicht wiederholt. + +Die Datenbank +führt eine Migrationstabelle und `PRAGMA user_version`. Neuere unbekannte Schemas +werden abgewiesen. Downgrades erfolgen über die zur Sicherung passende Version +und den beschriebenen Restore; keine alte Anwendung auf eine bereits migrierte +Produktivdatenbank starten. diff --git a/docs/sample-profile.json b/docs/sample-profile.json new file mode 100644 index 0000000..f67b343 --- /dev/null +++ b/docs/sample-profile.json @@ -0,0 +1,29 @@ +{ + "name": "lab-system-single-disk", + "kind": "installation", + "target_builds": ["9.1-1"], + "reason": "Beispiel zur Anpassung im isolierten Labor", + "values": { + "root_secret_id": "REPLACE_WITH_SECRET_ID", + "global": { + "keyboard": "de", + "country": "de", + "timezone": "Europe/Berlin", + "mailto": "admin@example.net" + }, + "network": { + "source": "from-answer", + "gateway": "192.0.2.1", + "dns": "192.0.2.53", + "filter": {"ID_NET_NAME_MAC": "enx020000000001"} + }, + "disk_setup": { + "filesystem": "ext4", + "filter": {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"}, + "filter_match": "all", + "expected_count": 1, + "expected_serials": ["LAB_SYSTEM_DISK_001"], + "inventory_evidence": "REPLACE_WITH_VERIFIED_DISK_INVENTORY_REFERENCE" + } + } +} diff --git a/docs/test-report.md b/docs/test-report.md new file mode 100644 index 0000000..d3f2971 --- /dev/null +++ b/docs/test-report.md @@ -0,0 +1,152 @@ +# Softwaretestbericht + +Stand: 13. September 2026. Die hier dokumentierten Softwaretests führen keine +Proxmox-Installation aus und verändern keine Hostdatenträger, Paketquellen oder +Produktivdienste. Die noch offenen praktischen Prüfungen stehen in der +[Kompatibilitätsmatrix](compatibility.md). + +## Automatisierte Prüfungen + +Die 49 Anwendungstests wurden unter Windows mit Python 3.14 ausgeführt: +47 Tests bestanden; zwei Linux-spezifische Prozesstests wurden dort übersprungen. +Die 68 zusätzlichen CI-Skripttests bestanden unter Windows mit Git Bash. +Insgesamt enthält die Testsuite jetzt 117 Testfälle; der aktuelle Gesamtlauf +unter Windows/Python 3.14.7 ergab 115 bestandene und zwei übersprungene Tests. +Unter Linux/Python 3.13.15 bestanden alle 117 Tests über das migrierte +CI-Einstiegsskript `ci/python-tests.sh`, einschließlich der OpenSSH-Prüfung. + +| Testgruppe | Umfang | Geprüftes Verhalten | +| --- | --- | --- | +| `tests/test_acceptance.py` | 14 bestanden | Authentifizierung, CSRF, Rollen und Vieraugenregel; Ablehnung unbekannter, gesperrter und widersprüchlicher Hosts; Gruppenzuordnung und Freigabeablauf; zehn parallele Antwortabrufe mit genau einem Lauf; unveränderliche Konfiguration; Enrollment, Signaturen, Nonce-Replay, fremde Läufe, Ereignisreihenfolge und Pflichtverifikation; Artefaktmanipulation; Verschlüsselung; Backup/Restore und Schlüsselprüfung; Abgleich verlassener Läufe erst nach Lease-Ablauf und ausdrücklicher lokaler Bestätigung | +| `tests/test_protocol.py` | 8 bestanden | Tatsächlicher Runner gegen die HTTP-API über einen Testtransport mit gültigen Ed25519-Signaturen; Check-/Apply-Abläufe, verlorene Abschlussantwort, simulierter Neustart, fehlgeschlagene Verifikation und Operatorfortsetzung, wiederholte Abbruchbestätigung, optionale Fehler mit verpflichtender Abschlussprüfung sowie redigierte Logs | +| `tests/test_runner.py` | Windows: 25 bestanden, 2 übersprungen; Linux-CI: 27 bestanden | Dauerhafte Checkpoints, Wiederaufnahme nach Unterbrechung, Ablauf von Leases, Rebootbudget, Queue- und Logbegrenzung, Secrets-Redigierung, Hashprüfung, TLS-Zwang, eigenständiger Starthelfer, lokale Hardwaremerkmale, echte OpenSSL-Signaturen, SSH-Schlüsselprüfung, Repositoryprüfung, Bash-Syntaxprüfung sowie Prozess-Timeout und Ausgaberedigierung | +| `tests/test_ci.py` | 68 bestanden | Veröffentlichung nur von geschützten Standardbranch-/Versions-Refs und erlaubten GitHub-Ereignissen; Release-Versionsabgleich; GHCR-Pfade; kein Push nach Build-/Smoke-Fehlern; Digest-Artefakte; temporäre Docker-Zugangsdaten und Image-Tags; Isolation nach Run-ID, Wiederholung und Job-ID; eigene Python-Testumgebung mit Aufräumen bei Erfolg und Fehlern; fehlende Host-Werkzeuge verhindern übersprungene Pflichtprüfungen | + +Die API- und Protokolltests verwenden eine temporäre SQLite-Datenbank sowie +temporäre Schlüssel. Die Protokolltests führen die Runner-Steuerung und +Signaturprüfung aus; die eigentlichen Modulphasen werden simuliert. Runner-Tests +prüfen Bash-Syntax und eingebetteten Python-Code ohne Provisionierungsänderungen. +Das ist kein Nachweis für funktionierende Betriebssysteminstallation oder +erfolgreiche Konfiguration echter Proxmox-Dienste. + +Unter Windows benötigt die Veröffentlichung von Bash-Modulen Git Bash. +Der vorhandene WSL-Bash-Starter ohne Linux-Distribution genügt nicht. Die Anwendung +erkennt die native Git-Bash-Installation für die Syntaxprüfung. + +Alle Tests erneut ausführen: + +```powershell +.\.venv\Scripts\python.exe -m pytest +``` + +Beim Testlauf können Upstream-DeprecationWarnings von Starlette/httpx und AnyIO +erscheinen. Sie sind von fehlgeschlagenen Tests zu unterscheiden. + +## Containerprüfung + +`proxmox-ais-server:0.1.0` wurde erfolgreich mit Python 3.13 und Debian slim +gebaut. Das Dockerfile fixiert die geprüfte Basis über den Digest +`sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285`. + +Ein kurzlebiger Container wurde mit schreibgeschütztem Dateisystem, deaktiviertem +Netzwerk und entfernten Linux-Capabilities gestartet. Die Prüfung ergab: + +| Merkmal | Beobachtung | +| --- | --- | +| Benutzer | UID 10001 | +| Bash | `/usr/bin/bash`, GNU Bash 5.2.37 | +| OpenSSL | `/usr/bin/openssl`, OpenSSL 3.5.7 | +| CLI-Einstiegspunkt | `proxmox-ais`, Unterbefehle `init`, `serve`, `backup`, `restore` | + +Auch Wheel-Erstellung und Installation wurden erfolgreich geprüft. In einem +früheren Linux-Testlauf im Anwendungsimage bestanden 48 Tests; ein Test wurde übersprungen, weil +`ssh-keygen` im Anwendungsimage nicht installiert ist. Die SSH-Schlüsselprüfung +wurde unter Windows ausgeführt. Der Linux-Lauf umfasste die POSIX-spezifischen +Prüfungen zur Prozessbeendigung nach Timeout und zur Redigierung von Ausgaben. +Der Testcontainer lief ohne Rootrechte, mit schreibgeschütztem Dateisystem, +entfernten Capabilities und `no-new-privileges`. + +`tests/container_smoke.py` prüfte zusätzlich den installierten Dienst als echten +Prozess im Container: Anmeldung über TLS mit eigener vertrauenswürdiger Test-CA, +Ablehnung eines nicht vertrauten Zertifikats, CSRF, ausgelieferte Webdateien sowie +Erhalt von Hosts und Sitzungen nach einem Dienstneustart. Der Prozess lief als +UID 10001 mit schreibgeschütztem Root-Dateisystem, ohne Capabilities und mit +`no-new-privileges`; sämtliche Testdaten lagen in einem temporären Verzeichnis. + +Die folgende Abfrage verändert weder Anwendungsdaten noch Konfiguration. Reproduzierbarer +Befehl für Bash unter Linux oder Git Bash: + +```bash +docker run --rm --read-only --network none --cap-drop ALL \ + --entrypoint python proxmox-ais-server:0.1.0 \ + -c 'import os, shutil, subprocess; print(os.getuid()); print(shutil.which("bash")); print(shutil.which("openssl")); subprocess.run(["bash", "--version"], check=True); subprocess.run(["openssl", "version"], check=True)' +``` + +TLS-Reverse-Proxy, reale Provisionierungsnetze, Medienboot, Initialisierung und +Wiederherstellung in der späteren Betriebsumgebung müssen zusätzlich geprüft werden. +Ein erfolgreicher Image-Build allein ist keine Betriebsfreigabe. + +## GitHub Actions und GHCR + +Der [Workflow](../.github/workflows/ci.yml) verwendet GitHub-gehostete +Ubuntu-24.04-Runner, Python 3.13 und Node.js 24. Python- und JavaScript-Prüfung +müssen erfolgreich sein, bevor das Containerimage gebaut und getestet wird. +Die [Einrichtungsanleitung](github-actions.md) beschreibt Schutzregeln, +Paketberechtigungen und die erste Veröffentlichung. + +Bei der Migration wurden die folgenden Prüfungen lokal ausgeführt: + +- Die vollständige Windows-Testsuite bestand mit 115 erfolgreichen Tests; + zwei Linux-Prozesstests wurden plattformbedingt übersprungen. +- Unter Linux/Python 3.13.15 führte `ci/python-tests.sh` alle 117 Tests + erfolgreich als UID 10001 aus. Das Skript installierte die Abhängigkeiten + in einer eigenen virtuellen Umgebung, schrieb den JUnit-Bericht und + entfernte die Umgebung anschließend. +- Die 68 CI-Skripttests prüfen mit Docker- und Python-Testdoubles + GitHub-Ereignisse, geschützte Refs, Versionsabgleich, GHCR-Pfade, + Publish-Reihenfolge, Digest-Artefakte und Fehlerpfade. Run-ID, Wiederholung + und Job-ID isolieren temporäre Ressourcen. Ein Build- oder Smoke-Fehler + verhindert Registry-Anmeldung und Push. +- Die im Workflow eingebettete Veröffentlichungsentscheidung wurde mit + 17 Fällen ausgeführt: geschützte Branches und Release-Tags, manueller Start, + andere Standardbranchnamen, Pull Requests, ungeschützte Refs, ungültige Tags + und Shell-Sonderzeichen im Ref-Namen. +- `actionlint` 1.7.12 prüfte den Workflow mit genau einer ausgenommenen + Schema-Meldung: Die aktuelle Version kennt `concurrency.queue` noch nicht. + `queue: max` wurde gegen die offizielle + [GitHub-Syntax](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) + geprüft; die fehlende Linter-Unterstützung ist in + [actionlint #657](https://github.com/rhysd/actionlint/issues/657) dokumentiert. + Weitere Linter-Befunde gab es nicht. +- `node --check provisioner/static/app.js` und `docker compose config --quiet` + mit gesetzten Beispielwerten für Image und öffentliche URL waren erfolgreich. +- `ci/container.sh verify` lief mit GitHub-Umgebungsvariablen gegen den lokalen + Linux-Docker-Dienst 29.7.2. amd64-Build und Container-Smoke-Test bestanden: + vertrauenswürdiges TLS, Ablehnung eines fremden Zertifikats, Anmeldung, + CSRF, Webdateien sowie Erhalt von Hosts und Sitzungen beim Dienstneustart. + Das Skript erzeugte `build.env` und entfernte den temporären Image-Tag. + +Ein tatsächlicher GitHub-Actions-Lauf einschließlich GHCR-Anmeldung, +Registry-Push und Artefakt-Upload wurde bei dieser lokalen Prüfung nicht +ausgeführt. Die Push-Fehlerpfade und Digest-Auswertung sind durch die +CI-Skripttests abgedeckt; die Paketberechtigungen und aktiven Schutzregeln +müssen im GitHub-Repository eingerichtet und beim ersten Lauf bestätigt werden. + +## Browserprüfung + +In Chromium wurden Anmeldung, alle neun Verwaltungsansichten, Hostanlage und +Hostbearbeitung, Geheimnisreferenzen, Gruppentoken, ISO-Registrierung und Buildbefehl, +Modulvorlagen, Skript- und Profilveröffentlichung, Installationsvorschau, +Freigabe, Abbruch, Laufabgleich, Benutzeranlage und Leserrechte durchgespielt. +Die mobile Ansicht bei 390 Pixeln wurde ebenfalls geprüft. Es traten keine +JavaScript- oder CSP-Fehler auf. Die separate Testkonfiguration verwendete +temporäre Daten und deaktivierte die Vieraugenregel für den Formulartest mit +einem Administrator; die Vieraugenregel wurde in den API-Tests geprüft. + +## API-Artefakt + +[openapi.json](openapi.json) wird über `create_app(Settings(...)).openapi()` aus +dem aktuellen Anwendungscode erzeugt. Der Export verwendet ausschließlich +temporäre Testverzeichnisse, keine produktiven Benutzer oder Schlüssel. Er +enthält Schemas und Endpunktbeschreibungen, keine Datenbankinhalte, Sessions oder +Geheimniswerte. Bei Änderungen an den API-Modellen muss der Export erneuert werden. diff --git a/provisioner/__init__.py b/provisioner/__init__.py new file mode 100644 index 0000000..af7473b --- /dev/null +++ b/provisioner/__init__.py @@ -0,0 +1,3 @@ +"""Proxmox AIS: controlled installation and post-installation provisioning.""" + +__version__ = "0.1.0" diff --git a/provisioner/app.py b/provisioner/app.py new file mode 100644 index 0000000..47a9e75 --- /dev/null +++ b/provisioner/app.py @@ -0,0 +1,766 @@ +from contextlib import asynccontextmanager +import asyncio +import base64 +from collections import defaultdict, deque +import hashlib +import hmac +import json +from pathlib import Path +import re +import shlex +import sqlite3 +import time + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +import jsonschema +from pydantic import ValidationError + +from .config import Settings +from .db import Database +from .models import (Approval, Completion, Enroll, EventBatch, GroupCreate, HostCreate, HostUpdate, IsoCreate, LeaseRequest, LogBatch, ModuleCreate, ProfileCreate, Publish, RunAction, RunReconcile, SecretCreate, UserCreate, normalize_identity) +from .security import Security, atomic_artifact, canonical, digest, token +from .service import Service, TERMINAL, audit, get_host, new_id, now_iso, public_run, require, unpack + +ASSETS = Path(__file__).parent + + +class RequestGuards: + """Bound bodies before FastAPI parses them; no capability URLs in access logs.""" + def __init__(self, app, max_bytes): + self.app, self.max_bytes = app, max_bytes + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + consumed = 0 + messages = [] + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + consumed += len(message.get("body", b"")) + if consumed > self.max_bytes: + return await JSONResponse({"detail":"Anfrage überschreitet das Größenlimit."},413)(scope,receive,send) + messages.append(message) + if not message.get("more_body", False): + break + async def replay(): + if messages: + return messages.pop(0) + return await receive() + async def guarded_send(message): + if message["type"] == "http.response.start": + referrer_policy = b"no-referrer" if scope["path"].startswith(("/bootstrap/","/installer/","/agent/")) else b"same-origin" + message.setdefault("headers", []).extend([(b"cache-control", b"no-store"),(b"x-content-type-options",b"nosniff"),(b"x-frame-options",b"DENY"),(b"referrer-policy",referrer_policy),(b"content-security-policy",b"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")]) + await send(message) + await self.app(scope,replay,guarded_send) + + +def create_app(settings: Settings | None = None): + settings = settings or Settings.from_env() + db = Database(settings) + security = Security(settings) + service = Service(settings, db, security) + rate_buckets = defaultdict(deque) + + def rate_limit(key, limit, seconds=60): + timestamp = time.monotonic() + queue = rate_buckets[key] + while queue and queue[0] < timestamp - seconds: + queue.popleft() + require(len(queue) < limit, 429, "Zu viele Anfragen. Bitte später erneut versuchen.") + queue.append(timestamp) + if len(rate_buckets) > 10000: + for item in list(rate_buckets): + if not rate_buckets[item] or rate_buckets[item][-1] < timestamp - 3600: + rate_buckets.pop(item, None) + + async def maintenance_loop(): + while True: + await asyncio.sleep(30) + await asyncio.to_thread(service.maintain) + + @asynccontextmanager + async def lifespan(app): + from .cli import ServiceLock + require(not (settings.data_dir / "RESTORE_FAILED").exists(), 503, "Unvollständige Wiederherstellung muss zuerst behoben werden.") + with ServiceLock(settings.data_dir): + db.initialize() + if settings.bootstrap_username and settings.bootstrap_password: + with db.connection(write=True) as connection: + if not connection.execute("SELECT 1 FROM users LIMIT 1").fetchone(): + connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)", (new_id("user"),settings.bootstrap_username,security.hash_password(settings.bootstrap_password),"admin",now_iso())) + service.maintain() + task = asyncio.create_task(maintenance_loop()) + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + app = FastAPI(title="Proxmox AIS", version="0.1.0", description="Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) + app.state.db, app.state.settings, app.state.security, app.state.service = db, settings, security, service + app.add_middleware(RequestGuards, max_bytes=settings.max_request_bytes) + (ASSETS / "static").mkdir(exist_ok=True) + (ASSETS / "templates").mkdir(exist_ok=True) + app.mount("/static",StaticFiles(directory=ASSETS / "static"),name="static") + templates = Jinja2Templates(directory=ASSETS / "templates") + + @app.exception_handler(sqlite3.IntegrityError) + async def conflict(request, error): + return JSONResponse({"detail":"Konflikt: Identität, IP, FQDN, Name oder aktiver Lauf ist bereits vergeben."},409) + + @app.exception_handler(sqlite3.OperationalError) + async def db_unavailable(request,error): + return JSONResponse({"detail":"Datenbank vorübergehend nicht verfügbar."},503) + + @app.exception_handler(RequestValidationError) + @app.exception_handler(ValidationError) + async def validation_failed(request,error): + errors = [{"loc":list(e["loc"]),"msg":e["msg"],"type":e["type"]} for e in error.errors()] + return JSONResponse({"detail":"Ungültige Eingaben.","errors":errors},422) + + @app.exception_handler(ValueError) + async def bad_value(request,error): + return JSONResponse({"detail":"Ungültiger Wert oder nicht lesbare Konfiguration."},422) + + def current_user(request: Request): + session_token = request.cookies.get("ais_session", "") + require(bool(session_token),401,"Anmeldung erforderlich.") + with db.connection() as connection: + row = connection.execute("SELECT u.id,u.username,u.role,s.csrf_token FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>? AND u.disabled=0",(digest(session_token),time.time())).fetchone() + require(row is not None,401,"Sitzung ist ungültig oder abgelaufen.") + user = dict(row) + if request.method not in {"GET","HEAD","OPTIONS"}: + csrf = request.headers.get("x-csrf-token", "") + require(hmac.compare_digest(csrf,user["csrf_token"]),403,"CSRF-Prüfung fehlgeschlagen. Seite neu laden.") + return user + + def roles(*allowed): + def check(user=Depends(current_user)): + require(user["role"] in {*allowed,"admin","developer"},403,"Für diese Aktion fehlt die Berechtigung.") + return user + return check + + @app.get("/health/live") + def health_live(): + return {"status":"ok"} + + @app.get("/health/ready") + def health_ready(): + with db.connection() as connection: + connection.execute("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").fetchone() + require(settings.master_key_file.is_file(),503,"Notwendige Konfiguration fehlt.") + return {"status":"ready"} + + @app.get("/login",response_class=HTMLResponse,include_in_schema=False) + def login_page(request:Request): + return templates.TemplateResponse(request=request,name="login.html",context={"error":None}) + + @app.post("/auth/login",include_in_schema=False) + def login(request:Request,username:str=Form(...),password:str=Form(...)): + rate_limit("login:" + (request.client.host if request.client else "unknown"),10,300) + origin = request.headers.get("origin") + require(not origin or origin == settings.public_url or (settings.testing and origin == str(request.base_url).rstrip("/")),403,"Anmeldeanfrage stammt von einer fremden Website.") + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM users WHERE username=? AND disabled=0",(username,)).fetchone() + if row is None or not security.verify_password(password,row["password_hash"]): + audit(connection,"anonymous","login.failed","",data={"username":username[:64]}) + return templates.TemplateResponse(request=request,name="login.html",context={"error":"Benutzername oder Passwort ist falsch."},status_code=401) + session_token, csrf = token(),token() + connection.execute("INSERT INTO sessions VALUES(?,?,?,?)",(digest(session_token),row["id"],csrf,time.time()+settings.session_hours*3600)) + audit(connection,row["id"],"login.succeeded",row["id"]) + response = RedirectResponse("/",status_code=303) + response.set_cookie("ais_session",session_token,httponly=True,secure=settings.secure_cookies,samesite="strict",max_age=settings.session_hours*3600,path="/") + return response + + @app.post("/auth/logout") + def logout(request:Request,user=Depends(current_user)): + with db.connection(write=True) as connection: + connection.execute("DELETE FROM sessions WHERE token_hash=?",(digest(request.cookies.get("ais_session","")),)) + audit(connection,user["id"],"logout",user["id"]) + response = JSONResponse({"status":"ok"}) + response.delete_cookie("ais_session",path="/") + return response + + @app.get("/",response_class=HTMLResponse,include_in_schema=False) + def index(request:Request): + try: + user = current_user(request) + except HTTPException: + return RedirectResponse("/login",303) + return templates.TemplateResponse(request=request,name="index.html",context={"user":user}) + + @app.get("/api/v1/me") + def me(user=Depends(current_user)): + return user + + @app.get("/openapi.json",include_in_schema=False) + def openapi(user=Depends(current_user)): + return app.openapi() + + @app.get("/api/v1/dashboard") + def dashboard(user=Depends(current_user)): + service.maintain() + with db.connection() as connection: + hosts = [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY created_at DESC")] + runs = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 20")] + counts = {"hosts":len(hosts),"ready":sum(h["status"] == "prepared" and not h["blocked"] for h in hosts),"active":sum(h["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","waiting_retry"} for h in hosts),"succeeded":sum(h["status"]=="succeeded" for h in hosts),"needs_review":sum(h["status"] in {"needs_review","failed"} for h in hosts),"discovered":connection.execute("SELECT COUNT(*) FROM discoveries").fetchone()[0]} + events = [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 12")] + return {"counts":counts,"recent_events":events,"hosts":hosts,"runs":runs,"maintenance":settings.maintenance} + + @app.get("/api/v1/hosts") + def hosts(user=Depends(current_user)): + with db.connection() as connection: + return [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY fqdn")] + + @app.post("/api/v1/hosts",status_code=201) + def create_host(payload:HostCreate,user=Depends(roles("operator"))): + with db.connection(write=True) as connection: + return service.create_host(connection,payload,user["id"]) + + @app.post("/api/v1/hosts/import",status_code=201) + def import_hosts(payload:list[HostCreate],user=Depends(roles("operator"))): + require(1 <= len(payload) <= 100,422,"Ein Import darf 1 bis 100 Hosts enthalten.") + with db.connection(write=True) as connection: + return [service.create_host(connection,item,user["id"]) for item in payload] + + @app.get("/api/v1/discoveries") + def discoveries(user=Depends(current_user)): + with db.connection() as connection: + return [unpack(r) for r in connection.execute("SELECT * FROM discoveries ORDER BY last_seen DESC")] + + @app.get("/api/v1/hosts/{host_id}") + def host_detail(host_id:str,user=Depends(current_user)): + with db.connection() as connection: + host = get_host(connection,host_id) + host["runs"] = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC",(host_id,))] + return host + + @app.patch("/api/v1/hosts/{host_id}") + def update_host(host_id:str,payload:HostUpdate,user=Depends(roles("operator"))): + with db.connection(write=True) as connection: + return service.update_host(connection,host_id,payload,user["id"]) + + @app.get("/api/v1/hosts/{host_id}/preview") + def preview(host_id:str,user=Depends(roles("operator","author"))): + with db.connection() as connection: + return service.resolve(connection,host_id)[0] + + @app.post("/api/v1/hosts/{host_id}/approve-install",status_code=201) + def approve(host_id:str,payload:Approval,user=Depends(roles("operator"))): + service.maintain() + with db.connection(write=True) as connection: + return service.approve(connection,host_id,payload,user["id"]) + + @app.get("/api/v1/profiles") + def profiles(user=Depends(current_user)): + with db.connection() as connection: + return [unpack(r) for r in connection.execute("SELECT * FROM profiles ORDER BY name,version DESC")] + + @app.post("/api/v1/profiles",status_code=201) + def create_profile(payload:ProfileCreate,user=Depends(roles("author"))): + with db.connection(write=True) as connection: + return service.create_profile(connection,payload,user["id"]) + + @app.get("/api/v1/modules") + def modules(user=Depends(current_user)): + with db.connection() as connection: + result = [unpack(r) for r in connection.execute("SELECT * FROM modules ORDER BY name,version DESC")] + if user["role"] not in {"author","admin","developer"}: + for module in result: + module.pop("source",None) + return result + + @app.post("/api/v1/modules",status_code=201) + def create_module(payload:ModuleCreate,user=Depends(roles("author"))): + try: + jsonschema.Draft202012Validator.check_schema(payload.parameters_schema) + except jsonschema.SchemaError: + raise HTTPException(422,"Parameterschema ist ungültig.") + require("$ref" not in canonical(payload.parameters_schema),422,"Externe und rekursive Schema-Referenzen werden nicht unterstützt.") + payload.source = payload.source.replace("\r\n","\n") + checksum = atomic_artifact(service.artifact_dir,payload.source) + with db.connection(write=True) as connection: + version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM modules WHERE name=?",(payload.name,)).fetchone()[0] + module_id = new_id("module") + connection.execute("INSERT INTO modules VALUES(?,?,?,?,?,?,?,?)",(module_id,payload.name,version,"draft",checksum,canonical(payload.model_dump()),user["id"],now_iso())) + audit(connection,user["id"],"module.created",module_id,payload.reason,{"digest":checksum}) + return unpack(connection.execute("SELECT * FROM modules WHERE id=?",(module_id,)).fetchone()) + + @app.get("/api/v1/modules/builtin") + def builtin_modules(user=Depends(roles("author"))): + from .builtin_modules import catalog + return catalog() + + @app.get("/api/v1/modules/{object_id}") + def module_detail(object_id:str,user=Depends(current_user)): + with db.connection() as connection: + result = unpack(connection.execute("SELECT * FROM modules WHERE id=?",(object_id,)).fetchone()) + if user["role"] not in {"admin","developer","author"}: + result.pop("source",None) + return result + + @app.get("/api/v1/profiles/{object_id}") + def profile_detail(object_id:str,user=Depends(current_user)): + with db.connection() as connection: + return unpack(connection.execute("SELECT * FROM profiles WHERE id=?",(object_id,)).fetchone()) + + @app.post("/api/v1/modules/{object_id}/publish") + def publish_module(object_id:str,payload:Publish,user=Depends(roles())): + return publish_object("modules",object_id,payload,user) + + @app.post("/api/v1/profiles/{object_id}/publish") + def publish_profile(object_id:str,payload:Publish,user=Depends(roles())): + return publish_object("profiles",object_id,payload,user) + + def publish_object(table,object_id,payload,user): + with db.connection() as connection: + item = unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone()) + require(item["status"] == "draft",409,"Veröffentlichte Versionen sind unveränderlich. Neue Version erstellen.") + require(not settings.four_eyes or item["created_by"] != user["id"],403,"Vieraugenprinzip: Eine andere Person muss diese Version veröffentlichen.") + require(item["target_builds"],422,"Mindestens ein getesteter Zielbuild ist erforderlich.") + if table == "modules": + service.module_syntax(item["source"]) + else: + require(item["kind"] != "postinstall" or item["steps"],422,"Postinstallationsprofil benötigt Schritte.") + with db.connection(write=True) as connection: + row = connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone() + require(row["status"] == "draft",409,"Version wurde bereits veröffentlicht.") + data = json.loads(row["data"]) + data.update({"test_evidence":payload.test_evidence,"published_by":user["id"],"published_at":now_iso()}) + if table == "profiles": + data["digest"] = digest(canonical({k:v for k,v in data.items() if k != "digest"})) + connection.execute(f"UPDATE {table} SET status='published',data=? WHERE id=?",(canonical(data),object_id)) + audit(connection,user["id"],table[:-1]+".published",object_id,payload.reason,{"test_evidence":payload.test_evidence}) + return unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone()) + + @app.get("/api/v1/groups") + def groups(user=Depends(roles("operator","author"))): + with db.connection() as connection: + return [dict(r) for r in connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups ORDER BY name")] + + @app.post("/api/v1/groups",status_code=201) + def create_group(payload:GroupCreate,user=Depends(roles())): + secret = token() + with db.connection(write=True) as connection: + group_id = new_id("group") + connection.execute("INSERT INTO groups VALUES(?,?,?,?,?,?,?)",(group_id,payload.name,payload.site,digest(secret),time.time()+payload.valid_hours*3600,0,now_iso())) + audit(connection,user["id"],"group.created",group_id) + result = dict(connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups WHERE id=?",(group_id,)).fetchone()) + return {**result,"token":payload.name + ":" + secret} + + @app.post("/api/v1/groups/{group_id}/revoke") + def revoke_group(group_id:str,user=Depends(roles())): + with db.connection(write=True) as connection: + changed = connection.execute("UPDATE groups SET revoked=1 WHERE id=?",(group_id,)).rowcount + require(changed,404,"Gruppe nicht gefunden.") + audit(connection,user["id"],"group.revoked",group_id) + return {"status":"revoked"} + + @app.get("/api/v1/iso-records") + def iso_records(user=Depends(current_user)): + with db.connection() as connection: + return [iso_command(unpack(r),connection) for r in connection.execute("SELECT * FROM iso_records ORDER BY created_at DESC")] + + def iso_command(iso,connection): + group = connection.execute("SELECT name FROM groups WHERE id=?",(iso["group_id"],)).fetchone() + args = ["proxmox-auto-install-assistant","prepare-iso","SOURCE.iso","--fetch-from","http","--url",settings.public_url + "/installer/v1/answer","--cert-fingerprint",iso["fingerprint"],"--answer-auth-token",(group[0] if group else "gruppe") + ":"] + iso["command"] = shlex.join(args) + return iso + + @app.post("/api/v1/iso-records",status_code=201) + def create_iso(payload:IsoCreate,user=Depends(roles())): + require(payload.test_status != "passed" or (payload.native_token_support and len(payload.test_evidence)>=5),422,"Freigegebenes Medium benötigt nativen Token-Support und dokumentierten Testnachweis.") + with db.connection(write=True) as connection: + require(connection.execute("SELECT 1 FROM groups WHERE id=?",(payload.group_id,)).fetchone(),422,"Gruppe nicht gefunden.") + iso_id = new_id("iso") + connection.execute("INSERT INTO iso_records VALUES(?,?,?,?)",(iso_id,payload.name,canonical(payload.model_dump()),now_iso())) + audit(connection,user["id"],"iso.registered",iso_id,data={"build":payload.build,"test_status":payload.test_status}) + return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection) + + @app.get("/api/v1/secrets") + def secrets_list(user=Depends(roles())): + with db.connection() as connection: + return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")] + + @app.post("/api/v1/secrets",status_code=201) + def create_secret(payload:SecretCreate,user=Depends(roles())): + with db.connection(write=True) as connection: + secret_id = new_id("secret") + connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso())) + audit(connection,user["id"],"secret.created",secret_id) + return {"id":secret_id,"name":payload.name} + + @app.get("/api/v1/users") + def users(user=Depends(roles())): + with db.connection() as connection: + return [dict(r) for r in connection.execute("SELECT id,username,role,disabled,created_at FROM users ORDER BY username")] + + @app.post("/api/v1/users",status_code=201) + def create_user(payload:UserCreate,user=Depends(roles())): + with db.connection(write=True) as connection: + user_id = new_id("user") + connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)",(user_id,payload.username,security.hash_password(payload.password),payload.role,now_iso())) + audit(connection,user["id"],"user.created",user_id,data={"role":payload.role}) + return {"id":user_id,"username":payload.username,"role":payload.role} + + @app.post("/api/v1/users/{user_id}/disable") + def disable_user(user_id:str,user=Depends(roles())): + require(user_id != user["id"],409,"Eigenes Konto kann nicht deaktiviert werden.") + with db.connection(write=True) as connection: + require(connection.execute("UPDATE users SET disabled=1 WHERE id=?",(user_id,)).rowcount,404,"Benutzer nicht gefunden.") + connection.execute("DELETE FROM sessions WHERE user_id=?",(user_id,)) + audit(connection,user["id"],"user.disabled",user_id) + return {"status":"disabled"} + + @app.get("/api/v1/audit") + def audit_list(user=Depends(current_user)): + with db.connection() as connection: + return [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 500")] + + @app.get("/api/v1/runs") + def runs(user=Depends(current_user)): + with db.connection() as connection: + return [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 500")] + + def decorate_run(row,connection): + result = public_run(row) + result["contact_status"] = "unknown" if row["last_seen"] and row["status"] not in TERMINAL and time.time()-row["last_seen"]>settings.heartbeat_unknown_seconds else "current" if row["last_seen"] else "pending" + specs = {s["id"]:s for s in result["manifest"]["steps"]} + result["steps"] = [{**specs[r["step_id"]],**dict(r),"verification":json.loads(r["verification"])} for r in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(row["id"],))] + return result + + @app.get("/api/v1/runs/{run_id}") + def run_detail(run_id:str,user=Depends(current_user)): + with db.connection() as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + require(row is not None,404,"Lauf nicht gefunden.") + result = decorate_run(row,connection) + result["events"] = [unpack(r) for r in connection.execute("SELECT * FROM events WHERE run_id=? ORDER BY sequence DESC LIMIT 200",(run_id,))][::-1] + result["logs"] = [unpack(r) for r in connection.execute("SELECT * FROM logs WHERE run_id=? ORDER BY sequence DESC LIMIT 100",(run_id,))][::-1] + return result + + @app.post("/api/v1/runs/{run_id}/cancel") + def cancel_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))): + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + run = unpack(row) + require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Laufzustand hat sich geändert oder ist bereits abgeschlossen.") + data = json.loads(row["data"]) + data["cancel_requested"] = True + status = "cancelled" if run["status"] == "prepared" else run["status"] + connection.execute("UPDATE runs SET data=?,status=?,version=version+1 WHERE id=?",(canonical(data),status,run_id)) + if status == "cancelled": + connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],)) + connection.execute("UPDATE hosts SET status='cancelled' WHERE id=?",(run["host_id"],)) + audit(connection,user["id"],"run.cancel_requested",run_id,payload.reason) + return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()) + + @app.post("/api/v1/runs/{run_id}/resume") + def resume_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))): + with db.connection(write=True) as connection: + run = unpack(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()) + require(run["version"] == payload.expected_version and run["status"] in {"needs_review","waiting_retry"},409,"Nur ein wartender, unverändert angezeigter Lauf kann fortgesetzt werden.") + require(run["device_key"] and not run.get("cancel_requested"),409,"Keine aktive Gerätebindung für sichere Wiederaufnahme vorhanden.") + require(not get_host(connection,run["host_id"])["blocked"],403,"Host ist gesperrt.") + connection.execute("UPDATE runs SET status='runner_ready',version=version+1 WHERE id=?",(run_id,)) + connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(run["host_id"],)) + audit(connection,user["id"],"run.resumed",run_id,payload.reason) + return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()) + + @app.post("/api/v1/runs/{run_id}/reconcile") + def reconcile_run(run_id:str,payload:RunReconcile,user=Depends(roles("operator"))): + """Close an externally checked abandoned run; never grants installation.""" + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + run = unpack(row) + host = get_host(connection,run["host_id"]) + require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Lauf wurde geändert oder ist bereits beendet.") + require(payload.execution_stopped and payload.confirmation == host["fqdn"],422,"Vor dem Abschließen muss lokal geprüft sein, dass Installer und Runner gestoppt sind; Host-FQDN bestätigen.") + require(not row["lease_until"] or row["lease_until"]<=time.time(),409,"Aktuelle Laufberechtigung muss vor dem Abgleich ablaufen. Zuerst Abbruch anfordern.") + require(not row["answer_until"] or row["answer_until"]<=time.time(),409,"Auslieferungsfenster ist noch aktiv. Abgleich erst nach dessen Ablauf möglich.") + data = json.loads(row["data"]) + data.update({"cancel_requested":True,"reconciled_by":user["id"],"reconciliation_reason":payload.reason}) + connection.execute("UPDATE runs SET status='cancelled',version=version+1,data=?,completed_at=?,device_key=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL,answer_ciphertext=NULL,bootstrap_ciphertext=NULL,lease_until=NULL WHERE id=?",(canonical(data),time.time(),run_id)) + connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],)) + connection.execute("UPDATE hosts SET status='cancelled',version=version+1 WHERE id=?",(host["id"],)) + audit(connection,user["id"],"run.reconciled",run_id,payload.reason,{"execution_stopped_confirmed":True,"fqdn":host["fqdn"]}) + return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()) + + def installer_group(request): + authorization = request.headers.get("authorization", "") + require(authorization.startswith("Bearer ") and ":" in authorization,401,"Gültiger Installer-Gruppentoken erforderlich.") + name,secret = authorization[7:].split(":",1) + with db.connection() as connection: + row = connection.execute("SELECT * FROM groups WHERE name=?",(name,)).fetchone() + require(row is not None and hmac.compare_digest(digest(secret),row["token_hash"]),401,"Ungültiger Installer-Gruppentoken.") + require(not row["revoked"],403,"Installer-Gruppentoken ist gesperrt.") + require(row["expires_at"] > time.time(),410,"Installer-Gruppentoken ist abgelaufen.") + rate_limit("installer:" + row["id"],120) + return row + + @app.post("/installer/v1/answer",response_class=Response) + async def installer_answer(request:Request): + rate_limit("answer-ip:" + (request.client.host if request.client else "unknown"),240) + group = installer_group(request) + try: + payload = await request.json() + except (ValueError,UnicodeDecodeError): + raise HTTPException(422,"Ungültige Installer-Systemdaten.") + service.maintain() + try: + with db.connection(write=True) as connection: + answer = service.serve_answer(connection,group,payload) + except HTTPException as error: + with db.connection(write=True) as connection: + audit(connection,"installer:"+group["name"],"installation.denied","",str(error.detail)) + raise + require(answer is not None,403,"Unbekannter Host wurde als entdeckt gespeichert. Vor erneutem Start zuordnen und freigeben.") + return Response(answer,media_type="application/toml") + + @app.get("/bootstrap/v1/{download_token}",response_class=Response) + def bootstrap(download_token:str): + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM runs WHERE bootstrap_hash=?",(digest(download_token),)).fetchone() + require(row is not None,401,"Ungültige Download-Berechtigung.") + require(row["status"] in {"answer_served","installed_reported"} and time.time()time.time(),410,"Report-Berechtigung ist abgelaufen.") + require(row["status"] in {"answer_served","installed_reported","runner_ready","running"},409,"Installationsbericht passt nicht zum Laufzustand.") + if row["status"] == "answer_served": + connection.execute("UPDATE runs SET status='installed_reported',version=version+1,last_seen=? WHERE id=?",(time.time(),row["id"])) + connection.execute("UPDATE hosts SET status='installed_reported' WHERE id=?",(row["host_id"],)) + audit(connection,"installer","installation.reported",row["id"]) + return {"status":"accepted"} + + @app.post("/agent/v1/enroll") + def enroll(payload:Enroll,request:Request): + rate_limit("enroll:" + (request.client.host if request.client else "unknown"),60) + try: + key = base64.b64decode(payload.public_key,validate=True) + Ed25519PublicKey.from_public_bytes(key) + except (ValueError,TypeError): + raise HTTPException(422,"Ungültiger Ed25519-Geräteschlüssel.") + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?",(payload.run_id,)).fetchone() + require(row is not None and row["enrollment_hash"] and hmac.compare_digest(row["enrollment_hash"],digest(payload.enrollment_secret)),401,"Ungültige Enrollment-Berechtigung.") + require(row["enroll_until"] > time.time(),410,"Enrollment-Berechtigung ist abgelaufen.") + require(row["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","needs_review","waiting_retry"},403,"Lauf erlaubt keine Registrierung.") + run_data = json.loads(row["data"]) + require(not run_data.get("cancel_requested"),403,"Lauf ist zum Abbruch markiert.") + identities = [{"kind":i.kind,"value":normalize_identity(i.kind,i.value)} for i in payload.identities] + host = service.match_host(connection,identities) + require(host and host["id"] == row["host_id"] and not host["blocked"],403,"Geräteidentität passt nicht zum freigegebenen Host.") + require(not row["device_key"] or row["device_key"] == payload.public_key,409,"Enrollment ist bereits an einen anderen Geräteschlüssel gebunden.") + if not row["device_key"]: + run_data["boot_id"] = payload.boot_id + connection.execute("UPDATE runs SET device_key=?,data=?,status='runner_ready',version=version+1,last_seen=? WHERE id=?",(payload.public_key,canonical(run_data),time.time(),row["id"])) + connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(row["host_id"],)) + audit(connection,"device:"+row["id"],"runner.enrolled",row["id"]) + return {"run_id":row["id"],"status":"enrolled","manifest_digest":run_data["manifest_digest"]} + + async def device(request:Request): + run_id = request.headers.get("x-run-id", "") + key = request.headers.get("x-device-key", "") + timestamp = request.headers.get("x-timestamp", "") + nonce = request.headers.get("x-nonce", "") + try: + require(abs(time.time()-int(timestamp)) <= 300,401,"Signaturzeit liegt außerhalb des zulässigen Fensters.") + require(bool(re.fullmatch(r"[A-Za-z0-9_-]{16,128}",nonce)),401,"Ungültige Request-Nonce.") + body = await request.body() + message = f"{request.method}\n{request.url.path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode() + signature = base64.b64decode(request.headers.get("x-signature", ""),validate=True) + with db.connection(write=True) as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + require(row is not None and row["device_key"] and hmac.compare_digest(row["device_key"],key),401,"Gerätebindung ungültig.") + Ed25519PublicKey.from_public_bytes(base64.b64decode(key,validate=True)).verify(signature,message) + require(not connection.execute("SELECT 1 FROM nonces WHERE run_id=? AND nonce=?",(run_id,nonce)).fetchone(),409,"Request-Nonce wurde bereits verwendet.") + if row["status"] in TERMINAL: + complete_retry = request.url.path == f"/agent/v1/runs/{run_id}/complete" and row["status"] == "succeeded" and row["completed_at"] and time.time()-row["completed_at"] < 3600 + cancel_retry = request.url.path == f"/agent/v1/runs/{run_id}/events" and row["status"] == "cancelled" and row["completed_at"] and time.time()-row["completed_at"] < 3600 + require(complete_retry or cancel_retry,403,"Laufberechtigung ist beendet.") + connection.execute("INSERT INTO nonces VALUES(?,?,?)",(run_id,nonce,time.time()+600)) + rate_limit("device:"+run_id,600) + return run_id + except (ValueError,TypeError,InvalidSignature): + raise HTTPException(401,"Ungültige Gerätesignatur.") + + def authorized_run(connection,run_id,device_id,lease=False): + require(run_id == device_id,403,"Kein Zugriff auf einen fremden Lauf.") + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + require(row is not None,404,"Lauf nicht gefunden.") + if lease: + require(row["status"] in {"runner_ready","running"} and row["lease_until"] and row["lease_until"]>time.time(),403,"Aktuelle Ausführungsberechtigung erforderlich.") + require(not json.loads(row["data"]).get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Lauf ist gesperrt.") + return row + + @app.post("/agent/v1/lease") + def lease(payload:LeaseRequest,device_id=Depends(device)): + with db.connection(write=True) as connection: + row = authorized_run(connection,payload.run_id,device_id) + data = json.loads(row["data"]) + host = get_host(connection,row["host_id"]) + action = "stop" if data.get("cancel_requested") else "wait" if host["blocked"] or row["status"] in {"needs_review","waiting_retry"} else "run" if row["status"] in {"runner_ready","running","reboot_pending"} else "revoked" + expiry = time.time()+settings.lease_seconds if action == "run" else time.time() + # A lease already held by an offline device cannot be recalled. + # Keep its horizon for the operator reconciliation gate. + remembered_expiry = expiry if action == "run" else row["lease_until"] + connection.execute("UPDATE runs SET lease_until=?,last_seen=? WHERE id=?",(remembered_expiry,time.time(),row["id"])) + return {"action":action,"expires_at":expiry,"run_version":row["version"]} + + @app.get("/agent/v1/runs/{run_id}/manifest") + def manifest(run_id:str,device_id=Depends(device)): + with db.connection() as connection: + row = authorized_run(connection,run_id,device_id,lease=True) + data = json.loads(row["data"]) + return {**data["manifest"],"digest":data["manifest_digest"]} + + @app.get("/agent/v1/artifacts/{checksum}",response_class=Response) + def artifact(checksum:str,device_id=Depends(device)): + require(bool(re.fullmatch(r"[a-f0-9]{64}",checksum)),404,"Artefakt nicht gefunden.") + with db.connection() as connection: + row = authorized_run(connection,device_id,device_id,lease=True) + require(checksum in {s["digest"] for s in json.loads(row["data"])["manifest"]["steps"]},403,"Artefakt gehört nicht zum Lauf.") + path = service.artifact_dir / checksum + require(path.is_file(),404,"Artefakt fehlt.") + content = path.read_bytes() + require(digest(content)==checksum,503,"Artefaktintegrität konnte nicht bestätigt werden.") + return Response(content,media_type="application/octet-stream") + + @app.get("/agent/v1/runs/{run_id}/secrets/{step_id}") + def step_secrets(run_id:str,step_id:str,device_id=Depends(device)): + with db.connection(write=True) as connection: + row = authorized_run(connection,run_id,device_id,lease=True) + step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(run_id,step_id)).fetchone() + require(step is not None and step["status"] != "succeeded",403,"Kein Geheimniszugriff für diesen Schritt.") + specs = {s["id"]:s for s in json.loads(row["data"])["manifest"]["steps"]} + remaining = [s for s in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(run_id,)) if s["status"] != "succeeded" and not (s["status"] == "failed" and not specs[s["step_id"]]["required"])] + require(remaining and remaining[0]["step_id"] == step_id,403,"Geheimnisse sind nur für den aktuellen Schritt verfügbar.") + audit(connection,"device:"+run_id,"step.secrets_read",run_id,data={"step_id":step_id}) + return json.loads(security.decrypt(row["secrets_ciphertext"]))["steps"].get(step_id,{}) + + @app.post("/agent/v1/runs/{run_id}/events") + def events(run_id:str,payload:EventBatch,device_id=Depends(device)): + with db.connection(write=True) as connection: + row = authorized_run(connection,run_id,device_id) + ack = connection.execute("SELECT COALESCE(MAX(sequence),0) FROM events WHERE run_id=?",(run_id,)).fetchone()[0] + for event in payload.events: + redacted = service.redact_payload(row,event.model_dump()) + if event.sequence <= ack: + stored = connection.execute("SELECT data FROM events WHERE run_id=? AND sequence=?",(run_id,event.sequence)).fetchone() + require(stored and stored[0] == redacted,409,"Sequenznummer wurde mit anderem Ereignisinhalt wiederholt.") + continue + require(event.sequence == ack+1,409,"Ereignisse müssen lückenlos und aufsteigend eintreffen.") + row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone() + apply_event(connection,row,event) + connection.execute("INSERT INTO events VALUES(?,?,?,?)",(run_id,event.sequence,redacted,now_iso())) + ack = event.sequence + connection.execute("UPDATE runs SET last_seen=? WHERE id=?",(time.time(),run_id)) + return {"ack_sequence":ack} + + def apply_event(connection,row,event): + data = json.loads(row["data"]) + status = row["status"] + require(status not in TERMINAL,409,"Terminaler Lauf nimmt keine neuen Ereignisse an.") + if event.type == "heartbeat": + return + if event.type.startswith("step."): + require(status in {"runner_ready","running"},409,"Schrittereignis ist in diesem Laufzustand nicht zulässig.") + step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(row["id"],event.step_id)).fetchone() + require(step is not None,422,"Schritt gehört nicht zum fixierten Manifest.") + specs = {s["id"]:s for s in data["manifest"]["steps"]} + previous = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND positiontime.time() and not data.get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Keine aktuelle Erlaubnis für einen neuen Schritt.") + require(step["status"] in {"pending","applying","failed"},409,"Abgeschlossener Schritt darf nicht erneut gestartet werden.") + connection.execute("UPDATE run_steps SET status='applying',attempt=attempt+1 WHERE run_id=? AND step_id=?",(row["id"],event.step_id)) + status = "running" + elif event.type == "step.succeeded": + require(step["status"] == "applying" and event.exit_code == 0 and bool(event.verification),409,"Erfolg benötigt einen laufenden Schritt und erfolgreiche Verifikation.") + require(event.verification.get("passed") is True or ("passed" not in event.verification and all(v is True for v in event.verification.values())),409,"Verifikation bestätigt keinen Erfolg.") + connection.execute("UPDATE run_steps SET status='succeeded',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id)) + else: + require(step["status"] == "applying",409,"Nur ein laufender Schritt kann fehlschlagen.") + connection.execute("UPDATE run_steps SET status='failed',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id)) + status = "needs_review" if specs[event.step_id]["required"] else "running" + elif event.type == "run.needs_review": + status = "needs_review" + elif event.type == "run.reboot_pending": + require(status in {"running","runner_ready"} and data.get("reboots",0) str: + required = {"api_url", "run_id", "enrollment_secret", "identities", "manifest_digest"} + if not required.issubset(config): + raise ValueError(f"Missing bootstrap values: {', '.join(sorted(required - config.keys()))}") + if urllib.parse.urlsplit(config["api_url"]).scheme != "https": + raise ValueError("Bootstrap requires a certificate-validated HTTPS API URL") + runner = base64.b64encode(Path(__file__).with_name("runner.py").read_bytes()).decode() + settings = base64.b64encode(json.dumps(config, ensure_ascii=False, sort_keys=True).encode()).decode() + script = f'''#!/bin/bash +set -euo pipefail +umask 077 +test "$(id -u)" = 0 +command -v python3 >/dev/null +command -v openssl >/dev/null +command -v systemctl >/dev/null +# Persist every input before enabling the first network operation. +python3 - <<'PVE_BOOTSTRAP_PY' +import base64, json, os, pathlib, tempfile +etc = pathlib.Path('/etc/pve-provisioner') +state = pathlib.Path('/var/lib/pve-provisioner') +etc.mkdir(mode=0o700, parents=True, exist_ok=True) +state.mkdir(mode=0o700, parents=True, exist_ok=True) +os.chmod(etc, 0o700) +os.chmod(state, 0o700) +config = base64.b64decode('{settings}') +settings = json.loads(config) +existing = state / 'state.json' +if existing.exists() and json.loads(existing.read_text())['run_id'] != json.loads(config)['run_id']: + raise SystemExit('Another run owns this host; explicit local state archival is required') +def persist(path, content, mode=0o600): + fd, name = tempfile.mkstemp(prefix='.tmp-', dir=path.parent) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, 'wb') as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) +if settings.get('ca_pem'): + persist(etc / 'trusted-ca.pem', settings.pop('ca_pem').encode()) + settings['ca_file'] = str(etc / 'trusted-ca.pem') + config = json.dumps(settings, sort_keys=True).encode() +persist(etc / 'config.json', config) +persist(etc / 'runner.py', base64.b64decode('{runner}')) +service = b"""[Unit] +Description=Proxmox one-run provisioner +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=3600 +StartLimitBurst=120 + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /etc/pve-provisioner/runner.py +Restart=on-failure +RestartSec=30 +TimeoutStopSec=14500 +KillMode=mixed +UMask=0077 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +""" +persist(pathlib.Path('/etc/systemd/system/pve-provisioner.service'), service, 0o644) +PVE_BOOTSTRAP_PY +systemctl daemon-reload +systemctl enable --now pve-provisioner.service +''' + if len(script.encode()) >= 1024 * 1024: + raise ValueError("Bootstrap exceeds installer size limit") + return script diff --git a/provisioner/builtin_modules/README.md b/provisioner/builtin_modules/README.md new file mode 100644 index 0000000..7673f25 --- /dev/null +++ b/provisioner/builtin_modules/README.md @@ -0,0 +1,26 @@ +# Mitgelieferte Modulentwürfe + +Alle acht Module werden als **Entwurf ohne Hardware-Testnachweis und ohne freigegebene Zielbuilds** angeboten. Vor Veröffentlichung müssen Quelltext, konkrete Parameter und Verhalten auf einem passenden Testhost geprüft werden. Die Syntaxprüfung ersetzt diesen Nachweis nicht. + +| Modul | Parameter und Umfang | +| --- | --- | +| Voraussetzungen | `allowed_versions`: exakte Versionsstrings aus `pveversion`; `dns_names`: aufzulösende Namen; `minimum_free_mb`: freier Platz auf `/`. Eine leere Versionsliste lässt die zusätzliche Versionsprüfung aus; die Buildfreigabe im Dienst bleibt erforderlich. | +| Paketquellen | `url`, `suite`, `components`, `keyring` sind zwingend. Verwaltet genau `/etc/apt/sources.list.d/pve-provisioner.sources` mit HTTPS und vorhandenem APT-Schlüsselbund. Andere Quellen, insbesondere Subscription-Konfigurationen, werden nicht automatisch entfernt. | +| Basispakete | `packages`: Debian-Paketnamen ohne Shell-Ausdrücke oder APT-Optionen. Installiert fehlende Pakete, wartet auf die Paketmanagersperre und prüft anschließend den Installationsstatus. Führt kein allgemeines Systemupgrade aus. | +| SSH-Zugang | `users`: Liste aus `name` und `authorized_keys`. Benutzer müssen bereits existieren und eine Login-Shell sowie ein sicher berechtigtes Home-Verzeichnis haben. OpenSSH validiert vollständige Schlüssel vor jeder Änderung. Schlüssel werden ergänzt und Dateirechte, effektive lokale SSH-Schlüsselrichtlinie, `sshd -t` sowie der aktive Dienst geprüft. Verbindung und gegebenenfalls abweichende `Match`-Regeln aus dem realen Managementnetz sind separat zu testen. | +| Zeitsynchronisation | `servers`: explizite NTP-Hostnamen oder IP-Adressen. `chrony` muss zuvor installiert sein und `sourcedir /etc/chrony/sources.d` verwenden. Verwaltet eine eigene Quelldatei und wartet begrenzt auf Synchronisation. | +| Monitoring | `enabled`: standardmäßig `false`. Bei Aktivierung muss `prometheus-node-exporter` bereits installiert sein. Aktiviert den Dienst und prüft dessen lokalen Metrics-Endpunkt. Netzwerkzugriff auf den Exporter muss im Standortnetz passend geregelt sein. | +| Zusätzlicher Storage | `id`, `path`, `content`: registriert ein bereits existierendes Verzeichnis unter `/mnt/` oder `/srv/` als PVE-Verzeichnisstorage. Kein Formatieren, kein Mounten, keine Änderung widersprüchlicher vorhandener Storage-Konfiguration. | +| Abschlussprüfung | `allowed_versions`, `dns_names`, `storage_ids`, `require_time_sync`: prüft PVE-Dienste, Version, DNS, Zeit und angegebenen aktiven Storage. | + +Modulabhängigkeiten beziehen sich im Verwaltungsmodell auf **Modulnamen**. Beim Freigeben werden sie auf die konkreten Schritt-IDs des unveränderlichen Laufmanifests aufgelöst. Profilparameter werden gegen das jeweilige JSON-Schema geprüft. Beispielsweise muss das Paketprofil `chrony` enthalten, wenn der Zeitschritt auf einem Host ohne Chrony eingeplant wird. + +## Modulvertrag + +Der Runner startet `bash modul.sh check|apply|verify parameter.json`. `check` liefert `0`, wenn der Sollzustand erreicht ist, `1`, wenn eine Änderung nötig ist, und einen anderen Rückgabecode für einen Prüffehler. Ein Schritt gilt erst nach erfolgreichem `verify` als erfolgreich. `apply` darf mit `194` einen geplanten Neustart anfordern; der Runner schreibt zuerst seinen Checkpoint und kontrolliert das Neustartbudget. Ein Modul darf den Neustart nicht selbst auslösen. + +Die Parameterdatei enthält die freigegebenen Parameter sowie ein Objekt `secrets` mit ausschließlich den Geheimnissen des aktuellen Schritts. Sie wird mit Modus `0600` angelegt und nach dem Schritt entfernt. Module sollen keine Geheimnisse ausgeben; zusätzlich redigiert der Runner bekannte Geheimniswerte vor der dauerhaften Logablage. Logausgabe ist pro Phase und in der lokalen Warteschlange begrenzt. + +Das Schritt-Timeout gilt gemeinsam für `check`, `apply` und `verify`. Nach Unterbrechungen werden `check` und `verify` erneut ausgeführt; ein nicht bestätigter Zustand darf nur bei `retry_safe=true` erneut angewendet werden. Ein permanenter Fehler wartet auf eine explizite Wiederaufnahme im Webtool. Die standardmäßige Wartefrist beträgt 24 Stunden, die maximale automatische Wiederherstellung bei Netzausfall 30 Minuten. + +Chrony-Kommandos orientieren sich an der offiziellen Dokumentation zu [chronyc](https://chrony-project.org/doc/4.7/chronyc.html) und [sourcedir](https://chrony-project.org/doc/4.7/chrony.conf.html). Die tatsächliche Paketversion und Distribution bleiben Bestandteil des Zielhost-Tests. diff --git a/provisioner/builtin_modules/__init__.py b/provisioner/builtin_modules/__init__.py new file mode 100644 index 0000000..d7a1c0c --- /dev/null +++ b/provisioner/builtin_modules/__init__.py @@ -0,0 +1,53 @@ +"""Conservative module drafts. Publication always requires target-host evidence.""" +from pathlib import Path + + +def schema(properties, required=()): + return {"type": "object", "additionalProperties": False, "properties": properties, "required": list(required)} + + +STRING_LIST = {"type": "array", "items": {"type": "string"}, "maxItems": 100} +DEFINITIONS = [ + ("prerequisites", "Voraussetzungen", "PVE-Version, DNS, Uhrzeit und freien Speicher prüfen.", + schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST, + "minimum_free_mb": {"type": "integer", "minimum": 512, "maximum": 1048576}}), + {"allowed_versions": [], "dns_names": [], "minimum_free_mb": 2048}, [], 120, True), + ("repositories", "Paketquellen", "Eine signierte, explizit freigegebene HTTPS-Paketquelle verwalten.", + schema({"url": {"type": "string"}, "suite": {"type": "string"}, "components": STRING_LIST, + "keyring": {"type": "string"}}, ("url", "suite", "components", "keyring")), + {}, ["prerequisites"], 600, True), + ("packages", "Basispakete", "Explizit genannte Pakete installieren; keine globale Aktualisierung.", + schema({"packages": STRING_LIST}), {"packages": []}, ["prerequisites"], 1800, True), + ("ssh", "SSH-Zugang", "Freigegebene Schlüssel vorhandenen Benutzern hinzufügen; sshd validieren.", + schema({"users": {"type": "array", "maxItems": 50, "items": schema({ + "name": {"type": "string", "pattern": "^[a-z_][a-z0-9_-]{0,31}$"}, + "authorized_keys": STRING_LIST}, ("name", "authorized_keys"))}}), + {"users": []}, ["prerequisites"], 120, True), + ("time", "Zeitsynchronisation", "Chrony mit expliziten Zeitservern konfigurieren und Synchronisation prüfen.", + schema({"servers": STRING_LIST}, ("servers",)), {"servers": []}, ["packages"], 600, True), + ("monitoring", "Monitoring", "Optional den Debian prometheus-node-exporter aktivieren.", + schema({"enabled": {"type": "boolean"}}), {"enabled": False}, ["packages"], 600, False), + ("storage", "Zusätzlicher Storage", "Vorhandenes Verzeichnis ohne Formatierung als PVE-Storage anbinden.", + schema({"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"}, + "path": {"type": "string"}, "content": STRING_LIST}, ("id", "path", "content")), + {}, ["prerequisites"], 120, True), + ("final-verification", "Abschlussprüfung", "PVE-Dienste, Versionsstand, DNS, Zeit und aktiven Storage prüfen.", + schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST, + "storage_ids": STRING_LIST, "require_time_sync": {"type": "boolean"}}), + {"allowed_versions": [], "dns_names": [], "storage_ids": [], "require_time_sync": True}, + ["prerequisites"], 180, True), +] + + +def catalog(): + result = [] + root = Path(__file__).parent + names = {definition[0]: definition[1] for definition in DEFINITIONS} + for module_id, name, description, parameters_schema, defaults, dependencies, timeout, required in DEFINITIONS: + result.append({"id": module_id, "name": name, "description": description, "version": "1.0.0", + "source": (root / f"{module_id}.sh").read_text(encoding="utf-8"), + "parameters_schema": parameters_schema, "default_parameters": defaults, + "dependencies": [names[dependency] for dependency in dependencies], "timeout_seconds": timeout, + "retry_safe": True, "required": required, "status": "draft", + "test_evidence": "", "target_builds": []}) + return result diff --git a/provisioner/builtin_modules/final-verification.sh b/provisioner/builtin_modules/final-verification.sh new file mode 100644 index 0000000..1c1ec07 --- /dev/null +++ b/provisioner/builtin_modules/final-verification.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, pathlib, re, socket, subprocess, sys +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +try: + version = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=30).stdout + match = re.search(r'pve-manager/([^/\s]+)', version) + if not match or (p.get('allowed_versions') and match[1] not in p['allowed_versions']): + raise ValueError('PVE version verification failed') + for service in ('pve-cluster.service', 'pvedaemon.service', 'pveproxy.service', 'pvestatd.service'): + subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30) + for name in p.get('dns_names', []): + socket.getaddrinfo(name, 443) + if p.get('require_time_sync', True): + sync = subprocess.run(['timedatectl', 'show', '-p', 'NTPSynchronized', '--value'], check=True, capture_output=True, text=True, timeout=30) + if sync.stdout.strip() != 'yes': + raise ValueError('System clock is not synchronized') + for storage_id in p.get('storage_ids', []): + if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id): + raise ValueError('Invalid storage identifier') + result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30) + if not any(re.match(r'^' + re.escape(storage_id) + r'\s+\S+\s+active\s', line) for line in result.stdout.splitlines()): + raise ValueError('Required storage is not active') + print(json.dumps({'passed': True, 'pve_version': match[1], 'services': 'active', 'clock': 'checked', 'storage': 'checked'})) +except (ValueError, OSError, subprocess.SubprocessError) as exc: + print(str(exc), file=sys.stderr) + sys.exit(2) +PY diff --git a/provisioner/builtin_modules/monitoring.sh b/provisioner/builtin_modules/monitoring.sh new file mode 100644 index 0000000..c11a79d --- /dev/null +++ b/provisioner/builtin_modules/monitoring.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, pathlib, subprocess, sys, urllib.request +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +if not isinstance(p.get('enabled', False), bool): + raise SystemExit('enabled must be a boolean') +if not p.get('enabled', False): + print('{"passed":true,"enabled":false}') + raise SystemExit(0) +service = 'prometheus-node-exporter.service' +active = subprocess.run(['systemctl', 'is-active', '--quiet', service], timeout=30).returncode == 0 +if mode == 'check': + sys.exit(0 if active else 1) +if mode == 'apply': + package = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'prometheus-node-exporter'], capture_output=True, text=True, timeout=30) + if package.returncode != 0 or package.stdout != 'install ok installed': + raise SystemExit('Install prometheus-node-exporter with the package module first') + subprocess.run(['systemctl', 'enable', '--now', service], check=True, timeout=60) +subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30) +with urllib.request.urlopen('http://127.0.0.1:9100/metrics', timeout=10) as response: + body = response.read(2 * 1024 * 1024) +if b'node_exporter_build_info' not in body: + raise SystemExit('Node exporter metrics validation failed') +print('{"passed":true,"metrics":"available"}') +PY diff --git a/provisioner/builtin_modules/packages.sh b/provisioner/builtin_modules/packages.sh new file mode 100644 index 0000000..6a8b8a0 --- /dev/null +++ b/provisioner/builtin_modules/packages.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, pathlib, re, subprocess, sys +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +packages = p.get('packages', []) +if not isinstance(packages, list) or len(packages) > 100 or any(not isinstance(x, str) or not re.fullmatch(r'[a-z0-9][a-z0-9+.-]{0,100}', x) for x in packages): + raise SystemExit('Invalid package list; names only, no options or shell expressions') +def installed(name): + result = subprocess.run(['dpkg-query', '-W', '-f=${Status}', name], capture_output=True, text=True, timeout=30) + return result.returncode == 0 and result.stdout == 'install ok installed' +missing = [name for name in packages if not installed(name)] +if mode == 'apply' and missing: + subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=600) + subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', '--no-install-recommends', '--', *missing], check=True, timeout=1200) + missing = [name for name in packages if not installed(name)] +print(json.dumps({'passed': not missing, 'missing': missing})) +sys.exit(1 if missing else 0) +PY diff --git a/provisioner/builtin_modules/prerequisites.sh b/provisioner/builtin_modules/prerequisites.sh new file mode 100644 index 0000000..cefcca7 --- /dev/null +++ b/provisioner/builtin_modules/prerequisites.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, pathlib, re, shutil, socket, subprocess, sys, time +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +try: + result = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=20) + match = re.search(r'pve-manager/([^/\s]+)', result.stdout) + if not match: + raise ValueError('Target does not report a Proxmox VE manager version') + if p.get('allowed_versions') and match[1] not in p['allowed_versions']: + raise ValueError('PVE version is outside the approved module target versions') + minimum = p.get('minimum_free_mb', 2048) + if not isinstance(minimum, int) or not 512 <= minimum <= 1048576: + raise ValueError('minimum_free_mb is invalid') + if shutil.disk_usage('/').free < minimum * 1024 * 1024: + raise ValueError('Insufficient free root filesystem space') + if time.time() < 1704067200: + raise ValueError('System clock is not plausible') + for name in p.get('dns_names', []): + if not isinstance(name, str) or not name or len(name) > 253: + raise ValueError('Invalid DNS target') + socket.getaddrinfo(name, 443) + print(json.dumps({'passed': True, 'pve_version': match[1], 'dns': 'resolved', 'free_space': 'sufficient'})) +except (ValueError, OSError, subprocess.SubprocessError) as exc: + print(str(exc), file=sys.stderr) + sys.exit(2) +PY diff --git a/provisioner/builtin_modules/repositories.sh b/provisioner/builtin_modules/repositories.sh new file mode 100644 index 0000000..b223cfb --- /dev/null +++ b/provisioner/builtin_modules/repositories.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, os, pathlib, re, subprocess, sys, tempfile, urllib.parse +def has_repository_indexes(policy, url, suite, components): + indexes = [line.split() for line in policy.splitlines()] + return all(any(any(field.rstrip('/') == url.rstrip('/') for field in fields) and f'{suite}/{component}' in fields for fields in indexes) for component in components) + +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +url, suite, components, keyring = (p.get(k) for k in ('url', 'suite', 'components', 'keyring')) +if not isinstance(url, str) or any(c.isspace() for c in url): + raise SystemExit('An explicit HTTPS repository URL is required') +parsed = urllib.parse.urlsplit(url) +if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise SystemExit('Repository URL must use HTTPS without credentials or query parameters') +if not isinstance(suite, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,40}', suite): + raise SystemExit('Invalid repository suite') +if not isinstance(components, list) or not components or any(not isinstance(c, str) or not re.fullmatch(r'[a-z][a-z0-9/-]{0,50}', c) for c in components): + raise SystemExit('Invalid repository components') +if not isinstance(keyring, str) or not re.fullmatch(r'/(usr/share|etc/apt)/keyrings/[A-Za-z0-9_.-]+\.(gpg|asc)', keyring) or not pathlib.Path(keyring).is_file(): + raise SystemExit('An existing administrator-provisioned APT keyring is required') +expected = f'Types: deb\nURIs: {url}\nSuites: {suite}\nComponents: {" ".join(components)}\nSigned-By: {keyring}\n' +target = pathlib.Path('/etc/apt/sources.list.d/pve-provisioner.sources') +matches = target.is_file() and target.read_text() == expected +if mode == 'check': + sys.exit(0 if matches else 1) +if mode == 'apply' and not matches: + fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent) + try: + os.fchmod(fd, 0o644) + with os.fdopen(fd, 'w') as stream: + stream.write(expected) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, target) + finally: + if os.path.exists(name): + os.unlink(name) +if mode == 'apply': + subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=500) +policy = subprocess.run(['apt-cache', 'policy'], check=True, capture_output=True, text=True, timeout=30).stdout +indexed = has_repository_indexes(policy, url, suite, components) +passed = target.is_file() and target.read_text() == expected and indexed +print(json.dumps({'passed': passed, 'repository': url, 'suite': suite})) +sys.exit(0 if passed else 1) +PY diff --git a/provisioner/builtin_modules/ssh.sh b/provisioner/builtin_modules/ssh.sh new file mode 100644 index 0000000..cffe571 --- /dev/null +++ b/provisioner/builtin_modules/ssh.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import base64, json, os, pathlib, pwd, re, subprocess, sys, tempfile +def validate_public_key(key): + if not isinstance(key, str) or '\n' in key or '\r' in key or len(key) > 16384: + raise SystemExit('Invalid SSH public key') + parts = key.split() + if len(parts) < 2 or parts[0] not in ('ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'): + raise SystemExit('Unsupported SSH public key format') + try: + blob = base64.b64decode(parts[1], validate=True) + size = int.from_bytes(blob[:4], 'big') + if blob[4:4 + size].decode() != parts[0] or len(blob) <= 4 + size: + raise ValueError() + except (ValueError, UnicodeError): + raise SystemExit('Invalid SSH public key encoding') + # sshd -t does not parse authorized_keys. Validate the complete public key. + fd, key_file = tempfile.mkstemp(prefix='pve-public-key-') + try: + with os.fdopen(fd, 'w') as stream: + stream.write(key + '\n') + parsed = subprocess.run(['ssh-keygen', '-l', '-f', key_file], capture_output=True, text=True, timeout=15) + if parsed.returncode != 0: + raise SystemExit('OpenSSH rejected the configured public key') + finally: + os.unlink(key_file) + +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +users = p.get('users', []) +if not isinstance(users, list) or len(users) > 50: + raise SystemExit('Invalid SSH users') +changes = [] +for entry in users: + name = entry.get('name', '') + if not isinstance(name, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', name): + raise SystemExit('Invalid SSH account name') + try: + user = pwd.getpwnam(name) + except KeyError: + raise SystemExit('SSH module requires an existing user account') + keys = entry.get('authorized_keys', []) + if not isinstance(keys, list) or not keys or len(keys) > 100: + raise SystemExit('At least one authorized key per configured user is required') + for key in keys: + validate_public_key(key) + home = pathlib.Path(user.pw_dir) + if not home.is_dir() or home.stat().st_uid not in (0, user.pw_uid) or home.stat().st_mode & 0o022: + raise SystemExit('Account home must have safe ownership and must not be writable by group or others') + if user.pw_shell in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'): + raise SystemExit('SSH account requires an interactive login shell') + effective = subprocess.run(['/usr/sbin/sshd', '-T', '-C', f'user={name},host=localhost,addr=127.0.0.1'], check=True, capture_output=True, text=True, timeout=30) + settings = dict(line.split(None, 1) for line in effective.stdout.splitlines() if ' ' in line) + if settings.get('pubkeyauthentication') != 'yes' or (name == 'root' and settings.get('permitrootlogin') not in ('yes', 'prohibit-password', 'without-password')): + raise SystemExit('Effective SSH policy does not permit public-key login for this account') + key_paths = settings.get('authorizedkeysfile', '').split() + accepted = {'.ssh/authorized_keys', '%h/.ssh/authorized_keys', str(home / '.ssh/authorized_keys')} + if not accepted.intersection(key_paths): + raise SystemExit('Effective SSH policy does not use the managed authorized_keys file') + directory = pathlib.Path(user.pw_dir) / '.ssh' + target = directory / 'authorized_keys' + if directory.is_symlink() or target.is_symlink(): + raise SystemExit('Refusing symlinked SSH paths') + existing = target.read_text() if target.exists() else '' + missing = [key for key in keys if key not in existing.splitlines()] + correct_permissions = directory.exists() and target.exists() and directory.stat().st_mode & 0o777 == 0o700 and target.stat().st_mode & 0o777 == 0o600 and target.stat().st_uid == user.pw_uid and directory.stat().st_uid == user.pw_uid + if missing or not correct_permissions: + changes.append((user, directory, target, existing, missing)) +if mode == 'check': + sys.exit(1 if changes else 0) +if mode == 'apply': + for user, directory, target, existing, missing in changes: + directory.mkdir(mode=0o700, exist_ok=True) + os.chmod(directory, 0o700) + os.chown(directory, user.pw_uid, user.pw_gid) + content = existing.rstrip('\n') + ('\n' if existing else '') + '\n'.join(missing) + ('\n' if missing else '') + fd, name = tempfile.mkstemp(prefix='.authorized-', dir=directory) + try: + os.fchmod(fd, 0o600) + os.fchown(fd, user.pw_uid, user.pw_gid) + with os.fdopen(fd, 'w') as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, target) + finally: + if os.path.exists(name): + os.unlink(name) +subprocess.run(['/usr/sbin/sshd', '-t'], check=True, timeout=30) +subprocess.run(['systemctl', 'is-active', '--quiet', 'ssh.service'], check=True, timeout=30) +if mode == 'verify' and changes: + raise SystemExit(1) +print(json.dumps({'passed': True, 'accounts': len(users), 'sshd': 'validated'})) +PY diff --git a/provisioner/builtin_modules/storage.sh b/provisioner/builtin_modules/storage.sh new file mode 100644 index 0000000..524debe --- /dev/null +++ b/provisioner/builtin_modules/storage.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import json, pathlib, re, subprocess, sys +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +storage_id, location, content = (p.get(k) for k in ('id', 'path', 'content')) +if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id): + raise SystemExit('Invalid storage identifier') +if not isinstance(location, str) or any(c.isspace() for c in location) or not location.startswith(('/mnt/', '/srv/')): + raise SystemExit('Storage must be an existing absolute directory under /mnt or /srv') +directory = pathlib.Path(location) +if not directory.is_dir() or directory.is_symlink() or str(directory.resolve()) != location.rstrip('/'): + raise SystemExit('Storage directory must already exist without symbolic links or traversal') +if not isinstance(content, list) or not content or not set(content).issubset({'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets'}): + raise SystemExit('Invalid storage content types') +def configuration(): + result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30) + if not any(entry.get('storage') == storage_id for entry in json.loads(result.stdout)): + return None + detail = subprocess.run(['pvesh', 'get', '/storage/' + storage_id, '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30) + return json.loads(detail.stdout) +existing = configuration() +if existing and (existing.get('type') != 'dir' or existing.get('path', '').rstrip('/') != location.rstrip('/') or set(existing.get('content', '').split(',')) != set(content)): + raise SystemExit('Existing storage has conflicting settings; automatic changes are refused') +if mode == 'check': + sys.exit(0 if existing else 1) +if mode == 'apply' and not existing: + subprocess.run(['pvesm', 'add', 'dir', storage_id, '--path', location, '--content', ','.join(content)], check=True, timeout=60) +result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30) +if not any(re.match(r'^' + re.escape(storage_id) + r'\s+dir\s+active\s', line) for line in result.stdout.splitlines()): + raise SystemExit('Storage is not active') +print(json.dumps({'passed': True, 'storage': storage_id, 'destructive_operations': False})) +PY diff --git a/provisioner/builtin_modules/time.sh b/provisioner/builtin_modules/time.sh new file mode 100644 index 0000000..f685fe3 --- /dev/null +++ b/provisioner/builtin_modules/time.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail +case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac +exec python3 - "$1" "$2" <<'PY' +import ipaddress, json, os, pathlib, re, subprocess, sys, tempfile +mode, params_file = sys.argv[1:] +p = json.loads(pathlib.Path(params_file).read_text()) +servers = p.get('servers') +if not isinstance(servers, list) or not 1 <= len(servers) <= 16: + raise SystemExit('Between one and sixteen explicit NTP servers are required') +for server in servers: + if not isinstance(server, str) or len(server) > 253: + raise SystemExit('Invalid NTP server') + try: + ipaddress.ip_address(server) + except ValueError: + if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', server): + raise SystemExit('NTP server must be a hostname or IP address') +target = pathlib.Path('/etc/chrony/sources.d/pve-provisioner.sources') +expected = ''.join(f'server {server} iburst\n' for server in servers) +active = subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], timeout=30).returncode == 0 +matches = target.is_file() and target.read_text() == expected +if mode == 'check': + sys.exit(0 if matches and active else 1) +if mode == 'apply': + # The package module owns installation; this module never swaps NTP daemons. + config = pathlib.Path('/etc/chrony/chrony.conf') + if not config.is_file() or not any(line.strip() == 'sourcedir /etc/chrony/sources.d' for line in config.read_text().splitlines()): + raise SystemExit('Install chrony first and enable its standard sources.d directory') + target.parent.mkdir(mode=0o755, exist_ok=True) + fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent) + try: + os.fchmod(fd, 0o644) + with os.fdopen(fd, 'w') as stream: + stream.write(expected) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, target) + finally: + if os.path.exists(name): + os.unlink(name) + subprocess.run(['systemctl', 'enable', '--now', 'chrony.service'], check=True, timeout=60) + subprocess.run(['chronyc', 'reload', 'sources'], check=True, timeout=30) +subprocess.run(['chronyc', 'waitsync', '30', '0.5', '0', '2'], check=True, timeout=90) +subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], check=True, timeout=30) +if not target.is_file() or target.read_text() != expected: + raise SystemExit(1) +print(json.dumps({'passed': True, 'clock': 'synchronized'})) +PY diff --git a/provisioner/cli.py b/provisioner/cli.py new file mode 100644 index 0000000..8f28a5f --- /dev/null +++ b/provisioner/cli.py @@ -0,0 +1,287 @@ +"""Operational commands; no administrative password is saved in clear text.""" + +from __future__ import annotations + +import argparse +from contextlib import closing, suppress +from dataclasses import asdict +import getpass +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sqlite3 +import sys +import tempfile +from datetime import datetime, timezone +from uuid import uuid4 + + +class ServiceLock: + """A process-lifetime lock shared by the web service and offline operations.""" + + def __init__(self, data_dir: Path): + self.path = Path(data_dir) / ".service.lock" + self.handle = None + + def __enter__(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("a+b") + try: + if self.path.stat().st_size == 0: + self.handle.write(b"0") + self.handle.flush() + self.handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, BlockingIOError) as exc: + self.handle.close() + self.handle = None + raise RuntimeError("Data directory is in use. Stop the service before this operation.") from exc + return self + + def __exit__(self, *_): + if self.handle is not None: + if os.name == "nt": + import msvcrt + + self.handle.seek(0) + msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + self.handle = None + + +def load_settings(): + """Read optional TOML, then environment overrides, with safe defaults.""" + from provisioner.config import Settings + + return Settings.from_env() + + +def initialize(settings, username: str | None = None) -> None: + from cryptography.fernet import Fernet + from provisioner.db import Database + from provisioner.security import Security + + data_dir = Path(settings.data_dir).resolve() + key_path = Path(settings.master_key_file).resolve() + if key_path == data_dir or data_dir in key_path.parents: + raise ValueError("MASTER_KEY_FILE must be outside DATA_DIR") + with ServiceLock(data_dir): + database = Database(settings) + database.initialize() + with database.connection() as connection: + if connection.execute("SELECT 1 FROM users LIMIT 1").fetchone(): + raise ValueError("Already initialized; use the administrator interface to manage users") + username = username or input("Administrator username: ").strip() + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}", username): + raise ValueError("Administrator username requires 2 to 64 letters, digits, dots, underscores or hyphens") + password = getpass.getpass("Administrator password (at least 12 characters): ") + if len(password) < 12: + raise ValueError("Use a password with at least 12 characters") + if password != getpass.getpass("Repeat administrator password: "): + raise ValueError("Passwords do not match") + if not key_path.exists(): + key_path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(Fernet.generate_key()) + security = Security(settings) + with database.connection() as connection: + connection.execute( + "INSERT INTO users(id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)", + (str(uuid4()), username, security.hash_password(password), "admin", datetime.now(timezone.utc).isoformat()), + ) + print(f"Initialized {data_dir}. Administrator: {username}") + print(f"Back up the encryption key separately: {key_path}") + + +def _digest(path: Path) -> str: + with path.open("rb") as handle: + return hashlib.file_digest(handle, "sha256").hexdigest() + + +def backup(settings, destination: Path) -> None: + """Snapshot SQLite, then immutable referenced artifacts; never include keys.""" + from provisioner.db import Database + + destination = destination.resolve() + data_dir = Path(settings.data_dir).resolve() + if destination == data_dir or data_dir in destination.parents: + raise ValueError("Backup destination must be outside DATA_DIR") + if destination.exists(): + raise ValueError("Backup destination already exists; use a new directory") + database = Database(settings) + if not database.path.exists(): + raise ValueError("No initialized database found") + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=".ais-backup-", dir=destination.parent)) + try: + database.backup(temporary / "database.sqlite3") + artifact_dir = data_dir / "artifacts" + if artifact_dir.exists(): + if artifact_dir.is_symlink(): + raise ValueError("Refusing symlink in artifact storage") + # Artifacts are immutable; copying after the database snapshot includes + # every artifact referenced by that snapshot, plus possibly newer ones. + for source in artifact_dir.rglob("*"): + if source.is_symlink(): + raise ValueError("Refusing symlink in artifact storage") + if source.is_file(): + target = temporary / "artifacts" / source.relative_to(artifact_dir) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + config = { + "public_url": settings.public_url, + "secure_cookies": settings.secure_cookies, + "key_included": False, + "master_key_sha256": _digest(Path(settings.master_key_file)), + "format": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "configuration": { + name: value for name, value in asdict(settings).items() + if name not in {"data_dir", "master_key_file", "bootstrap_username", "bootstrap_password", "testing"} + }, + } + (temporary / "settings.json").write_text(json.dumps(config, indent=2), encoding="utf-8") + hashes = {path.relative_to(temporary).as_posix(): _digest(path) for path in temporary.rglob("*") if path.is_file()} + (temporary / "manifest.json").write_text(json.dumps(hashes, indent=2), encoding="utf-8") + temporary.rename(destination) + except BaseException: + if temporary.resolve().parent == destination.parent and temporary.name.startswith(".ais-backup-"): + with suppress(OSError): + shutil.rmtree(temporary) + raise + print(f"Backup complete: {destination}") + print("Encryption key is excluded. Save it separately and test restoration.") + + +def _invalidate_restored_state(connection: sqlite3.Connection) -> None: + """Prevent rollback of the database from resurrecting machine credentials.""" + connection.execute("DELETE FROM sessions") + connection.execute("DELETE FROM nonces") + connection.execute("UPDATE approvals SET status='revoked' WHERE status='approved'") + connection.execute("UPDATE groups SET revoked=1") + connection.execute("UPDATE hosts SET status='needs_review' WHERE id IN (SELECT host_id FROM runs WHERE status NOT IN ('succeeded','failed','cancelled','expired'))") + connection.execute("UPDATE runs SET status='needs_review' WHERE status NOT IN ('succeeded','failed','cancelled','expired')") + connection.execute( + "UPDATE runs SET device_key=NULL, bootstrap_hash=NULL, enrollment_hash=NULL, report_hash=NULL, " + "answer_until=0, enroll_until=0, lease_until=0, answer_ciphertext=NULL, bootstrap_ciphertext=NULL" + ) + connection.execute( + "INSERT INTO audit(id,actor,action,object_id,reason,data,created_at) VALUES(?,?,?,?,?,?,?)", + (str(uuid4()), "system", "backup.restore", "database", "Offline restore; credentials revoked and active runs require review", "{}", datetime.now(timezone.utc).isoformat()), + ) + + +def restore(settings, source: Path) -> None: + """Restore into a new data directory; the original data is never overwritten.""" + from cryptography.fernet import Fernet + from provisioner.db import Database + + source = source.resolve() + data_dir = Path(settings.data_dir).resolve() + if not Path(settings.master_key_file).is_file(): + raise ValueError("Restore the separately protected MASTER_KEY_FILE first") + Fernet(Path(settings.master_key_file).read_bytes().strip()) + if not (source / "manifest.json").is_file(): + raise ValueError("Missing backup manifest") + hashes = json.loads((source / "manifest.json").read_text(encoding="utf-8")) + if "database.sqlite3" not in hashes or "settings.json" not in hashes: + raise ValueError("Incomplete backup manifest") + for name, expected in hashes.items(): + original = source / name + candidate = original.resolve() + if source not in candidate.parents or original.is_symlink() or any(parent.is_symlink() for parent in original.parents if parent != source.parent) or not candidate.is_file(): + raise ValueError("Unsafe or missing backup file") + if _digest(candidate) != expected: + raise ValueError(f"Backup checksum mismatch: {name}") + config = json.loads((source / "settings.json").read_text(encoding="utf-8")) + if config.get("format") != 1: + raise ValueError("Unsupported backup format") + if _digest(Path(settings.master_key_file)) != config.get("master_key_sha256"): + raise ValueError("MASTER_KEY_FILE does not match this backup") + with ServiceLock(data_dir): + existing = [path for path in data_dir.iterdir() if path.name != ".service.lock"] + if existing: + raise ValueError("Restore requires an empty DATA_DIR. Keep the old directory until validation completes.") + database = Database(settings) + shutil.copy2(source / "database.sqlite3", database.path) + try: + with closing(sqlite3.connect(database.path)) as connection, connection: + connection.execute("PRAGMA foreign_keys=ON") + if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise ValueError("Backup database failed integrity check") + if connection.execute("PRAGMA user_version").fetchone()[0] != 1: + raise ValueError("Unsupported backup database schema") + _invalidate_restored_state(connection) + for name in hashes: + if name.startswith("artifacts/"): + target = data_dir / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / name, target) + except BaseException: + # Leave failed restoration for inspection; never start it silently. + (data_dir / "RESTORE_FAILED").write_text("Restore failed. Do not start this data directory.\n", encoding="utf-8") + raise + print(f"Restored into {data_dir}; active runs need review and previous machine credentials are invalid.") + print("Review settings.json, revoke/reissue installation media, and reconcile hosts before new approvals.") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="proxmox-ais") + commands = parser.add_subparsers(dest="command", required=True) + init_parser = commands.add_parser("init", help="Create external encryption key and initial administrator") + init_parser.add_argument("--username") + serve_parser = commands.add_parser("serve", help="Start the HTTP service behind a trusted TLS proxy") + serve_parser.add_argument("--host", default="127.0.0.1") + serve_parser.add_argument("--port", type=int, default=8080) + serve_parser.add_argument("--tls-cert", type=Path) + serve_parser.add_argument("--tls-key", type=Path) + backup_parser = commands.add_parser("backup", help="Create a consistent database and artifact snapshot") + backup_parser.add_argument("destination", type=Path) + restore_parser = commands.add_parser("restore", help="Restore offline into an empty DATA_DIR") + restore_parser.add_argument("source", type=Path) + args = parser.parse_args(argv) + try: + settings = load_settings() + if args.command == "init": + initialize(settings, args.username) + elif args.command == "backup": + backup(settings, args.destination) + elif args.command == "restore": + restore(settings, args.source) + elif args.command == "serve": + import uvicorn + from provisioner.app import create_app + + if bool(args.tls_cert) != bool(args.tls_key): + raise ValueError("Provide both --tls-cert and --tls-key") + if (Path(settings.data_dir) / "RESTORE_FAILED").exists(): + raise ValueError("This data directory contains a failed restore") + uvicorn.run( + create_app(settings), host=args.host, port=args.port, + proxy_headers=False, access_log=False, + ssl_certfile=str(args.tls_cert) if args.tls_cert else None, + ssl_keyfile=str(args.tls_key) if args.tls_key else None, + ) + except (OSError, ValueError, RuntimeError, sqlite3.Error) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/provisioner/config.py b/provisioner/config.py new file mode 100644 index 0000000..9377c99 --- /dev/null +++ b/provisioner/config.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass, field, fields +import os +from pathlib import Path +import tomllib +from urllib.parse import urlsplit + + +@dataclass +class Settings: + data_dir: Path = Path("data") + master_key_file: Path = Path("secrets/master.key") + public_url: str = "https://localhost:8080" + secure_cookies: bool = True + bootstrap_username: str | None = None + bootstrap_password: str | None = None + testing: bool = False + session_hours: int = 8 + answer_window_seconds: int = 300 + enrollment_hours: int = 4 + lease_seconds: int = 900 + heartbeat_unknown_seconds: int = 180 + log_retention_days: int = 30 + audit_retention_days: int = 180 + max_request_bytes: int = 1048576 + four_eyes: bool = True + maintenance: bool = False + trusted_proxy_ips: str = "127.0.0.1" + runner_ca_file: str | None = None + defaults: dict = field(default_factory=dict) + sites: dict = field(default_factory=dict) + + def __post_init__(self): + self.data_dir = Path(self.data_dir) + self.master_key_file = Path(self.master_key_file) + self.public_url = self.public_url.rstrip("/") + url = urlsplit(self.public_url) + if not url.hostname or url.username or url.password or url.query or url.fragment or url.path: + raise ValueError("PUBLIC_URL muss eine absolute Basis-URL ohne Pfad sein.") + if url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "testserver"}): + raise ValueError("PUBLIC_URL benötigt HTTPS; HTTP ist nur für lokale Entwicklung erlaubt.") + if self.master_key_file.resolve().is_relative_to(self.data_dir.resolve()): + raise ValueError("Der Master-Key muss außerhalb des Datenverzeichnisses liegen.") + for name in ("session_hours", "answer_window_seconds", "enrollment_hours", "lease_seconds", "max_request_bytes"): + if getattr(self, name) <= 0: + raise ValueError(f"{name} muss positiv sein.") + + @classmethod + def from_env(cls): + values = {} + if os.environ.get("APP_CONFIG"): + with open(os.environ["APP_CONFIG"], "rb") as stream: + loaded = tomllib.load(stream) + values.update(loaded.get("app", loaded)) + bools = {"secure_cookies", "testing", "four_eyes", "maintenance"} + ints = {f.name for f in fields(cls) if f.type is int} + allowed = {f.name for f in fields(cls)} + unknown = set(values) - allowed + if unknown: + raise ValueError(f"Unbekannte Konfiguration: {', '.join(sorted(unknown))}") + for key in allowed: + value = os.environ.get(key.upper()) + if value is not None: + values[key] = value.lower() in {"true", "1", "yes"} if key in bools else int(value) if key in ints else value + return cls(**values) diff --git a/provisioner/db.py b/provisioner/db.py new file mode 100644 index 0000000..d7fedef --- /dev/null +++ b/provisioner/db.py @@ -0,0 +1,66 @@ +from contextlib import closing, contextmanager +from pathlib import Path +import sqlite3 + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0); +CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), csrf_token TEXT NOT NULL, expires_at REAL NOT NULL); +CREATE TABLE IF NOT EXISTS hosts(id TEXT PRIMARY KEY, fqdn TEXT NOT NULL UNIQUE COLLATE NOCASE, management_ip TEXT UNIQUE, site TEXT NOT NULL, status TEXT NOT NULL, blocked INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS host_identities(host_id TEXT NOT NULL REFERENCES hosts(id), kind TEXT NOT NULL, value TEXT NOT NULL, UNIQUE(kind,value)); +CREATE TABLE IF NOT EXISTS profiles(id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,kind,version)); +CREATE TABLE IF NOT EXISTS modules(id TEXT PRIMARY KEY, name TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, digest TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,version)); +CREATE TABLE IF NOT EXISTS groups(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, site TEXT NOT NULL, token_hash TEXT NOT NULL, expires_at REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS iso_records(id TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS secrets(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, ciphertext TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS approvals(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), status TEXT NOT NULL, expires_at REAL NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE UNIQUE INDEX IF NOT EXISTS one_open_approval ON approvals(host_id) WHERE status='approved'; +CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), approval_id TEXT NOT NULL UNIQUE REFERENCES approvals(id), status TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, answer_ciphertext TEXT, bootstrap_ciphertext TEXT, secrets_ciphertext TEXT, bootstrap_hash TEXT UNIQUE, enrollment_hash TEXT, report_hash TEXT UNIQUE, device_key TEXT, answer_until REAL, enroll_until REAL, lease_until REAL, last_seen REAL, completed_at REAL, created_at TEXT NOT NULL); +CREATE UNIQUE INDEX IF NOT EXISTS one_active_run ON runs(host_id) WHERE status NOT IN ('succeeded','failed','cancelled','expired'); +CREATE TABLE IF NOT EXISTS run_steps(run_id TEXT NOT NULL REFERENCES runs(id), step_id TEXT NOT NULL, position INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt INTEGER NOT NULL DEFAULT 0, verification TEXT NOT NULL DEFAULT '{}', PRIMARY KEY(run_id,step_id)); +CREATE TABLE IF NOT EXISTS events(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence)); +CREATE TABLE IF NOT EXISTS logs(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence)); +CREATE TABLE IF NOT EXISTS nonces(run_id TEXT NOT NULL REFERENCES runs(id), nonce TEXT NOT NULL, expires_at REAL NOT NULL, PRIMARY KEY(run_id,nonce)); +CREATE TABLE IF NOT EXISTS audit(id TEXT PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, object_id TEXT NOT NULL, reason TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS discoveries(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL UNIQUE, site TEXT NOT NULL, data TEXT NOT NULL, reason TEXT NOT NULL, last_seen REAL NOT NULL); +INSERT OR IGNORE INTO schema_migrations(version) VALUES(1); +""" + + +class Database: + def __init__(self, settings): + self.path = settings.data_dir / "provisioner.sqlite3" + + def initialize(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.connection() as connection: + connection.execute("PRAGMA journal_mode=WAL") + version = connection.execute("PRAGMA user_version").fetchone()[0] + if version > 1: + raise RuntimeError("Datenbankschema ist neuer als diese Anwendung.") + connection.executescript(SCHEMA) + connection.execute("PRAGMA user_version=1") + + @contextmanager + def connection(self, write=False): + connection = sqlite3.connect(self.path, timeout=15, isolation_level=None) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=15000") + try: + if write: + connection.execute("BEGIN IMMEDIATE") + yield connection + if connection.in_transaction: + connection.commit() + except BaseException: + if connection.in_transaction: + connection.rollback() + raise + finally: + connection.close() + + def backup(self, destination: Path): + destination.parent.mkdir(parents=True, exist_ok=True) + with self.connection() as source, closing(sqlite3.connect(destination)) as target: + source.backup(target) diff --git a/provisioner/models.py b/provisioner/models.py new file mode 100644 index 0000000..ffe2f82 --- /dev/null +++ b/provisioner/models.py @@ -0,0 +1,199 @@ +from typing import Any, Literal +from ipaddress import ip_interface +import re +import uuid + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class Model(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +class Identity(Model): + kind: Literal["uuid", "serial", "mac"] + value: str = Field(min_length=1, max_length=200) + + @field_validator("value") + @classmethod + def meaningful(cls, value): + if value.lower() in {"unknown", "none", "not specified", "default string", "to be filled by o.e.m."}: + raise ValueError("Identität enthält einen Hersteller-Platzhalter.") + return value + + +def normalize_identity(kind, value): + value = value.strip().lower() + if kind == "mac": + value = value.replace("-", ":") + if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}: + raise ValueError("Ungültige MAC-Adresse.") + if kind == "uuid": + parsed = uuid.UUID(value) + if parsed.int in {0, 2**128 - 1}: + raise ValueError("Ungültige System-UUID.") + value = str(parsed) + return value + + +class HostCreate(Model): + fqdn: str = Field(min_length=3, max_length=253) + site: str = Field(min_length=1, max_length=80) + management_ip: str | None = None + tags: list[str] = Field(default_factory=list, max_length=30) + identities: list[Identity] = Field(min_length=1, max_length=32) + installation_profile_id: str | None = None + postinstall_profile_id: str | None = None + iso_id: str | None = None + overrides: dict[str, Any] = Field(default_factory=dict) + blocked: bool = False + + @field_validator("fqdn") + @classmethod + def hostname(cls, value): + value = value.lower().rstrip(".") + if "." not in value or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", part) for part in value.split(".")): + raise ValueError("Ein gültiger FQDN ist erforderlich.") + return value + + @field_validator("management_ip") + @classmethod + def address(cls, value): + return str(ip_interface(value)) if value else None + + +class HostUpdate(Model): + expected_version: int = Field(ge=1) + fqdn: str | None = None + site: str | None = None + management_ip: str | None = None + tags: list[str] | None = None + identities: list[Identity] | None = None + installation_profile_id: str | None = None + postinstall_profile_id: str | None = None + iso_id: str | None = None + overrides: dict | None = None + blocked: bool | None = None + + +class StepSpec(Model): + id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$") + module_id: str + parameters: dict = Field(default_factory=dict) + secret_refs: dict[str, str] = Field(default_factory=dict) + required: bool = True + + +class ProfileCreate(Model): + name: str = Field(min_length=1, max_length=120) + kind: Literal["installation", "postinstall"] + values: dict = Field(default_factory=dict) + steps: list[StepSpec] = Field(default_factory=list, max_length=50) + target_builds: list[str] = Field(default_factory=list, max_length=30) + locked_fields: list[str] = Field(default_factory=lambda: ["disk_setup", "global.root-password", "global.root-password-hashed"]) + reboot_budget: int = Field(default=1, ge=0, le=5) + reason: str = Field(default="", max_length=1000) + + +class ModuleCreate(Model): + name: str = Field(min_length=1, max_length=120) + source: str = Field(min_length=10, max_length=262144) + parameters_schema: dict = Field(default_factory=lambda: {"type": "object", "additionalProperties": False}) + dependencies: list[str] = Field(default_factory=list, max_length=20) + target_builds: list[str] = Field(default_factory=list, max_length=30) + timeout_seconds: int = Field(default=600, ge=1, le=7200) + retry_safe: bool = False + test_evidence: str = Field(default="", max_length=2000) + reason: str = Field(default="", max_length=1000) + + +class Publish(Model): + test_evidence: str = Field(min_length=5, max_length=2000) + reason: str = Field(min_length=3, max_length=1000) + + +class Approval(Model): + expected_version: int = Field(ge=1) + valid_minutes: int = Field(default=30, ge=1, le=1440) + confirmation: str + disks_confirmed: bool + reason: str = Field(min_length=5, max_length=1000) + + +class RunAction(Model): + expected_version: int = Field(ge=1) + reason: str = Field(min_length=5, max_length=1000) + + +class RunReconcile(RunAction): + confirmation: str + execution_stopped: bool + + +class GroupCreate(Model): + name: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") + site: str = Field(min_length=1, max_length=80) + valid_hours: int = Field(default=720, ge=1, le=8760) + + +class IsoCreate(Model): + name: str = Field(min_length=1, max_length=120) + build: str = Field(pattern=r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?-[0-9]+$") + sha256: str = Field(pattern=r"^[a-fA-F0-9]{64}$") + assistant_version: str = Field(min_length=1, max_length=80) + fingerprint: str = Field(pattern=r"^(?:[a-fA-F0-9]{64}|(?:[a-fA-F0-9]{2}:){31}[a-fA-F0-9]{2})$") + group_id: str + test_status: Literal["draft", "passed"] = "draft" + test_evidence: str = Field(default="", max_length=3000) + native_token_support: bool = False + + +class UserCreate(Model): + username: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}$") + password: str = Field(min_length=12, max_length=1024) + role: Literal["reader", "operator", "author", "admin", "developer"] + + +class SecretCreate(Model): + name: str = Field(min_length=1, max_length=120) + value: str = Field(min_length=1, max_length=16384) + + +class Enroll(Model): + run_id: str + enrollment_secret: str = Field(min_length=20, max_length=200) + public_key: str = Field(min_length=40, max_length=100) + identities: list[Identity] = Field(min_length=1, max_length=32) + boot_id: str = Field(min_length=1, max_length=80) + + +class Event(Model): + sequence: int = Field(ge=1) + boot_id: str = Field(min_length=1, max_length=80) + step_id: str | None = None + type: Literal["step.started", "step.succeeded", "step.failed", "run.needs_review", "run.reboot_pending", "run.resumed", "run.cancelled", "heartbeat"] + occurred_at: str = Field(max_length=80) + exit_code: int | None = None + verification: dict = Field(default_factory=dict) + + +class EventBatch(Model): + events: list[Event] = Field(min_length=1, max_length=100) + + +class LogChunk(Model): + sequence: int = Field(ge=1) + step_id: str | None = None + text: str = Field(max_length=16384) + + +class LogBatch(Model): + chunks: list[LogChunk] = Field(min_length=1, max_length=32) + + +class Completion(Model): + verification: dict + + +class LeaseRequest(Model): + run_id: str diff --git a/provisioner/runner.py b/provisioner/runner.py new file mode 100644 index 0000000..0109d79 --- /dev/null +++ b/provisioner/runner.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python3 +"""Short-lived Linux provisioning runner. Target dependencies: Python 3, Bash, OpenSSL. + +Module contract: check=0 means converged, check=1 means apply is needed; all other +check exits fail. Every successful apply is followed by verify. Exit 194 from +apply requests a checkpointed reboot. Module scripts must not reboot themselves. +""" +from __future__ import annotations + +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import random +import re +import selectors +import signal +import ssl +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +MAX_ARTIFACT = 2 * 1024 * 1024 +MAX_RESPONSE = 4 * 1024 * 1024 +MAX_LOG_BYTES = 1024 * 1024 +MAX_PHASE_OUTPUT = 128 * 1024 +MAX_EVENTS = 4096 +DIGEST = re.compile(r"[a-f0-9]{64}\Z") +STEP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z") + + +def canonical_json(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + + +def atomic_write(path, content, mode=0o600): + path = Path(path) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent) + try: + os.fchmod(descriptor, mode) if hasattr(os, "fchmod") else None + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + if os.name == "posix": + parent_fd = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(parent_fd) + finally: + os.close(parent_fd) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def verified_digest(content, expected): + if not isinstance(expected, str) or not DIGEST.fullmatch(expected): + raise Halt("Invalid artifact digest") + if hashlib.sha256(content).hexdigest() != expected: + raise Halt("Artifact digest mismatch; execution refused") + return content + + +def discover_identities(expected, sys_root=Path("/sys")): + """Check bootstrap host binding against identities actually observed on target.""" + def normalize(kind, value): + value = value.strip().lower() + if kind == "uuid": + parsed = uuid.UUID(value) + if parsed.int in (0, 2 ** 128 - 1): + raise ValueError("Empty UUID") + return str(parsed) + if kind == "mac": + value = value.replace("-", ":") + if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"): + raise ValueError("Invalid MAC") + if not value or value in ("unknown", "none", "not specified", "default string", "to be filled by o.e.m."): + raise ValueError("Placeholder identity") + return value + observed = set() + candidates = [("uuid", sys_root / "class/dmi/id/product_uuid"), + ("serial", sys_root / "class/dmi/id/product_serial")] + candidates.extend(("mac", path) for path in (sys_root / "class/net").glob("*/address")) + for kind, path in candidates: + try: + observed.add((kind, normalize(kind, path.read_text()))) + except (OSError, ValueError): + continue + try: + required = {(item["kind"], normalize(item["kind"], item["value"])) for item in expected} + except (KeyError, ValueError) as exc: + raise Halt("Invalid expected host identity") from exc + if not required or not required.issubset(observed): + raise Halt("Observed target identities do not match the bootstrap host binding") + return [{"kind": kind, "value": value} for kind, value in sorted(required)] + + +class TransportError(Exception): + pass + + +class Rejected(Exception): + pass + + +class Halt(Exception): + pass + + +class Deferred(Exception): + pass + + +class RebootRequested(Exception): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise Rejected("API redirects are not permitted") + + +class DeviceKey: + def __init__(self, directory): + self.directory = Path(directory) + self.path = self.directory / "device-key.pem" + + def ensure(self): + if not self.path.exists(): + result = subprocess.run(["openssl", "genpkey", "-algorithm", "ED25519"], + capture_output=True, check=True, timeout=15) + atomic_write(self.path, result.stdout) + os.chmod(self.path, 0o600) + + @property + def public_key(self): + result = subprocess.run(["openssl", "pkey", "-in", str(self.path), "-pubout", + "-outform", "DER"], capture_output=True, check=True, timeout=15) + # RFC 8410 Ed25519 SubjectPublicKeyInfo: fixed 12-byte prefix + raw key. + if not result.stdout.startswith(bytes.fromhex("302a300506032b6570032100")) or len(result.stdout) != 44: + raise Halt("Device key is not Ed25519") + return base64.b64encode(result.stdout[12:]).decode() + + def sign(self, content): + fd, path = tempfile.mkstemp(prefix=".signature-", dir=self.directory) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + result = subprocess.run(["openssl", "pkeyutl", "-sign", "-rawin", "-inkey", + str(self.path), "-in", path], capture_output=True, + check=True, timeout=15) + return base64.b64encode(result.stdout).decode() + finally: + os.unlink(path) + + +class API: + def __init__(self, config, key): + self.config, self.key = config, key + self.base = config["api_url"].rstrip("/") + parsed = urllib.parse.urlsplit(self.base) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise Halt("api_url must be an HTTPS origin with normal certificate validation") + context = ssl.create_default_context(cafile=config.get("ca_file")) + self.opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context), NoRedirect()) + + def request(self, method, path, payload=None, *, signed=True, raw=False, attempts=5): + body = b"" if payload is None else canonical_json(payload) + if len(body) > MAX_RESPONSE: + raise Halt("Outgoing request exceeds size limit") + url = self.base + path + for attempt in range(attempts): + headers = {"Accept": "application/octet-stream" if raw else "application/json"} + if payload is not None: + headers["Content-Type"] = "application/json" + if signed: + timestamp, nonce = str(int(time.time())), os.urandom(24).hex() + request_path = urllib.parse.urlsplit(url).path + message = f"{method}\n{request_path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode() + headers.update({"X-Run-ID": self.config["run_id"], "X-Device-Key": self.key.public_key, + "X-Timestamp": timestamp, "X-Nonce": nonce, + "X-Signature": self.key.sign(message)}) + request = urllib.request.Request(url, data=body if payload is not None else None, + method=method, headers=headers) + try: + with self.opener.open(request, timeout=10) as response: + result = response.read((MAX_ARTIFACT if raw else MAX_RESPONSE) + 1) + if len(result) > (MAX_ARTIFACT if raw else MAX_RESPONSE): + raise Halt("Response exceeds size limit") + if raw: + return result + try: + return json.loads(result) + except (ValueError, UnicodeError) as exc: + raise Halt("Invalid API response") from exc + except urllib.error.HTTPError as exc: + if exc.code not in (408, 425, 429, 500, 502, 503, 504): + raise Rejected(f"API rejected {method} {path}: HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, OSError): + pass + if attempt + 1 < attempts: + time.sleep(min(20, 2 ** attempt) + random.random()) + raise TransportError("API unavailable after bounded retries") + + +class Runner: + def __init__(self, config, directory="/var/lib/pve-provisioner", api=None): + self.config = config + self.directory = Path(directory) + self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + self.state_path = self.directory / "state.json" + self.key = DeviceKey(self.directory) + self.api = api + self.boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip() if os.name == "posix" else "test-boot" + self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else { + "format": 1, "run_id": config["run_id"], "status": "pending", "steps": {}, + "events": [], "logs": [], "event_sequence": 0, "log_sequence": 0, + "reboot_count": 0, "boot_id": self.boot_id, + } + if self.state["run_id"] != config["run_id"]: + raise Halt("Existing local state belongs to another run") + self.lease = {"action": "wait", "expires_at": 0, "run_version": 0} + self.last_heartbeat = 0 + self.secret_values = [] + self.stop_requested = False + self.step_deadline = None + self.save() + + def save(self): + atomic_write(self.state_path, canonical_json(self.state)) + + def event(self, event_type, step_id=None, **fields): + if len(self.state["events"]) >= MAX_EVENTS: + # Reserve a durable halt locally; never evict unacknowledged events. + self.state.update(status="needs_review", reason="Event queue limit reached") + self.save() + raise Halt("Event queue limit reached") + self.state["event_sequence"] += 1 + self.state["events"].append({"sequence": self.state["event_sequence"], "boot_id": self.boot_id, + "step_id": step_id, "type": event_type, + "occurred_at": datetime.now(timezone.utc).isoformat(), **fields}) + self.save() + + def log(self, step_id, content): + for value in sorted(self.secret_values, key=len, reverse=True): + if value: + content = content.replace(value, "[REDACTED]") + content = content[:MAX_PHASE_OUTPUT] + remaining = MAX_LOG_BYTES - sum(len(chunk["text"].encode()) for chunk in self.state["logs"]) + encoded = content.encode() + if len(encoded) > remaining: + content = encoded[:max(0, remaining)].decode(errors="ignore") + self.state["logs_truncated"] = True + # Drop newly arriving overflow, never evict a sequenced/unacknowledged + # chunk: server acknowledgements require a contiguous sequence. + for position in range(0, len(content), 16000): + self.state["log_sequence"] += 1 + self.state["logs"].append({"sequence": self.state["log_sequence"], "step_id": step_id, + "text": content[position:position + 16000]}) + self.save() + + def flush(self): + base = f"/agent/v1/runs/{self.config['run_id']}" + for queue, endpoint, key in (("logs", "logs", "chunks"), ("events", "events", "events")): + while self.state[queue]: + batch = self.state[queue][:100 if queue == "events" else 8] + response = self.api.request("POST", f"{base}/{endpoint}", {key: batch}, attempts=1) + ack = response.get("ack_sequence") + if not isinstance(ack, int) or ack < batch[0]["sequence"] or ack > self.state["event_sequence" if queue == "events" else "log_sequence"]: + raise Halt("Invalid event/log acknowledgement") + self.state[queue] = [event for event in self.state[queue] if event["sequence"] > ack] + self.save() + + def network_failure(self): + self.state.setdefault("network_failed_since", time.time()) + self.save() + if time.time() - self.state["network_failed_since"] > int(self.config.get("network_deadline_seconds", 1800)): + raise Halt("Network recovery deadline exceeded") + + def renew_lease(self): + self.lease = self.api.request("POST", "/agent/v1/lease", {"run_id": self.config["run_id"]}, attempts=1) + if self.lease.get("action") not in ("run", "wait", "stop", "revoked"): + raise Halt("Invalid lease action") + self.state["run_version"] = self.lease.get("run_version", 0) + self.state.pop("network_failed_since", None) + self.save() + + def authorize(self): + if self.stop_requested: + raise Deferred("Runner service is stopping") + self.renew_lease() + if self.lease["action"] == "stop": + self.cancel() + raise Deferred("Run cancelled") + if self.lease["action"] == "revoked": + raise Halt("Execution permission revoked or stopped") + if self.lease["action"] != "run" or self.lease.get("expires_at", 0) <= time.time(): + raise Deferred("Waiting for execution permission") + + def heartbeat(self, step_id): + if time.monotonic() - self.last_heartbeat < 30: + return + self.last_heartbeat = time.monotonic() + try: + self.renew_lease() + self.event("heartbeat", step_id) + self.flush() + except TransportError: + self.state.setdefault("network_failed_since", time.time()) + self.save() + except Rejected: + # Finish the running safe phase; authorize() prevents another apply. + self.lease = {"action": "revoked", "expires_at": 0} + + def manifest(self): + path = self.directory / "manifest.json" + if path.exists(): + manifest = json.loads(path.read_text()) + else: + manifest = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/manifest") + claimed = manifest.get("digest") + unsigned = {key: value for key, value in manifest.items() if key != "digest"} + digest = hashlib.sha256(canonical_json(unsigned)).hexdigest() + expected = self.config.get("manifest_digest") or self.state.get("manifest_digest") or claimed + if not expected or digest != expected or (claimed and claimed != digest): + raise Halt("Manifest digest mismatch") + if manifest.get("run_id") != self.config["run_id"]: + raise Halt("Manifest belongs to another run") + steps = manifest.get("steps") + if not isinstance(steps, list) or not 1 <= len(steps) <= 100: + raise Halt("Manifest must contain 1 to 100 steps") + seen = set() + for step in steps: + if not STEP_ID.fullmatch(step.get("id", "")) or step["id"] in seen: + raise Halt("Invalid or duplicate step identifier") + if not DIGEST.fullmatch(step.get("digest", "")) or not isinstance(step.get("parameters", {}), dict): + raise Halt("Invalid module digest or parameters") + if not isinstance(step.get("timeout_seconds", 600), int) or not 1 <= step.get("timeout_seconds", 600) <= 14400: + raise Halt("Invalid module timeout") + if not set(step.get("dependencies", [])).issubset(seen): + raise Halt("Module dependencies must precede their dependants") + seen.add(step["id"]) + self.state["manifest_digest"] = digest + atomic_write(path, canonical_json(manifest)) + self.save() + return manifest + + def artifact(self, step): + digest = step["digest"] + if not DIGEST.fullmatch(digest): + raise Halt("Invalid artifact digest") + path = self.directory / "artifacts" / digest + content = path.read_bytes() if path.exists() else self.api.request("GET", f"/agent/v1/artifacts/{digest}", raw=True) + if len(content) > MAX_ARTIFACT: + raise Halt("Artifact exceeds size limit") + verified_digest(content, digest) + if not path.exists(): + atomic_write(path, content) + return path + + def execute(self, step, phase, artifact, parameters): + # Verify again immediately before *every* execution, including check/verify. + verified_digest(Path(artifact).read_bytes(), step["digest"]) + deadline = self.step_deadline or (time.monotonic() + step.get("timeout_seconds", 600)) + if time.monotonic() >= deadline: + self.log(step["id"], f"[{phase}] Step timeout reached before phase start") + return 124 + process = subprocess.Popen(["/bin/bash", str(artifact), phase, str(parameters)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + start_new_session=True, cwd=self.directory, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", + "DEBIAN_FRONTEND": "noninteractive", "PVE_RUN_ID": self.config["run_id"]}) + output = bytearray() + capture_limit = MAX_PHASE_OUTPUT + max((len(value.encode()) for value in self.secret_values), default=0) + timed_out = False + poller = selectors.DefaultSelector() + poller.register(process.stdout, selectors.EVENT_READ) + try: + while process.poll() is None or poller.get_map(): + if time.monotonic() >= deadline: + timed_out = True + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + break + for key, _ in poller.select(timeout=0.25): + chunk = os.read(key.fileobj.fileno(), 8192) + if not chunk: + poller.unregister(key.fileobj) + elif len(output) < capture_limit: + output.extend(chunk[:capture_limit - len(output)]) + # Continued lease renewal never interrupts a package operation. + self.heartbeat(step["id"]) + process.wait(timeout=5) + except BaseException: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + raise + finally: + poller.close() + process.stdout.close() + text = output.decode("utf-8", errors="replace") + if len(output) >= MAX_PHASE_OUTPUT: + text += "\n[output truncated]" + self.log(step["id"], f"[{phase}]\n{text}") + return 124 if timed_out else process.returncode + + def run_step(self, step, reboot_budget): + step_id = step["id"] + checkpoint = self.state["steps"].get(step_id, {}) + if checkpoint.get("status") == "succeeded": + return + if checkpoint.get("status") == "failed": + if step.get("required", True): + raise Halt(f"Step {step_id} previously failed; explicit review is required") + return + self.authorize() + self.flush() + artifact = self.artifact(step) + secrets = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/secrets/{step_id}") + if not isinstance(secrets, dict): + raise Halt("Invalid step secret response") + def secret_strings(value): + if isinstance(value, dict): + return [item for child in value.values() for item in secret_strings(child)] + if isinstance(value, list): + return [item for child in value for item in secret_strings(child)] + return [str(value)] if value is not None else [] + self.secret_values = secret_strings(secrets) + if any(len(value.encode()) > 16384 for value in self.secret_values): + raise Halt("Step secret exceeds the supported redaction limit") + parameters = self.directory / "step-parameters.json" + atomic_write(parameters, canonical_json({**step.get("parameters", {}), "secrets": secrets})) + self.step_deadline = time.monotonic() + step.get("timeout_seconds", 600) + try: + self.event("step.started", step_id) + self.flush() # Server knows the attempt before any module phase. + check = self.execute(step, "check", artifact, parameters) + recovering = checkpoint.get("status") in ("applying", "reboot_pending") + if recovering: + verified = self.execute(step, "verify", artifact, parameters) + if check == 0 and verified == 0: + self.succeed(step_id, recovered=True) + return + if not step.get("retry_safe", False): + raise Halt(f"Interrupted step {step_id} cannot be repeated safely") + if check == 0 and verified != 0: + check = 1 + if check not in (0, 1): + self.fail(step, check, "check failed") + return + if check == 0: + verified = self.execute(step, "verify", artifact, parameters) + if verified == 0: + self.succeed(step_id, unchanged=True) + return + # A converged check with a failing verify is an inconsistent module. + self.fail(step, verified, "verification failed") + return + self.authorize() + self.state["steps"][step_id] = {"status": "applying", "attempt": checkpoint.get("attempt", 0) + 1} + self.state["status"] = "running" + self.save() # Durable checkpoint before any mutation. + code = self.execute(step, "apply", artifact, parameters) + if code == 194: + if self.state["reboot_count"] >= reboot_budget: + raise Halt("Reboot budget exhausted") + self.state["reboot_count"] += 1 + self.state["status"] = "reboot_pending" + self.state["reboot_boot_id"] = self.boot_id + self.state["steps"][step_id]["status"] = "reboot_pending" + self.save() + self.event("run.reboot_pending", step_id) + try: + self.flush() + except (TransportError, Rejected): + pass + self.authorize() + raise RebootRequested() + if code != 0: + self.fail(step, code, "apply failed") + return + verified = self.execute(step, "verify", artifact, parameters) + if verified != 0: + self.fail(step, verified, "verification failed") + return + self.succeed(step_id) + finally: + parameters.unlink(missing_ok=True) + self.secret_values = [] + self.step_deadline = None + + def succeed(self, step_id, **verification): + self.state["steps"].setdefault(step_id, {})["status"] = "succeeded" + self.state["steps"][step_id]["verification"] = {"passed": True, **verification} + self.event("step.succeeded", step_id, exit_code=0, verification={"passed": True, **verification}) + + def fail(self, step, code, reason): + self.state["steps"].setdefault(step["id"], {}).update(status="failed", exit_code=code) + self.event("step.failed", step["id"], exit_code=code, verification={"passed": False, "reason": reason}) + if step.get("required", True): + raise Halt(f"Required step {step['id']}: {reason} (exit {code})") + + def halt(self, reason): + self.state.update(status="needs_review", reason=reason, halted_version=self.state.get("run_version", 0)) + self.state.setdefault("review_started_at", time.time()) + self.save() + if len(self.state["events"]) < MAX_EVENTS: + self.event("run.needs_review", verification={"reason": reason}) + if self.api is None: + return + try: + self.flush() + except (TransportError, Rejected, Halt): + pass + + def cancel(self): + self.state["status"] = "cancellation_pending" + self.event("run.cancelled", verification={"reason": "Operator cancelled at a safe transition"}) + self.flush() + self.state["status"] = "cancelled" + self.save() + + def review(self): + if time.time() - self.state.get("review_started_at", time.time()) >= self.config.get("review_deadline_seconds", 86400): + return False + self.flush() + self.renew_lease() + if self.lease["action"] == "stop": + self.cancel() + return False + if self.lease["action"] == "run" and self.lease.get("run_version", 0) > self.state.get("halted_version", 0): + # Only an explicit server-side resume permits recovery. Failed steps + # become interrupted steps and still pass check/verify + retry_safe. + for checkpoint in self.state["steps"].values(): + if checkpoint.get("status") == "failed": + checkpoint["status"] = "applying" + self.state.update(status="running") + self.state.pop("review_started_at", None) + self.state.pop("reason", None) + self.event("run.resumed") + return True + raise Deferred("Awaiting explicit operator resume") + + def finish(self): + self.flush() + verification = {key: value.get("verification", {"passed": False}) for key, value in self.state["steps"].items()} + response = self.api.request("POST", f"/agent/v1/runs/{self.config['run_id']}/complete", {"verification": verification}) + if response.get("status") != "succeeded": + raise Halt("Completion was not acknowledged") + self.state["status"] = "succeeded" + self.save() + + def run(self): + try: + if self.state["status"] in ("succeeded", "cancelled"): + return 0 + if self.api is None: + self.key.ensure() + self.api = API(self.config, self.key) + if self.state["status"] == "cancellation_pending": + self.flush() + self.state["status"] = "cancelled" + self.save() + return 0 + if self.state["status"] == "needs_review": + if not self.review(): + return 0 + if self.state["status"] == "completion_pending": + self.finish() + return 0 + if not self.state.get("enrolled"): + self.api.request("POST", "/agent/v1/enroll", { + "run_id": self.config["run_id"], "enrollment_secret": self.config["enrollment_secret"], + "public_key": self.key.public_key, "identities": discover_identities(self.config["identities"]), + "boot_id": self.boot_id}, signed=False) + self.state["enrolled"] = True + self.save() + if self.state["status"] == "reboot_pending": + if self.state.get("reboot_boot_id") == self.boot_id: + self.authorize() + raise RebootRequested() + self.state.update(status="running", boot_id=self.boot_id) + self.save() + self.event("run.resumed") + self.flush() + self.authorize() + manifest = self.manifest() + self.flush() + for step in manifest["steps"]: + for dependency in step.get("dependencies", []): + if self.state["steps"].get(dependency, {}).get("status") != "succeeded": + raise Halt(f"Dependency {dependency} has not succeeded") + self.run_step(step, int(manifest.get("reboot_budget", 1))) + try: + self.flush() + except TransportError: + self.network_failure() + self.state["status"] = "completion_pending" + self.save() + self.finish() + return 0 + except RebootRequested: + return 194 + except (Halt, Rejected) as exc: + self.halt(str(exc)) + return 75 + except Deferred: + return 0 if self.state["status"] == "cancelled" else 75 + except TransportError: + try: + self.network_failure() + except Halt as exc: + self.halt(str(exc)) + return 75 + return 75 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", default="/etc/pve-provisioner/config.json") + parser.add_argument("--state-dir", default="/var/lib/pve-provisioner") + args = parser.parse_args() + if os.name != "posix" or os.geteuid() != 0: + parser.error("The runner requires a Linux target and root privileges") + import fcntl + os.umask(0o077) + directory = Path(args.state_dir) + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with (directory / "runner.lock").open("a") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return 0 + # Secrets from a power failure are removed before any recovery operation. + (directory / "step-parameters.json").unlink(missing_ok=True) + config = json.loads(Path(args.config).read_text()) + runner = Runner(config, directory) + def request_stop(signum, frame): + runner.stop_requested = True + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + result = runner.run() + if runner.state.get("enrolled") and "enrollment_secret" in config: + config.pop("enrollment_secret") + atomic_write(args.config, canonical_json(config)) + if result == 194: + subprocess.run(["systemctl", "reboot"], check=True, timeout=15) + return 0 + if runner.state["status"] in ("succeeded", "cancelled"): + subprocess.run(["systemctl", "disable", "pve-provisioner.service"], check=True, timeout=15) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/provisioner/security.py b/provisioner/security.py new file mode 100644 index 0000000..6bec862 --- /dev/null +++ b/provisioner/security.py @@ -0,0 +1,86 @@ +import base64 +import hashlib +import hmac +import json +import os +from pathlib import Path +import re +import secrets + +from cryptography.fernet import Fernet + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def digest(value): + if not isinstance(value, bytes): + value = value.encode() + return hashlib.sha256(value).hexdigest() + + +def token(): + return secrets.token_urlsafe(32) + + +class Security: + def __init__(self, settings): + path = settings.master_key_file + if not path.exists(): + if not settings.testing: + raise RuntimeError("Master-Key fehlt. Zuerst 'provisioner init' ausführen.") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(Fernet.generate_key()) + path.chmod(0o600) + self.fernet = Fernet(path.read_bytes().strip()) + + @staticmethod + def hash_password(password): + if len(password) < 12 or len(password) > 1024: + raise ValueError("Passwörter benötigen 12 bis 1024 Zeichen.") + salt = os.urandom(16) + result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1) + return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode() + + @staticmethod + def verify_password(password, stored): + try: + algorithm, salt, expected = stored.split("$") + if algorithm != "scrypt" or len(password) > 1024: + return False + result = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=16384, r=8, p=1) + return hmac.compare_digest(result, base64.b64decode(expected)) + except (ValueError, TypeError): + return False + + def encrypt(self, value): + return self.fernet.encrypt(value.encode()).decode() + + def decrypt(self, value): + return self.fernet.decrypt(value.encode()).decode() + + +def redact(text, values=()): + text = str(text) + for value in sorted({str(v) for v in values if v}, key=len, reverse=True): + text = text.replace(value, "[REDACTED]") + text = re.sub(r"(?i)(bearer\s+)[^\s\"']+", r"\1[REDACTED]", text) + text = re.sub(r"(/(?:bootstrap/v1|installer/v1/report)/)[A-Za-z0-9_-]+", r"\1[REDACTED]", text) + text = re.sub(r"(?i)((?:password|secret|token|authorization)\s*[=:]\s*)[^\s,;]+", r"\1[REDACTED]", text) + return text + + +def atomic_artifact(directory: Path, source: str): + content = source.encode("utf-8") + checksum = digest(content) + directory.mkdir(parents=True, exist_ok=True) + target = directory / checksum + if not target.exists(): + temporary = directory / (".tmp-" + token()) + with temporary.open("xb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + return checksum diff --git a/provisioner/service.py b/provisioner/service.py new file mode 100644 index 0000000..a41ae4e --- /dev/null +++ b/provisioner/service.py @@ -0,0 +1,433 @@ +"""Transactional domain rules shared by browser and machine APIs.""" +from copy import deepcopy +from datetime import datetime, timezone +from fnmatch import fnmatchcase +from ipaddress import ip_address, ip_interface +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time +import uuid +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import HTTPException +import jsonschema +import tomli_w + +from .models import HostCreate, normalize_identity +from .security import atomic_artifact, canonical, digest, redact, token + +TERMINAL = {"succeeded", "failed", "cancelled", "expired"} + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def new_id(prefix): + return prefix + "-" + uuid.uuid4().hex + + +def require(condition, status, detail): + if not condition: + raise HTTPException(status, detail) + + +def unpack(row): + if row is None: + raise HTTPException(404, "Objekt nicht gefunden.") + result = dict(row) + data = json.loads(result.pop("data", "{}")) + return {**data, **result} + + +def audit(connection, actor, action, object_id, reason="", data=None): + connection.execute("INSERT INTO audit VALUES(?,?,?,?,?,?,?)", (new_id("audit"), actor, action, object_id, reason, canonical(data or {}), now_iso())) + + +def get_host(connection, host_id): + host = unpack(connection.execute("SELECT * FROM hosts WHERE id=?", (host_id,)).fetchone()) + host["identities"] = [dict(r) for r in connection.execute("SELECT kind,value FROM host_identities WHERE host_id=? ORDER BY kind,value", (host_id,))] + host["blocked"] = bool(host["blocked"]) + stored = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0]) + host["management_ip"] = stored.get("management_ip") + latest = connection.execute("SELECT last_seen FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1",(host_id,)).fetchone() + host["last_seen"] = latest[0] if latest else None + for field,table,label in (("installation_profile_id","profiles","installation_profile_name"),("postinstall_profile_id","profiles","postinstall_profile_name"),("iso_id","iso_records","iso_name")): + name = connection.execute(f"SELECT name FROM {table} WHERE id=?",(host.get(field),)).fetchone() + host[label] = name[0] if name else None + return host + + +def public_run(row): + result = unpack(row) + for field in ("answer_ciphertext", "bootstrap_ciphertext", "secrets_ciphertext", "bootstrap_hash", "enrollment_hash", "report_hash", "device_key"): + result.pop(field, None) + return result + + +def deep_merge(base, patch, provenance=None, source="", prefix=""): + for key, value in patch.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + if not isinstance(base.get(key), dict): + base[key] = {} + deep_merge(base[key], value, provenance, source, path) + else: + base[key] = deepcopy(value) + if provenance is not None: + provenance[path] = source + return base + + +def leaf_paths(value, prefix=""): + for key, item in value.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(item, dict): + yield from leaf_paths(item, path) + else: + yield path + + +class Service: + def __init__(self, settings, db, security): + self.settings, self.db, self.security = settings, db, security + self.artifact_dir = settings.data_dir / "artifacts" + + def create_host(self, connection, payload, actor): + host_id = new_id("host") + data = payload.model_dump() + normalized = [(item.kind, normalize_identity(item.kind, item.value)) for item in payload.identities] + require(len(set(normalized)) == len(normalized), 422, "Identitäten sind doppelt angegeben.") + address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None + connection.execute("INSERT INTO hosts(id,fqdn,management_ip,site,status,data,blocked,created_at) VALUES(?,?,?,?,?,?,?,?)", (host_id, payload.fqdn, address, payload.site, "ready", canonical(data), payload.blocked, now_iso())) + connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in normalized]) + audit(connection, actor, "host.created", host_id) + return get_host(connection, host_id) + + def update_host(self, connection, host_id, patch, actor): + previous = get_host(connection, host_id) + require(previous["version"] == patch.expected_version, 409, "Host wurde zwischenzeitlich geändert. Ansicht neu laden.") + values = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0]) + values.update(patch.model_dump(exclude_unset=True, exclude={"expected_version"})) + payload = HostCreate.model_validate(values) + identities = [(i.kind, normalize_identity(i.kind, i.value)) for i in payload.identities] + require(len(set(identities)) == len(identities), 422, "Identitäten sind doppelt angegeben.") + active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone() + changed_keys = set(patch.model_fields_set) - {"expected_version", "blocked", "tags"} + require(not active or not changed_keys, 409, "Während eines aktiven Laufs sind nur Sperre und Tags änderbar.") + address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None + connection.execute("UPDATE hosts SET fqdn=?,management_ip=?,site=?,blocked=?,data=?,version=version+1 WHERE id=?", (payload.fqdn,address,payload.site,payload.blocked,canonical(payload.model_dump()),host_id)) + connection.execute("DELETE FROM host_identities WHERE host_id=?", (host_id,)) + connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in identities]) + audit(connection, actor, "host.updated", host_id, data={"fields": sorted(patch.model_fields_set)}) + return get_host(connection, host_id) + + def create_profile(self, connection, payload, actor): + data = payload.model_dump() + require(payload.kind == "postinstall" or not payload.steps, 422, "Installationsprofile enthalten keine Skriptschritte.") + self.reject_inline_secrets(data["values"]) + for step in data["steps"]: + self.reject_inline_secrets(step["parameters"]) + version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM profiles WHERE name=? AND kind=?", (payload.name,payload.kind)).fetchone()[0] + profile_id = new_id("profile") + data["digest"] = digest(canonical(data)) + connection.execute("INSERT INTO profiles VALUES(?,?,?,?,?,?,?,?)", (profile_id,payload.name,payload.kind,version,"draft",canonical(data),actor,now_iso())) + audit(connection, actor, "profile.created", profile_id, payload.reason) + return unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (profile_id,)).fetchone()) + + @staticmethod + def reject_inline_secrets(value): + for path in leaf_paths(value): + name = path.rsplit(".", 1)[-1].lower().replace("-", "_") + require(name not in {"password", "root_password", "root_password_hashed", "secret", "token", "private_key"}, 422, "Geheimnisse müssen über Secret-Referenzen eingebunden werden.") + + def resolve(self, connection, host_id): + host = get_host(connection, host_id) + require(not host["blocked"], 403, "Host ist gesperrt.") + install = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("installation_profile_id"),)).fetchone()) + post = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("postinstall_profile_id"),)).fetchone()) + require(install["kind"] == "installation" and post["kind"] == "postinstall", 422, "Profiltypen passen nicht zur Zuordnung.") + require(install["status"] == post["status"] == "published", 403, "Beide Profile müssen veröffentlicht sein.") + iso = unpack(connection.execute("SELECT * FROM iso_records WHERE id=?", (host.get("iso_id"),)).fetchone()) + require(iso["test_status"] == "passed" and iso["native_token_support"] and len(iso["test_evidence"]) >= 5, 403, "ISO benötigt Testnachweis und native Token-Unterstützung.") + group = connection.execute("SELECT * FROM groups WHERE id=?", (iso["group_id"],)).fetchone() + require(group and not group["revoked"] and group["expires_at"] > time.time() and group["site"] == host["site"], 403, "ISO-Gruppe ist ungültig oder gehört zu einem anderen Standort.") + require(iso["build"] in install["target_builds"] and iso["build"] in post["target_builds"], 422, "Zielbuild ist nicht in beiden Profilen freigegeben.") + resolved, provenance = {}, {} + deep_merge(resolved, self.settings.defaults, provenance, "Globale Vorgaben") + deep_merge(resolved, self.settings.sites.get(host["site"], {}), provenance, f"Standort {host['site']}") + deep_merge(resolved, install["values"], provenance, f"Profil {install['name']} v{install['version']}") + overrides = host.get("overrides", {}) + paths = list(leaf_paths(overrides)) + for locked in install.get("locked_fields", []): + require(not any(path == locked or path.startswith(locked + ".") for path in paths), 422, f"Host darf gesperrtes Feld {locked} nicht überschreiben.") + require(set(overrides) <= {"global", "network", "root_secret_id", "disk_setup"}, 422, "Unzulässige Hostparameter.") + deep_merge(resolved, overrides, provenance, "Host") + stored_host = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0]) + deep_merge(resolved, {"global": {"fqdn": host["fqdn"]}, "network": {"cidr": stored_host.get("management_ip")}}, provenance, "Host") + self.validate_installation(resolved) + secret_row = connection.execute("SELECT * FROM secrets WHERE id=?", (resolved["root_secret_id"],)).fetchone() + require(secret_row is not None, 422, "Root-Passwort-Hash als Secret fehlt.") + root_hash = self.security.decrypt(secret_row["ciphertext"]) + require(root_hash.startswith(("$6$", "$5$", "$y$")) and len(root_hash) > 30, 422, "Root-Secret muss ein unterstützter crypt-Passwort-Hash sein.") + steps, seen, seen_names = [], set(), {} + secrets_snapshot = {"root": root_hash, "steps": {}} + for step in post["steps"]: + require(step["id"] not in seen, 422, "Schritt-IDs müssen eindeutig sein.") + module = unpack(connection.execute("SELECT * FROM modules WHERE id=?", (step["module_id"],)).fetchone()) + require(module["status"] == "published" and iso["build"] in module["target_builds"], 403, f"Modul {module['name']} ist für den Build nicht freigegeben.") + require(set(module["dependencies"]) <= set(seen_names), 422, f"Abhängigkeiten von {module['name']} sind nicht vorher eingeplant.") + try: + jsonschema.Draft202012Validator(module["parameters_schema"]).validate(step["parameters"]) + except jsonschema.ValidationError: + raise HTTPException(422, f"Parameter von {module['name']} passen nicht zum Schema.") + checksum = module["digest"] + path = self.artifact_dir / checksum + require(path.is_file() and digest(path.read_bytes()) == checksum, 422, "Modulartefakt fehlt oder ist beschädigt.") + secrets_snapshot["steps"][step["id"]] = {} + for name, secret_id in step.get("secret_refs", {}).items(): + require(bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", name)), 422, "Ungültiger Secret-Parametername.") + secret = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (secret_id,)).fetchone() + require(secret is not None, 422, "Ein Schritt-Secret fehlt.") + secrets_snapshot["steps"][step["id"]][name] = self.security.decrypt(secret[0]) + steps.append({**step, "name": module["name"], "module_version": module["version"], "digest": checksum, "timeout_seconds": module["timeout_seconds"], "retry_safe": module["retry_safe"], "dependencies": [seen_names[name] for name in module["dependencies"]]}) + seen.add(step["id"]) + seen_names[module["name"]] = step["id"] + require(steps and any(step["required"] for step in steps), 422, "Mindestens eine verpflichtende Abschlussprüfung ist erforderlich.") + snapshot = {"resolved": resolved, "provenance": provenance, "profiles": [{"id": p["id"], "name": p["name"], "version": p["version"], "digest": p["digest"]} for p in (install,post)], "steps": steps, "disks": resolved["disk_setup"], "iso": iso, "identities": host["identities"], "reboot_budget": post.get("reboot_budget",1), "warnings": ["Die Hardwarekennung dient der Zuordnung im kontrollierten Provisionierungsnetz."]} + snapshot["digest"] = digest(canonical(snapshot)) + return snapshot, secrets_snapshot + + @staticmethod + def validate_installation(values): + require(isinstance(values,dict),422,"Installationsparameter müssen ein Objekt sein.") + require(set(values) <= {"global", "network", "disk_setup", "root_secret_id"}, 422, "Unbekannte Installationsparameter.") + glob = values.get("global", {}) + require(isinstance(glob,dict) and all(isinstance(glob.get(k),str) for k in ("keyboard","country","timezone","mailto","fqdn")),422,"Globale Pflichtfelder müssen Zeichenketten sein.") + require(set(glob) <= {"keyboard", "country", "timezone", "mailto", "fqdn", "root-ssh-keys", "reboot-on-error"}, 422, "Nicht freigegebene globale Antwortoption.") + require(all(glob.get(k) for k in ("keyboard", "country", "timezone", "mailto", "fqdn")), 422, "Globale Pflichtfelder fehlen.") + require(bool(re.fullmatch(r"[a-z]{2}", glob["country"])) and "@" in glob["mailto"], 422, "Land oder E-Mail ungültig.") + require(glob["keyboard"] in {"de","de-ch","dk","en-gb","en-us","es","fi","fr","fr-be","fr-ca","fr-ch","hu","is","it","jp","lt","mk","nl","no","pl","pt","pt-br","se","si","tr"},422,"Tastaturlayout wird vom Installer nicht unterstützt.") + require(isinstance(glob.get("reboot-on-error",False),bool),422,"reboot-on-error muss ein Wahrheitswert sein.") + require(isinstance(glob.get("root-ssh-keys",[]),list) and all(isinstance(k,str) and k.startswith(("ssh-ed25519 ","ssh-rsa ","ecdsa-sha2-")) for k in glob.get("root-ssh-keys",[])),422,"Root-SSH-Schlüssel müssen als Liste öffentlicher Schlüssel angegeben werden.") + try: + ZoneInfo(glob["timezone"]) + except (ZoneInfoNotFoundError, TypeError): + raise HTTPException(422, "Ungültige Zeitzone.") + network = values.get("network", {}) + require(isinstance(network,dict),422,"Netzwerkparameter müssen ein Objekt sein.") + require(set(network) <= {"source", "cidr", "gateway", "dns", "filter"}, 422, "Nicht freigegebene Netzwerkoption.") + require(network.get("source") == "from-answer" and network.get("filter"), 422, "Explizites Managementnetz und Interface-Filter erforderlich.") + try: + address = ip_interface(network["cidr"]) + gateway, dns = ip_address(network["gateway"]), ip_address(network["dns"]) + require(gateway.version == address.version and gateway in address.network, 422, "Gateway liegt außerhalb des Managementnetzes.") + require(address.ip != gateway, 422, "Hostadresse darf nicht der Gatewayadresse entsprechen.") + if address.version == 4: + require(address.ip not in {address.network.network_address,address.network.broadcast_address}, 422, "Hostadresse ist Netz- oder Broadcastadresse.") + except (ValueError, KeyError, TypeError): + raise HTTPException(422, "Management-IP mit CIDR, Gateway und DNS müssen gültig sein.") + require(isinstance(network["filter"],dict) and all(isinstance(k,str) and isinstance(v,str) and v and v != "*" for k,v in network["filter"].items()), 422, "Expliziter Interface-Filter erforderlich.") + disks = values.get("disk_setup", {}) + require(isinstance(disks,dict),422,"Datenträgerparameter müssen ein Objekt sein.") + require(set(disks) <= {"filesystem", "filter", "filter_match", "expected_count", "expected_serials", "inventory_evidence", "zfs", "lvm"}, 422, "Nicht freigegebene Datenträgeroption.") + require(isinstance(disks.get("filesystem"),str) and disks["filesystem"] in {"ext4", "xfs", "zfs"}, 422, "Unterstützte Dateisysteme: ext4, xfs, zfs.") + filters = disks.get("filter", {}) + require(isinstance(filters,dict) and bool(filters) and set(filters) <= {"ID_SERIAL", "ID_SERIAL_SHORT", "ID_WWN"}, 422, "Datenträger benötigen stabile Seriennummer- oder WWN-Filter.") + serials = disks.get("expected_serials", []) + require(isinstance(serials,list) and all(isinstance(s,str) for s in serials) and 1 <= len(serials) <= 16 and len(set(serials)) == len(serials) and type(disks.get("expected_count")) is int and disks["expected_count"] == len(serials), 422, "Erwartete Datenträger und Anzahl müssen explizit übereinstimmen.") + require(all(isinstance(s,str) and s and not any(c in s for c in "*?[]") for s in serials), 422, "Erwartete Seriennummern müssen konkret sein.") + require(all(isinstance(v,str) and v and v not in {"*", "?"} for v in filters.values()), 422, "Pauschale Datenträgerfilter sind unzulässig.") + require(len(filters) == 1 and all(fnmatchcase(s, next(iter(filters.values()))) for s in serials), 422, "Ein stabiler Filter muss alle bestätigten Systemdatenträger auswählen.") + require(isinstance(disks.get("inventory_evidence"),str) and len(disks["inventory_evidence"]) >= 5, 422, "Datenträger benötigen einen Inventarisierungsnachweis.") + require(isinstance(disks.get("filter_match","all"),str) and disks.get("filter_match","all") in {"all","any"},422,"Ungültiger Datenträger-Filtermodus.") + if disks["filesystem"] in {"ext4", "xfs"}: + require(len(serials) == 1, 422, "LVM-Dateisysteme benötigen genau einen Systemdatenträger.") + require("zfs" not in disks,422,"ZFS-Optionen sind mit einem LVM-Dateisystem nicht kombinierbar.") + lvm = disks.get("lvm",{}) + require(isinstance(lvm,dict) and set(lvm) <= {"hdsize","swapsize","maxroot","maxvz","minfree"},422,"Nicht unterstützte LVM-Option.") + require(all(isinstance(v,(int,float)) and not isinstance(v,bool) and v >= (2 if k in {"hdsize","maxroot"} else 0) and v < 1000000 for k,v in lvm.items()),422,"Ungültige LVM-Größenangabe.") + else: + require("lvm" not in disks,422,"LVM-Optionen sind mit ZFS nicht kombinierbar.") + zfs = disks.get("zfs",{}) + require(isinstance(zfs,dict) and set(zfs) <= {"raid","ashift","arc-max","checksum","compress","copies","hdsize"},422,"Nicht unterstützte ZFS-Option.") + raid = zfs.get("raid") + minimum = {"raid0":1,"raid1":2,"raid10":4,"raidz-1":3,"raidz-2":4,"raidz-3":5} + require(isinstance(raid,str) and raid in minimum, 422, "ZFS benötigt einen expliziten RAID-Modus.") + require(len(serials)>=minimum[raid] and (raid!="raid10" or len(serials)%2==0),422,"Datenträgeranzahl passt nicht zum ZFS-RAID-Modus.") + for key,lower,upper in (("ashift",9,16),("arc-max",64,1048576),("copies",1,3),("hdsize",2,1000000)): + if key in zfs: + require(isinstance(zfs[key],(int,float)) and not isinstance(zfs[key],bool) and lower<=zfs[key]<=upper and (key=="hdsize" or isinstance(zfs[key],int)),422,f"Ungültige ZFS-Option {key}.") + require(isinstance(zfs.get("checksum","on"),str) and isinstance(zfs.get("compress","on"),str) and zfs.get("checksum","on") in {"on","fletcher4","sha256"} and zfs.get("compress","on") in {"on","off","lzjb","lz4","zle","gzip","zstd"},422,"Nicht unterstützte ZFS-Kompression oder Prüfsumme.") + require(isinstance(values.get("root_secret_id"),str) and bool(values["root_secret_id"]), 422, "Root-Secret-Referenz fehlt.") + + def approve(self, connection, host_id, payload, actor): + require(not self.settings.maintenance, 503, "Wartungsmodus: Neue Freigaben sind gesperrt.") + host = get_host(connection, host_id) + require(host["version"] == payload.expected_version, 409, "Host wurde geändert. Vorschau erneut prüfen.") + require(payload.confirmation == host["fqdn"] and payload.disks_confirmed, 422, "FQDN und Überschreiben der aufgeführten Systemdatenträger müssen bestätigt werden.") + active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone() + require(not active, 409, "Für diesen Host existiert bereits ein aktiver Lauf.") + snapshot, secret_values = self.resolve(connection, host_id) + run_id, approval_id = new_id("run"), new_id("approval") + manifest = {"run_id": run_id, "steps": snapshot["steps"], "reboot_budget": snapshot["reboot_budget"]} + manifest_digest = digest(canonical(manifest)) + run_data = {"snapshot": snapshot, "manifest": manifest, "manifest_digest": manifest_digest, "fqdn": host["fqdn"], "site": host["site"], "cancel_requested": False, "reboots": 0} + expiry = time.time() + payload.valid_minutes * 60 + connection.execute("INSERT INTO approvals VALUES(?,?,?,?,?,?)", (approval_id,host_id,"approved",expiry,canonical({"actor":actor,"reason":payload.reason,"snapshot_digest":snapshot["digest"]}),now_iso())) + connection.execute("INSERT INTO runs(id,host_id,approval_id,status,data,secrets_ciphertext,created_at) VALUES(?,?,?,?,?,?,?)", (run_id,host_id,approval_id,"prepared",canonical(run_data),self.security.encrypt(canonical(secret_values)),now_iso())) + connection.executemany("INSERT INTO run_steps(run_id,step_id,position) VALUES(?,?,?)", [(run_id,s["id"],i) for i,s in enumerate(snapshot["steps"])]) + connection.execute("UPDATE hosts SET status='prepared',version=version+1 WHERE id=?", (host_id,)) + audit(connection, actor, "installation.approved", host_id, payload.reason, {"run_id":run_id,"disks":snapshot["disks"],"expires_at":expiry}) + return public_run(connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()) + + @staticmethod + def installer_identity(payload): + require(isinstance(payload,dict), 422, "Installer-Payload muss ein Objekt sein.") + meta = payload.get("$schema", payload.get("$fetchinfo", {})) + dmi = payload.get("dmi",{}) + require(isinstance(meta,dict) and isinstance(dmi,dict),422,"Native Metadaten oder DMI-Daten sind ungültig.") + schema = meta.get("version", "legacy") + require(isinstance(schema,str) and schema in {"1.0","legacy"},422,"Nicht freigegebenes Installer-Payload-Schema.") + system = dmi.get("system", {}) + interfaces = payload.get("network-interfaces",payload.get("network_interfaces",[])) + require(isinstance(system,dict) and isinstance(interfaces,list) and len(interfaces)<=64,422,"Native Hardwarekennungen sind ungültig.") + raw = [] + for field,kind in (("uuid","uuid"),("serial","serial")): + if system.get(field): + raw.append((kind,system[field])) + for item in interfaces: + if isinstance(item,dict) and item.get("mac"): + raw.append(("mac",item["mac"])) + identities = [] + for kind,value in raw: + if not isinstance(value,str) or value.lower() in {"unknown","none","not specified","default string","to be filled by o.e.m."}: + continue + try: + identities.append({"kind":kind,"value":normalize_identity(kind,value)}) + except ValueError: + continue + require(identities, 422, "Installer übermittelt keine verwendbare Hardwarekennung.") + product = payload.get("product", {}) + iso = payload.get("iso", {}) + require(isinstance(product,dict) and isinstance(iso,dict),422,"Native Produkt- und ISO-Angaben fehlen.") + require(product.get("product") == "pve", 422, "Nur Proxmox VE Installer werden unterstützt.") + release, build = iso.get("release"), iso.get("build") + require(release and build, 422, "Native ISO Release- und Build-Informationen fehlen.") + return identities, f"{release}-{build}", schema + + @staticmethod + def match_host(connection, identities, site=None): + candidates = set() + for identity in identities: + for row in connection.execute("SELECT host_id FROM host_identities WHERE kind=? AND value=?", (identity["kind"],identity["value"])): + candidates.add(row[0]) + require(len(candidates) <= 1, 409, "Widersprüchliche Hardwarekennungen gehören zu mehreren Hosts.") + if not candidates: + return None + host = get_host(connection, candidates.pop()) + if site is not None: + require(host["site"] == site, 403, "Host gehört nicht zum Standort des Gruppentokens.") + for kind in ("uuid", "serial"): + expected = {x["value"] for x in host["identities"] if x["kind"] == kind} + supplied = {x["value"] for x in identities if x["kind"] == kind} + require(not expected or not supplied or supplied <= expected, 409, "UUID und Seriennummer widersprechen der gespeicherten Hostidentität.") + return host + + def serve_answer(self, connection, group, payload): + identities, build, schema = self.installer_identity(payload) + host = self.match_host(connection, identities, group["site"]) + if host is None: + fingerprint = digest(canonical(sorted(identities,key=lambda i:(i["kind"],i["value"])))) + connection.execute("INSERT INTO discoveries VALUES(?,?,?,?,?,?) ON CONFLICT(fingerprint) DO UPDATE SET last_seen=excluded.last_seen", (new_id("discovery"),fingerprint,group["site"],canonical({"identities":identities,"build":build,"schema":schema}),"Host ist nicht zugeordnet.",time.time())) + # The caller commits discovery before returning the denial. + return None + require(not host["blocked"], 403, "Host ist gesperrt.") + row = connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1", (host["id"],)).fetchone() + require(row is not None, 403, "Keine ausdrückliche Installationsfreigabe vorhanden.") + run = unpack(row) + iso = run["snapshot"]["iso"] + require(iso["group_id"] == group["id"] and iso["build"] == build, 403, "Installergruppe oder Zielbuild stimmt nicht mit der Freigabe überein.") + require(not run.get("cancel_requested"), 403, "Lauf ist zum Abbruch markiert.") + require(run["status"] != "expired", 410, "Installationsfreigabe ist abgelaufen.") + if run["status"] == "answer_served": + require(time.time() < run["answer_until"], 410, "Auslieferungsfenster ist abgelaufen.") + audit(connection, "installer:" + group["name"], "answer.repeated", run["id"]) + return self.security.decrypt(run["answer_ciphertext"]) + require(run["status"] == "prepared", 403, "Dieser Lauf erlaubt keine weitere Installation.") + approval = connection.execute("SELECT * FROM approvals WHERE id=?", (run["approval_id"],)).fetchone() + require(approval["status"] == "approved" and approval["expires_at"] > time.time(), 410, "Installationsfreigabe ist abgelaufen.") + require(self.settings.testing or self.settings.public_url.startswith("https://"), 503, "Maschinenendpunkte benötigen eine konfigurierte HTTPS-Adresse.") + bootstrap_token, enrollment_secret, report_token = token(), token(), token() + from .bootstrap import render_bootstrap + bootstrap_config = {"api_url":self.settings.public_url,"run_id":run["id"],"enrollment_secret":enrollment_secret,"identities":run["snapshot"]["identities"],"manifest_digest":run["manifest_digest"]} + if self.settings.runner_ca_file: + from pathlib import Path + bootstrap_config["ca_pem"] = Path(self.settings.runner_ca_file).read_text() + bootstrap = render_bootstrap(bootstrap_config) + require(len(bootstrap.encode()) <= 1024 * 1024, 422, "Starthelfer überschreitet das Größenlimit.") + resolved = deepcopy(run["snapshot"]["resolved"]) + resolved["global"]["root-password-hashed"] = json.loads(self.security.decrypt(run["secrets_ciphertext"]))["root"] + disks = resolved["disk_setup"] + native_disks = {k:v for k,v in disks.items() if k not in {"expected_count","expected_serials","inventory_evidence","filter_match"}} + native_disks["filter-match"] = disks.get("filter_match","all") + answer_data = {"global":resolved["global"],"network":resolved["network"],"disk-setup":native_disks,"first-boot":{"source":"from-url","ordering":"network-online","url":self.settings.public_url + "/bootstrap/v1/" + bootstrap_token,"cert-fingerprint":iso["fingerprint"]},"post-installation-webhook":{"url":self.settings.public_url + "/installer/v1/report/" + report_token,"cert-fingerprint":iso["fingerprint"]}} + answer = tomli_w.dumps(answer_data) + run_data = json.loads(row["data"]) + run_data.update({"answer_digest":digest(answer),"installer_schema":schema}) + connection.execute("UPDATE runs SET status='answer_served',version=version+1,data=?,answer_ciphertext=?,bootstrap_ciphertext=?,bootstrap_hash=?,enrollment_hash=?,report_hash=?,answer_until=?,enroll_until=?,last_seen=? WHERE id=?", (canonical(run_data),self.security.encrypt(answer),self.security.encrypt(bootstrap),digest(bootstrap_token),digest(enrollment_secret),digest(report_token),time.time()+self.settings.answer_window_seconds,time.time()+self.settings.enrollment_hours*3600,time.time(),run["id"])) + connection.execute("UPDATE approvals SET status='consumed' WHERE id=?", (run["approval_id"],)) + connection.execute("UPDATE hosts SET status='answer_served' WHERE id=?", (host["id"],)) + audit(connection, "installer:" + group["name"], "answer.served", run["id"]) + return answer + + def redact_run(self, row, text): + values = [] + if row["secrets_ciphertext"]: + secret_values = json.loads(self.security.decrypt(row["secrets_ciphertext"])) + values.append(secret_values.get("root", "")) + for step in secret_values.get("steps", {}).values(): + values.extend(step.values()) + return redact(text, values) + + def redact_payload(self, row, value): + def walk(item): + if isinstance(item, str): + return self.redact_run(row, item) + if isinstance(item, dict): + return {key:walk(child) for key,child in item.items()} + if isinstance(item, list): + return [walk(child) for child in item] + return item + return canonical(walk(value)) + + def maintain(self): + with self.db.connection(write=True) as connection: + timestamp = time.time() + connection.execute("DELETE FROM sessions WHERE expires_at', + server: '', + activity: '', + layers: '', + workflow: '', + code: '', + disc: '', + shield: '', + settings: '', + plus: '', + arrow: '', + refresh: '', + search: '', + check: '', + clock: '', + alert: '', + edit: '', + key: '', + users: '', + lock: '', + copy: '', + back: '', + stop: '', + play: '', + file: '', +}; +const svg = (name) => ``; +const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const json = value => JSON.stringify(value ?? {}, null, 2); +const arr = value => Array.isArray(value) ? value : (value?.items || []); +const toDate = value => new Date(typeof value==='number' && value<1e12 ? value*1000 : value); +const fmtDate = value => value && !Number.isNaN(toDate(value).getTime()) ? new Intl.DateTimeFormat('de-DE', {dateStyle:'short',timeStyle:'short'}).format(toDate(value)) : 'Noch kein Kontakt'; +const shortId = value => String(value || '–').slice(0, 10); +const roleNames = {reader:'Leser',operator:'Operator',author:'Skriptautor',admin:'Administrator',developer:'Entwickler'}; +const statusNames = {draft:'Entwurf',published:'Veröffentlicht',discovered:'Entdeckt',ready:'Bereit',prepared:'Freigegeben',approved:'Freigegeben',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installing:'Installation',installed:'Installiert',installed_reported:'Basisinstallation gemeldet',bootstrapping:'Erster Start',enrolled:'Registriert',runner_ready:'Runner bereit',running:'In Ausführung',postinstall_running:'Nachkonfiguration',postinstalling:'Nachkonfiguration',waiting_retry:'Wiederaufnahme erwartet',reboot_pending:'Neustart erwartet',waiting_reboot:'Neustart erwartet',rebooting:'Neustart',succeeded:'Erfolgreich',failed:'Fehlgeschlagen',needs_review:'Prüfung nötig',unknown:'Kontakt unbekannt',cancelled:'Abgebrochen',canceled:'Abgebrochen',cancel_requested:'Abbruch angefordert',blocked:'Gesperrt',pending:'Ausstehend',checking:'Prüfen',applying:'Anwenden',verifying:'Validieren',skipped:'Übersprungen',active:'Aktiv',passed:'Geprüft',expired:'Abgelaufen',revoked:'Widerrufen'}; +const greenStatuses = new Set(['succeeded','passed','published','active','ready']); +const redStatuses = new Set(['failed','needs_review','blocked','revoked']); +const orangeStatuses = new Set(['draft','pending','discovered','unknown','expired','waiting_reboot','waiting_retry','reboot_pending','cancel_requested']); +const blueStatuses = new Set(['approved','prepared','answer_served','answer_delivered','installing','installed','bootstrapping','enrolled','runner_ready','running','postinstall_running','postinstalling','checking','applying','verifying','rebooting']); +const badge = status => `${esc(statusNames[status] || status || 'Unbekannt')}`; +const state = {me:null,page:'dashboard',id:null,data:null,routeVersion:0,settingsTab:'users',runTab:'steps',refreshing:false}; +const canOperate = () => ['admin','developer','operator'].includes(state.me?.role); +const canAuthor = () => ['admin','developer','author'].includes(state.me?.role); +const canAdmin = () => ['admin','developer'].includes(state.me?.role); +const main = document.getElementById('main'); +const modal = document.getElementById('modal'); +let modalSubmit = null; + +function errorMessage(detail) { + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) return detail.map(e => `${(e.loc || []).filter(v=>v!=='body').join('.')}: ${e.msg || json(e)}`).join('\n'); + return detail?.message || json(detail); +} +async function api(path, options = {}) { + const headers = {'Accept':'application/json', ...(options.headers || {})}; + if (options.body !== undefined && typeof options.body !== 'string') { + headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(options.body); + } + if (options.method && options.method !== 'GET') headers['X-CSRF-Token'] = state.me?.csrf_token || ''; + const response = await fetch(path.startsWith('/auth/') ? path : `/api/v1${path}`, {...options, headers, credentials:'same-origin'}); + if (response.status === 401) { window.location.href = '/login'; throw new Error('Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.'); } + const contentType = response.headers.get('content-type') || ''; + const result = response.status === 204 ? null : contentType.includes('json') ? await response.json() : await response.text(); + if (!response.ok) throw new Error(errorMessage(result?.errors || result?.detail || result?.message || result || `Anfrage fehlgeschlagen (${response.status})`)); + return result; +} +function toast(message, isError=false) { + const el=document.createElement('div'); el.className=`toast${isError?' error':''}`; el.textContent=message; + const region=document.getElementById('toast-region');while(region.children.length>=3)region.firstElementChild.remove();region.append(el); setTimeout(()=>el.remove(), isError?9000:4500); +} +function actionButton(label, action, icon='plus', attrs='', primary=false) { + if(action==='preview-host' && !canOperate() && !canAuthor())return ''; + return ``; +} +function header(title, description, actions='', eyebrow='PROVISIONING CONSOLE') { + return ``; +} +function empty(title, description, icon='server', action='') { + return `
${svg(icon)}

${esc(title)}

${esc(description)}

${action}
`; +} +function table(headings, rows) { + return `
${headings.map(h=>``).join('')}${rows.join('')}
${esc(h)}
`; +} +function toolbar(placeholder='Suchen …', sites=[], statuses=[]) { + return `
${sites.length?``:''}${statuses.length?``:''}
`; +} +const searchAttrs = (value, site='', status='') => `data-search="${esc(String(value).toLowerCase())}" data-site="${esc(site)}" data-status="${esc(status)}"`; +function applyFilters() { + const query=(document.getElementById('list-search')?.value || '').toLowerCase(); + const site=document.getElementById('site-filter')?.value || ''; + const status=document.getElementById('status-filter')?.value || ''; + let count=0, total=0; + main.querySelectorAll('[data-search]').forEach(row=>{ + total++; const visible=row.dataset.search.includes(query) && (!site || row.dataset.site===site) && (!status || row.dataset.status===status); + row.hidden=!visible; if(visible) count++; + }); + const countEl=document.getElementById('filter-count'); if(countEl) countEl.textContent=`${count} ${count===1?'Eintrag':'Einträge'}${count!==total?` von ${total}`:''}`; + let noResults=document.getElementById('no-filter-results'); + if(!noResults && total) {noResults=document.createElement('div');noResults.id='no-filter-results';noResults.className='empty-state';noResults.textContent='Keine Einträge für diese Auswahl.';main.querySelector('.filterable')?.append(noResults);} + if(noResults) noResults.hidden=count!==0; +} +function hostRows(hosts, compact=false) { + return hosts.map(h=>`
${svg('server')}
${esc(h.fqdn || h.name || `Entdeckter Host ${shortId(h.id)}`)}${esc(h.management_ip || 'Keine Management-IP')}
${esc(h.site || '–')}${badge(h.blocked?'blocked':h.status)}${compact?'':`${(h.tags||[]).map(t=>`${esc(t)}`).join('') || ''}${esc(fmtDate(h.last_contact || h.last_seen))}`}Details ${svg('arrow')}`); +} +function runRows(runs) { + return runs.map(r=>{ + const steps=arr(r.steps),finished=steps.filter(s=>['succeeded','failed','skipped'].includes(s.status)).length; + const waiting={prepared:'Freigabe aktiv · wartet auf ISO',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installed_reported:'Wartet auf den ersten Start',installing:'Basisinstallation',installed:'Wartet auf den Runner'}[r.status]; + const progress=steps.length?Math.round(finished/steps.length*100):0; + const progressCell=waiting?esc(waiting):steps.length?`${finished} / ${steps.length} geprüft${progress}%`:esc(fmtDate(r.started_at || r.created_at)); + return `
${svg('activity')}
${badge(r.cancel_requested && !['cancelled','succeeded','failed','expired'].includes(r.status)?'cancel_requested':r.status)}${r.contact_status==='unknown'?` ${badge('unknown')}`:''}${progressCell}Ansehen ${svg('arrow')}`; + }); +} +function eventList(events, blank='Noch keine Ereignisse') { + if(!events.length) return empty(blank,'Ereignisse erscheinen hier, sobald Sie Hosts und Konfigurationen verwalten.','activity'); + return `
${events.map(e=>`
${svg(e.status==='failed'?'alert':'activity')}
${esc(e.message || e.action || e.type || e.event_type || 'Statusänderung')}

${esc(e.host_fqdn || e.actor || e.username || e.object_id || shortId(e.run_id))} · ${esc(fmtDate(e.created_at || e.timestamp))}

`).join('')}
`; +} +function dashboard(data) { + const c=data.counts||{}, hosts=arr(data.hosts), runs=arr(data.runs), events=arr(data.recent_events); + const metrics=[['Server gesamt',c.hosts||0,'Inventarisierte Hosts','server',''],['Bereit zur Installation',c.ready||0,'Freigegebene Server','shield','orange'],['Aktive Läufe',c.active||0,'Installation & Nachkonfiguration','activity','green'],['Prüfung erforderlich',c.needs_review||0,'Vorgänge mit Handlungsbedarf','alert','red']]; + return header('Infrastruktur im Überblick','Installationen steuern. Konfigurationen prüfen. Den Überblick behalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Server hinzufügen','create-host','plus','',true):''),'WORKSPACE / ÜBERSICHT')+ + `
${metrics.map(m=>`
${m[0]}${svg(m[3])}
${Number(m[1])}
${m[2]}
`).join('')}

Installationsläufe ${runs.length}

Die letzten Provisionierungen und ihr aktueller Stand

Alle Läufe ${svg('arrow')}
${runs.length?table(['SERVER','STATUS','FORTSCHRITT',''],runRows(runs.slice(0,6))):empty('Bereit für den ersten Lauf','Erfassen Sie einen Server, weisen Sie geprüfte Profile zu und erteilen Sie die Installationsfreigabe.','activity',canOperate()?actionButton('Server erfassen','create-host','plus'):'')}

Serverinventar ${Number(c.hosts||0)}

Ihre zuletzt erfassten Server

Zum Inventar ${svg('arrow')}
${hosts.length?table(['SERVER','STANDORT','STATUS',''],hostRows(hosts.slice(0,5),true)):empty('Ihr Inventar beginnt hier','Identitäten, Managementnetz und Profilzuordnungen an einem Ort.','server')}
${c.hosts?'IHR PROVISIONIERUNGSABLAUF':'ERSTE SCHRITTE'}${svg('workflow')}

Letzte Ereignisse

Was sich in Ihrem Workspace verändert

${eventList(events.slice(0,5))}
`; +} +function hostsPage(hosts, discoveries=[]) { + const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase())))); + return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`
${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}
`+(unassigned.length?`

Entdeckte Hardware ${unassigned.length}

Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.

${badge('discovered')}
${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`${arr(d.identities).map(i=>`
${esc(i.kind)}: ${esc(i.value)}
`).join('')}${esc(d.site)}
${esc(d.build)}${esc(d.reason)}${canOperate()?``:''}`))}
`:''); +} +function hostPage(host) { + const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities); + return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`${svg('back')}Inventar${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+ + `

Serverkonfiguration

${badge(host.blocked?'blocked':host.status)}
FQDN
${esc(host.fqdn||'–')}
Management-IP
${esc(host.management_ip||'–')}
Standort
${esc(host.site||'–')}
Tags
${(host.tags||[]).map(t=>`${esc(t)}`).join('')||'–'}
Letzter Kontakt
${esc(fmtDate(host.last_contact||host.last_seen))}
Versionsstand
${esc(host.version)}
${identities.length?`
${identities.map(i=>`
${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}
${esc(i.value)}
`).join('')}
`:'

Noch keine Identitäten hinterlegt.

'}

Installationshistorie

${runs.length}
${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}

Profilzuordnung

${svg('layers')}
Installation
${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}
Postinstallation
${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}
ISO-Medium
${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}

Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.

${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}
${host.discovered_data?`

Erkannte Systemdaten

${esc(json(host.discovered_data))}
`:''}

Hostüberschreibungen

${esc(json(host.overrides))}
`; +} +function profilesPage(profiles, kind) { + const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind); + return header(installation?'Installationsprofile':'Postinstallationsprofile',installation?'Sprache, Managementnetz und explizite Systemdatenträger versioniert definieren.':'Geprüfte Module in einen nachvollziehbaren Ablauf mit festen Versionen bringen.',canAuthor()?actionButton('Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'',`KONFIGURATION / ${installation?'INSTALLATION':'POSTINSTALLATION'}`)+ + (list.length?`
${toolbar('Profilname oder Zielbuild suchen …')}
${list.map(p=>`
${svg(installation?'layers':'workflow')}${badge(p.status)}

${esc(p.name)}

Version ${esc(p.version)} · ${installation?'Installationskonfiguration':`${arr(p.steps).length} Konfigurationsschritte`}

${(p.target_builds||[]).map(b=>`PVE ${esc(b)}`).join('')||'Keine Zielbuilds'}
${p.digest?`SHA256 ${esc(p.digest.slice(0,25))}…`:'Digest nach Veröffentlichung'}
${canAuthor()?``:''}${canAdmin()&&p.status==='draft'?``:''}
`).join('')}
`:`
${empty(installation?'Noch keine Installationsprofile':'Noch keine Postinstallationsprofile',installation?'Legen Sie zuerst ein Root-Geheimnis und die Netzwerk- und Datenträgerkonfiguration Ihres Hardwaretyps an.':'Erstellen und veröffentlichen Sie zunächst Skriptmodule. Fassen Sie diese anschließend zu einem Ablauf zusammen.',installation?'layers':'workflow',canAuthor()?actionButton('Erstes Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'')}
`); +} +function modulesPage(modules) { + return header('Skriptmodule','Check, Apply und Verify: versionierte Bausteine für die Nachkonfiguration.',canAuthor()?actionButton('Aus Vorlage','module-catalog','layers')+actionButton('Modul erstellen','create-module','plus','',true):'','KONFIGURATION / SKRIPTE')+`
${toolbar('Name oder Zielbuild suchen …',[],['draft','published'])}${modules.length?table(['MODUL','VERSION','STATUS','ZIELBUILDS','WIEDERHOLUNG',''],modules.map(m=>`
${svg('code')}
${esc(m.name)}${Number(m.timeout_seconds||300)} s Timeout
v${esc(m.version)}${badge(m.status)}${(m.target_builds||[]).map(b=>`${esc(b)}`).join('')}${m.retry_safe?'Explizit erlaubt':'Manuelle Prüfung'}
${canAuthor()?``:''}${canAdmin()&&m.status==='draft'?``:''}
`)):empty('Ihre Konfiguration als Bausteine','Jedes Modul definiert Zustandsprüfung, Änderung und Erfolgskontrolle. Eine Veröffentlichung erfordert einen Testnachweis.','code',canAuthor()?actionButton('Erstes Modul erstellen','create-module','plus','',true):'')}
`; +} +function runsPage(runs) { + return header('Installationsläufe','Installationen und Nachkonfigurationen vom ersten Kontakt bis zur Abschlussprüfung.',actionButton('Aktualisieren','refresh','refresh'),'VERWALTUNG / LÄUFE')+`
${toolbar('Host oder Lauf-ID suchen …',[],[...new Set(runs.map(r=>r.status))])}${runs.length?table(['SERVER / LAUF','STATUS','FORTSCHRITT / START',''],runRows(runs)):empty('Noch keine Installationsläufe','Starten Sie einen freigegebenen Server mit dem registrierten Installationsmedium. Der Lauf wird beim Antwortabruf automatisch angelegt.','activity','Zum Serverinventar →')}
`; +} +function runPage(run) { + const steps=arr(run.steps), events=arr(run.events), logs=arr(run.logs), terminal=['succeeded','failed','cancelled','canceled','expired'].includes(run.status); + const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested; + const actions=`${svg('back')}Alle Läufe${canOperate()&&resumeAllowed?actionButton('Wiederaufnehmen','resume-run','play',`data-id="${esc(run.id)}"`,true):''}${canOperate()&&!terminal?actionButton('Abbrechen','cancel-run','stop',`data-id="${esc(run.id)}"`)+actionButton('Lauf abgleichen','reconcile-run','shield',`data-id="${esc(run.id)}"`):''}`; + let content=''; + if(state.runTab==='steps') content=steps.length?`
    ${steps.map((s,i)=>`
  1. ${s.status==='succeeded'?'✓':i+1}
    ${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}

    Modul ${esc(s.module_name || s.module_id || '–')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}

    ${s.required===false?'

    Optionaler Schritt

    ':''}${s.error?`

    ${esc(s.error)}

    `:''}${s.verification&&Object.keys(s.verification).length?`
    Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}
    ${esc(json(s.verification))}
    `:''}${s.checkpoint?`
    Checkpoint
    ${esc(json(s.checkpoint))}
    `:''}
    ${badge(s.status)}
  2. `).join('')}
`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow'); + if(state.runTab==='events') content=eventList(events,'Noch keine Laufereignisse'); + if(state.runTab==='logs') content=logs.length?`
${esc(logs.map(l=>typeof l==='string'?l:`${l.created_at?fmtDate(l.created_at)+' ':''}${l.step_id?'['+l.step_id+'] ':''}${l.content||l.text||l.message||json(l)}`).join('\n'))}
`:empty('Noch keine Protokolldaten','Der Runner übermittelt redigierte Protokolle während der Ausführung.','code'); + return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`

Ausführungsstatus

${badge(run.status)}
${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>``).join('')}
${run.error||run.error_reason?`
${esc(run.error||run.error_reason)}
`:''}
${content}

Laufdaten

Server
${esc(run.host_fqdn||shortId(run.host_id))} ↗
Gestartet
${esc(fmtDate(run.started_at||run.created_at))}
Abgeschlossen
${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):'–'}
Letzter Heartbeat
${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}
Versionsstand
${esc(run.version)}
Manifest-Digest
${esc(run.manifest_digest||'Noch nicht erstellt')}
Antwort-Digest
${esc(run.answer_digest||run.answer_sha256||'–')}

Fixierte Konfiguration

${svg('lock')}

Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.

${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}
`; +} +function mediaPage(records, groups=[]) { + return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+ + `
Die ISO wird auf einer Build-Maschine mit dem Proxmox Auto Install Assistant vorbereitet. Registrieren Sie anschließend den konkreten Build mit Prüfsumme und Testnachweis.

Registrierte ISO-Medien ${records.length}

Freigabe ausschließlich für die dokumentierte Build-Kombination

${records.length?table(['MEDIUM','ZIELBUILD','ASSISTANT','TESTSTATUS',''],records.map(r=>`
${svg('disc')}
${esc(r.name)}${esc(shortId(r.sha256))}…
${esc(r.build)}${esc(r.assistant_version)}${badge(r.test_status)}`)):empty('Noch keine Installationsmedien','Erstellen Sie zuerst eine Bereitstellungsgruppe. Registrieren Sie danach Ihr geprüftes ISO-Medium.','disc',canAdmin()?actionButton('ISO registrieren','create-iso','plus','',true):'')}

Bereitstellungsgruppen

Zeitlich begrenzte Gruppentoken für den Antwortabruf

${groups.length?table(['GRUPPE','STANDORT','GÜLTIG BIS','STATUS'],groups.map(g=>`${esc(g.name)}
${esc(g.id)}${esc(g.site||'–')}${esc(fmtDate(g.expires_at))}${badge(g.revoked?'revoked':toDate(g.expires_at)`)):empty('Keine Gruppen vorhanden','Ein Gruppentoken berechtigt zum Antwortabruf für zugeordnete und freigegebene Hosts.','key',canAdmin()?actionButton('Gruppe erstellen','create-group','plus'):'')}
`; +} +function auditPage(events) { + return header('Auditprotokoll','Änderungen, Freigaben und privilegierte Zugriffe nachvollziehen.',actionButton('Aktualisieren','refresh','refresh'),'SYSTEM / AUDIT')+`
${toolbar('Aktion, Benutzer oder Objekt suchen …')}${events.length?table(['ZEITPUNKT','AKTEUR','AKTION','OBJEKT',''],events.map(e=>`${esc(fmtDate(e.created_at||e.timestamp))}${esc(e.actor||e.username||e.actor_id||'System')}${esc(e.action)}${esc(e.object_type||e.target_type||'')} ${esc(shortId(e.object_id||e.target_id))}`)):empty('Noch keine Auditereignisse','Änderungen und Freigaben werden mit Akteur, Zeitpunkt und Änderungsgrund aufgezeichnet.','shield')}
`; +} +function settingsPage(users=[], secrets=[]) { + if(!canAdmin()) return header('Einstellungen','Ihre Zugriffsrechte im Workspace.','','SYSTEM / EINSTELLUNGEN')+`

Ihr Konto

Benutzername
${esc(state.me.username)}
Rolle
${esc(roleNames[state.me.role]||state.me.role)}

Benutzer und Geheimnisse werden durch Administratoren verwaltet.

`; + const tab=state.settingsTab; + return header('Einstellungen','Benutzerkonten und verschlüsselte Betriebsgeheimnisse verwalten.',tab==='users'?actionButton('Benutzer anlegen','create-user','plus','',true):actionButton('Geheimnis hinterlegen','create-secret','key','',true),'SYSTEM / EINSTELLUNGEN')+`
`+(tab==='users'?`

Benutzerkonten ${users.length}

${users.length?table(['BENUTZER','ROLLE','ERSTELLT'],users.map(u=>`
${svg('users')}${esc(u.username)}${u.id===state.me.id?'Sie':''}
${esc(roleNames[u.role]||u.role)}${esc(fmtDate(u.created_at))}`)):empty('Keine Benutzer gefunden','Legen Sie ein Benutzerkonto mit der passenden Rolle an.','users')}
Leser sehen redigierte Daten. Operatoren verwalten Hosts und Läufe. Skriptautoren erstellen Entwürfe. Administratoren verwalten Freigaben, Benutzer und Geheimnisse. Entwickler haben alle Berechtigungen.
`:`
Geheimnisse werden verschlüsselt gespeichert und über ihre ID referenziert. Der gespeicherte Wert wird in der Konsole nicht erneut angezeigt.

Geheimnisreferenzen ${secrets.length}

${secrets.length?table(['NAME','REFERENZ-ID','ERSTELLT'],secrets.map(s=>`
${svg('key')}${esc(s.name)}
${esc(s.id)}${esc(fmtDate(s.created_at))}`)):empty('Noch keine Geheimnisse hinterlegt','Hinterlegen Sie beispielsweise den Root-Passwort-Hash für ein Installationsprofil.','key',actionButton('Geheimnis hinterlegen','create-secret','key','',true))}
`); +} + +function showModal(title, content, submit=null, eyebrow='PROXMOX AIS') { + document.getElementById('modal-title').textContent=title; + document.getElementById('modal-eyebrow').textContent=eyebrow; + document.getElementById('modal-body').innerHTML=content; + modalSubmit=submit; + if(!modal.open) modal.showModal(); +} +function closeModal() {modal.close();modalSubmit=null;document.getElementById('modal-body').replaceChildren();} +function field(name,label,value='',options={}) { + const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`; + let input; + if(options.type==='textarea'||options.type==='json') input=``; + else if(options.type==='select') input=``; + else if(options.type==='checkbox') return ``; + else input=``; + return ``; +} +function form(fields,label='Speichern',intro='') { + return `${intro}`; +} +function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}} +const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean); +const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))]; +async function hostForm(existing=null, discovery=null) { + const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]); + const h=existing||discovery||{}; + const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'}); + showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{ + const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};}); + const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')}; + if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;} + await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh(); + },'INVENTAR'); +} +function hostImportForm() { + const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}]; + showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT'); +} +async function moduleCatalog() { + const catalog=arr(await api('/modules/builtin')); + showModal('Basismodul als Entwurf übernehmen',`
Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.
${catalog.map(m=>`
${svg('code')}
${esc(m.name)}

${esc(m.description)}

`).join('')}
`,null,'MODULVORLAGEN'); + state.catalog=catalog; +} +function previewContent(preview) { + const p=preview||{}, warnings=arr(p.warnings); + const network=p.resolved?.network||{}; + return `${warnings.length?`
${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('
')}
`:''}
Aufgelöste Konfiguration & Herkunft
${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}
`; +} +async function approveHost(id, previewOnly=false) { + const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]); + if(previewOnly){showModal('Aufgelöste Konfiguration',previewContent(preview)+`
`,null,host.fqdn);return;} + const intro=`
Die Installation überschreibt die ausgewählten Systemdatenträger. Prüfen Sie Host, Netzwerk, Zielbuild und Datenträger vor der Freigabe.
${previewContent(preview)}
`; + const fields=field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('valid_minutes','Freigabefenster in Minuten',30,{type:'number',required:true,min:5,max:240})+field('reason','Freigabegrund','',{required:true,placeholder:'Geplante Erstinstallation'})+field('disks_confirmed','Ich habe die Zieldatenträger geprüft und bestätige, dass diese überschrieben werden dürfen.',false,{type:'checkbox',required:true,full:true}); + showModal('Installation freigeben',form(fields,'Verbindlich freigeben',intro),async data=>{ + if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.'); + await api(`/hosts/${encodeURIComponent(id)}/approve-install`,{method:'POST',body:{expected_version:host.version,valid_minutes:Number(data.get('valid_minutes')),confirmation:data.get('confirmation'),disks_confirmed:data.get('disks_confirmed')==='on',reason:data.get('reason')}}); + closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh(); + },'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE'); +} +const installationExample = { + global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'}, + network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}}, + disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'}, + root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES' +}; +async function profileForm(kind, existing=null) { + const p=existing||{}, install=kind==='installation'; + let info=''; + if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`
Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: ${esc(m.id)}`).join('
'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}
`;} + const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'}); + showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{ + await api('/profiles',{method:'POST',body:{name:data.get('name'),kind,target_builds:split(data.get('target_builds')),values:parseJSON(data,'values'),steps:install?[]:parseJSON(data,'steps',[]),reason:data.get('reason')||'',...(!install?{reboot_budget:Number(data.get('reboot_budget'))}:{})}}); + closeModal();toast('Profilentwurf gespeichert. Eine Veröffentlichung benötigt einen Testnachweis.');await refresh(); + },install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL'); +} +const moduleExample = '#!/usr/bin/env bash\nset -euo pipefail\n\ncheck() {\n systemctl is-active --quiet pveproxy\n}\n\napply() {\n # Nur erforderliche, geprüfte Änderungen ausführen.\n return 0\n}\n\nverify() {\n systemctl is-active --quiet pveproxy\n}\n\ncase "${1:-}" in\n check) check ;;\n apply) apply ;;\n verify) verify ;;\n *) echo "Usage: $0 {check|apply|verify}" >&2; exit 2 ;;\nesac\n'; +function moduleForm(existing=null) { + const m=existing||{}; + const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(m.target_builds).join(', '),{required:true,placeholder:'z. B. 9.1-1'})+field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds||300,{type:'number',required:true,min:1,max:7200})+field('source','Bash-Quelltext',m.source||moduleExample,{type:'json',required:true,full:true,rows:17,hint:'Aufruf: bash modul.sh check|apply|verify parameter.json. Parameter werden als JSON-Datei übergeben.'})+field('parameters_schema','Parameterschema (JSON Schema)',m.parameters_schema||{type:'object',properties:{},additionalProperties:false},{type:'json',full:true,rows:6})+field('dependencies','Abhängige Modulnamen',arr(m.dependencies).join(', '),{full:true,placeholder:'Optional: exakte Modulnamen, durch Komma getrennt'})+field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe||false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit im Test nachgewiesen wurde.'})+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Modulstand'}); + showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{ + await api('/modules',{method:'POST',body:{name:data.get('name'),source:data.get('source'),parameters_schema:parseJSON(data,'parameters_schema'),dependencies:split(data.get('dependencies')),target_builds:split(data.get('target_builds')),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh(); + },'VERSIONIERTE SKRIPTMODULE'); +} +async function publishObject(type, id) { + const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`); + const intro=`
${esc(item.name)} · Version ${esc(item.version)}
Veröffentlichten Inhalt können Sie nicht mehr ändern. Dokumentieren Sie den praktischen Test auf einem passenden Testhost. Im Vieraugenmodus muss eine andere Person den Entwurf veröffentlichen.
`; + showModal(type==='profiles'?'Profil veröffentlichen':'Modul veröffentlichen',form(field('test_evidence','Praktischer Testnachweis','',{type:'textarea',required:true,full:true,placeholder:'Testhost, Build, Datum, Ergebnisse und Referenz zum Prüfprotokoll'})+field('reason','Änderungsgrund','',{type:'textarea',required:true,full:true}),'Veröffentlichen',intro),async data=>{ + await api(`/${type}/${encodeURIComponent(id)}/publish`,{method:'POST',body:{test_evidence:data.get('test_evidence'),reason:data.get('reason')}});closeModal();toast('Version veröffentlicht.');await refresh(); + },'VERÖFFENTLICHUNG'); +} +function inspectProfile(p) { + showModal(p.name,`
${esc(json({values:p.values,steps:p.steps}))}
${canAuthor()?``:''}
`,null,'PROFILDETAILS'); +} +function inspectModule(m) { + showModal(m.name,`${m.source?`
${esc(m.source)}
`:'
Der Quelltext ist für Skriptautoren und Administratoren sichtbar.
'}
Parameterschema & Abhängigkeiten
${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}
`,null,'MODULDETAILS'); +} +async function isoForm() { + const groups=arr(await api('/groups')); + const fields=field('name','Medienname','',{required:true,placeholder:'Berlin · Proxmox VE'})+field('build','Exakter Proxmox ISO-Build','',{required:true,placeholder:'z. B. 9.1-1'})+field('sha256','SHA256-Prüfsumme des ISO-Mediums','',{required:true,full:true,placeholder:'64 hexadezimale Zeichen'})+field('assistant_version','Version des Auto Install Assistant','',{required:true,placeholder:'Exakte Paketversion'})+field('group_id','Bereitstellungsgruppe','',{required:true,type:'select',options:selectObjects(groups.filter(g=>!g.revoked))})+field('fingerprint','SHA256-Zertifikatsfingerprint','',{required:true,full:true,placeholder:'Fingerprint des HTTPS-Zertifikats des Antwortdienstes'})+field('test_status','Prüfstatus','draft',{type:'select',options:[{value:'draft',label:'Entwurf – noch nicht freigegeben'},{value:'passed',label:'Geprüft – Nachweis liegt vor'}]})+field('native_token_support','Native Unterstützung von --answer-auth-token ist nachgewiesen.',false,{type:'checkbox',required:true,full:true})+field('test_evidence','Kompatibilitätsnachweis','',{type:'textarea',full:true,placeholder:'Antwortschema, Token-Header, First Boot, Startnetz und Bootverfahren: Testhost, Datum und Prüfprotokoll.'}); + showModal('ISO-Medium registrieren',form(fields,'Medium registrieren',groups.length?'':'
Erstellen Sie zuerst eine Bereitstellungsgruppe im Bereich Installationsmedien.
'),async data=>{ + await api('/iso-records',{method:'POST',body:{name:data.get('name'),build:data.get('build'),sha256:data.get('sha256'),assistant_version:data.get('assistant_version'),group_id:data.get('group_id'),fingerprint:data.get('fingerprint'),test_status:data.get('test_status'),native_token_support:data.get('native_token_support')==='on',test_evidence:data.get('test_evidence')}});closeModal();toast('ISO-Medium registriert.');await refresh(); + },'INSTALLATIONSMEDIUM'); +} +function buildCommand(iso, token=':') { + if(iso.command)return iso.command; + const shellQuote = value => "'"+String(value).replace(/'/g,"'\\''")+"'"; + const base=(iso.answer_url||`${window.location.origin}/installer/v1/answer`); + return `proxmox-auto-install-assistant prepare-iso SOURCE.iso \\\n --fetch-from http \\\n --url ${shellQuote(base)} \\\n --cert-fingerprint ${shellQuote(iso.fingerprint||'')} \\\n --answer-auth-token ${shellQuote(token)}`; +} +function inspectISO(iso) { + showModal(iso.name,`

SOURCE.iso und den Token-Platzhalter durch Ihre Eingaben ersetzen. Die URL muss aus dem Provisionierungsnetz per HTTPS erreichbar sein.

`,null,'ISO-DETAILS'); +} +function groupForm() { + showModal('Bereitstellungsgruppe erstellen',form(field('name','Gruppenname','',{required:true,placeholder:'berlin-rack-a'})+field('site','Standort','',{required:true,placeholder:'Rechenzentrum Berlin'})+field('valid_hours','Gültigkeit in Stunden',24,{type:'number',required:true,min:1,max:8760,full:true}),'Token erstellen'),async data=>{ + const result=await api('/groups',{method:'POST',body:{name:data.get('name'),site:data.get('site'),valid_hours:Number(data.get('valid_hours'))}}); + showModal('Gruppentoken erstellt',`
Der vollständige Token wird nur jetzt angezeigt. Speichern Sie ihn für die ISO-Vorbereitung. Er ist auf die Bereitstellungsgruppe und deren Gültigkeitsfenster beschränkt.
Gruppe
${esc(result.name)}
Referenz-ID
${esc(result.id)}
Gültig bis
${esc(fmtDate(result.expires_at))}
${result.command?`
${esc(result.command)}
`:''}
`,null,'EINMALIGE TOKENAUSGABE'); + await refresh(); + },'ISO-ZUGRIFF'); +} +function userForm() { + showModal('Benutzer anlegen',form(field('username','Benutzername','',{required:true,autocomplete:'off'})+field('role','Rolle','reader',{type:'select',options:Object.entries(roleNames).map(([value,label])=>({value,label}))})+field('password','Initiales Passwort','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Mindestens 12 Zeichen verwenden.'}),'Benutzer anlegen'),async data=>{ + await api('/users',{method:'POST',body:{username:data.get('username'),password:data.get('password'),role:data.get('role')}});closeModal();toast('Benutzerkonto angelegt.');await refresh(); + },'BENUTZER & ROLLEN'); +} +function secretForm() { + showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','
Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.
'),async data=>{ + const result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh(); + if(result?.id)showModal('Geheimnis gespeichert',`

Referenz für root_secret_id oder einen Schritt:

`); + },'BETRIEBSGEHEIMNIS'); +} +async function runAction(id, action) { + const run=await api(`/runs/${encodeURIComponent(id)}`), resume=action==='resume'; + showModal(resume?'Lauf wiederaufnehmen':'Lauf abbrechen',form(field('reason',resume?'Begründung und durchgeführte Prüfung':'Abbruchgrund','',{type:'textarea',full:true,required:true}),resume?'Wiederaufnahme anfordern':'Abbruch anfordern',`
${resume?'Der Runner prüft gespeicherte Checkpoints und Modulzustände vor weiteren Änderungen. Unklare, nicht wiederholbare Schritte benötigen eine manuelle Klärung.':'Der Runner stoppt am nächsten sicheren Übergang. Eine bereits gestartete Datenträgeroperation oder ein Paketmanager wird dadurch nicht rückgängig gemacht.'}
`),async data=>{ + await api(`/runs/${encodeURIComponent(id)}/${action}`,{method:'POST',body:{reason:data.get('reason'),expected_version:run.version}});closeModal();toast(resume?'Wiederaufnahme angefordert.':'Abbruch angefordert.');await refresh(); + },`LAUF ${shortId(id)}`); +} +async function reconcileRun(id) { + const run=await api(`/runs/${encodeURIComponent(id)}`); + const host=await api(`/hosts/${encodeURIComponent(run.host_id)}`); + const fields=field('reason','Durchgeführte Prüfung und Abgleichgrund','',{type:'textarea',full:true,required:true})+field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('execution_stopped','Ich habe am Host geprüft, dass Installer und Runner gestoppt sind.',false,{type:'checkbox',full:true,required:true}); + showModal('Lauf manuell abgleichen',form(fields,'Lauf als abgebrochen schließen',`
Dieser Abgleich schließt einen aufgegebenen oder nach Wiederherstellung unklaren Lauf. Prüfen Sie zuvor direkt am Host, dass keine Ausführung mehr stattfindet. Bereits erteilte Laufberechtigungen und das Antwortfenster müssen abgelaufen sein. Für eine neue Installation ist eine neue Freigabe erforderlich.
`),async data=>{ + if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.'); + await api(`/runs/${encodeURIComponent(id)}/reconcile`,{method:'POST',body:{expected_version:run.version,reason:data.get('reason'),confirmation:data.get('confirmation'),execution_stopped:data.get('execution_stopped')==='on'}});closeModal();toast('Lauf abgeglichen und als abgebrochen geschlossen.');await refresh(); + },host.fqdn); +} + +async function renderRoute(showLoading=true) { + const route=window.location.hash.replace(/^#\/?/,'').split('/'); + const pages=['dashboard','hosts','installation','postinstall','modules','runs','media','audit','settings']; + state.page=pages.includes(route[0])?route[0]:'dashboard';state.id=route[1]?decodeURIComponent(route[1]):null; + const version=++state.routeVersion; + const labels={dashboard:'Übersicht',hosts:'Serverinventar',installation:'Installationsprofile',postinstall:'Postinstallation',modules:'Skriptmodule',runs:'Installationsläufe',media:'Installationsmedien',audit:'Auditprotokoll',settings:'Einstellungen'}; + document.getElementById('breadcrumb').textContent=labels[state.page];document.title=`${labels[state.page]} · Proxmox AIS`; + document.querySelectorAll('[data-nav]').forEach(a=>{a.classList.toggle('active',a.dataset.nav===state.page);if(a.dataset.nav===state.page)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current');}); + if(showLoading)main.innerHTML='
Daten werden geladen …
'; + try { + let output, data; + if(state.page==='dashboard'){data=await api('/dashboard');output=dashboard(data);} + if(state.page==='hosts'){if(state.id){data=await api(`/hosts/${encodeURIComponent(state.id)}`);output=hostPage(data);}else{const results=await Promise.all([api('/hosts'),api('/discoveries')]);data={hosts:arr(results[0]),discoveries:arr(results[1])};output=hostsPage(data.hosts,data.discoveries);}} + if(['installation','postinstall'].includes(state.page)){data=arr(await api('/profiles'));output=profilesPage(data,state.page);} + if(state.page==='modules'){data=arr(await api('/modules'));output=modulesPage(data);} + if(state.page==='runs'){data=await api(state.id?`/runs/${encodeURIComponent(state.id)}`:'/runs');output=state.id?runPage(data):runsPage(arr(data));} + if(state.page==='media'){const result=await Promise.all([api('/iso-records'),(canOperate()||canAuthor())?api('/groups'):Promise.resolve([])]);data={records:arr(result[0]),groups:arr(result[1])};output=mediaPage(data.records,data.groups);} + if(state.page==='audit'){data=arr(await api('/audit'));output=auditPage(data);} + if(state.page==='settings'){data=canAdmin()?await Promise.all([api('/users'),api('/secrets')]):[[],[]];output=settingsPage(arr(data[0]),arr(data[1]));} + if(version!==state.routeVersion)return; + state.data=data; main.innerHTML=output; + if(state.page==='media'&&!canOperate()&&!canAuthor())main.querySelector('.groups-card')?.remove(); + applyFilters(); + document.getElementById('connection').className='connection';document.getElementById('connection').innerHTML=' Verbunden'; + }catch(error){ + if(version!==state.routeVersion)return; + document.getElementById('connection').className='connection disconnected';document.getElementById('connection').innerHTML=' Abruf fehlgeschlagen'; + if(showLoading)main.innerHTML=header('Daten konnten nicht geladen werden','Prüfen Sie Ihre Verbindung und Zugriffsrechte.',actionButton('Erneut versuchen','refresh','refresh'))+``; + else throw error; + } +} +async function refresh() { + if(state.refreshing)return; + state.refreshing=true; + const filters=['list-search','site-filter','status-filter'].map(id=>[id,document.getElementById(id)?.value]); + try{await renderRoute(false);for(const [id,value]of filters){const el=document.getElementById(id);if(el&&value!==undefined)el.value=value;}applyFilters();}finally{state.refreshing=false;} +} +async function handleAction(button) { + const action=button.dataset.action,id=button.dataset.id; + if(action==='close-modal'){closeModal();return;} + if(action==='menu'){const open=document.getElementById('sidebar').classList.toggle('open');button.setAttribute('aria-expanded',String(open));return;} + if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;} + if(action==='refresh'){await refresh();return;} + if(action==='create-host'){await hostForm();return;} + if(action==='import-hosts'){hostImportForm();return;} + if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;} + if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;} + if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;} + if(action==='toggle-host'){ + const host=await api(`/hosts/${encodeURIComponent(id)}`); + await api(`/hosts/${encodeURIComponent(id)}`,{method:'PATCH',body:{expected_version:host.version,blocked:!host.blocked}});toast(host.blocked?'Host entsperrt.':'Host gesperrt.');await refresh();return; + } + if(action==='create-profile'){await profileForm(button.dataset.kind);return;} + if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;} + if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;} + if(action==='publish-profile'){await publishObject('profiles',id);return;} + if(action==='create-module'){moduleForm();return;} + if(action==='module-catalog'){await moduleCatalog();return;} + if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;} + if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;} + if(action==='publish-module'){await publishObject('modules',id);return;} + if(action==='create-iso'){await isoForm();return;} + if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;} + if(action==='create-group'){groupForm();return;} + if(action==='create-user'){userForm();return;} + if(action==='create-secret'){secretForm();return;} + if(action==='settings-tab'){state.settingsTab=button.dataset.tab;await renderRoute(false);return;} + if(action==='run-tab'){state.runTab=button.dataset.tab;main.innerHTML=runPage(state.data);return;} + if(action==='resume-run'||action==='cancel-run'){await runAction(id,action==='resume-run'?'resume':'cancel');return;} + if(action==='reconcile-run'){await reconcileRun(id);return;} + if(action==='copy-value'){ + const el=document.getElementById('copy-value'); + try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return; + } + if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`
${esc(json(e))}
`,null,e?.action||'AUDIT');} +} +document.addEventListener('click',async event=>{ + const button=event.target.closest('[data-action]'); + if(button){event.preventDefault();if(button.disabled)return;button.disabled=true;try{await handleAction(button);}catch(error){toast(error.message,true);}finally{button.disabled=false;}} + if(event.target.closest('[data-nav]')){document.getElementById('sidebar').classList.remove('open');document.querySelector('[data-action="menu"]').setAttribute('aria-expanded','false');} +}); +document.addEventListener('input',event=>{if(event.target.id==='list-search')applyFilters();}); +document.addEventListener('change',event=>{if(['site-filter','status-filter'].includes(event.target.id))applyFilters();}); +document.addEventListener('submit',async event=>{ + if(event.target.id!=='modal-form')return; + event.preventDefault();if(!modalSubmit)return; + const formEl=event.target, submit=formEl.querySelector('[type="submit"]'),errorEl=formEl.querySelector('.form-error'); + submit.disabled=true;errorEl.textContent=''; + try{await modalSubmit(new FormData(formEl));}catch(error){errorEl.textContent=error.message;errorEl.scrollIntoView({block:'nearest'});}finally{submit.disabled=false;} +}); +modal.addEventListener('cancel',()=>{modalSubmit=null;document.getElementById('modal-body').replaceChildren();}); +window.addEventListener('hashchange',()=>{renderRoute();}); +async function init() { + document.querySelectorAll('[data-icon]').forEach(el=>el.insertAdjacentHTML('afterbegin',svg(el.dataset.icon))); + try{ + state.me=await api('/me'); + document.getElementById('username').textContent=state.me.username; + document.getElementById('user-role').textContent=roleNames[state.me.role]||state.me.role; + document.getElementById('avatar').textContent=state.me.username.slice(0,2).toUpperCase(); + await renderRoute(); + setInterval(async()=>{ + if(document.hidden||modal.open||state.refreshing||['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)||!['dashboard','runs','hosts'].includes(state.page))return; + try{await refresh();}catch{/* The connection indicator reports refresh failures. */} + },15000); + }catch(error){main.innerHTML=``;} +} +init(); diff --git a/provisioner/static/style.css b/provisioner/static/style.css new file mode 100644 index 0000000..5d672ca --- /dev/null +++ b/provisioner/static/style.css @@ -0,0 +1,12 @@ + +:root{--bg:#f5f6f8;--card:#fff;--ink:#1d2839;--muted:#7b8493;--line:#e7eaf0;--orange:#eb6c35;--orange-soft:#fff2e9;--sidebar:#182131;--green:#218866;--red:#ca4655;--blue:#537bc0;--radius:11px;--shadow:0 3px 12px #1b263708;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;color:var(--ink);background:var(--bg)}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}a{color:inherit;text-decoration:none}a:hover{color:var(--orange)}button:focus-visible,a:focus-visible{outline:3px solid #ee9967;outline-offset:3px}input:focus,textarea:focus,select:focus{outline:0;border-color:#ee9967;box-shadow:0 0 0 3px #ee99671f}svg{width:20px;height:20px;flex-shrink:0}.app-shell{display:flex;min-height:100vh}.sidebar{width:240px;background:var(--sidebar);color:#aab3c3;position:fixed;inset:0 auto 0 0;display:flex;flex-direction:column;padding:31px 18px 22px;z-index:30}.brand{display:flex;align-items:center;gap:12px;color:#fff;font-weight:700;letter-spacing:1.5px;font-size:15px;padding:0 10px}.brand:hover{color:#fff}.brand-mark{font-size:29px;line-height:1;letter-spacing:-2px;display:inline-block;color:#fff;font-weight:800}.brand-mark span{color:var(--orange)}.brand-sub{display:block;font-size:8px;color:#98a4b7;letter-spacing:1.45px;font-weight:500;margin-top:6px}.workspace-label{display:flex;align-items:center;gap:9px;border:1px solid #354051;border-radius:6px;padding:11px;margin:32px 8px 19px;font-size:12px;color:#d7dce5}.workspace-dot{width:7px;height:7px;background:#ed8a54;border-radius:2px}.workspace-version{margin-left:auto;color:#8490a3;font-size:10px}.nav-list{display:flex;flex-direction:column;gap:4px}.nav-caption{font-size:9px;letter-spacing:1.3px;font-weight:600;color:#7e8a9d;padding:19px 14px 7px}.nav-list a{display:flex;gap:12px;align-items:center;padding:12px 13px;font-size:12px;font-weight:500;border-radius:6px;transition:background .15s,color .15s}.nav-list a svg{width:18px;height:18px;color:#919fb4}.nav-list a:hover{background:#232f42;color:#fff}.nav-list a.active{background:#eb6c351b;color:#ffa77a}.nav-list a.active svg{color:#ef8d60}.sidebar-bottom{margin-top:auto;padding:25px 13px 0}.sidebar-status{font-size:10px;color:#bbc5d2;display:flex;gap:7px;align-items:center}.status-dot{height:6px;width:6px;border-radius:50%;background:#57a98d;display:inline-block;flex-shrink:0}.sidebar-bottom p{font-size:10px;line-height:1.8;color:#7e8b9f;margin:12px 0 20px}.sidebar-bottom>a{display:flex;justify-content:space-between;border-top:1px solid #2b3648;padding-top:16px;font-size:11px;color:#95a2b7}.main-shell{margin-left:240px;width:calc(100% - 240px);display:flex;min-height:100vh;flex-direction:column}.topbar{height:78px;background:#fff;border-bottom:1px solid var(--line);padding:0 36px;display:flex;align-items:center;justify-content:space-between;gap:16px}.breadcrumb{font-size:11px;color:#a1a7b1}.breadcrumb span{padding:0 12px;color:#c6cbd3}.breadcrumb strong{font-weight:500;color:#556170}.topbar-right{display:flex;align-items:center;gap:13px}.connection{display:flex;align-items:center;gap:7px;font-size:10px;color:#7c8795}.connection.disconnected .status-dot{background:#cd6868}.topbar-divider{height:25px;border-left:1px solid var(--line);margin:0 5px}.avatar{background:#fcece1;color:#bb623c;font-size:11px;font-weight:700;border:1px solid #f3dfd1;width:32px;height:32px;border-radius:50%;display:grid;place-items:center}.user-info{display:flex;flex-direction:column;gap:3px;min-width:75px}.user-info strong{font-size:11px;font-weight:600}.user-info span{font-size:9px;color:var(--muted)}.icon-button{background:transparent;border:0;color:#8390a1;padding:6px;display:inline-flex;align-items:center;justify-content:center;border-radius:5px;font-size:25px;line-height:1}.icon-button:hover{background:#f0f2f5;color:#26354a}.icon-button svg{width:18px;height:18px}.mobile-menu{display:none}#main{padding:34px 36px 40px;flex:1;min-width:0}.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:27px}.eyebrow{display:block;font-size:9px;font-weight:700;letter-spacing:1.4px;color:#a17c62;margin-bottom:11px}.page-header h1{font-size:26px;letter-spacing:-.8px;line-height:1.3;font-weight:650;margin:0 0 9px}.page-header p{font-size:12px;color:#7e8795;margin:0;line-height:1.6}.header-actions{display:flex;align-items:center;gap:9px;padding-top:14px;flex-shrink:0}.button{border:1px solid #dde2e9;border-radius:6px;padding:10px 14px;background:white;color:#516074;font-size:11px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:36px;white-space:nowrap;line-height:1.3}.button svg{width:14px;height:14px}.button:hover{background:#f7f8fa;color:var(--ink)}.button.primary{background:var(--orange);color:#fff;border-color:var(--orange);box-shadow:0 2px 4px #eb6c351a}.button.primary:hover{background:#d95c25;border-color:#d95c25}.button.danger{border-color:#efc3c6;color:#bd4450;background:#fff7f7}.button.small{padding:7px 10px;min-height:30px;font-size:10px}.button.ghost{background:transparent;border-color:transparent;color:#788394}.button-link{color:var(--orange);font-size:11px;font-weight:600;border:0;background:transparent;display:inline-flex;align-items:center;gap:7px;padding:0}.metrics-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:25px}.metric{background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);padding:21px 22px;position:relative}.metric-label{font-size:11px;color:#778294;display:flex;justify-content:space-between;align-items:center}.metric-icon{width:31px;height:31px;border-radius:7px;display:grid;place-items:center;background:#f0f3f8;color:#657c9b}.metric-icon svg{width:16px;height:16px}.metric-icon.orange{background:var(--orange-soft);color:var(--orange)}.metric-icon.green{background:#edf7f1;color:#409777}.metric-icon.red{background:#fdf0f1;color:#c56b73}.metric-value{font-size:31px;font-weight:650;letter-spacing:-1px;margin:6px 0 8px}.metric-note{font-size:10px;color:#98a0ac;display:flex;align-items:center;gap:6px}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1fr) 315px;gap:21px}.stack{display:flex;flex-direction:column;gap:21px}.card{border:1px solid var(--line);background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);min-width:0;overflow:hidden}.card-header{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:21px 22px 18px}.card-header h2{font-size:13px;font-weight:650;margin:0;letter-spacing:-.15px;display:flex;gap:8px;align-items:center}.card-header p{font-size:10px;color:#8d96a2;margin:6px 0 0}.count-label{font-size:9px;background:#f0f2f6;color:#8893a3;padding:2px 6px;border-radius:4px}.card-content{padding:0 22px 23px}.card-footer{padding:13px 22px;border-top:1px solid #f0f2f5;background:#fcfcfd;font-size:10px;color:#8e98a6;display:flex;justify-content:space-between;gap:10px}.muted{color:var(--muted)}.small-text{font-size:11px;line-height:1.7}.table-wrap{overflow-x:auto;width:100%}table{border-collapse:collapse;width:100%;text-align:left;font-size:11px;white-space:nowrap}th{color:#8b96a6;font-weight:500;background:#fafbfc;border-top:1px solid #f0f2f5;border-bottom:1px solid #eef0f4;font-size:9px;letter-spacing:.3px;padding:12px 21px}td{padding:16px 21px;border-bottom:1px solid #f0f2f5;color:#6c788a}tr:last-child td{border-bottom:0}tbody tr:hover{background:#fdfdfd}td strong{font-weight:600;color:#36445a}.table-title{display:flex;align-items:center;gap:11px}.table-title .row-icon{width:30px;height:30px;background:#f3f5f8;border-radius:6px;display:grid;place-items:center;color:#7e8ea2}.row-icon svg{width:15px;height:15px}.table-title div:not(.row-icon){display:flex;flex-direction:column;gap:5px}.table-title small{font-size:9px;color:#97a0ad}.table-title a:hover strong{color:var(--orange)}.badge{display:inline-flex;align-items:center;gap:5px;font-size:9px;font-weight:500;border-radius:5px;padding:5px 7px;background:#f0f3f7;color:#7a8799;white-space:nowrap}.badge::before{content:"";width:4px;height:4px;border-radius:50%;background:currentColor}.badge.green{color:#33896c;background:#edf8f2}.badge.orange{color:#c77f35;background:#fff5e6}.badge.red{color:#c25961;background:#fdf0f1}.badge.blue{color:#5b83bd;background:#eff4fc}.tag{font-size:9px;color:#7e8a9e;border:1px solid #e6eaf0;border-radius:4px;padding:3px 6px;margin-right:4px;display:inline-block}.tag-list{display:flex;flex-wrap:wrap;gap:5px}.table-actions{display:flex;gap:6px;justify-content:flex-end}.empty-state{text-align:center;padding:37px 24px 40px}.empty-icon{width:46px;height:46px;display:grid;place-items:center;background:#f5f7fa;border:1px solid #e9edf3;color:#a0aabd;border-radius:12px;margin:0 auto 16px}.empty-icon svg{width:21px;height:21px}.empty-state h3{font-size:13px;font-weight:600;margin:0 0 8px;color:#526176}.empty-state p{font-size:11px;line-height:1.8;color:#929baa;max-width:320px;margin:0 auto 18px}.empty-state .button{font-size:10px}.activity-list{padding:0 22px 19px}.activity-item{display:flex;gap:11px;position:relative;padding:13px 0}.activity-item+.activity-item{border-top:1px solid #f1f3f6}.event-dot{height:27px;width:27px;border-radius:50%;background:#f1f4f8;color:#8494a9;display:grid;place-items:center;flex-shrink:0}.event-dot svg{width:12px;height:12px}.activity-copy{font-size:10px;line-height:1.65;word-break:break-word}.activity-copy strong{display:block;font-weight:500;color:#536277}.activity-copy p{margin:3px 0 0;color:#98a1af;font-size:9px}.intro-card{background:linear-gradient(135deg,#fff9f3,#fff);border-color:#f0e6dc}.intro-card .card-header{padding-bottom:12px}.intro-card .eyebrow{font-size:8px;color:#c08457}.intro-card h3{font-size:17px;letter-spacing:-.4px;margin:0 0 10px;line-height:1.5}.intro-card p{font-size:11px;line-height:1.8;color:#9b897b;margin:0 0 19px}.setup-steps{display:flex;flex-direction:column;gap:14px}.setup-step{display:flex;gap:11px;align-items:flex-start}.setup-number{width:23px;height:23px;border:1px solid #eddfd3;border-radius:6px;font-size:10px;display:grid;place-items:center;color:#b68c6a;flex-shrink:0;background:#fff}.setup-step strong{font-size:11px;font-weight:500;display:block;color:#887363;margin-top:3px}.setup-step small{display:block;color:#a7988c;font-size:9px;line-height:1.6;margin-top:3px}.setup-step.complete .setup-number{background:#eef7ef;border-color:#d4e8d5;color:#61916a}.toolbar{display:flex;justify-content:space-between;align-items:center;padding:17px 21px;gap:12px}.filter-group{display:flex;align-items:center;gap:8px}.search-field{display:flex;align-items:center;gap:8px;border:1px solid #e3e7ed;background:#fff;border-radius:6px;padding:0 10px;min-width:240px}.search-field svg{width:14px;height:14px;color:#98a3b2}.search-field input{border:0;background:none;box-shadow:none;padding:9px 0;width:100%;font-size:10px}.filter-select{font-size:10px!important;padding:8px 26px 8px 10px!important;min-height:33px;width:auto!important}.toolbar-info{font-size:10px;color:#9aa3b1}.alert{padding:13px 15px;border:1px solid #eedfbe;border-radius:7px;background:#fff9ed;color:#997b41;font-size:11px;line-height:1.8;display:block;margin-bottom:18px;overflow-wrap:anywhere}.alert-danger{border-color:#f0cbd0;background:#fff2f3;color:#b04d58}.alert-info{border-color:#dbe5f1;background:#f5f9fe;color:#67809b}.alert-success{background:#f0faf4;border-color:#d3e9db;color:#4e8464}.cards-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:19px}.profile-card{padding:22px;display:flex;flex-direction:column}.profile-top{display:flex;justify-content:space-between;margin-bottom:19px}.profile-symbol{width:37px;height:37px;border-radius:8px;display:grid;place-items:center;background:var(--orange-soft);color:var(--orange)}.profile-symbol.blue{background:#edf3fc;color:#6a8bbf}.profile-card h2{font-size:14px;word-break:break-word;margin:0 0 9px}.profile-card p{font-size:10px;line-height:1.7;color:#8d98a7;margin:0 0 18px}.profile-meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:20px;min-height:23px}.profile-actions{margin-top:auto;display:flex;gap:8px;border-top:1px solid #eff1f5;padding-top:17px}.profile-card code{font-size:9px;color:#8d97a5;display:block;margin-bottom:16px;overflow:hidden;text-overflow:ellipsis}.detail-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,1fr);gap:21px}.detail-list{display:grid;grid-template-columns:145px minmax(0,1fr);font-size:11px;line-height:1.6;gap:16px 14px;margin:0}.detail-list dt{color:#95a0af}.detail-list dd{margin:0;color:#566377;overflow-wrap:anywhere}.detail-list code{font-size:10px}.section-label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#9ca5b1;margin:24px 0 14px}.mono,code,pre,textarea.code{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace}.code-block{margin:0;border-radius:7px;padding:17px;background:#182232;color:#c9d3e2;white-space:pre-wrap;overflow-wrap:anywhere;font-size:10px;line-height:1.8;max-height:420px;overflow:auto}.code-block.light{background:#f5f7fa;color:#6a7b91;border:1px solid #e9edf4}.tabs{display:flex;gap:22px;border-bottom:1px solid var(--line);margin-bottom:23px}.tab{font-size:12px;border:0;border-bottom:2px solid transparent;padding:0 1px 14px;background:transparent;color:#8a94a3}.tab.active{color:var(--orange);border-color:var(--orange);font-weight:600}.progress-track{height:4px;border-radius:3px;background:#eef1f5;min-width:70px;max-width:100px;overflow:hidden;margin-top:6px}.progress-fill{height:100%;background:#7cab91;border-radius:3px}.run-steps{list-style:none;margin:0;padding:0 22px 18px}.run-step{display:flex;gap:13px;padding:14px 0;align-items:flex-start}.run-step+.run-step{border-top:1px solid #edf0f4}.step-number{display:grid;place-items:center;flex-shrink:0;border-radius:50%;border:1px solid #e3e8ef;background:#f7f8fa;color:#8c9aab;width:28px;height:28px;font-size:10px}.run-step-copy{flex:1}.run-step-copy strong{font-size:11px;font-weight:600}.run-step-copy p{margin:5px 0 0;font-size:10px;line-height:1.6;color:#99a2b0}.main-footer{display:flex;justify-content:space-between;gap:15px;padding:18px 36px;border-top:1px solid #e8ebf0;color:#a2aab6;font-size:9px}.main-footer .muted{padding:0 7px;color:#c1c7d0}dialog{width:min(740px,calc(100vw - 36px));padding:0;border:1px solid #e5e8ee;border-radius:13px;color:var(--ink);max-height:90vh;box-shadow:0 30px 100px #15223640}dialog::backdrop{background:#0e192f70;backdrop-filter:blur(3px)}.modal-header{padding:24px 27px 20px;display:flex;justify-content:space-between;align-items:flex-start;border-bottom:1px solid var(--line)}.modal-header .eyebrow{font-size:8px;margin-bottom:8px}.modal-header h2{margin:0;font-size:21px;letter-spacing:-.5px}#modal-body{padding:22px 27px 26px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.form-grid .full{grid-column:1/-1}label{display:flex;flex-direction:column;gap:8px;font-size:11px;font-weight:600;color:#58667a}input,select,textarea{border:1px solid #dfe4eb;border-radius:6px;background:#fff;color:#3d4b61;padding:10px 11px;width:100%;min-height:37px}input::placeholder,textarea::placeholder{color:#b0b7c3}textarea{resize:vertical;min-height:92px;line-height:1.7;font-size:11px}textarea.code{font-size:10px;min-height:180px}label small,.field-hint{font-weight:400;font-size:10px;color:#8d98a8;line-height:1.6}label.checkbox{flex-direction:row;align-items:flex-start;font-weight:400;line-height:1.8;gap:9px}label.checkbox input{width:14px;height:14px;min-height:0;flex-shrink:0;margin:3px 0 0;accent-color:var(--orange)}.form-section{margin:8px 0 -2px;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#9ca5b2;font-weight:600}.form-actions{display:flex;justify-content:flex-end;gap:9px;padding-top:21px;margin-top:23px;border-top:1px solid #edf0f4}.form-error:empty{display:none}.form-error{margin-top:17px;margin-bottom:0}.modal-summary{margin-bottom:20px}.modal-summary .detail-list{gap:9px 12px;font-size:10px;grid-template-columns:135px minmax(0,1fr)}details{margin-top:17px;font-size:11px}summary{cursor:pointer;font-weight:600;color:#7e8b9e;padding:7px 0}.toast-region{position:fixed;right:25px;bottom:24px;z-index:100;display:flex;flex-direction:column;gap:9px;max-width:min(440px,calc(100vw - 40px))}.toast{border:1px solid #d9e8df;background:#fff;color:#47715a;box-shadow:0 5px 30px #23334c22;padding:15px 18px;font-size:12px;line-height:1.6;border-radius:8px;animation:appear .2s ease}.toast.error{color:#b74f5c;border-color:#efccd2}.spinner{width:22px;height:22px;border:2px solid #eceff3;border-top-color:var(--orange);border-radius:50%;animation:spin .8s linear infinite;display:inline-block}.loading-screen{display:flex;align-items:center;justify-content:center;gap:12px;min-height:320px;color:#8f9baa;font-size:12px}.spin{animation:spin .8s linear infinite}.nowrap{white-space:nowrap}.break{overflow-wrap:anywhere}.empty-page{padding:65px 20px}.login-page{background:#fff}.login-layout{display:grid;grid-template-columns:1fr 1fr;min-height:100vh}.login-story{background:#192333;color:white;padding:52px 60px;display:flex;flex-direction:column;position:relative;overflow:hidden}.login-story::after{content:"";width:600px;height:600px;border:1px solid #34405477;border-radius:50%;position:absolute;left:46%;top:54%;box-shadow:0 0 0 60px #34405418,0 0 0 120px #34405410;pointer-events:none}.login-story .brand{padding:0}.login-intro{margin:auto 0;max-width:420px;padding:80px 0;z-index:1}.login-intro .eyebrow{font-size:10px;color:#df9169}.login-intro h1{font-size:58px;line-height:1.1;letter-spacing:-2.5px;font-weight:650;margin:22px 0}.login-intro>p{color:#98a6bb;line-height:1.9;font-size:14px;max-width:360px}.login-flow{display:flex;gap:27px;margin-top:45px;font-size:11px;color:#c87851}.login-flow strong{color:#b0bbcd;display:block;font-size:10px;font-weight:400;margin-top:9px}.login-footnote{font-size:10px;color:#8190a6;display:flex;gap:9px;align-items:center;z-index:1}.login-form-panel{display:flex;flex-direction:column;justify-content:center;padding:60px 40px;align-items:center;position:relative}.login-form{width:100%;max-width:340px}.login-emblem{width:42px;height:42px;display:grid;place-items:center;color:#d97643;background:#fff3eb;border:1px solid #fae5d7;border-radius:10px;font-size:24px;margin-bottom:31px}.login-form .eyebrow{font-size:9px;color:#a9aeb8}.login-form h2{font-size:24px;letter-spacing:-.7px;margin:0 0 13px}.login-form>p{font-size:12px;margin-bottom:30px;line-height:1.8}.login-form label{margin-bottom:20px}.login-form input{font-size:12px;padding:12px}.login-submit{width:100%;justify-content:space-between;padding:13px;margin-top:5px;font-size:12px}.login-form .login-help{font-size:10px;color:#a4abb5;text-align:center;margin-top:23px;line-height:1.8}.login-copyright{position:absolute;bottom:37px;display:flex;gap:20px;font-size:9px;color:#8992a1}.login-copyright span{color:#b0b7c1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes appear{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@media(min-width:1600px){#main{max-width:1500px;width:100%;margin:0 auto}.dashboard-grid{grid-template-columns:minmax(0,1fr) 350px}}@media(max-width:1250px){.sidebar{width:212px;padding-left:12px;padding-right:12px}.main-shell{margin-left:212px;width:calc(100% - 212px)}.topbar{padding:0 25px}#main{padding:29px 25px}.dashboard-grid{grid-template-columns:minmax(0,1fr) 280px}.metric{padding:17px}.cards-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.header-actions{padding-top:10px}.main-footer{padding:18px 25px}.main-footer>span:last-child{display:none}td,th{padding-left:16px;padding-right:16px}}@media(max-width:1050px){.dashboard-grid{grid-template-columns:1fr}.dashboard-grid>.stack:last-child{display:grid;grid-template-columns:1fr 1fr;align-items:start}.detail-grid{grid-template-columns:1fr}.metrics-grid{gap:11px}.metric-label{font-size:10px}.metric-icon{width:25px;height:25px}.metric{padding:15px}.metric-value{font-size:28px}.metric-note{font-size:9px}.connection{display:none}.login-story{padding:40px}.login-intro h1{font-size:46px}.login-flow{gap:20px}}@media(max-width:760px){.sidebar{transform:translateX(-100%);transition:transform .2s ease;width:240px;box-shadow:10px 0 40px #15213830}.sidebar.open{transform:translateX(0)}.main-shell{margin-left:0;width:100%}.mobile-menu{display:inline-flex;font-size:18px}.topbar{height:66px;padding:0 18px;gap:10px}.topbar-right{margin-left:auto;gap:8px}.breadcrumb{font-size:10px}.breadcrumb>span{padding:0 7px}.topbar-divider,.user-info{display:none}#main{padding:25px 18px}.page-header{flex-wrap:wrap;gap:8px;margin-bottom:22px}.page-header h1{font-size:24px}.page-header p{font-size:11px}.header-actions{padding-top:7px}.metrics-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.metric{padding:17px}.dashboard-grid>.stack:last-child{display:flex}.cards-grid{grid-template-columns:1fr}.toolbar{flex-wrap:wrap;padding:15px}.filter-group{flex-wrap:wrap;width:100%}.search-field{min-width:0;flex:1}.toolbar-info{display:none}.form-grid{grid-template-columns:1fr}.form-grid .full{grid-column:auto}.modal-header{padding:20px}#modal-body{padding:20px}.modal-header h2{font-size:19px}.detail-list{grid-template-columns:115px minmax(0,1fr);gap:14px 9px}.main-footer{padding:18px}.login-layout{grid-template-columns:1fr}.login-story{min-height:0;padding:25px 28px}.login-intro,.login-footnote{display:none}.login-form-panel{min-height:calc(100vh - 82px);padding:45px 28px 100px}.login-copyright{bottom:24px}.login-form{max-width:380px}.tabs{gap:17px}.tab{font-size:11px}.card-header{padding:19px}.card-content{padding:0 19px 21px}.header-actions .button{font-size:10px}.toast-region{right:18px;bottom:18px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} + +[hidden]{display:none!important}.space-top{margin-top:22px}.space-bottom{margin-bottom:20px}.form-divider{border:0;border-top:1px solid #edf0f4;margin:22px 0}textarea.token-value{min-height:90px}.run-progress{appearance:none;display:block;width:100px;height:4px;border:0;border-radius:3px;background:#eef1f5;margin-top:7px;overflow:hidden}.run-progress::-webkit-progress-bar{background:#eef1f5;border-radius:3px}.run-progress::-webkit-progress-value{background:#7cab91;border-radius:3px}.run-progress::-moz-progress-bar{background:#7cab91;border-radius:3px} + +.header-actions{flex-wrap:wrap;flex-shrink:1;max-width:100%}.modal-header h2{overflow-wrap:anywhere} + +.stack{min-width:0}@media(max-width:1050px){.dashboard-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:760px){.dashboard-grid>.stack:last-child{align-items:stretch}} + +.search-field{flex-direction:row;font-weight:400}@media(max-width:760px){.search-field{flex:1 0 100%}} + +.affiliation-notice{margin:0;color:#68758a;font-size:11px;line-height:1.7;overflow-wrap:break-word}.main-footer{flex-wrap:wrap}.main-footer>.affiliation-notice{flex-basis:100%}.login-form-panel>.affiliation-notice{width:100%;max-width:340px;text-align:center;margin-top:24px}@media(max-width:1050px){.main-footer>span:nth-of-type(2){display:none}}@media(max-width:760px){.login-form-panel>.affiliation-notice{max-width:380px}} diff --git a/provisioner/templates/affiliation_notice.html b/provisioner/templates/affiliation_notice.html new file mode 100644 index 0000000..df0cd44 --- /dev/null +++ b/provisioner/templates/affiliation_notice.html @@ -0,0 +1 @@ +

Proxmox AIS ist ein unabhängiges Projekt und steht in keiner Verbindung zur Proxmox Server Solutions GmbH oder den Entwicklern von Proxmox Virtual Environment.

diff --git a/provisioner/templates/index.html b/provisioner/templates/index.html new file mode 100644 index 0000000..1f66b90 --- /dev/null +++ b/provisioner/templates/index.html @@ -0,0 +1,45 @@ + + + + + + + Proxmox AIS · Provisionierung + + + + +
+ +
+
+ + +
Verbinden …
+
+
Konsole wird geladen …
+
Proxmox AIS / Automated Installation ServiceVersionierte Konfiguration. Nachvollziehbare Ausführung.{% include "affiliation_notice.html" %}
+
+
+ +
+ + diff --git a/provisioner/templates/login.html b/provisioner/templates/login.html new file mode 100644 index 0000000..c627a16 --- /dev/null +++ b/provisioner/templates/login.html @@ -0,0 +1,16 @@ + + + + + + + Anmelden · Proxmox AIS + + + +
+ + +
+ + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2f25ccb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools==84.0.0", "wheel==0.48.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "proxmox-ais-server" +version = "0.1.0" +description = "Controlled Proxmox automated installation and resumable post-installation" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi==0.141.1", + "pydantic==2.13.5", + "uvicorn==0.52.4", + "jinja2==3.1.6", + "python-multipart==0.0.32", + "cryptography==50.0.1", + "tomli-w==1.2.0", + "jsonschema==4.26.0", + "tzdata==2026.4", +] + +[project.optional-dependencies] +dev = ["pytest==9.1.1", "httpx==0.28.1"] + +[project.scripts] +proxmox-ais = "provisioner.cli:main" + +[tool.setuptools.packages.find] +include = ["provisioner*"] + +[tool.setuptools.package-data] +provisioner = ["templates/*.html", "static/*", "builtin_modules/*.sh", "builtin_modules/*.json"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/tests/container_smoke.py b/tests/container_smoke.py new file mode 100644 index 0000000..6a39f3b --- /dev/null +++ b/tests/container_smoke.py @@ -0,0 +1,100 @@ +"""Standalone container smoke: real TLS, login, persistence and restart. + +Run inside the application image. All data lives in a temporary directory; +no installer, runner or provisioning module is executed. +""" +from http.cookiejar import CookieJar +import json +import os +from pathlib import Path +import secrets +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +from provisioner import cli +from provisioner.config import Settings + + +def main(): + assert os.getuid() == 10001, "Container must use its unprivileged service account" + with tempfile.TemporaryDirectory(prefix="ais-smoke-") as directory: + root = Path(directory) + with socket.socket() as sock: + sock.bind(("127.0.0.1",0)) + port = sock.getsockname()[1] + base = f"https://localhost:{port}" + settings = Settings(data_dir=root / "data",master_key_file=root / "keys" / "master.key",public_url=base) + password = secrets.token_urlsafe(24) + cli.getpass.getpass = lambda _:password + cli.initialize(settings,"smoke-admin") + certificate,private = root / "tls.crt",root / "tls.key" + subprocess.run(["openssl","req","-x509","-newkey","ed25519","-nodes","-keyout",str(private),"-out",str(certificate),"-days","1","-subj","/CN=localhost","-addext","subjectAltName=DNS:localhost"],check=True,capture_output=True) + environment = {**os.environ,"DATA_DIR":str(settings.data_dir),"MASTER_KEY_FILE":str(settings.master_key_file),"PUBLIC_URL":base,"SECURE_COOKIES":"true","TESTING":"false"} + context = ssl.create_default_context(cafile=str(certificate)) + opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context),urllib.request.HTTPCookieProcessor(CookieJar())) + process = None + def start(): + child = subprocess.Popen([sys.executable,"-m","provisioner.cli","serve","--host","127.0.0.1","--port",str(port),"--tls-cert",str(certificate),"--tls-key",str(private)],env=environment,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + try: + for _ in range(60): + if child.poll() is not None: + raise AssertionError("Service exited during startup") + try: + with opener.open(base+"/health/ready",timeout=2) as response: + assert json.load(response)["status"] == "ready" + return child + except urllib.error.URLError: + time.sleep(0.1) + raise AssertionError("Service did not become ready") + except BaseException: + child.terminate() + child.wait(timeout=15) + raise + def request(path,payload=None,csrf=None): + headers = {} + body = None + if payload is not None: + body = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + if csrf: + headers["X-CSRF-Token"] = csrf + return opener.open(urllib.request.Request(base+path,data=body,headers=headers),timeout=5) + try: + process = start() + try: + urllib.request.urlopen(base+"/health/ready",timeout=3) + except urllib.error.URLError as error: + assert isinstance(error.reason,ssl.SSLCertVerificationError),repr(error) + else: + raise AssertionError("An untrusted TLS certificate was accepted") + form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode() + with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response: + assert response.status == 200 + assert b"/static/app.js" in response.read() + with request("/api/v1/me") as response: + csrf = json.load(response)["csrf_token"] + with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response: + host_id = json.load(response)["id"] + with opener.open(base+"/static/app.js",timeout=5) as response: + assert response.status == 200 and len(response.read())>1000 + process.terminate() + process.wait(timeout=15) + process = start() + with request("/api/v1/hosts") as response: + assert any(host["id"] == host_id for host in json.load(response)) + print(json.dumps({"uid":os.getuid(),"checks":["TLS trusted certificate","untrusted certificate rejected","browser login and CSRF","static assets packaged","host persists across service restart","session persists across service restart"],"status":"passed"})) + finally: + if process and process.poll() is None: + process.terminate() + process.wait(timeout=15) + + +if __name__ == "__main__": + main() diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py new file mode 100644 index 0000000..c09ca7e --- /dev/null +++ b/tests/test_acceptance.py @@ -0,0 +1,392 @@ +"""Acceptance tests exercise authorization and installation state through HTTP.""" + +import base64 +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from dataclasses import replace +import hashlib +import json +from pathlib import Path +import re +import time +import tomllib +from uuid import uuid4 + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient +import pytest + +from provisioner.app import create_app +from provisioner.cli import ServiceLock, backup, initialize, restore +from provisioner.config import Settings +from provisioner.db import Database +from provisioner.security import Security + + +PASSWORD = "Ais-test-admin-only-452!" +ROOT_HASH = "$6$testsalt$" + "A" * 86 +HOST_UUID = "d2e59b03-13cf-4ac9-a390-78c55f6a36d3" +HOST_MAC = "02:00:00:00:00:01" +SOURCE = '#!/bin/bash\nset -euo pipefail\ncase "$1" in\ncheck|apply|verify) exit 0;;\n*) exit 64;;\nesac\n' + + +def login(client, username="admin", password=PASSWORD): + response = client.post("/auth/login", data={"username": username, "password": password}, follow_redirects=False) + assert response.status_code == 303, response.text + response = client.get("/api/v1/me") + assert response.status_code == 200, response.text + return {"X-CSRF-Token": response.json()["csrf_token"]} + + +def post(client, path, data, csrf): + response = client.post(path, json=data, headers=csrf) + assert response.status_code in {200, 201}, f"{path}: {response.status_code} {response.text}" + return response.json() + + +@pytest.fixture +def environment(tmp_path): + settings = Settings( + data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key", + public_url="https://testserver", secure_cookies=False, bootstrap_username="admin", + bootstrap_password=PASSWORD, testing=True, four_eyes=False, + ) + app = create_app(settings) + with TestClient(app, base_url="https://testserver") as client: + csrf = login(client) + yield app, client, csrf + + +@pytest.fixture +def prepared(environment): + app, client, csrf = environment + secret = post(client, "/api/v1/secrets", {"name": "test-root", "value": ROOT_HASH}, csrf) + group = post(client, "/api/v1/groups", {"name": "test-lab", "site": "lab", "valid_hours": 1}, csrf) + iso = post(client, "/api/v1/iso-records", { + "name": "Simulated ISO; not a real hardware certification", "build": "9.1-1", "sha256": "1" * 64, + "assistant_version": "test-only", "fingerprint": "2" * 64, "group_id": group["id"], + "native_token_support": True, "test_status": "passed", "test_evidence": "Synthetic HTTP acceptance fixture", + }, csrf) + module = post(client, "/api/v1/modules", {"name": "test-verification", "source": SOURCE, + "target_builds": ["9.1-1"], "retry_safe": True}, csrf) + publication = {"test_evidence": "Synthetic HTTP acceptance fixture", "reason": "Testing publication"} + post(client, f"/api/v1/modules/{module['id']}/publish", publication, csrf) + profile_data = json.loads((Path(__file__).parents[1] / "docs" / "sample-profile.json").read_text()) + profile_data["values"]["root_secret_id"] = secret["id"] + installation = post(client, "/api/v1/profiles", profile_data, csrf) + post(client, f"/api/v1/profiles/{installation['id']}/publish", publication, csrf) + postinstall = post(client, "/api/v1/profiles", {"name": "test-postinstall", "kind": "postinstall", + "target_builds": ["9.1-1"], "steps": [{"id": "verify", "module_id": module["id"], "required": True}]}, csrf) + post(client, f"/api/v1/profiles/{postinstall['id']}/publish", publication, csrf) + identities = [{"kind": "uuid", "value": HOST_UUID}, {"kind": "serial", "value": "LAB-HOST-001"}, + {"kind": "mac", "value": HOST_MAC}] + host = post(client, "/api/v1/hosts", {"fqdn": "pve01.lab.example.net", "site": "lab", + "management_ip": "192.0.2.10/24", "identities": identities, + "installation_profile_id": installation["id"], "postinstall_profile_id": postinstall["id"], "iso_id": iso["id"]}, csrf) + run = post(client, f"/api/v1/hosts/{host['id']}/approve-install", { + "expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"], + "disks_confirmed": True, "reason": "Dedicated simulated test host"}, csrf) + payload = {"$schema": {"version": "1.0"}, "product": {"product": "pve"}, + "iso": {"release": "9.1", "build": "1"}, "dmi": {"system": {"uuid": HOST_UUID, "serial": "LAB-HOST-001"}}, + "network-interfaces": [{"mac": HOST_MAC}]} + return {"app": app, "client": client, "csrf": csrf, "group": group, "iso": iso, + "module": module, "installation": installation, "profile_data": profile_data, + "host": host, "run": run, "payload": payload, "identities": identities} + + +def answer(prepared, payload=None): + return prepared["client"].post("/installer/v1/answer", json=payload or prepared["payload"], + headers={"Authorization": f"Bearer {prepared['group']['token']}"}) + + +def enroll(prepared): + response = answer(prepared) + assert response.status_code == 200, response.text + config = tomllib.loads(response.text) + bootstrap = prepared["client"].get(config["first-boot"]["url"]) + assert bootstrap.status_code == 200 + encoded = re.search(r"config = base64.b64decode\('([^']+)'\)", bootstrap.text).group(1) + runtime = json.loads(base64.b64decode(encoded)) + key = Ed25519PrivateKey.generate() + public_key = base64.b64encode(key.public_key().public_bytes_raw()).decode() + registration = {"run_id": prepared["run"]["id"], "enrollment_secret": runtime["enrollment_secret"], + "public_key": public_key, "identities": prepared["identities"], "boot_id": "boot-test-1"} + response = prepared["client"].post("/agent/v1/enroll", json=registration) + assert response.status_code == 200, response.text + return key, public_key, registration + + +def signed(prepared, key, method, path, payload=None, headers=None): + body = b"" if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + timestamp, nonce = str(int(time.time())), uuid4().hex + message = f"{method}\n{path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode() + request_headers = {"X-Run-ID": prepared["run"]["id"], + "X-Device-Key": base64.b64encode(key.public_key().public_bytes_raw()).decode(), + "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(key.sign(message)).decode(), + "Content-Type": "application/json"} + request_headers.update(headers or {}) + return prepared["client"].request(method, path, content=body, headers=request_headers) + + +def test_authentication_csrf_and_reader_permissions(environment): + app, client, csrf = environment + anonymous = TestClient(app, base_url="https://testserver") + try: + assert anonymous.get("/api/v1/hosts").status_code == 401 + finally: + anonymous.close() + assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}).status_code == 403 + post(client, "/api/v1/users", {"username": "reader", "password": PASSWORD, "role": "reader"}, csrf) + reader_csrf = login(client, "reader") + assert client.get("/api/v1/hosts").status_code == 200 + assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}, headers=reader_csrf).status_code == 403 + assert client.get("/api/v1/secrets").status_code == 403 + + +def test_unknown_conflicting_and_blocked_hosts_never_receive_answer(prepared): + unknown = deepcopy(prepared["payload"]) + unknown["dmi"]["system"] = {"uuid": str(uuid4()), "serial": "UNKNOWN-HOST"} + unknown["network-interfaces"] = [{"mac": "02:00:00:00:ff:fe"}] + assert answer(prepared, unknown).status_code == 403 + contradictory = deepcopy(prepared["payload"]) + contradictory["dmi"]["system"]["uuid"] = str(uuid4()) + assert answer(prepared, contradictory).status_code == 409 + host = prepared["client"].get(f"/api/v1/hosts/{prepared['host']['id']}").json() + response = prepared["client"].patch(f"/api/v1/hosts/{host['id']}", json={"expected_version": host["version"], "blocked": True}, headers=prepared["csrf"]) + assert response.status_code == 200, response.text + assert answer(prepared).status_code == 403 + + +def test_expired_approval_and_wrong_group_cannot_install(prepared): + other = post(prepared["client"], "/api/v1/groups", {"name": "other-group", "site": "lab"}, prepared["csrf"]) + denied = prepared["client"].post("/installer/v1/answer", json=prepared["payload"], headers={"Authorization": f"Bearer {other['token']}"}) + assert denied.status_code == 403 + with prepared["app"].state.db.connection(write=True) as connection: + connection.execute("UPDATE approvals SET expires_at=?", (time.time() - 1,)) + assert answer(prepared).status_code == 410 + + +def test_author_cannot_publish_own_version_when_four_eyes_enabled(environment): + app, client, csrf = environment + app.state.settings.four_eyes = True + draft = post(client, "/api/v1/profiles", {"name": "self-publish-denied", "kind": "installation"}, csrf) + response = client.post(f"/api/v1/profiles/{draft['id']}/publish", json={ + "test_evidence": "A laboratory test report", "reason": "Self publication attempt"}, headers=csrf) + assert response.status_code == 403 + + +def test_concurrent_installer_retries_reserve_one_immutable_answer(prepared): + with ThreadPoolExecutor(max_workers=10) as executor: + responses = list(executor.map(lambda _: answer(prepared), range(10))) + assert {response.status_code for response in responses} == {200}, [r.text for r in responses] + assert len({response.text for response in responses}) == 1 + assert all(response.headers["cache-control"] == "no-store" for response in responses) + native = tomllib.loads(responses[0].text) + assert native["global"]["fqdn"] == prepared["host"]["fqdn"] + assert native["disk-setup"]["filter"] == {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"} + assert "expected_count" not in native["disk-setup"] + with prepared["app"].state.db.connection() as connection: + assert connection.execute("SELECT count(*) FROM runs").fetchone()[0] == 1 + assert connection.execute("SELECT status FROM approvals").fetchone()[0] == "consumed" + + +def test_new_profile_version_cannot_change_prepared_run(prepared): + original = prepared["run"]["snapshot"] + data = deepcopy(prepared["profile_data"]) + data["values"]["network"]["dns"] = "192.0.2.54" + version2 = post(prepared["client"], "/api/v1/profiles", data, prepared["csrf"]) + assert version2["version"] == prepared["installation"]["version"] + 1 + assert version2["id"] != prepared["installation"]["id"] + current = prepared["client"].get(f"/api/v1/runs/{prepared['run']['id']}").json() + assert current["snapshot"] == original + response = answer(prepared) + assert response.status_code == 200, response.text + assert tomllib.loads(response.text)["network"]["dns"] == "192.0.2.53" + + +def test_enrollment_signature_sequence_and_verified_completion(prepared): + key, _, registration = enroll(prepared) + run_id = prepared["run"]["id"] + assert answer(prepared).status_code in {403, 410} + # Retrying a lost enrollment response must not generate a second device identity. + assert prepared["client"].post("/agent/v1/enroll", json=registration).status_code == 200 + lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}) + assert lease.status_code == 200 and lease.json()["action"] == "run" + manifest = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest") + assert manifest.status_code == 200, manifest.text + wrong_key = Ed25519PrivateKey.generate() + assert signed(prepared, wrong_key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {401, 403} + premature = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}}) + assert premature.status_code == 409 + def event(sequence, kind, **extra): + return {"sequence": sequence, "type": kind, "boot_id": "boot-test-1", "step_id": "verify", + "occurred_at": "2026-09-13T12:00:00Z", **extra} + out_of_order = {"events": [event(2, "step.started")]} + assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", out_of_order).status_code == 409 + started = {"events": [event(1, "step.started")]} + assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200 + assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200 + false_success = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": False})]} + assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", false_success).status_code == 409 + succeeded = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": True})]} + response = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", succeeded) + assert response.status_code == 200, response.text + false_completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": False}}}) + assert false_completion.status_code == 409 + completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}}) + assert completion.status_code == 200, completion.text + status = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["status"] + assert status == "succeeded" + assert answer(prepared).status_code in {403, 410} + assert signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {403, 410} + + +def test_device_signature_replay_and_cross_run_access_are_denied(prepared): + key, _, _ = enroll(prepared) + run_id = prepared["run"]["id"] + assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200 + original = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest") + assert original.status_code == 200 + repeated = prepared["client"].request(original.request.method, original.request.url, content=original.request.content, headers=original.request.headers) + assert repeated.status_code == 409 + foreign_path = "/agent/v1/runs/another-run/manifest" + tampered = prepared["client"].get(foreign_path, headers=original.request.headers) + assert tampered.status_code == 401 + assert signed(prepared, key, "GET", foreign_path).status_code == 403 + + +def test_tampered_module_is_not_delivered_to_runner(prepared): + key, _, _ = enroll(prepared) + run_id = prepared["run"]["id"] + assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200 + checksum = prepared["module"]["digest"] + endpoint = f"/agent/v1/artifacts/{checksum}" + assert signed(prepared, key, "GET", endpoint).status_code == 200 + artifact = prepared["app"].state.settings.data_dir / "artifacts" / checksum + artifact.write_bytes(b"#!/bin/bash\nexit 99\n") + response = signed(prepared, key, "GET", endpoint) + assert response.status_code == 503 + assert "exit 99" not in response.text + + +def test_reconciliation_waits_for_issued_lease_and_requires_local_confirmation(prepared): + key, _, _ = enroll(prepared) + client, csrf = prepared["client"], prepared["csrf"] + run_id = prepared["run"]["id"] + lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}) + assert lease.status_code == 200 and lease.json()["action"] == "run" + issued_until = lease.json()["expires_at"] + current = client.get(f"/api/v1/runs/{run_id}").json() + cancelled = post(client, f"/api/v1/runs/{run_id}/cancel", { + "expected_version": current["version"], "reason": "Stop before checking local host state"}, csrf) + stop = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}) + assert stop.status_code == 200 and stop.json()["action"] == "stop" + with prepared["app"].state.db.connection(write=True) as connection: + assert connection.execute("SELECT lease_until FROM runs WHERE id=?", (run_id,)).fetchone()[0] == issued_until + connection.execute("UPDATE runs SET answer_until=? WHERE id=?", (time.time() - 1, run_id)) + request = {"expected_version": cancelled["version"], "reason": "Installer and runner stopped locally and checked", + "confirmation": prepared["host"]["fqdn"], "execution_stopped": True} + endpoint = f"/api/v1/runs/{run_id}/reconcile" + assert client.post(endpoint, json=request, headers=csrf).status_code == 409 + with prepared["app"].state.db.connection(write=True) as connection: + connection.execute("UPDATE runs SET lease_until=? WHERE id=?", (time.time() - 1, run_id)) + assert client.post(endpoint, json={**request, "confirmation": "wrong.lab.example.net"}, headers=csrf).status_code == 422 + assert client.post(endpoint, json={**request, "execution_stopped": False}, headers=csrf).status_code == 422 + reconciled = post(client, endpoint, request, csrf) + assert reconciled["status"] == "cancelled" + with prepared["app"].state.db.connection() as connection: + row = connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone() + assert row["device_key"] is None and row["enrollment_hash"] is None + assert connection.execute("SELECT status FROM approvals WHERE id=?", (row["approval_id"],)).fetchone()[0] == "revoked" + assert connection.execute("SELECT 1 FROM audit WHERE action='run.reconciled' AND object_id=?", (run_id,)).fetchone() + assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 401 + host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json() + next_run = post(client, f"/api/v1/hosts/{host['id']}/approve-install", { + "expected_version": host["version"], "valid_minutes": 30, "confirmation": host["fqdn"], + "disks_confirmed": True, "reason": "Explicit new simulation after verified local stop"}, csrf) + assert next_run["id"] != run_id and next_run["status"] == "prepared" + + +def test_sensitive_values_are_encrypted_and_absent_from_management(prepared): + response = answer(prepared) + assert response.status_code == 200, response.text + bootstrap_url = tomllib.loads(response.text)["first-boot"]["url"] + for endpoint in ("/api/v1/hosts", "/api/v1/profiles", "/api/v1/runs", "/api/v1/audit", "/api/v1/groups"): + management = prepared["client"].get(endpoint) + assert management.status_code == 200, management.text + assert ROOT_HASH not in management.text + assert prepared["group"]["token"] not in management.text + assert bootstrap_url not in management.text + with prepared["app"].state.db.connection() as connection: + row = connection.execute("SELECT * FROM runs").fetchone() + assert ROOT_HASH not in row["answer_ciphertext"] + assert ROOT_HASH in prepared["app"].state.security.decrypt(row["answer_ciphertext"]) + secret = connection.execute("SELECT ciphertext FROM secrets").fetchone()[0] + assert secret != ROOT_HASH + assert prepared["app"].state.security.decrypt(secret) == ROOT_HASH + + +def test_live_backup_offline_restore_revokes_all_active_credentials(prepared, tmp_path): + enroll(prepared) + settings = prepared["app"].state.settings + destination = tmp_path / "snapshot" + backup(settings, destination) + key_bytes = settings.master_key_file.read_bytes() + for path in destination.rglob("*"): + if path.is_file(): + assert key_bytes not in path.read_bytes() + restored = replace(settings, data_dir=tmp_path / "restored") + restore(restored, destination) + db = Database(restored) + with db.connection() as connection: + assert connection.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0 + assert connection.execute("SELECT count(*) FROM groups WHERE revoked=0").fetchone()[0] == 0 + row = connection.execute("SELECT * FROM runs").fetchone() + assert row["status"] == "needs_review" + assert all(row[name] is None for name in ("device_key", "bootstrap_hash", "enrollment_hash", "report_hash", "answer_ciphertext")) + assert Security(restored).decrypt(connection.execute("SELECT ciphertext FROM secrets").fetchone()[0]) == ROOT_HASH + assert list((restored.data_dir / "artifacts").iterdir()) + with pytest.raises((RuntimeError, ValueError), match="in use|empty"): + restore(settings, destination) + restored_app = create_app(restored) + with TestClient(restored_app, base_url="https://testserver") as restored_client: + restored_csrf = login(restored_client) + current = restored_client.get(f"/api/v1/runs/{prepared['run']['id']}").json() + reconciled = post(restored_client, f"/api/v1/runs/{current['id']}/reconcile", { + "expected_version": current["version"], "confirmation": prepared["host"]["fqdn"], + "execution_stopped": True, "reason": "Physical host state checked after restore"}, restored_csrf) + assert reconciled["status"] == "cancelled" + + +def test_init_creates_only_password_hash_and_separate_key(tmp_path, monkeypatch): + settings = Settings(data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key") + monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD) + initialize(settings, "admin") + with Database(settings).connection() as connection: + stored = connection.execute("SELECT password_hash FROM users").fetchone()[0] + assert PASSWORD not in stored + assert Security.verify_password(PASSWORD, stored) + assert settings.master_key_file.is_file() + with ServiceLock(settings.data_dir): + with pytest.raises(RuntimeError, match="in use"): + with ServiceLock(settings.data_dir): + pass + + +def test_restore_rejects_wrong_key_and_modified_backup(tmp_path, monkeypatch): + settings = Settings(data_dir=tmp_path / "original", master_key_file=tmp_path / "keys" / "master.key") + monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD) + initialize(settings, "admin") + destination = tmp_path / "backup" + backup(settings, destination) + wrong_key = tmp_path / "keys" / "wrong.key" + wrong_key.write_bytes(Fernet.generate_key()) + wrong_settings = replace(settings, data_dir=tmp_path / "wrong-restore", master_key_file=wrong_key) + with pytest.raises(ValueError, match="does not match"): + restore(wrong_settings, destination) + assert not wrong_settings.data_dir.exists() + config = destination / "settings.json" + config.write_text(config.read_text() + "\n", encoding="utf-8") + with pytest.raises(ValueError, match="checksum mismatch"): + restore(replace(settings, data_dir=tmp_path / "corrupt-restore"), destination) diff --git a/tests/test_ci.py b/tests/test_ci.py new file mode 100644 index 0000000..ad8cfe3 --- /dev/null +++ b/tests/test_ci.py @@ -0,0 +1,393 @@ +"""Exercise publication gates with a fake Docker CLI, never a real daemon.""" +import os +from pathlib import Path +import shutil +import subprocess +import tomllib +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] +SHA = "1234567890abcdef1234567890abcdef12345678" +REGISTRY_IMAGE = "ghcr.io/team/proxmox-ais" +DIGEST = f"{REGISTRY_IMAGE}@sha256:{'a' * 64}" +PASSWORD = "dummy-ci-token-$()-do-not-print" + + +def posix_shell(): + if os.name == "nt": + shell = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe" + if not shell.is_file(): + pytest.skip("CI shell tests need Git Bash on Windows") + else: + shell = shutil.which("sh") + if not shell: + pytest.skip("CI shell tests need a POSIX shell") + return str(shell) + + +@pytest.fixture +def container_ci(tmp_path): + shell = posix_shell() + (tmp_path / "ci").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "bin").mkdir() + (tmp_path / "tmp").mkdir() + shutil.copyfile(ROOT / "ci/container.sh", tmp_path / "ci/container.sh") + shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml") + smoke_source = (ROOT / "tests/container_smoke.py").read_bytes() + (tmp_path / "tests/container_smoke.py").write_bytes(smoke_source) + docker = tmp_path / "bin/docker" + docker.write_text( + """#!/bin/sh +set -eu +{ + printf '%s' "$1" + for argument in "$@"; do printf '\\t%s' "$argument"; done + printf '\\n' +} >> "$MOCK_DOCKER_LOG" +if [ "$1" = "${MOCK_FAIL_COMMAND:-}" ]; then exit 37; fi +case "$1" in + run) + cat > "$MOCK_SMOKE_STDIN" + exit "${MOCK_RUN_EXIT:-0}" + ;; + login) + cat > "$MOCK_LOGIN_STDIN" + printf '%s' "$DOCKER_CONFIG" > "$MOCK_DOCKER_CONFIG" + printf '{"auths":{"test":"dummy-credentials"}}' > "$DOCKER_CONFIG/config.json" + ;; + image) + printf '%s\\n' "$MOCK_REPO_DIGESTS" + ;; +esac +""", + encoding="utf-8", + newline="\n", + ) + docker.chmod(0o755) + environment = { + **os.environ, + "PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"], + "TMPDIR": (tmp_path / "tmp").as_posix(), + "GITHUB_SHA": SHA, + "GITHUB_RUN_ID": "210", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_JOB": "container_publish", + "GITHUB_REPOSITORY": "Team/Proxmox-AIS", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF_PROTECTED": "true", + "GITHUB_REF_TYPE": "branch", + "GITHUB_REF_NAME": "main", + "DEFAULT_BRANCH": "main", + "GITHUB_ACTOR": "ci-user", + "GHCR_TOKEN": PASSWORD, + "MOCK_DOCKER_LOG": (tmp_path / "docker.log").as_posix(), + "MOCK_SMOKE_STDIN": (tmp_path / "smoke.stdin").as_posix(), + "MOCK_LOGIN_STDIN": (tmp_path / "login.stdin").as_posix(), + "MOCK_DOCKER_CONFIG": (tmp_path / "docker-config.path").as_posix(), + "MOCK_REPO_DIGESTS": DIGEST, + } + + def run(mode="publish", **overrides): + effective_environment = {**environment, **overrides} + for name in [name for name, value in effective_environment.items() if value is None]: + del effective_environment[name] + result = subprocess.run( + [str(shell), "ci/container.sh", mode], + cwd=tmp_path, + env=effective_environment, + capture_output=True, + text=True, + timeout=30, + ) + log = tmp_path / "docker.log" + calls = [line.split("\t")[1:] for line in log.read_text().splitlines()] if log.exists() else [] + return SimpleNamespace(result=result, calls=calls, root=tmp_path, smoke_source=smoke_source) + + return run + + +def test_default_branch_publishes_only_after_hardened_image_smoke(container_ci): + outcome = container_ci() + assert outcome.result.returncode == 0, outcome.result.stderr + commands = [call[0] for call in outcome.calls] + assert commands == ["build", "run", "login", "tag", "push", "tag", "push", "image", "rm", "image"] + build_image = f"{REGISTRY_IMAGE}:ci-210-1-container_publish" + assert outcome.calls[-1] == ["image", "rm", build_image] + build = outcome.calls[0] + assert build[build.index("--platform") + 1] == "linux/amd64" + assert "--provenance=false" in build and "--sbom=false" in build + assert f"org.opencontainers.image.revision={SHA}" in build + assert "org.opencontainers.image.source=https://github.com/Team/Proxmox-AIS" in build + assert build[build.index("--tag") + 1] == build_image + smoke = outcome.calls[1] + for option in ["--read-only", "--cap-drop", "ALL", "no-new-privileges:true", "/tmp:rw,noexec,nosuid,size=128m"]: + assert option in smoke + assert smoke[smoke.index("--entrypoint") + 1] == "python" + assert smoke[smoke.index("--entrypoint") + 2] == build_image + assert all(call[1] == build_image for call in outcome.calls if call[0] == "tag") + assert "--volume" not in smoke and "-v" not in smoke + assert (outcome.root / "smoke.stdin").read_bytes() == outcome.smoke_source + assert [call[1] for call in outcome.calls if call[0] == "push"] == [ + f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:edge" + ] + assert (outcome.root / "deploy.env").read_text() == f"PROVISIONER_IMAGE={DIGEST}\n" + assert "ci-210-1-container_publish" in (outcome.root / "build.env").read_text() + assert outcome.calls[2] == ["login", "ghcr.io", "--username", "ci-user", "--password-stdin"] + assert (outcome.root / "login.stdin").read_text() == PASSWORD + assert PASSWORD not in outcome.result.stdout + outcome.result.stderr + (outcome.root / "docker.log").read_text() + config_path = Path((outcome.root / "docker-config.path").read_text()) + assert not config_path.exists(), "Temporary registry credentials must be removed" + + +def test_release_tag_matches_project_version_and_does_not_move_edge(container_ci): + outcome = container_ci(GITHUB_REF_TYPE="tag", GITHUB_REF_NAME=f"v{VERSION}") + assert outcome.result.returncode == 0, outcome.result.stderr + assert [call[1] for call in outcome.calls if call[0] == "push"] == [ + f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:{VERSION}" + ] + + +def test_pull_request_verifies_without_registry_credentials(container_ci): + outcome = container_ci( + "verify", GITHUB_EVENT_NAME="pull_request", GITHUB_REF_NAME="1/merge", + GITHUB_REF_PROTECTED="false", GITHUB_ACTOR=None, GHCR_TOKEN=None, + ) + assert outcome.result.returncode == 0, outcome.result.stderr + assert [call[0] for call in outcome.calls] == ["build", "run", "rm", "image"] + assert "--provenance=false" in outcome.calls[0] and "--sbom=false" in outcome.calls[0] + assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"] + assert (outcome.root / "build.env").is_file() + assert not (outcome.root / "deploy.env").exists() + + +@pytest.mark.parametrize("overrides", [ + {"GITHUB_REF_NAME": "feature/ci"}, + {"GITHUB_REF_NAME": f"v{VERSION}"}, + {"GITHUB_REF_NAME": None}, + {"DEFAULT_BRANCH": ""}, + {"GITHUB_REF_PROTECTED": "false"}, + {"GITHUB_REF_PROTECTED": None}, + {"GITHUB_REF_PROTECTED": "TRUE"}, + {"GITHUB_EVENT_NAME": "pull_request"}, + {"GITHUB_EVENT_NAME": "pull_request_target"}, + {"GITHUB_EVENT_NAME": "workflow_run"}, + {"GITHUB_EVENT_NAME": "schedule"}, + {"GITHUB_EVENT_NAME": "web"}, + {"GITHUB_EVENT_NAME": None}, + {"GITHUB_REF_TYPE": None}, + {"GITHUB_REF_TYPE": "pull_request"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "main"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v9999.9999.9999"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "nightly"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0-rc1"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v00.1.0"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.01.0"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.00"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0.0"}, + {"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": f"v{VERSION}", "GITHUB_REF_PROTECTED": "false"}, + {"GHCR_TOKEN": None}, + {"GITHUB_ACTOR": None}, + {"GITHUB_SHA": "1234"}, + {"GITHUB_RUN_ID": "invalid"}, + {"GITHUB_RUN_ATTEMPT": "../2"}, + {"GITHUB_RUN_ATTEMPT": None}, + {"GITHUB_JOB": "../invalid"}, + {"GITHUB_JOB": "has space"}, + {"GITHUB_JOB": "-invalid"}, + {"GITHUB_JOB": ""}, + {"GITHUB_REPOSITORY": "team"}, + {"GITHUB_REPOSITORY": "team/repo/extra"}, + {"GITHUB_REPOSITORY": "team/repo:tag"}, +]) +def test_unauthorized_or_invalid_publication_fails_before_build(container_ci, overrides): + outcome = container_ci(**overrides) + assert outcome.result.returncode != 0 + assert not outcome.calls + assert not (outcome.root / "deploy.env").exists() + + +@pytest.mark.parametrize("ref_type, ref_name", [("branch", "main"), ("tag", f"v{VERSION}")]) +def test_manual_run_can_publish_protected_default_branch_or_release(container_ci, ref_type, ref_name): + outcome = container_ci( + GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_TYPE=ref_type, GITHUB_REF_NAME=ref_name, + ) + assert outcome.result.returncode == 0, outcome.result.stderr + assert (outcome.root / "deploy.env").is_file() + + +def test_manual_feature_branch_cannot_publish(container_ci): + outcome = container_ci(GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_NAME="feature/ci") + assert outcome.result.returncode != 0 + assert not outcome.calls + + +@pytest.mark.parametrize("attempt, job", [("2", "container_publish"), ("1", "container-verify")]) +def test_container_names_include_run_attempt_and_job(container_ci, attempt, job): + outcome = container_ci("verify", GITHUB_RUN_ATTEMPT=attempt, GITHUB_JOB=job) + assert outcome.result.returncode == 0, outcome.result.stderr + build_image = f"{REGISTRY_IMAGE}:ci-210-{attempt}-{job}" + build, smoke = outcome.calls[:2] + assert build[build.index("--tag") + 1] == build_image + assert smoke[smoke.index("--name") + 1] == f"ais-smoke-210-{attempt}-{job}" + assert outcome.calls[-1] == ["image", "rm", build_image] + + +@pytest.mark.parametrize("overrides", [ + {"MOCK_FAIL_COMMAND": "build"}, + {"MOCK_RUN_EXIT": "19"}, +]) +def test_failed_build_or_smoke_never_logs_in_or_pushes(container_ci, overrides): + outcome = container_ci(**overrides) + assert outcome.result.returncode != 0 + assert not any(call[0] in {"login", "tag", "push"} for call in outcome.calls) + assert not (outcome.root / "build.env").exists() + assert not (outcome.root / "deploy.env").exists() + if "MOCK_RUN_EXIT" in overrides: + assert outcome.calls[-2] == ["rm", "--force", "ais-smoke-210-1-container_publish"] + assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"] + + +def test_push_failure_does_not_create_deployment_artifact_and_cleans_credentials(container_ci): + outcome = container_ci(MOCK_FAIL_COMMAND="push") + assert outcome.result.returncode != 0 + assert not (outcome.root / "deploy.env").exists() + assert not Path((outcome.root / "docker-config.path").read_text()).exists() + assert len([call for call in outcome.calls if call[0] == "push"]) == 1 + + +@pytest.mark.parametrize("digests", ["", "other.example.test/image@sha256:" + "a" * 64, REGISTRY_IMAGE + "@sha256:invalid"]) +def test_missing_or_invalid_registry_digest_fails_without_artifact(container_ci, digests): + outcome = container_ci(MOCK_REPO_DIGESTS=digests) + assert outcome.result.returncode != 0 + assert not (outcome.root / "deploy.env").exists() + + +@pytest.fixture +def python_ci(tmp_path): + shell = posix_shell() + (tmp_path / "ci").mkdir() + (tmp_path / "bin").mkdir() + shutil.copyfile(ROOT / "ci/python-tests.sh", tmp_path / "ci/python-tests.sh") + shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml") + venv_python = tmp_path / "venv-python" + venv_python.write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "$*" >> "$MOCK_PYTHON_LOG" +case "$2" in + pip) exit "${MOCK_PIP_EXIT:-0}" ;; + pytest) + printf '\\n' > reports/pytest.xml + exit "${MOCK_PYTEST_EXIT:-0}" + ;; + *) exit 65 ;; +esac +""", encoding="utf-8", newline="\n", + ) + python = tmp_path / "bin/python3" + python.write_text( + """#!/bin/sh +set -eu +case "$1" in + -c) exit "${MOCK_PYTHON_VERSION_EXIT:-0}" ;; + -m) + [ "$2" = venv ] + printf '%s' "$3" > "$MOCK_VENV_PATH" + mkdir -p "$3/bin" + cp "$MOCK_VENV_PYTHON" "$3/bin/python" + chmod +x "$3/bin/python" + ;; + *) exit 65 ;; +esac +""", encoding="utf-8", newline="\n", + ) + python.chmod(0o755) + for name in ["bash", "openssl", "ssh-keygen"]: + executable = tmp_path / "bin" / name + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n") + executable.chmod(0o755) + environment = { + **os.environ, + "PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"], + "GITHUB_WORKSPACE": tmp_path.as_posix(), + "GITHUB_RUN_ID": "543", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_JOB": "python_tests", + "MOCK_PYTHON_LOG": (tmp_path / "python.log").as_posix(), + "MOCK_VENV_PATH": (tmp_path / "venv.path").as_posix(), + "MOCK_VENV_PYTHON": venv_python.as_posix(), + } + + def run(missing_tool=None, **overrides): + effective_environment = {**environment, **overrides} + effective_environment = {name: value for name, value in effective_environment.items() if value is not None} + command = [shell, "ci/python-tests.sh"] + if missing_tool: + (tmp_path / "bin" / missing_tool).unlink() + # Git Bash adds its own tools to PATH on Windows startup. Limit + # PATH inside the shell to model a genuinely missing system tool. + command = [shell, "-c", 'PATH="$(pwd -P)/bin"\nexport PATH\n. ci/python-tests.sh'] + result = subprocess.run( + command, cwd=tmp_path, env=effective_environment, + capture_output=True, text=True, timeout=30, + ) + log = tmp_path / "python.log" + calls = log.read_text().splitlines() if log.exists() else [] + return SimpleNamespace(result=result, calls=calls, root=tmp_path) + + return run + + +@pytest.mark.parametrize("overrides, expected_code, expected_calls", [ + ({}, 0, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]), + ({"MOCK_PYTEST_EXIT": "1"}, 1, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]), + ({"MOCK_PIP_EXIT": "42"}, 42, ["-m pip install .[dev]"]), +]) +def test_python_job_isolates_dependencies_and_cleans_venv(python_ci, overrides, expected_code, expected_calls): + outcome = python_ci(**overrides) + assert outcome.result.returncode == expected_code, outcome.result.stderr + assert outcome.calls == expected_calls + assert (outcome.root / "venv.path").is_file() + assert ".venv-ci-543-1-python_tests." in (outcome.root / "venv.path").read_text() + assert not list(outcome.root.glob(".venv-ci-*")), "The job must clean its virtual environment even when tests or installation fail" + assert (outcome.root / "reports/pytest.xml").exists() == ("MOCK_PIP_EXIT" not in overrides) + + +@pytest.mark.parametrize("required_tool", ["python3", "bash", "openssl", "ssh-keygen"]) +def test_python_job_requires_system_tools_instead_of_skipping_tests(python_ci, required_tool): + outcome = python_ci(missing_tool=required_tool) + assert outcome.result.returncode != 0 + assert f"Required runner tool is missing: {required_tool}" in outcome.result.stderr + assert not outcome.calls + assert not list(outcome.root.glob(".venv-ci-*")) + + +@pytest.mark.parametrize("overrides", [ + {"GITHUB_RUN_ID": "../invalid"}, + {"GITHUB_RUN_ATTEMPT": "invalid"}, + {"GITHUB_RUN_ATTEMPT": None}, + {"GITHUB_JOB": "../invalid"}, + {"GITHUB_JOB": "has space"}, + {"GITHUB_JOB": "-invalid"}, + {"GITHUB_JOB": ""}, + {"MOCK_PYTHON_VERSION_EXIT": "1"}, +]) +def test_python_job_rejects_invalid_job_or_python_version_before_installing(python_ci, overrides): + outcome = python_ci(**overrides) + assert outcome.result.returncode != 0 + assert not outcome.calls + assert not list(outcome.root.glob(".venv-ci-*")) + + +def test_python_job_names_venv_for_the_run_attempt_and_job(python_ci): + outcome = python_ci(GITHUB_RUN_ATTEMPT="2", GITHUB_JOB="python-tests") + assert outcome.result.returncode == 0, outcome.result.stderr + assert ".venv-ci-543-2-python-tests." in (outcome.root / "venv.path").read_text() + assert not list(outcome.root.glob(".venv-ci-*")) diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..6536ce5 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,165 @@ +"""Actual Runner/API protocol with Ed25519; only target-side phases are mocked.""" +import base64 +from copy import deepcopy +import io +import json +import re +import urllib.error +import urllib.parse +from unittest.mock import Mock +import tomllib + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +import pytest + +from provisioner.runner import API, Runner, TransportError +from test_acceptance import environment, prepared, answer, post, enroll, signed + + +class Key: + def __init__(self): + self.key = Ed25519PrivateKey.generate() + self.public_key = base64.b64encode(self.key.public_key().public_bytes_raw()).decode() + + def sign(self, message): + return base64.b64encode(self.key.sign(message)).decode() + + +class TestOpener: + __test__ = False + + def __init__(self, client): + self.client = client + self.drop_complete = False + self.drop_cancel = False + + def open(self, request, timeout): + path = urllib.parse.urlsplit(request.full_url).path + result = self.client.request(request.method, path, content=request.data, headers=dict(request.header_items())) + if result.status_code >= 400: + raise urllib.error.HTTPError(request.full_url,result.status_code,result.text,result.headers,io.BytesIO(result.content)) + if self.drop_complete and path.endswith("/complete"): + self.drop_complete = False + raise TransportError("Simulated lost completion acknowledgement") + if self.drop_cancel and path.endswith("/events") and b"run.cancelled" in (request.data or b""): + self.drop_cancel = False + raise TransportError("Simulated lost cancellation acknowledgement") + return io.BytesIO(result.content) + + +def make_runner(prepared,tmp_path,monkeypatch): + response = answer(prepared) + assert response.status_code == 200,response.text + bootstrap = prepared["client"].get(tomllib.loads(response.text)["first-boot"]["url"]).text + encoded = re.search(r"config = base64.b64decode\('([^']+)'\)",bootstrap).group(1) + config = json.loads(base64.b64decode(encoded)) + key = Key() + api = API(config,key) + api.opener = TestOpener(prepared["client"]) + runner = Runner(config,tmp_path / "target",api) + runner.key = key + monkeypatch.setattr("provisioner.runner.discover_identities",lambda *args:prepared["identities"]) + return runner,api + + +@pytest.mark.parametrize("phases",[[0,0],[1,0,0]]) +def test_actual_protocol_completes_verified_check_or_apply(prepared,tmp_path,monkeypatch,phases): + runner,api = make_runner(prepared,tmp_path,monkeypatch) + runner.execute = Mock(side_effect=phases) + result = runner.run() + assert result == 0,runner.state + assert runner.state["status"] == "succeeded" + detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json() + assert detail["status"] == "succeeded" + assert detail["steps"][0]["status"] == "succeeded" + assert [item["type"] for item in detail["events"]] == ["step.started","step.succeeded"] + + +def test_runner_retries_lost_completion_without_running_scripts(prepared,tmp_path,monkeypatch): + runner,api = make_runner(prepared,tmp_path,monkeypatch) + runner.execute = Mock(return_value=0) + api.opener.drop_complete = True + assert runner.run() == 75,runner.state + assert runner.state["status"] == "completion_pending" + resumed = Runner(runner.config,runner.directory,api) + resumed.execute = Mock() + assert resumed.run() == 0,resumed.state + resumed.execute.assert_not_called() + + +def test_actual_reboot_resumes_same_verified_step(prepared,tmp_path,monkeypatch): + runner,api = make_runner(prepared,tmp_path,monkeypatch) + runner.execute = Mock(side_effect=[1,194]) + assert runner.run() == 194,runner.state + detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json() + assert detail["status"] == "reboot_pending" + resumed = Runner(runner.config,runner.directory,api) + resumed.boot_id = "new-boot-id" + resumed.execute = Mock(return_value=0) + assert resumed.run() == 0,resumed.state + assert [call.args[1] for call in resumed.execute.call_args_list] == ["check","verify"] + + +def test_failed_verification_requires_explicit_operator_resume(prepared,tmp_path,monkeypatch): + runner,api = make_runner(prepared,tmp_path,monkeypatch) + runner.execute = Mock(side_effect=[1,0,2]) + assert runner.run() == 75,runner.state + detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json() + assert detail["status"] == "needs_review" + waiting = Runner(runner.config,runner.directory,api) + waiting.execute = Mock() + assert waiting.run() == 75,waiting.state + waiting.execute.assert_not_called() + detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json() + post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/resume",{"expected_version":detail["version"],"reason":"Examined interrupted test step"},prepared["csrf"]) + resumed = Runner(runner.config,runner.directory,api) + resumed.execute = Mock(return_value=0) + assert resumed.run() == 0,resumed.state + + +def test_lost_cancellation_acknowledgement_is_idempotent(prepared,tmp_path,monkeypatch): + runner,api = make_runner(prepared,tmp_path,monkeypatch) + # Enrollment before cancellation, without executing any module. + api.request("POST","/agent/v1/enroll",{"run_id":runner.config["run_id"],"enrollment_secret":runner.config["enrollment_secret"],"public_key":runner.key.public_key,"identities":prepared["identities"],"boot_id":runner.boot_id},signed=False) + runner.state["enrolled"] = True + runner.save() + detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json() + post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/cancel",{"expected_version":detail["version"],"reason":"Cancel test at safe boundary"},prepared["csrf"]) + api.opener.drop_cancel = True + runner.execute = Mock() + assert runner.run() == 75,runner.state + resumed = Runner(runner.config,runner.directory,api) + resumed.execute = Mock() + assert resumed.run() == 0,resumed.state + resumed.execute.assert_not_called() + assert resumed.state["status"] == "cancelled" + + +def test_step_logs_redact_secrets_without_corrupting_json(prepared): + key,_,_ = enroll(prepared) + run_id = prepared["run"]["id"] + from test_acceptance import ROOT_HASH + result = signed(prepared,key,"POST",f"/agent/v1/runs/{run_id}/logs",{"chunks":[{"sequence":1,"step_id":"verify","text":f'password=abc\nquoted secret: "{ROOT_HASH}" token=xyz'}]}) + assert result.status_code == 200,result.text + logs = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["logs"] + assert ROOT_HASH not in json.dumps(logs) + assert "[REDACTED]" in logs[0]["text"] + + +def test_optional_failure_does_not_bypass_required_final_verification(prepared,tmp_path,monkeypatch): + client,csrf = prepared["client"],prepared["csrf"] + old = client.get(f"/api/v1/runs/{prepared['run']['id']}").json() + post(client,f"/api/v1/runs/{old['id']}/cancel",{"expected_version":old["version"],"reason":"Replace prepared test profile"},csrf) + profile = post(client,"/api/v1/profiles",{"name":"optional-then-required","kind":"postinstall","target_builds":["9.1-1"],"steps":[{"id":"optional","module_id":prepared["module"]["id"],"required":False},{"id":"final","module_id":prepared["module"]["id"],"required":True}]},csrf) + post(client,f"/api/v1/profiles/{profile['id']}/publish",{"test_evidence":"Synthetic optional failure protocol test","reason":"Test full runner protocol"},csrf) + host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json() + updated = client.patch(f"/api/v1/hosts/{host['id']}",json={"expected_version":host["version"],"postinstall_profile_id":profile["id"]},headers=csrf) + assert updated.status_code == 200,updated.text + host = updated.json() + prepared["run"] = post(client,f"/api/v1/hosts/{host['id']}/approve-install",{"expected_version":host["version"],"confirmation":host["fqdn"],"disks_confirmed":True,"reason":"Approve simulated optional failure run"},csrf) + runner,api = make_runner(prepared,tmp_path,monkeypatch) + runner.execute = Mock(side_effect=[2,0,0]) + assert runner.run() == 0,runner.state + detail = client.get(f"/api/v1/runs/{runner.config['run_id']}").json() + assert [(s["step_id"],s["status"]) for s in detail["steps"]] == [("optional","failed"),("final","succeeded")] + assert detail["status"] == "succeeded" diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..a3a71fa --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,396 @@ +"""Runner recovery tests use mocked phases: no provisioning code runs on this host.""" +import base64 +import ast +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +from unittest.mock import Mock + +import pytest + +from provisioner.bootstrap import render_bootstrap +from provisioner.builtin_modules import catalog +from provisioner.runner import API, DeviceKey, Halt, RebootRequested, Runner, TransportError, canonical_json, discover_identities + + +SOURCE = b"#!/bin/bash\nexit 0\n" +DIGEST = hashlib.sha256(SOURCE).hexdigest() + + +class FakeAPI: + def __init__(self, manifest): + self.manifest = manifest + self.artifact = SOURCE + self.events = [] + self.logs = [] + self.secrets = {"password": "secret-value-do-not-log"} + self.calls = [] + self.action = "run" + self.version = 1 + self.offline = False + self.drop_completion = False + + def request(self, method, path, payload=None, **kwargs): + self.calls.append((method, path, payload)) + if self.offline: + raise TransportError("offline") + if path.endswith("/lease"): + return {"action": self.action, "expires_at": time.time() + 900, "run_version": self.version} + if path.endswith("/manifest"): + return self.manifest + if "/artifacts/" in path: + return self.artifact + if "/secrets/" in path: + return self.secrets + if path.endswith("/events"): + self.events.extend(payload["events"]) + return {"ack_sequence": payload["events"][-1]["sequence"]} + if path.endswith("/logs"): + self.logs.extend(payload["chunks"]) + return {"ack_sequence": payload["chunks"][-1]["sequence"]} + if path.endswith("/complete"): + if self.drop_completion: + self.drop_completion = False + raise TransportError("completion response lost") + return {"status": "succeeded"} + raise AssertionError(path) + + +@pytest.fixture +def setup_runner(tmp_path): + step = {"id": "example", "name": "Example", "digest": DIGEST, "parameters": {}, + "timeout_seconds": 60, "retry_safe": False, "dependencies": [], "required": True} + manifest = {"run_id": "run-example", "steps": [step], "reboot_budget": 1} + config = {"api_url": "https://provision.example.test", "run_id": manifest["run_id"], + "enrollment_secret": "enrollment-only", "identities": [], + "manifest_digest": hashlib.sha256(canonical_json(manifest)).hexdigest()} + api = FakeAPI(manifest) + runner = Runner(config, tmp_path, api) + runner.state["enrolled"] = True + runner.save() + return runner, api, step + + +def test_digest_mismatch_never_executes(setup_runner): + runner, api, step = setup_runner + api.artifact = b"tampered" + runner.execute = Mock() + assert runner.run() == 75 + assert runner.state["status"] == "needs_review" + assert "digest mismatch" in runner.state["reason"] + runner.execute.assert_not_called() + + +def test_cached_artifact_is_revalidated_before_phase(setup_runner): + runner, api, step = setup_runner + path = runner.artifact(step) + path.write_bytes(b"corrupted cached content") + with pytest.raises(Halt, match="digest mismatch"): + runner.execute(step, "check", path, runner.directory / "unused-parameters.json") + + +def test_apply_checkpoint_is_durable_before_mutation(setup_runner): + runner, api, step = setup_runner + phases = [] + def execute(step, phase, artifact, parameters): + phases.append(phase) + if phase == "apply": + state = json.loads(runner.state_path.read_text()) + assert state["steps"][step["id"]]["status"] == "applying" + assert api.events[-1]["type"] == "step.started" + assert json.loads(parameters.read_text())["secrets"] == api.secrets + return 1 if phase == "check" else 0 + runner.execute = execute + assert runner.run() == 0 + assert phases == ["check", "apply", "verify"] + assert runner.state["status"] == "succeeded" + assert not (runner.directory / "step-parameters.json").exists() + assert "secret-value" not in runner.state_path.read_text() + + +def test_interrupted_non_repeatable_step_requires_review(setup_runner): + runner, api, step = setup_runner + runner.state["steps"]["example"] = {"status": "applying", "attempt": 1} + runner.execute = Mock(side_effect=[1, 1]) + runner.run() + assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"] + assert runner.state["status"] == "needs_review" + assert "cannot be repeated safely" in runner.state["reason"] + + +def test_interrupted_converged_step_is_verified_without_apply(setup_runner): + runner, api, step = setup_runner + runner.state["steps"]["example"] = {"status": "applying", "attempt": 1} + runner.execute = Mock(return_value=0) + runner.run() + assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"] + assert runner.state["status"] == "succeeded" + + +def test_retry_safe_interrupted_step_can_reapply(setup_runner): + runner, api, step = setup_runner + step["retry_safe"] = True + runner.state["steps"]["example"] = {"status": "applying", "attempt": 1} + runner.execute = Mock(side_effect=[1, 1, 0, 0]) + runner.run_step(step, 1) + assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify", "apply", "verify"] + assert runner.state["steps"]["example"]["status"] == "succeeded" + assert runner.state["steps"]["example"]["attempt"] == 2 + + +def test_success_exit_without_verification_is_not_success(setup_runner): + runner, api, step = setup_runner + runner.execute = Mock(side_effect=[1, 0, 2]) + runner.run() + assert runner.state["status"] == "needs_review" + assert runner.state["steps"]["example"]["status"] == "failed" + assert not any(path.endswith("/complete") for _, path, _ in api.calls) + + +def test_terminal_response_loss_retries_completion_without_module_execution(setup_runner): + runner, api, step = setup_runner + runner.execute = Mock(return_value=0) + api.drop_completion = True + assert runner.run() == 75 + assert runner.state["status"] == "completion_pending" + restarted = Runner(runner.config, runner.directory, api) + restarted.execute = Mock() + assert restarted.run() == 0 + restarted.execute.assert_not_called() + assert restarted.state["status"] == "succeeded" + assert len([path for _, path, _ in api.calls if path.endswith("/complete")]) == 2 + + +def test_event_queue_survives_network_loss_and_acknowledges(setup_runner): + runner, api, step = setup_runner + runner.event("step.started", "example") + api.offline = True + with pytest.raises(TransportError): + runner.flush() + restarted = Runner(runner.config, runner.directory, api) + assert restarted.state["events"][0]["sequence"] == 1 + api.offline = False + restarted.flush() + assert restarted.state["events"] == [] + assert api.events[0]["sequence"] == 1 + + +def test_reboot_checkpoint_waits_for_changed_boot_id(setup_runner): + runner, api, step = setup_runner + runner.execute = Mock(side_effect=[1, 194]) + assert runner.run() == 194 + assert runner.state["status"] == "reboot_pending" + restarted = Runner(runner.config, runner.directory, api) + restarted.execute = Mock(return_value=0) + assert restarted.run() == 194 + restarted.execute.assert_not_called() + restarted.boot_id = "next-boot" + assert restarted.run() == 0 + assert [call.args[1] for call in restarted.execute.call_args_list] == ["check", "verify"] + assert any(event["type"] == "run.resumed" for event in api.events) + + +def test_reboot_budget_cannot_be_exceeded(setup_runner): + runner, api, step = setup_runner + runner.execute = Mock(side_effect=[1, 194]) + runner.state["reboot_count"] = 1 + with pytest.raises(Halt, match="budget exhausted"): + runner.run_step(step, 1) + + +def test_review_requires_explicit_server_resume_version(setup_runner): + runner, api, step = setup_runner + runner.state.update(status="needs_review", halted_version=1, review_started_at=time.time()) + runner.execute = Mock(return_value=0) + assert runner.run() == 75 + runner.execute.assert_not_called() + api.version = 2 + assert runner.run() == 0 + assert runner.state["status"] == "succeeded" + + +def test_secret_redaction_happens_before_durable_log_write(setup_runner): + runner, api, step = setup_runner + runner.secret_values = ["super-secret", "secret"] + runner.log("example", "output super-secret and secret") + state = runner.state_path.read_text() + assert "super-secret" not in state + assert runner.state["logs"][0]["text"] == "output [REDACTED] and [REDACTED]" + + +def test_expired_lease_prevents_any_module_execution(setup_runner): + runner, api, step = setup_runner + api.action = "wait" + runner.execute = Mock() + assert runner.run() == 75 + runner.execute.assert_not_called() + + +def test_tls_cannot_be_disabled(setup_runner): + runner, api, step = setup_runner + with pytest.raises(Halt, match="HTTPS"): + API({**runner.config, "api_url": "http://example.test"}, Mock()) + with pytest.raises(ValueError, match="HTTPS"): + render_bootstrap({**runner.config, "api_url": "http://example.test"}) + + +def test_bootstrap_is_self_contained_persistent_and_bounded(setup_runner): + runner, api, step = setup_runner + result = render_bootstrap({**runner.config, "ca_pem": "TEST CA"}) + assert len(result.encode()) < 1024 * 1024 + assert result.index("persist(etc / 'config.json'") < result.index("systemctl enable --now") + embedded_python = result.split("<<'PVE_BOOTSTRAP_PY'\n", 1)[1].split("\nPVE_BOOTSTRAP_PY", 1)[0] + compile(embedded_python, "bootstrap-embedded", "exec") + assert "Restart=on-failure" in result + assert "trusted-ca.pem" in result + + +def test_module_drafts_have_compilable_embedded_python_and_no_release_claims(): + modules = catalog() + assert len(modules) == 8 + for module in modules: + assert module["status"] == "draft" + assert not module["test_evidence"] and not module["target_builds"] + python = module["source"].split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + compile(python, module["id"], "exec") + assert 'case "${1:-}" in check|apply|verify)' in module["source"] + + +def test_identity_binding_uses_observed_target_data(tmp_path): + dmi = tmp_path / "class/dmi/id" + dmi.mkdir(parents=True) + (dmi / "product_serial").write_text("SERIAL-123\n") + observed = discover_identities([{"kind": "serial", "value": "Serial-123"}], tmp_path) + assert observed == [{"kind": "serial", "value": "serial-123"}] + with pytest.raises(Halt, match="do not match"): + discover_identities([{"kind": "serial", "value": "different-host"}], tmp_path) + + +def test_logs_remain_bounded_with_contiguous_sequence(setup_runner): + runner, api, step = setup_runner + for index in range(12): + runner.log("example", "x" * 131072) + chunks = runner.state["logs"] + assert sum(len(chunk["text"].encode()) for chunk in chunks) <= 1024 * 1024 + assert all(len(chunk["text"]) <= 16384 for chunk in chunks) + assert [chunk["sequence"] for chunk in chunks] == list(range(1, len(chunks) + 1)) + runner.flush() + assert not runner.state["logs"] + + +def test_openssl_device_key_signatures_and_key_reuse(tmp_path, monkeypatch): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + git_bin = Path("C:/Program Files/Git/usr/bin") + if not shutil.which("openssl") and (git_bin / "openssl.exe").exists(): + monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"]) + if not shutil.which("openssl"): + pytest.skip("OpenSSL is unavailable on this test workstation") + key = DeviceKey(tmp_path) + key.ensure() + public_key = key.public_key + body = canonical_json({"run_id": "run-test"}) + message = f"POST\n/agent/v1/lease\n1234567890\nonce-123456789\n{hashlib.sha256(body).hexdigest()}".encode() + signature = base64.b64decode(key.sign(message)) + Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key)).verify(signature, message) + key.ensure() + assert key.public_key == public_key + if os.name == "posix": + assert key.path.stat().st_mode & 0o777 == 0o600 + + +def test_bash_syntax_without_executing_provisioning_modules(setup_runner): + runner, api, step = setup_runner + git_bash = Path("C:/Program Files/Git/usr/bin/bash.exe") + bash = str(git_bash) if git_bash.exists() else shutil.which("bash") + if not bash: + pytest.skip("Bash parser unavailable") + sources = [module["source"] for module in catalog()] + [render_bootstrap(runner.config)] + for source in sources: + result = subprocess.run([bash, "-n"], input=source, text=True, capture_output=True, timeout=15) + assert result.returncode == 0, result.stderr + + +def module_helper(module_id, function_name, namespace=None): + """Load one pure/helper function without executing the module's host actions.""" + source = next(module["source"] for module in catalog() if module["id"] == module_id) + python = source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + function = next(node for node in ast.parse(python).body if isinstance(node, ast.FunctionDef) and node.name == function_name) + scope = {} if namespace is None else namespace + exec(compile(ast.Module(body=[function], type_ignores=[]), module_id, "exec"), scope) + return scope[function_name] + + +def test_ssh_rejects_truncated_public_key_before_writing_accounts(monkeypatch): + git_bin = Path("C:/Program Files/Git/usr/bin") + if not shutil.which("ssh-keygen") and (git_bin / "ssh-keygen.exe").exists(): + monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"]) + if not shutil.which("ssh-keygen"): + pytest.skip("OpenSSH public-key validator unavailable") + validate = module_helper("ssh", "validate_public_key", {"base64": base64, "os": os, + "subprocess": subprocess, "tempfile": tempfile}) + malformed = base64.b64encode((11).to_bytes(4, "big") + b"ssh-ed25519" + b"x").decode() + with pytest.raises(SystemExit, match="OpenSSH rejected"): + validate("ssh-ed25519 " + malformed) + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + valid = Ed25519PrivateKey.generate().public_key().public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode() + assert validate(valid) is None + + +def test_repository_verification_rejects_stale_other_suite_indexes(): + verify = module_helper("repositories", "has_repository_indexes") + policy = " 500 https://repo.example.test/debian bookworm/main amd64 Packages\n" + assert verify(policy, "https://repo.example.test/debian", "bookworm", ["main"]) + assert not verify(policy, "https://repo.example.test/debian", "trixie", ["main"]) + assert not verify(policy, "https://repo.example.test/debian", "bookworm", ["main", "contrib"]) + assert not verify(policy, "https://repo.example.test/deb", "bookworm", ["main"]) + + +def test_pending_reboot_waits_for_permission(setup_runner): + runner, api, step = setup_runner + runner.state.update(status="reboot_pending", reboot_boot_id=runner.boot_id) + api.action = "wait" + assert runner.run() == 75 + assert runner.state["status"] == "reboot_pending" + + +def test_review_deadline_stops_even_when_authorization_is_unavailable(setup_runner): + runner, api, step = setup_runner + runner.state.update(status="needs_review", review_started_at=time.time() - 86401) + api.offline = True + assert runner.run() == 0 + assert api.calls == [] + + +@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision") +def test_native_phase_obeys_shared_timeout(setup_runner): + runner, api, step = setup_runner + api.artifact = b"#!/bin/bash\nprintf 'timeout-probe\\n'\nsleep 30\n" + step["digest"] = hashlib.sha256(api.artifact).hexdigest() + runner.heartbeat = Mock() + artifact = runner.artifact(step) + runner.step_deadline = time.monotonic() + 1 + before = time.monotonic() + result = runner.execute(step, "apply", artifact, runner.directory / "unused.json") + assert result == 124 + assert time.monotonic() - before < 5 + assert "timeout-probe" in runner.state["logs"][0]["text"] + + +@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision") +def test_native_output_redacts_secret_crossing_capture_boundary(setup_runner): + runner, api, step = setup_runner + secret = "sensitive-value-across-output-boundary" + payload = "x" * (131072 - 5) + secret + api.artifact = ("#!/bin/bash\nprintf '%s' '" + payload + "'\n").encode() + step["digest"] = hashlib.sha256(api.artifact).hexdigest() + runner.secret_values = [secret] + runner.heartbeat = Mock() + result = runner.execute(step, "check", runner.artifact(step), runner.directory / "unused.json") + assert result == 0 + assert "sensi" not in "".join(chunk["text"] for chunk in runner.state["logs"])