Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b37329c92 | ||
|
|
7beeafd2f6 | ||
|
|
9c4dd6da3d | ||
|
|
5379f34a0c | ||
|
|
533a821087 | ||
|
|
7f07efe05d | ||
|
|
e21ac97dbb | ||
|
|
6d6b0ff121 | ||
|
|
99357d59bd | ||
|
|
7f6cf00a0e | ||
|
|
8dca30e254 |
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
paket
|
||||
proxdeploy-*.tar.gz
|
||||
cli/bin
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
src/generated
|
||||
@@ -1,7 +1,11 @@
|
||||
# ── Datenbank ────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||
POSTGRES_PASSWORT=
|
||||
# Nur für Tests; wird vor jedem Test geleert.
|
||||
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test
|
||||
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||
# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst).
|
||||
POSTGRES_PASSWORT=_test
|
||||
|
||||
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
||||
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
||||
@@ -22,12 +26,15 @@ ADMIN_BENUTZER=admin
|
||||
ADMIN_PASSWORT=
|
||||
|
||||
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
||||
# Beim IdP als Redirect-URI eintragen: <APP_URL>/api/v1/auth/oidc/rueckruf
|
||||
# Client-Typ „confidential“, Scopes openid profile email; der Gruppen-Claim muss im ID-Token stehen.
|
||||
OIDC_AKTIV=false
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_ANZEIGENAME=SSO
|
||||
# Gruppen-Claim und Gruppennamen für die Rollen.
|
||||
# Gruppen-Claim und Gruppennamen für die Rollen. Wer in keiner der beiden Gruppen ist, kommt nicht
|
||||
# hinein. Beide leer = jeder beim IdP angemeldete Benutzer wird Operator.
|
||||
OIDC_GRUPPEN_CLAIM=groups
|
||||
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||
|
||||
@@ -13,3 +13,5 @@ cli/bin/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.DS_Store
|
||||
paket/
|
||||
proxdeploy-*.tar.gz
|
||||
|
||||
@@ -10,3 +10,4 @@ cli/
|
||||
catalog/
|
||||
test-results
|
||||
public/brand
|
||||
paket
|
||||
|
||||
@@ -17,6 +17,44 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.6.1] – 2026-10-12
|
||||
|
||||
### Behoben
|
||||
- Debian-12-Templates bekamen keine cloud-init-Konfiguration: das cloud-init-Laufwerk hing an ide2 (SATA bei q35), den der Debian-12-Cloud-Kernel nicht kennt. Neue Templates nutzen scsi1 (#021)
|
||||
|
||||
## [0.6.0] – 2026-10-12
|
||||
|
||||
### Hinzugefügt
|
||||
- Betrieb per Docker Compose (postgres, migrate, app, worker) mit einem Image für alles, non-root (#019)
|
||||
- LXC-Installer `deploy/install-lxc.sh`: legt auf dem Proxmox-Host einen Debian-13-Container an, installiert Node.js und PostgreSQL, richtet systemd-Dienste ein; mit Update-Pfad (#019)
|
||||
- Migrationen im Betrieb ohne Prisma-CLI, kompatibel zu Prisma (#019)
|
||||
- Übersicht zeigt Nodes, CPUs und freien RAM je Server (#018)
|
||||
- README mit Banner, Screenshots, Installation, ersten Schritten und Katalog-Anleitungen (#018)
|
||||
- Marken-Dateien nach Vorgabe unter `public/brand/`: Symbol (dunkel/hell), kleines Icon, Avatar, Logo (dunkel/hell), Banner 1280×640, Favicons, Apple-Touch- und Manifest-Icons; erzeugt mit `pnpm marke` (#017)
|
||||
- Web-App-Manifest; Favicon passt sich hellem und dunklem Browser an (#017)
|
||||
|
||||
### Behoben
|
||||
- Die Übersicht zeigte statt der letzten Deployments nur einen Platzhalter (#018)
|
||||
|
||||
## [0.5.0] – 2026-10-12
|
||||
|
||||
### Hinzugefügt
|
||||
- Anmeldung per OIDC (z. B. authentik): PKCE, State und Nonce, Rolle aus dem Gruppen-Claim bei jeder Anmeldung, neue Konten beim ersten Anmelden, Verknüpfen eines lokalen Kontos aus den Einstellungen (#016)
|
||||
- Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015)
|
||||
- Eigenes Passwort in den Einstellungen ändern (#015)
|
||||
- Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015)
|
||||
- Verlauf filterbar nach Server, Typ, Distribution und Status, mit Seitenblättern; die Filter stehen in der URL. Auch per API und `proxdeploy history` (#014)
|
||||
- „Wiederholen“: der Assistent übernimmt alle Werte eines früheren Auftrags, Name, VMID und feste IP gibt man neu an (#014)
|
||||
- „Aufräumen“ nach Fehlschlag oder Abbruch: löscht nach Bestätigung den halb angelegten Gast – nur, wenn seine Beschreibung auf den Auftrag verweist; Templates nie. Web, API und `proxdeploy cleanup` (#014)
|
||||
|
||||
### Behoben
|
||||
- „Einstellungen“ fehlte in der Navigation (#015)
|
||||
- Erzeugte SSH-Schlüssel (je Server und Bereitstellungsschlüssel) waren in etwa 4 von 1000 Fällen für ssh2 selbst nicht lesbar; jetzt wird geprüft und neu erzeugt (#014)
|
||||
|
||||
### Sicherheit
|
||||
- SSO übernimmt nie ein lokales Konto über den Benutzernamen; Verknüpfen geht nur angemeldet (#016)
|
||||
- Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
|
||||
|
||||
## [0.4.0] – 2026-10-12
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 5 h 20 min (Sitzung 1–3)
|
||||
**Gesamt:** 8 h 14 min (Sitzung 1–4)
|
||||
|
||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||
|
||||
@@ -22,21 +22,46 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
| #012 Vorlagen | 0 h 11 min |
|
||||
| #022 Profile im Template | 0 h 22 min |
|
||||
| #015 (Teil) SSH-Schlüssel | 0 h 10 min |
|
||||
| #021 Template-Bau ohne SSH | 0 h 19 min |
|
||||
| #021 Template-Bau ohne SSH | 0 h 19 min + 0 h 35 min Abnahme |
|
||||
| #013 CLI und API-Tokens | 0 h 42 min |
|
||||
| #014 Verlauf, Wiederholen, Aufräumen | 1 h 21 min |
|
||||
| #015 Benutzer, Passwort, Audit-Log | 0 h 10 min |
|
||||
| #016 OIDC | 0 h 08 min |
|
||||
| #017 Marken-Dateien | 0 h 17 min |
|
||||
| #018 README | 0 h 02 min (parallel zu #019) |
|
||||
| #019 Docker und LXC-Installer | 0 h 20 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||
| v0.2.0 | 1 h 32 min | **abgeschlossen**
|
||||
| v0.3.0 | 1 h 48 min | **abgeschlossen** (Abnahme #021 offen)
|
||||
| v0.3.0 | 1 h 48 min | **abgeschlossen**
|
||||
| v0.4.0 | 0 h 42 min | **abgeschlossen**
|
||||
| v0.5.0 | 1 h 39 min | **abgeschlossen**
|
||||
| v0.6.0 | 0 h 40 min | **abgeschlossen**
|
||||
| v0.6.1 | 0 h 35 min | **abgeschlossen**
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 · Sitzung 3 · 14:39–15:21
|
||||
**Issue:** #013
|
||||
**Version:** 0.4.0
|
||||
## 2026-10-12 · Sitzung 4 · 19:50–20:26
|
||||
**Issue:** #021 (Abnahme), Vorüberlegung #020
|
||||
**Version:** 0.6.1
|
||||
|
||||
### Erledigt
|
||||
- Freie Bau-Adresse gesucht (Ping/ARP-Probe vom Testhost, .150–.199 stumm) → 10.0.10.180
|
||||
- #021 abgenommen: Template 9003 (Debian 12) ohne Node-SSH über die Bau-Adresse gebaut, daraus VM 111 `pd-deb12` ohne Node-SSH ausgerollt
|
||||
- Dabei gefunden und behoben: cloud-init-Laufwerk an `ide2` wird vom Debian-12-Cloud-Kernel nicht gesehen → `scsi1`
|
||||
- Fehlgeschlagenen ersten Bau mit `proxdeploy cleanup` aufgeräumt (VM 9003 gestoppt und gelöscht)
|
||||
|
||||
### Entscheidungen
|
||||
- **cloud-init-Laufwerk an virtio-SCSI** für neue Templates; vorhandene (9000–9002) bleiben – deren Kernel kennen SATA.
|
||||
- **Feldtest 8.x (#020) nicht als verschachtelte VM auf pve-demo:** 7 GB RAM, VMs liefen dort auf dritter Virtualisierungsebene; braucht einen echten 8.x-Host.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 · Sitzung 3 · 14:39–17:40
|
||||
**Issue:** #013–#019
|
||||
**Version:** 0.4.0, 0.5.0, 0.6.0
|
||||
|
||||
### Erledigt
|
||||
- v0.3.0 freigegeben (Abnahme #021 bleibt offen, braucht eine freie IP)
|
||||
@@ -47,11 +72,22 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
- Abnahme auf pve-demo per `batch`: pd-cli01 (VM 107, Debian 13 aus Template 9002, docker läuft) und pd-cli02 (CT 108, Ubuntu 24.04) parallel; das Verfolgen überstand einen App-Neustart mitten im Lauf
|
||||
- 267 Vitest, 20 Playwright-Abläufe, Go-Tests grün
|
||||
|
||||
- Etappe 5 (v0.5.0): Verlauf mit Filtern in der URL, Wiederholen, Aufräumen (auf pve-demo: CT 109 nach Abbruch gelöscht, Original „aufgeräumt“); Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht; OIDC gegen Mock-IdP
|
||||
- CLI: `history` und `cleanup`
|
||||
- Gefunden: ssh2 erzeugt in ~0,4 % unlesbare ed25519-Schlüssel (war die Ursache des wackligen SSH-Tests) – behoben; „Einstellungen“ fehlte seit v0.3.0 in der Navigation
|
||||
- 308 Vitest, 26 Playwright-Abläufe grün
|
||||
- Etappe 6 (v0.6.0): Marken-Dateien per Skript (Texte als Pfade), README mit Banner und Screenshots, Docker Compose (lokal hochgefahren) und LXC-Installer – auf pve-demo in CT 109 installiert, darin Server angelegt, CLI-Login, Dry-Run und ein echter LXC (pd-abnahme, CT 110); Update-Pfad geprüft
|
||||
- Gefunden: die Übersicht war seit Etappe 1 ein Platzhalter; opentype.js schreibt gelegentlich „NaN“ in Pfade
|
||||
|
||||
### Entscheidungen
|
||||
- **Anfragen als Map, Antworten typisiert:** Go-Structs aus oapi-codegen schickten leere Pflichtfelder und überschrieben Vorlagenwerte.
|
||||
- **`[instances.<name>]`** in der Konfiguration statt `profiles` – „profile“ ist schon `--profile`.
|
||||
- **Strg+C hängt nur ab**, Abbruch nur mit `proxdeploy cancel`.
|
||||
- **Ohne Terminal zeilenweise** (je Phase eine Zeile), damit CI-Logs lesbar bleiben.
|
||||
- **Aufräumen löscht nur, was die Beschreibung „(Auftrag <kurzId>)“ trägt** – deshalb setzt das Klonen die Beschreibung jetzt sofort; Templates nie.
|
||||
- **Sitzungen beenden über `Benutzer.sitzungenAb`** statt einer Sitzungstabelle.
|
||||
- **SSO übernimmt nie ein lokales Konto über den Namen**; die Rolle kommt bei jeder Anmeldung aus den Gruppen.
|
||||
- **Migrationen im Betrieb ohne Prisma-CLI**, kompatibel zu `_prisma_migrations`; ein gemeinsames Laufzeit-Paket für Docker und LXC; der worker bekommt nur die Pakete, die er importiert (103 statt 741 MB).
|
||||
- Gefunden durch Tests: SSE-Daten zeigten in einen wiederverwendeten Puffer (jetzt kopiert); mehrzeilige Proxmox-Parameter brachen die Dry-Run-Zeile (jetzt `⏎`).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ProxDeploy – ein Image für app, worker und migrate. Gebaut wird mit scripts/paket-bauen.sh,
|
||||
# derselben Struktur wie im LXC-Installer.
|
||||
#
|
||||
# docker build --build-arg VERSION=0.6.0 -t proxdeploy:0.6.0 .
|
||||
|
||||
FROM node:24.21.0-trixie-slim AS bau
|
||||
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH CI=true
|
||||
RUN corepack enable && corepack prepare pnpm@12.6.0 --activate
|
||||
WORKDIR /quelle
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ARG VERSION
|
||||
# Ohne .git im Kontext gibt es keinen Commit; dann steht nur die Version da.
|
||||
RUN scripts/paket-bauen.sh "${VERSION:-$(node -p 'require("./package.json").version')}"
|
||||
|
||||
FROM node:24.21.0-trixie-slim
|
||||
LABEL org.opencontainers.image.title="ProxDeploy" \
|
||||
org.opencontainers.image.description="VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." \
|
||||
org.opencontainers.image.source="https://git.wilde-server.de/florian.wilde/proxdeploy"
|
||||
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /opt/proxdeploy
|
||||
COPY --from=bau --chown=root:root /quelle/paket ./
|
||||
# Läuft als „node“ (UID 1000), nie als root; der Code gehört root und ist nicht beschreibbar.
|
||||
USER node
|
||||
WORKDIR /opt/proxdeploy/app
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/v1/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
# app; worker und migrate setzen ihr Kommando in docker-compose.yml.
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,28 +1,80 @@
|
||||
# ProxDeploy
|
||||
<p align="center"><img src="public/brand/banner.svg" alt="ProxDeploy – VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." width="100%"></p>
|
||||
|
||||
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
|
||||
**ProxDeploy** ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man
|
||||
einen von mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig
|
||||
unattended eine VM oder einen LXC-Container mit Debian oder Ubuntu aus – mit Profilen wie `base`,
|
||||
`docker` oder `zabbix-agent`. Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
||||
|
||||
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
|
||||
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
|
||||
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
|
||||
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
||||
|
||||
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
|
||||
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von der Proxmox Server Solutions GmbH.
|
||||
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
||||
|
||||
## Stand
|
||||
<p align="center"><img src="docs/bilder/live-ansicht.png" alt="Live-Ansicht eines Deployments: Phasen mit Status, Abschlussbox mit IP und SSH-Befehl" width="100%"></p>
|
||||
|
||||
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
||||
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
||||
## Was es kann
|
||||
|
||||
| Etappe | Inhalt | Stand |
|
||||
- **Assistent in neun Schritten** – Server, Typ, Distribution, Name, Ressourcen, Netzwerk (DHCP/statisch,
|
||||
VLAN), Zugang (SSH-Schlüssel), Profile mit Variablen – und am Ende ein **Dry-Run** mit allen geplanten
|
||||
Proxmox-Aufrufen und dem passenden CLI-Befehl.
|
||||
- **Live-Fortschritt** je Phase (Server-Sent Events), in Web und CLI mit denselben Texten; bei Fehlern
|
||||
Ursache und Lösungshinweis, danach auf Wunsch **Aufräumen** des halb angelegten Gasts.
|
||||
- **Templates** aus offiziellen Cloud-Images (Prüfsumme geprüft, UEFI, Guest-Agent eingebaut), auf
|
||||
Wunsch mit eingebackenen Profilen – ein Docker-Host ist dann in Sekunden statt Minuten fertig.
|
||||
- **Ohne SSH zum Node** per API und Guest-Agent; mit SSH zusätzlich LXC-Provisionierung und
|
||||
cloud-init-Snippets. Was ein Server kann, zeigt der Verbindungstest.
|
||||
- **Verlauf** mit Filtern und Wiederholen, **Vorlagen** („Docker-Host klein“), hinterlegte
|
||||
**SSH-Schlüssel**, **Rollen** Admin/Operator, **Audit-Log**, Anmeldung lokal oder per **OIDC**.
|
||||
- **CLI** als einzelnes Binary für Linux, macOS und Windows – mit Batch-Dateien für viele Gäste.
|
||||
|
||||
| Übersicht | Assistent mit Dry-Run | Am Handy |
|
||||
|---|---|---|
|
||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel | ✓ fertig, auf PVE 9.2 geprüft (Bau-Adresse #021: Abnahme offen) |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ◐ als Nächstes |
|
||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||
|  |  |  |
|
||||
|
||||
Weitere Ansichten: [Verlauf](docs/bilder/verlauf.png) · [Server mit Verbindungstest](docs/bilder/server.png) · [Katalog](docs/bilder/katalog.png).
|
||||
Erzeugt mit `scripts/screenshots.ts` aus dem gebauten Stand.
|
||||
|
||||
## Installation
|
||||
|
||||
ProxDeploy braucht PostgreSQL und läuft als zwei Prozesse (app und worker). Es gehört hinter einen
|
||||
Reverse-Proxy mit TLS; dann `TRUST_PROXY=true` und `APP_URL` auf die öffentliche Adresse setzen.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://git.wilde-server.de/florian.wilde/proxdeploy.git && cd proxdeploy
|
||||
cp .env.example .env
|
||||
# POSTGRES_PASSWORT=$(openssl rand -hex 24)
|
||||
# PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32) ← sichern! ohne ihn sind Tokens und Schlüssel verloren
|
||||
# SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||
# APP_URL=https://proxdeploy.example
|
||||
# ADMIN_PASSWORT=… ← erster Admin, nur beim allerersten Start
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose startet `postgres`, spielt mit `migrate` die Datenbank ein und startet dann `app` (Port 3000 auf
|
||||
127.0.0.1) und `worker`. Aktualisieren: `git pull && docker compose up -d --build`.
|
||||
|
||||
### In einem Debian-LXC auf dem Proxmox-Host
|
||||
|
||||
```bash
|
||||
scripts/paket-bauen.sh 0.6.0 --tar # im Repository: proxdeploy-0.6.0.tar.gz
|
||||
scp proxdeploy-0.6.0.tar.gz deploy/install-lxc.sh deploy/im-container.sh root@pve01:/root/
|
||||
ssh root@pve01 ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz --url https://proxdeploy.example
|
||||
```
|
||||
|
||||
Der Installer legt einen unprivilegierten Debian-13-Container an (DHCP auf `vmbr0`; `--ip`, `--gw`,
|
||||
`--vlan`, `--storage`, `--ctid` u. a. siehe `--help`), installiert Node.js 24 (Prüfsumme geprüft) und
|
||||
PostgreSQL, richtet systemd-Dienste ein und zeigt am Ende Adresse und das Passwort des ersten Admins.
|
||||
Aktualisieren mit `./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz` – die
|
||||
letzten drei Fassungen bleiben unter `/opt/proxdeploy/versionen/`.
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
1. **Token in Proxmox:** eigenen Benutzer und die Rolle `ProxDeploy` anlegen, Token ohne
|
||||
Privilege Separation – die Befehle für 8.x und 9.x stehen in [docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
||||
2. **Server anlegen:** URL, Token, Zertifikat bestätigen (Fingerprint-Pinning für selbst signierte
|
||||
Zertifikate), „Verbindung testen“. Optional SSH-Zugang zum Node hinterlegen.
|
||||
3. **SSH-Schlüssel** unter Einstellungen hinterlegen – sie kommen automatisch in jeden Gast.
|
||||
4. **Ausrollen:** fehlt das Template, baut ProxDeploy es vorher (oder unter „Templates“).
|
||||
|
||||
## Was ProxDeploy auf einem Server kann
|
||||
|
||||
@@ -53,6 +105,8 @@ proxdeploy template build --server rz1 --distro debian-13 --profile base,docker
|
||||
proxdeploy batch gaeste.yaml
|
||||
proxdeploy status a-7f3k
|
||||
proxdeploy logs a-7f3k --follow
|
||||
proxdeploy history --type lxc --status fehlgeschlagen
|
||||
proxdeploy cleanup a-7f3k # halb angelegten Gast nach Fehlschlag löschen (mit Rückfrage)
|
||||
```
|
||||
|
||||
```
|
||||
@@ -103,6 +157,52 @@ guests:
|
||||
|
||||
Bauen: `cli/bauen.sh 0.4.0` legt die sechs Binaries und `SHA256SUMS` unter `cli/bin/` ab (Go 1.27).
|
||||
|
||||
## Anmeldung per OIDC (z. B. authentik)
|
||||
|
||||
Optional zusätzlich zu lokalen Konten. Beim Identity Provider einen vertraulichen Client anlegen,
|
||||
Redirect-URI `<APP_URL>/api/v1/auth/oidc/rueckruf`, Scopes `openid profile email`, den
|
||||
Gruppen-Claim ins ID-Token geben. Dann in der `.env`:
|
||||
|
||||
```bash
|
||||
OIDC_AKTIV=true
|
||||
OIDC_ISSUER=https://auth.example/application/o/proxdeploy/
|
||||
OIDC_CLIENT_ID=…
|
||||
OIDC_CLIENT_SECRET=…
|
||||
OIDC_ANZEIGENAME=authentik
|
||||
OIDC_GRUPPEN_CLAIM=groups
|
||||
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||
```
|
||||
|
||||
- Ablauf: Authorization Code mit PKCE, State und Nonce; das ID-Token wird gegen die Schlüssel des IdP geprüft.
|
||||
- Die Rolle kommt bei **jeder** Anmeldung aus den Gruppen; wer in keiner der beiden Gruppen ist, kommt nicht hinein.
|
||||
- Neue SSO-Benutzer werden beim ersten Anmelden angelegt. Ein vorhandenes lokales Konto wird **nie**
|
||||
über den Namen übernommen; verknüpfen geht angemeldet unter Einstellungen → „Mit … verknüpfen“.
|
||||
- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`);
|
||||
ein echter IdP folgt beim Feldtest.
|
||||
|
||||
## Katalog erweitern
|
||||
|
||||
- **Neue Distribution:** eine YAML-Datei unter `catalog/distros/` – Image-URL, Prüfsummen-Quelle,
|
||||
Standardbenutzer, LXC-Template-Muster. Für eine neue Familie (z. B. `rhel` mit dnf) kommt ein
|
||||
Adapter unter `src/engine/adapter/` dazu. → [docs/katalog.md](docs/katalog.md#neue-distribution-hinzufügen)
|
||||
- **Eigenes Profil:** in der Web-GUI unter Katalog → „Profil anlegen“ (YAML mit Live-Prüfung und
|
||||
Skriptvorschau) oder als Datei unter `catalog/profiles/`. → [docs/katalog.md](docs/katalog.md#eigenes-profil-anlegen)
|
||||
|
||||
## Stand
|
||||
|
||||
Version und Änderungen stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
||||
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
||||
|
||||
| Etappe | Inhalt | Stand |
|
||||
|---|---|---|
|
||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt, Assistent | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen, Profile im Template, SSH-Schlüssel, Template-Bau ohne SSH | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go), API-Tokens | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 5 · v0.5.0 | Verlauf, Aufräumen, Audit-Log, Benutzer, OIDC | ✓ fertig (Aufräumen auf PVE 9.2 geprüft, OIDC gegen Mock-IdP) |
|
||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Docker Compose, LXC-Installer | ✓ fertig, Installer auf PVE 9.2 geprüft |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||
@@ -128,12 +228,6 @@ Go-Client nutzt die aus `cli/api/openapi.json` erzeugten Typen.
|
||||
|
||||
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Betrieb
|
||||
|
||||
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
|
||||
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
|
||||
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
|
||||
|
||||
## Lizenz
|
||||
|
||||
Noch nicht festgelegt.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "ProxDeploy API",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.1",
|
||||
"description": "REST-API von ProxDeploy – dieselbe für Web-GUI und CLI. Fehler: `{ fehler: { code, nachricht, hinweis?, felder? } }`."
|
||||
},
|
||||
"servers": [
|
||||
@@ -927,6 +927,33 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"aufraeumbar": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"typ": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"VM",
|
||||
"LXC"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"knoten": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"typ",
|
||||
"vmid",
|
||||
"knoten"
|
||||
]
|
||||
},
|
||||
"phasen": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -954,9 +981,42 @@
|
||||
"gestartetAm",
|
||||
"beendetAm",
|
||||
"abschluss",
|
||||
"aufraeumbar",
|
||||
"phasen"
|
||||
]
|
||||
},
|
||||
"Verlauf": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"eintraege": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Auftrag"
|
||||
}
|
||||
},
|
||||
"gesamt": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"seite": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"seiten": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"eintraege",
|
||||
"gesamt",
|
||||
"seite",
|
||||
"seiten"
|
||||
]
|
||||
},
|
||||
"LogZeile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1347,6 +1407,81 @@
|
||||
}
|
||||
},
|
||||
"/auftraege": {
|
||||
"get": {
|
||||
"operationId": "verlauf",
|
||||
"summary": "Verlauf, neueste zuerst, 25 je Seite",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "server",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "typ",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"vm",
|
||||
"lxc",
|
||||
"template",
|
||||
"aufraeumen"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "distribution",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "seite",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Seite des Verlaufs",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Verlauf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Fehler",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FehlerAntwort"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "ausrollen",
|
||||
"summary": "VM oder LXC ausrollen (Auftrag)",
|
||||
@@ -1558,6 +1693,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auftraege/{id}/aufraeumen": {
|
||||
"post": {
|
||||
"operationId": "auftragAufraeumen",
|
||||
"summary": "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID oder Kurz-ID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Auftrag angenommen",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuftragAngenommen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Fehler",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FehlerAntwort"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auftraege/{id}/abbrechen": {
|
||||
"post": {
|
||||
"operationId": "auftragAbbrechen",
|
||||
|
||||
@@ -200,6 +200,27 @@ func (c *Client) Log(ctx context.Context, id, ab string) ([]LogZeile, error) {
|
||||
return zeilen, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &zeilen)
|
||||
}
|
||||
|
||||
// Verlauf: eine Seite, gefiltert. Leere Filter fallen weg.
|
||||
func (c *Client) Verlauf(ctx context.Context, filter map[string]string) (Verlauf, error) {
|
||||
werte := url.Values{}
|
||||
for k, v := range filter {
|
||||
if v != "" {
|
||||
werte.Set(k, v)
|
||||
}
|
||||
}
|
||||
pfad := "/auftraege"
|
||||
if len(werte) > 0 {
|
||||
pfad += "?" + werte.Encode()
|
||||
}
|
||||
var v Verlauf
|
||||
return v, c.Aufrufen(ctx, http.MethodGet, pfad, nil, &v)
|
||||
}
|
||||
|
||||
func (c *Client) Aufraeumen(ctx context.Context, id string) (AuftragAngenommen, error) {
|
||||
var a AuftragAngenommen
|
||||
return a, c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/aufraeumen", nil, &a)
|
||||
}
|
||||
|
||||
func (c *Client) Abbrechen(ctx context.Context, id string) error {
|
||||
return c.Aufrufen(ctx, http.MethodPost, "/auftraege/"+url.PathEscape(id)+"/abbrechen", nil, nil)
|
||||
}
|
||||
|
||||
@@ -30,18 +30,36 @@ func (e AuftragArt) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AuftragAufraeumbarTyp.
|
||||
const (
|
||||
AuftragAufraeumbarTypLXC AuftragAufraeumbarTyp = "LXC"
|
||||
AuftragAufraeumbarTypVM AuftragAufraeumbarTyp = "VM"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AuftragAufraeumbarTyp enum.
|
||||
func (e AuftragAufraeumbarTyp) Valid() bool {
|
||||
switch e {
|
||||
case AuftragAufraeumbarTypLXC:
|
||||
return true
|
||||
case AuftragAufraeumbarTypVM:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AuftragGastTyp.
|
||||
const (
|
||||
LXC AuftragGastTyp = "LXC"
|
||||
VM AuftragGastTyp = "VM"
|
||||
AuftragGastTypLXC AuftragGastTyp = "LXC"
|
||||
AuftragGastTypVM AuftragGastTyp = "VM"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the AuftragGastTyp enum.
|
||||
func (e AuftragGastTyp) Valid() bool {
|
||||
switch e {
|
||||
case LXC:
|
||||
case AuftragGastTypLXC:
|
||||
return true
|
||||
case VM:
|
||||
case AuftragGastTypVM:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -201,6 +219,30 @@ func (e ProfilVariableType) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for VerlaufParamsTyp.
|
||||
const (
|
||||
VerlaufParamsTypAufraeumen VerlaufParamsTyp = "aufraeumen"
|
||||
VerlaufParamsTypLxc VerlaufParamsTyp = "lxc"
|
||||
VerlaufParamsTypTemplate VerlaufParamsTyp = "template"
|
||||
VerlaufParamsTypVm VerlaufParamsTyp = "vm"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the VerlaufParamsTyp enum.
|
||||
func (e VerlaufParamsTyp) Valid() bool {
|
||||
switch e {
|
||||
case VerlaufParamsTypAufraeumen:
|
||||
return true
|
||||
case VerlaufParamsTypLxc:
|
||||
return true
|
||||
case VerlaufParamsTypTemplate:
|
||||
return true
|
||||
case VerlaufParamsTypVm:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DistributionenListeParamsTyp.
|
||||
const (
|
||||
DistributionenListeParamsTypLxc DistributionenListeParamsTyp = "lxc"
|
||||
@@ -229,8 +271,13 @@ type Abschluss struct {
|
||||
|
||||
// Auftrag defines model for Auftrag.
|
||||
type Auftrag struct {
|
||||
Abschluss *Abschluss `json:"abschluss"`
|
||||
Art AuftragArt `json:"art"`
|
||||
Abschluss *Abschluss `json:"abschluss"`
|
||||
Art AuftragArt `json:"art"`
|
||||
Aufraeumbar *struct {
|
||||
Knoten *string `json:"knoten"`
|
||||
Typ AuftragAufraeumbarTyp `json:"typ"`
|
||||
Vmid int `json:"vmid"`
|
||||
} `json:"aufraeumbar"`
|
||||
|
||||
// BeendetAm ISO 8601
|
||||
BeendetAm *string `json:"beendetAm"`
|
||||
@@ -267,6 +314,9 @@ type Auftrag struct {
|
||||
// AuftragArt defines model for Auftrag.Art.
|
||||
type AuftragArt string
|
||||
|
||||
// AuftragAufraeumbarTyp defines model for Auftrag.Aufraeumbar.Typ.
|
||||
type AuftragAufraeumbarTyp string
|
||||
|
||||
// AuftragGastTyp defines model for Auftrag.GastTyp.
|
||||
type AuftragGastTyp string
|
||||
|
||||
@@ -537,6 +587,14 @@ type TokenAnmeldung struct {
|
||||
Tage *int `json:"tage,omitempty"`
|
||||
}
|
||||
|
||||
// Verlauf defines model for Verlauf.
|
||||
type Verlauf struct {
|
||||
Eintraege []Auftrag `json:"eintraege"`
|
||||
Gesamt int `json:"gesamt"`
|
||||
Seite int `json:"seite"`
|
||||
Seiten int `json:"seiten"`
|
||||
}
|
||||
|
||||
// Vorlage defines model for Vorlage.
|
||||
type Vorlage struct {
|
||||
Beschreibung *string `json:"beschreibung"`
|
||||
@@ -544,6 +602,18 @@ type Vorlage struct {
|
||||
Titel string `json:"titel"`
|
||||
}
|
||||
|
||||
// VerlaufParams defines parameters for Verlauf.
|
||||
type VerlaufParams struct {
|
||||
Server *string `form:"server,omitempty" json:"server,omitempty"`
|
||||
Typ *VerlaufParamsTyp `form:"typ,omitempty" json:"typ,omitempty"`
|
||||
Distribution *string `form:"distribution,omitempty" json:"distribution,omitempty"`
|
||||
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||
Seite *int `form:"seite,omitempty" json:"seite,omitempty"`
|
||||
}
|
||||
|
||||
// VerlaufParamsTyp defines parameters for Verlauf.
|
||||
type VerlaufParamsTyp string
|
||||
|
||||
// AuftragEreignisseParams defines parameters for AuftragEreignisse.
|
||||
type AuftragEreignisseParams struct {
|
||||
LastEventID *string `json:"Last-Event-ID,omitempty"`
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package befehle
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wilde-server.de/florian.wilde/proxdeploy/cli/internal/api"
|
||||
)
|
||||
@@ -172,3 +175,146 @@ func (u *umgebung) cancelBefehl() *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Statustexte wie in der Web-GUI.
|
||||
var statusText = map[api.AuftragStatus]string{
|
||||
api.AuftragStatusWARTEND: "wartet", api.AuftragStatusLAEUFT: "läuft", api.AuftragStatusERFOLGREICH: "erfolgreich",
|
||||
api.AuftragStatusFEHLGESCHLAGEN: "fehlgeschlagen", api.AuftragStatusABGEBROCHEN: "abgebrochen",
|
||||
api.AuftragStatusAUFGERAEUMT: "aufgeräumt",
|
||||
}
|
||||
|
||||
// statusAusText nimmt „fehlgeschlagen“, „läuft“, „aufgeräumt“ oder den API-Wert.
|
||||
func statusAusText(text string) string {
|
||||
for wert, t := range statusText {
|
||||
if strings.EqualFold(t, text) || strings.EqualFold(string(wert), text) {
|
||||
return string(wert)
|
||||
}
|
||||
}
|
||||
return strings.ToUpper(text)
|
||||
}
|
||||
|
||||
func (u *umgebung) historyBefehl() *cobra.Command {
|
||||
var server, typ, distro, status string
|
||||
var seite int
|
||||
befehl := &cobra.Command{
|
||||
Use: "history",
|
||||
Aliases: []string{"verlauf"},
|
||||
Short: "Verlauf der Aufträge, neueste zuerst",
|
||||
Example: " proxdeploy history --type lxc --status FEHLGESCHLAGEN\n proxdeploy history --server rz1 --page 2",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter := map[string]string{"server": server, "typ": typ, "distribution": distro, "status": ""}
|
||||
if status != "" {
|
||||
filter["status"] = statusAusText(status)
|
||||
}
|
||||
if seite > 1 {
|
||||
filter["seite"] = fmt.Sprint(seite)
|
||||
}
|
||||
v, err := c.Verlauf(cmd.Context(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.opt.json {
|
||||
return u.jsonAusgeben(v)
|
||||
}
|
||||
s := u.stil()
|
||||
if len(v.Eintraege) == 0 {
|
||||
fmt.Fprintln(u.aus, s.Muted("Keine Aufträge."))
|
||||
return nil
|
||||
}
|
||||
symbole := map[api.AuftragStatus]string{
|
||||
api.AuftragStatusERFOLGREICH: "✓", api.AuftragStatusFEHLGESCHLAGEN: "✗", api.AuftragStatusABGEBROCHEN: "✗",
|
||||
api.AuftragStatusLAEUFT: "◐", api.AuftragStatusWARTEND: "○", api.AuftragStatusAUFGERAEUMT: "○",
|
||||
}
|
||||
zeilen := [][]string{}
|
||||
for _, a := range v.Eintraege {
|
||||
art := map[api.AuftragArt]string{api.AUSROLLEN: "VM", api.TEMPLATEBAUEN: "Template", api.AUFRAEUMEN: "Aufräumen"}[a.Art]
|
||||
if a.Art == api.AUSROLLEN && a.GastTyp != nil && *a.GastTyp == api.AuftragGastTypLXC {
|
||||
art = "LXC"
|
||||
}
|
||||
id := "–"
|
||||
if a.Vmid != nil {
|
||||
id = fmt.Sprint(*a.Vmid)
|
||||
}
|
||||
zustand := symbole[a.Status] + " " + statusText[a.Status]
|
||||
if s.ASCII {
|
||||
zustand = strings.NewReplacer("✓", "[OK]", "✗", "[!!]", "◐", "[..]", "○", "[--]").Replace(zustand)
|
||||
}
|
||||
zeit := a.ErstelltAm
|
||||
if t, ok := zeitAus(&a.ErstelltAm); ok {
|
||||
zeit = t.Local().Format("02.01. 15:04")
|
||||
}
|
||||
name := wert(a.Name)
|
||||
if name == "" {
|
||||
name = "–"
|
||||
}
|
||||
zeilen = append(zeilen, []string{a.KurzId, name, art, a.Server.Name, wert(a.DistributionId), id, zustand, zeit})
|
||||
}
|
||||
u.tabelle(s, []string{"AUFTRAG", "NAME", "TYP", "SERVER", "DISTRIBUTION", "ID", "STATUS", "ZEIT"}, zeilen)
|
||||
if v.Seiten > 1 {
|
||||
fmt.Fprintln(u.aus, s.Muted(fmt.Sprintf("Seite %d von %d · %d Aufträge · weiter mit --page %d", v.Seite, v.Seiten, v.Gesamt, min(v.Seite+1, v.Seiten))))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := befehl.Flags()
|
||||
f.StringVar(&server, "server", "", "nur dieser Server")
|
||||
f.StringVar(&typ, "type", "", "vm, lxc, template oder aufraeumen")
|
||||
f.StringVar(&distro, "distro", "", "nur diese Distribution")
|
||||
f.StringVar(&status, "status", "", "z. B. fehlgeschlagen, erfolgreich, laeuft")
|
||||
f.IntVar(&seite, "page", 1, "Seite (25 je Seite)")
|
||||
return befehl
|
||||
}
|
||||
|
||||
func (u *umgebung) cleanupBefehl() *cobra.Command {
|
||||
var ja, nichtWarten bool
|
||||
befehl := &cobra.Command{
|
||||
Use: "cleanup <job-id>",
|
||||
Short: "Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen",
|
||||
Long: "Stoppt und löscht die VM bzw. den Container, den ein fehlgeschlagener oder abgebrochener\n" +
|
||||
"Auftrag angelegt hat. Gelöscht wird nur, was laut Beschreibung zu diesem Auftrag gehört.",
|
||||
Args: eineID("cleanup"),
|
||||
RunE: func(cmd *cobra.Command, argumente []string) error {
|
||||
c, _, err := u.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := c.Auftrag(cmd.Context(), argumente[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Aufraeumbar == nil {
|
||||
return bedienfehler("bei %s gibt es nichts aufzuräumen (Status %s)", a.KurzId, statusText[a.Status])
|
||||
}
|
||||
z := a.Aufraeumbar
|
||||
wort := map[api.AuftragAufraeumbarTyp]string{api.AuftragAufraeumbarTypVM: "VM", api.AuftragAufraeumbarTypLXC: "Container"}[z.Typ]
|
||||
ziel := fmt.Sprintf("%s %d", wort, z.Vmid)
|
||||
if z.Knoten != nil {
|
||||
ziel += " auf " + *z.Knoten
|
||||
}
|
||||
if !ja {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return bedienfehler("%s löschen? Ohne Terminal mit --yes bestätigen", ziel)
|
||||
}
|
||||
fmt.Fprintf(u.fehler, "%s stoppen und mit allen Disks löschen? [j/N] ", ziel)
|
||||
antwort, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if a := strings.ToLower(strings.TrimSpace(antwort)); a != "j" && a != "ja" && a != "y" && a != "yes" {
|
||||
fmt.Fprintln(u.fehler, u.stil().Muted("Nichts gelöscht."))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
angenommen, err := c.Aufraeumen(cmd.Context(), a.KurzId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.angenommen(cmd.Context(), c, angenommen, nichtWarten)
|
||||
},
|
||||
}
|
||||
befehl.Flags().BoolVarP(&ja, "yes", "y", false, "ohne Rückfrage")
|
||||
befehl.Flags().BoolVar(&nichtWarten, "no-wait", false, "nicht verfolgen, nur die Auftrags-ID ausgeben")
|
||||
return befehl
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var aktualisieren = flag.Bool("update", false, "Golden-Dateien neu schreiben")
|
||||
@@ -78,6 +79,8 @@ type fakeAPI struct {
|
||||
plan map[string]any
|
||||
status int
|
||||
antwort any
|
||||
verlauf map[string]any
|
||||
einzeln map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
@@ -88,6 +91,11 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
fmt.Fprint(w, `{"fehler":{"code":"nicht_angemeldet","nachricht":"Nicht angemeldet."}}`)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.anfragen == nil {
|
||||
f.anfragen = map[string][]map[string]any{}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if r.Body != nil && r.Method == http.MethodPost {
|
||||
var rumpf map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&rumpf)
|
||||
@@ -112,6 +120,15 @@ func (f *fakeAPI) handler(t *testing.T) http.Handler {
|
||||
schreiben(200, map[string]any{"benutzername": "florian", "anzeigename": "Florian", "rolle": "OPERATOR", "rechte": []string{}, "version": "0.4.0"})
|
||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodPost:
|
||||
schreiben(202, map[string]any{"id": "c1", "kurzId": "a-7f3k"})
|
||||
case r.URL.Path == "/api/v1/auftraege" && r.Method == http.MethodGet:
|
||||
f.mu.Lock()
|
||||
f.anfragen["GET "+r.URL.RawQuery] = nil
|
||||
f.mu.Unlock()
|
||||
schreiben(200, f.verlauf)
|
||||
case strings.HasSuffix(r.URL.Path, "/aufraeumen"):
|
||||
schreiben(202, map[string]any{"id": "c2", "kurzId": "c-9x9x9"})
|
||||
case r.URL.Path == "/api/v1/auftraege/d-kaputt":
|
||||
schreiben(200, f.einzeln)
|
||||
case r.URL.Path == "/api/v1/auftraege/plan":
|
||||
schreiben(200, f.plan)
|
||||
case strings.HasSuffix(r.URL.Path, "/ereignisse"):
|
||||
@@ -323,3 +340,48 @@ func TestLoginMitToken(t *testing.T) {
|
||||
t.Error("falsches Token hat die Konfiguration verändert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory(t *testing.T) {
|
||||
ok := auftrag("ERFOLGREICH", alle, nil)
|
||||
ok["erstelltAm"] = "2026-10-12T08:05:00.000Z"
|
||||
ct := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||
ct["kurzId"], ct["name"], ct["gastTyp"], ct["vmid"], ct["distributionId"] = "d-ct001", "dns01", "LXC", 121, "ubuntu-24.04"
|
||||
ct["erstelltAm"] = "2026-10-12T08:00:00.000Z"
|
||||
f := &fakeAPI{verlauf: map[string]any{"eintraege": []any{ok, ct}, "gesamt": 30, "seite": 1, "seiten": 2}}
|
||||
alt := time.Local
|
||||
time.Local = time.UTC
|
||||
t.Cleanup(func() { time.Local = alt })
|
||||
aus, fehler, code := lauf(t, f, "history", "--type", "lxc", "--status", "fehlgeschlagen")
|
||||
if code != 0 {
|
||||
t.Fatalf("Exit %d: %s", code, fehler)
|
||||
}
|
||||
golden(t, "history.golden", aus)
|
||||
if _, gesehen := f.anfragen["GET status=FEHLGESCHLAGEN&typ=lxc"]; !gesehen {
|
||||
t.Errorf("Filter nicht gesendet: %v", f.anfragen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup(t *testing.T) {
|
||||
kaputt := auftrag("FEHLGESCHLAGEN", alle, nil)
|
||||
kaputt["kurzId"] = "d-kaputt"
|
||||
kaputt["aufraeumbar"] = map[string]any{"typ": "VM", "vmid": 120, "knoten": "pve01"}
|
||||
f := &fakeAPI{einzeln: kaputt}
|
||||
// Ohne Terminal keine Rückfrage möglich: ohne --yes wird nichts gelöscht.
|
||||
_, fehler, code := lauf(t, f, "cleanup", "d-kaputt")
|
||||
if code != ExitBedienung || !strings.Contains(fehler, "VM 120 auf pve01 löschen?") {
|
||||
t.Errorf("ohne --yes: Exit %d, %s", code, fehler)
|
||||
}
|
||||
if len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 0 {
|
||||
t.Error("ohne Bestätigung aufgeräumt")
|
||||
}
|
||||
aus, _, code := lauf(t, f, "cleanup", "d-kaputt", "--yes", "--no-wait")
|
||||
if code != 0 || strings.TrimSpace(aus) != "c-9x9x9" || len(f.anfragen["/api/v1/auftraege/d-kaputt/aufraeumen"]) != 1 {
|
||||
t.Errorf("mit --yes: Exit %d, %q", code, aus)
|
||||
}
|
||||
|
||||
kaputt["aufraeumbar"] = nil
|
||||
_, fehler, code = lauf(t, f, "cleanup", "d-kaputt", "--yes")
|
||||
if code != ExitBedienung || !strings.Contains(fehler, "nichts aufzuräumen") {
|
||||
t.Errorf("nicht aufräumbar: Exit %d, %s", code, fehler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
AUFTRAG NAME TYP SERVER DISTRIBUTION ID STATUS ZEIT
|
||||
a-7f3k web01 VM rz1 debian-13 120 ✓ erfolgreich 12.10. 08:05
|
||||
d-ct001 dns01 LXC rz1 ubuntu-24.04 121 ✗ fehlgeschlagen 12.10. 08:00
|
||||
Seite 1 von 2 · 30 Aufträge · weiter mit --page 2
|
||||
@@ -140,6 +140,8 @@ func (u *umgebung) wurzel() *cobra.Command {
|
||||
u.statusBefehl(),
|
||||
u.logsBefehl(),
|
||||
u.cancelBefehl(),
|
||||
u.historyBefehl(),
|
||||
u.cleanupBefehl(),
|
||||
)
|
||||
hilfeEindeutschen(wurzel)
|
||||
return wurzel
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# Läuft im Debian-13-Container (von install-lxc.sh per `pct exec` aufgerufen):
|
||||
#
|
||||
# im-container.sh installieren --url http://10.0.10.50:3000
|
||||
# im-container.sh aktualisieren
|
||||
#
|
||||
# Erwartet das Paket unter /root/proxdeploy-paket.tar.gz.
|
||||
set -euo pipefail
|
||||
# Über SSH/pct exec reisen die Locales des Aufrufers mit; im frischen Container gibt es sie nicht.
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
unset LANGUAGE
|
||||
|
||||
NODE_VERSION="24.21.0"
|
||||
BASIS="/opt/proxdeploy"
|
||||
ENV_DATEI="/etc/proxdeploy/proxdeploy.env"
|
||||
PAKET="/root/proxdeploy-paket.tar.gz"
|
||||
|
||||
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||
schritt() { echo "◐ $*"; }
|
||||
fertig() { echo "✓ $*"; }
|
||||
|
||||
node_installieren() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch=x64 ;;
|
||||
aarch64) arch=arm64 ;;
|
||||
*) fehler "Architektur $(uname -m) wird nicht unterstützt." ;;
|
||||
esac
|
||||
if [ -x /opt/node/bin/node ] && [ "$(/opt/node/bin/node --version)" = "v$NODE_VERSION" ]; then return; fi
|
||||
schritt "Node.js $NODE_VERSION"
|
||||
local datei="node-v$NODE_VERSION-linux-$arch.tar.xz" tmp
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/$datei" "https://nodejs.org/dist/v$NODE_VERSION/$datei"
|
||||
curl -fsSL -o "$tmp/SHASUMS256.txt" "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
|
||||
(cd "$tmp" && grep " $datei\$" SHASUMS256.txt | sha256sum -c --quiet) || fehler "Prüfsumme von $datei stimmt nicht."
|
||||
rm -rf /opt/node && mkdir -p /opt/node
|
||||
tar -xJf "$tmp/$datei" -C /opt/node --strip-components=1
|
||||
rm -rf "$tmp"
|
||||
fertig "Node.js $(/opt/node/bin/node --version)"
|
||||
}
|
||||
|
||||
paket_auspacken() {
|
||||
[ -f "$PAKET" ] || fehler "$PAKET fehlt."
|
||||
local version ziel
|
||||
version="$(tar -xzOf "$PAKET" ./VERSION)"
|
||||
ziel="$BASIS/versionen/$version"
|
||||
rm -rf "$ziel" && mkdir -p "$ziel"
|
||||
tar -xzf "$PAKET" -C "$ziel"
|
||||
chown -R root:root "$ziel"
|
||||
ln -sfn "$ziel" "$BASIS/aktuell"
|
||||
rm -f "$PAKET"
|
||||
# Die letzten drei Fassungen behalten – für ein Zurück per Symlink.
|
||||
find "$BASIS/versionen" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | tail -n +4 |
|
||||
cut -d' ' -f2- | xargs -r rm -rf
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
dienste_schreiben() {
|
||||
cat > /etc/systemd/system/proxdeploy-migrate.service <<EOF
|
||||
[Unit]
|
||||
Description=ProxDeploy – Datenbank-Migrationen
|
||||
After=postgresql.service network-online.target
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=proxdeploy
|
||||
EnvironmentFile=$ENV_DATEI
|
||||
WorkingDirectory=$BASIS/aktuell/app
|
||||
ExecStart=/opt/node/bin/node ../worker/migrieren.mjs
|
||||
EOF
|
||||
for dienst in app worker; do
|
||||
if [ "$dienst" = app ]; then start="server.js"; beschreibung="Web-GUI und REST-API"; else start="../worker/worker.mjs"; beschreibung="worker (Aufträge)"; fi
|
||||
cat > "/etc/systemd/system/proxdeploy-$dienst.service" <<EOF
|
||||
[Unit]
|
||||
Description=ProxDeploy – $beschreibung
|
||||
After=proxdeploy-migrate.service
|
||||
Requires=proxdeploy-migrate.service
|
||||
|
||||
[Service]
|
||||
User=proxdeploy
|
||||
EnvironmentFile=$ENV_DATEI
|
||||
Environment=NODE_ENV=production
|
||||
WorkingDirectory=$BASIS/aktuell/app
|
||||
ExecStart=/opt/node/bin/node $start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=40
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
done
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
auf_gesundheit_warten() {
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null "http://127.0.0.1:3000/api/v1/gesundheit" 2>/dev/null; then return 0; fi
|
||||
sleep 2
|
||||
done
|
||||
journalctl -u proxdeploy-app -u proxdeploy-migrate --no-pager -n 30 >&2
|
||||
fehler "Die app antwortet nicht."
|
||||
}
|
||||
|
||||
installieren() {
|
||||
local url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--url) url="$2"; shift 2 ;;
|
||||
*) fehler "Unbekannte Option $1" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$url" ] || fehler "--url fehlt."
|
||||
[ -f "$ENV_DATEI" ] && fehler "ProxDeploy ist hier schon eingerichtet – zum Aktualisieren: install-lxc.sh --aktualisieren <ctid>"
|
||||
|
||||
schritt "Pakete (PostgreSQL, curl)"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends ca-certificates curl xz-utils openssl postgresql >/dev/null
|
||||
fertig "PostgreSQL $(psql --version | awk '{print $3}')"
|
||||
node_installieren
|
||||
|
||||
schritt "Benutzer und Datenbank"
|
||||
id proxdeploy >/dev/null 2>&1 || useradd --system --home-dir /var/lib/proxdeploy --create-home --shell /usr/sbin/nologin proxdeploy
|
||||
local db_passwort admin_passwort
|
||||
db_passwort="$(openssl rand -hex 24)"
|
||||
admin_passwort="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
|
||||
su postgres -c "psql -q -v ON_ERROR_STOP=1" <<SQL
|
||||
DO \$\$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'proxdeploy') THEN
|
||||
CREATE ROLE proxdeploy LOGIN PASSWORD '$db_passwort';
|
||||
ELSE
|
||||
ALTER ROLE proxdeploy PASSWORD '$db_passwort';
|
||||
END IF;
|
||||
END \$\$;
|
||||
SQL
|
||||
su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname = 'proxdeploy'\"" | grep -q 1 ||
|
||||
su postgres -c "createdb -O proxdeploy proxdeploy"
|
||||
fertig "Datenbank proxdeploy"
|
||||
|
||||
schritt "Konfiguration $ENV_DATEI"
|
||||
mkdir -p /etc/proxdeploy
|
||||
umask 027
|
||||
cat > "$ENV_DATEI" <<EOF
|
||||
# ProxDeploy – erzeugt von im-container.sh am $(date -Iseconds)
|
||||
DATABASE_URL=postgresql://proxdeploy:$db_passwort@127.0.0.1:5432/proxdeploy
|
||||
PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32)
|
||||
SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
|
||||
APP_URL=$url
|
||||
# Hinter einem Reverse-Proxy auf true setzen (wertet X-Forwarded-* aus).
|
||||
TRUST_PROXY=false
|
||||
PORT=3000
|
||||
HOSTNAME=0.0.0.0
|
||||
LOG_LEVEL=info
|
||||
WORKER_PARALLEL=4
|
||||
ADMIN_BENUTZER=admin
|
||||
ADMIN_PASSWORT=$admin_passwort
|
||||
EOF
|
||||
chown root:proxdeploy "$ENV_DATEI"
|
||||
chmod 0640 "$ENV_DATEI"
|
||||
umask 022
|
||||
|
||||
schritt "ProxDeploy"
|
||||
local version
|
||||
version="$(paket_auspacken)"
|
||||
dienste_schreiben
|
||||
systemctl enable --now proxdeploy-app proxdeploy-worker >/dev/null 2>&1
|
||||
auf_gesundheit_warten
|
||||
# Der erste Admin ist angelegt – das Passwort hat in der Datei nichts mehr zu suchen.
|
||||
sed -i '/^ADMIN_PASSWORT=/d' "$ENV_DATEI"
|
||||
fertig "ProxDeploy $version läuft"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
ProxDeploy ist bereit: $url
|
||||
Benutzer: admin
|
||||
Passwort: $admin_passwort
|
||||
(wird nur jetzt angezeigt – nach der Anmeldung unter
|
||||
Einstellungen → Passwort ändern)
|
||||
Konfiguration: $ENV_DATEI
|
||||
Logs: journalctl -u proxdeploy-app -u proxdeploy-worker
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
EOF
|
||||
}
|
||||
|
||||
aktualisieren() {
|
||||
[ -f "$ENV_DATEI" ] || fehler "Hier ist ProxDeploy noch nicht eingerichtet."
|
||||
node_installieren
|
||||
local alt version
|
||||
alt="$(cat "$BASIS/aktuell/VERSION" 2>/dev/null || echo –)"
|
||||
systemctl stop proxdeploy-app proxdeploy-worker
|
||||
version="$(paket_auspacken)"
|
||||
dienste_schreiben
|
||||
# migrate bleibt nach dem ersten Lauf „aktiv“ – für die neue Fassung ausdrücklich neu starten.
|
||||
systemctl restart proxdeploy-migrate
|
||||
systemctl start proxdeploy-app proxdeploy-worker
|
||||
auf_gesundheit_warten
|
||||
fertig "ProxDeploy $alt → $version"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
installieren) shift; installieren "$@" ;;
|
||||
aktualisieren) shift; aktualisieren "$@" ;;
|
||||
*) fehler "Aufruf: $0 installieren --url URL | aktualisieren" ;;
|
||||
esac
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# ProxDeploy in einem Debian-13-LXC einrichten – auf dem Proxmox-Host als root ausführen.
|
||||
#
|
||||
# ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz [Optionen]
|
||||
# ./install-lxc.sh --aktualisieren <ctid> --paket proxdeploy-0.6.1.tar.gz
|
||||
#
|
||||
# Optionen (alle optional außer --paket):
|
||||
# --ctid N Container-ID (Standard: nächste freie)
|
||||
# --hostname NAME Hostname (Standard: proxdeploy)
|
||||
# --storage NAME Storage für die Disk (Standard: local-lvm)
|
||||
# --disk GB Disk in GB (Standard: 8)
|
||||
# --cores N CPU-Kerne (Standard: 2)
|
||||
# --memory MB RAM (Standard: 2048)
|
||||
# --bridge NAME Bridge (Standard: vmbr0)
|
||||
# --vlan TAG VLAN-Tag
|
||||
# --ip CIDR feste Adresse, z. B. 10.0.10.50/24 (Standard: DHCP); dazu --gw
|
||||
# --gw ADRESSE Gateway bei fester Adresse
|
||||
# --url URL öffentliche Adresse (APP_URL), z. B. https://proxdeploy.example
|
||||
# (Standard: http://<IP des Containers>:3000)
|
||||
# --tags LISTE zusätzliche Proxmox-Tags, z. B. proxdeploy-test
|
||||
#
|
||||
# Das Paket baut `scripts/paket-bauen.sh <version> --tar` im Repository.
|
||||
set -euo pipefail
|
||||
|
||||
fehler() { echo "✗ $*" >&2; exit 1; }
|
||||
schritt() { echo "◐ $*"; }
|
||||
fertig() { echo "✓ $*"; }
|
||||
|
||||
[ "$(id -u)" = 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen."
|
||||
command -v pct >/dev/null || fehler "pct fehlt – läuft das auf einem Proxmox-VE-Host?"
|
||||
|
||||
ctid="" hostname="proxdeploy" storage="local-lvm" disk=8 cores=2 memory=2048 bridge="vmbr0"
|
||||
vlan="" ip="dhcp" gw="" url="" paket="" aktualisieren="" tags=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ctid) ctid="$2"; shift 2 ;;
|
||||
--hostname) hostname="$2"; shift 2 ;;
|
||||
--storage) storage="$2"; shift 2 ;;
|
||||
--disk) disk="$2"; shift 2 ;;
|
||||
--cores) cores="$2"; shift 2 ;;
|
||||
--memory) memory="$2"; shift 2 ;;
|
||||
--bridge) bridge="$2"; shift 2 ;;
|
||||
--vlan) vlan="$2"; shift 2 ;;
|
||||
--ip) ip="$2"; shift 2 ;;
|
||||
--gw) gw="$2"; shift 2 ;;
|
||||
--url) url="$2"; shift 2 ;;
|
||||
--tags) tags="$2"; shift 2 ;;
|
||||
--paket) paket="$2"; shift 2 ;;
|
||||
--aktualisieren) aktualisieren="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
|
||||
*) fehler "Unbekannte Option $1 (siehe --help)" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$paket" ] || fehler "--paket fehlt (proxdeploy-<version>.tar.gz)."
|
||||
[ -f "$paket" ] || fehler "$paket gibt es nicht."
|
||||
tar -tzf "$paket" ./VERSION >/dev/null 2>&1 || fehler "$paket ist kein ProxDeploy-Paket (VERSION fehlt)."
|
||||
version="$(tar -xzOf "$paket" ./VERSION)"
|
||||
innen="$(dirname "$0")/im-container.sh"
|
||||
[ -f "$innen" ] || fehler "im-container.sh fehlt neben diesem Skript."
|
||||
|
||||
pushen() {
|
||||
pct push "$1" "$paket" /root/proxdeploy-paket.tar.gz
|
||||
pct push "$1" "$innen" /root/proxdeploy-im-container.sh --perms 0700
|
||||
}
|
||||
|
||||
# ── Aktualisieren ───────────────────────────────────────────────────────────────────────────────
|
||||
if [ -n "$aktualisieren" ]; then
|
||||
pct status "$aktualisieren" | grep -q running || fehler "Container $aktualisieren läuft nicht."
|
||||
schritt "Aktualisiere Container $aktualisieren auf $version"
|
||||
pushen "$aktualisieren"
|
||||
pct exec "$aktualisieren" -- bash /root/proxdeploy-im-container.sh aktualisieren
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Neu einrichten ──────────────────────────────────────────────────────────────────────────────
|
||||
[ -n "$ctid" ] || ctid="$(pvesh get /cluster/nextid)"
|
||||
pct status "$ctid" >/dev/null 2>&1 && fehler "Container $ctid gibt es schon."
|
||||
if [ "$ip" != dhcp ]; then
|
||||
[[ "$ip" =~ ^[0-9.]+/[0-9]+$ ]] || fehler "--ip: Format 10.0.10.50/24"
|
||||
[ -n "$gw" ] || fehler "Bei fester Adresse fehlt --gw."
|
||||
fi
|
||||
|
||||
schritt "LXC-Template Debian 13"
|
||||
pveam update >/dev/null
|
||||
vorlage="$(pveam available --section system | awk '/debian-13-standard_.*_amd64\.tar\.zst/ {print $2}' | sort -V | tail -1)"
|
||||
[ -n "$vorlage" ] || fehler "Kein Debian-13-Template in pveam gefunden."
|
||||
template_storage="$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')"
|
||||
[ -n "$template_storage" ] || fehler "Kein Storage für Container-Templates (Inhalt vztmpl)."
|
||||
if ! pveam list "$template_storage" | grep -q "$vorlage"; then
|
||||
pveam download "$template_storage" "$vorlage" >/dev/null
|
||||
fi
|
||||
fertig "$vorlage auf $template_storage"
|
||||
|
||||
netz="name=eth0,bridge=$bridge,firewall=1"
|
||||
[ -n "$vlan" ] && netz="$netz,tag=$vlan"
|
||||
if [ "$ip" = dhcp ]; then netz="$netz,ip=dhcp"; else netz="$netz,ip=$ip,gw=$gw"; fi
|
||||
|
||||
schritt "Container $ctid anlegen"
|
||||
pct create "$ctid" "$template_storage:vztmpl/$vorlage" \
|
||||
--hostname "$hostname" --cores "$cores" --memory "$memory" --swap 512 \
|
||||
--rootfs "$storage:$disk" --net0 "$netz" \
|
||||
--unprivileged 1 --features nesting=1 --onboot 1 \
|
||||
--tags "proxdeploy${tags:+;$tags}" \
|
||||
--description "ProxDeploy $version – eingerichtet mit install-lxc.sh" >/dev/null
|
||||
pct start "$ctid"
|
||||
fertig "Container $ctid ($hostname) läuft"
|
||||
|
||||
schritt "Netzwerk"
|
||||
adresse=""
|
||||
for _ in $(seq 1 60); do
|
||||
adresse="$(pct exec "$ctid" -- ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)"
|
||||
[ -n "$adresse" ] && pct exec "$ctid" -- getent hosts deb.debian.org >/dev/null 2>&1 && break
|
||||
adresse=""
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$adresse" ] || fehler "Container $ctid bekommt keine Adresse oder kein DNS."
|
||||
fertig "IP $adresse"
|
||||
|
||||
[ -n "$url" ] || url="http://$adresse:3000"
|
||||
pushen "$ctid"
|
||||
pct exec "$ctid" -- bash /root/proxdeploy-im-container.sh installieren --url "$url"
|
||||
@@ -0,0 +1,65 @@
|
||||
# ProxDeploy im Betrieb: postgres, migrate (einmalig), app, worker.
|
||||
#
|
||||
# cp .env.example .env # Schlüssel erzeugen, ADMIN_PASSWORT und APP_URL setzen
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Die app lauscht auf 127.0.0.1:3000 – davor gehört ein Reverse-Proxy mit TLS (TRUST_PROXY=true).
|
||||
|
||||
name: proxdeploy
|
||||
|
||||
x-proxdeploy: &proxdeploy
|
||||
image: proxdeploy:${PROXDEPLOY_VERSION:-latest}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${PROXDEPLOY_VERSION:-}
|
||||
env_file: ${PROXDEPLOY_ENV_DATEI:-.env}
|
||||
environment:
|
||||
DATABASE_URL: postgresql://proxdeploy:${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}@postgres:5432/proxdeploy
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.11-trixie
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: proxdeploy
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}
|
||||
POSTGRES_DB: proxdeploy
|
||||
volumes:
|
||||
- datenbank:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U proxdeploy"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
migrate:
|
||||
<<: *proxdeploy
|
||||
command: ["node", "../worker/migrieren.mjs"]
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
app:
|
||||
<<: *proxdeploy
|
||||
ports:
|
||||
- "${PROXDEPLOY_BIND:-127.0.0.1}:${PROXDEPLOY_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
worker:
|
||||
<<: *proxdeploy
|
||||
command: ["node", "../worker/worker.mjs"]
|
||||
healthcheck:
|
||||
disable: true
|
||||
# Lange Aufträge (Template-Bau) dürfen beim Stoppen zu Ende laufen.
|
||||
stop_grace_period: 40s
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
datenbank:
|
||||
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 137 KiB |
@@ -1,21 +1,28 @@
|
||||
# #014 Verlauf und Wiederholen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 1 h 21 min
|
||||
|
||||
## Ziel
|
||||
Alle Aufträge filterbar, mit gleichen Parametern wiederholen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Filter Server/Typ/Distribution/Status, Seitenblättern
|
||||
- [ ] Wiederholen (neue VMID/Name abfragen)
|
||||
- [x] Filter Server/Typ/Distribution/Status, Seitenblättern (Web, `GET /api/v1/auftraege?…`, `proxdeploy history`)
|
||||
- [x] Wiederholen (neue VMID/Name abfragen): `/ausrollen?wiederholen=<kurzId>`, Name, VMID und feste IP leer
|
||||
- [x] Aufräumen nach Fehlschlag/Abbruch mit Bestätigung (Vorgabe „Allgemeine Anforderungen“): Web-Knopf, `POST …/aufraeumen`, `proxdeploy cleanup`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Filter bleiben in der URL
|
||||
- [x] Filter bleiben in der URL (Playwright)
|
||||
- [x] Aufräumen auf pve-demo: CT 109 nach Abbruch erkannt, gestoppt, gelöscht; Original danach „aufgeräumt“
|
||||
|
||||
## Abhängigkeiten
|
||||
#008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Gelöscht wird nur, was nachweislich zum Auftrag gehört:** die Beschreibung des Gastes enthält
|
||||
„(Auftrag <kurzId>)“. Dafür setzt das Klonen die Beschreibung jetzt sofort (vorher erst in der
|
||||
Hardware-Phase). Templates, gesperrte Gäste und fremde Beschreibungen: Abbruch, nichts angefasst.
|
||||
- Wiederholen leert auch eine feste IP – dieselbe Adresse zweimal ist fast nie gewollt.
|
||||
- Nebenbei gefunden: ssh2 erzeugt in ~0,4 % der Fälle ed25519-Schlüssel, die es selbst nicht
|
||||
lesen kann (Ursache des „wackligen“ SSH-Tests). Behoben mit `ed25519Erzeugen`.
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
# #015 Rollen, Benutzer, Tokens, Audit-Log
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** feature, sicherheit
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Admin/Operator serverseitig erzwungen, jede Aktion protokolliert.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test)
|
||||
- [ ] Benutzerverwaltung, CLI-Tokens
|
||||
- [x] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) – seit #001, jetzt mit den neuen Routen
|
||||
- [x] Benutzerverwaltung (anlegen, Rolle, sperren, Passwort setzen), eigenes Passwort ändern; CLI-Tokens seit v0.4.0
|
||||
- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar
|
||||
- [ ] Audit-Log-Ansicht mit Filter
|
||||
- [x] Audit-Log-Ansicht mit Filter (Benutzer, Quelle, Text, Zeitraum), auch `GET /api/v1/protokoll`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Operator kann keine Server/Katalog/Benutzer ändern (Tests)
|
||||
- [x] Operator kann keine Server/Katalog/Benutzer ändern (Tests in `tests/benutzer.test.ts`)
|
||||
|
||||
## Abhängigkeiten
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Sitzungen beenden ohne Sitzungstabelle:** `Benutzer.sitzungenAb`; ältere JWTs gelten nicht mehr.
|
||||
Gesetzt bei Sperre, Passwort-Reset und eigenem Passwortwechsel (die eigene Sitzung bekommt ein
|
||||
neues Cookie). Auf volle Sekunden, weil `iat` Sekunden hat.
|
||||
- **Schutzregeln serverseitig:** der letzte aktive Admin bleibt Admin und aktiv (Zeilensperre gegen
|
||||
zwei gleichzeitige Herabstufungen), das eigene Konto lässt sich nicht sperren.
|
||||
- Benutzer werden nicht gelöscht, nur gesperrt – Aufträge und Audit-Log verweisen auf sie.
|
||||
- „Einstellungen“ fehlte seit v0.3.0 in der Navigation (`ab: "#015"`) – jetzt sichtbar, dazu „Audit-Log“ für Admins.
|
||||
- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault).
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
# #016 Anmeldung per OIDC
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** feature, sicherheit
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 08 min
|
||||
|
||||
## Ziel
|
||||
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] openid-client mit PKCE, Discovery, Gruppen → Rolle, Konto-Verknüpfung
|
||||
- [x] openid-client 6.8.8 mit PKCE, State, Nonce, Discovery; Gruppen → Rolle bei jeder Anmeldung; Konto-Verknüpfung aus den Einstellungen
|
||||
- [x] Anmeldeseite mit „Mit … anmelden“ und verständlichen Fehlermeldungen (`?sso=…`)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Tests gegen einen Mock-IdP (echter IdP später)
|
||||
- [x] Tests gegen einen Mock-IdP (`tests/oidc-mock.ts`): Anlegen, Rollenwechsel, keine Gruppe, keine Übernahme per Name, Verknüpfen, gesperrt, gefälschter State, fehlendes Cookie, falsche Nonce, fremde Audience – echter IdP beim Feldtest (#020)
|
||||
|
||||
## Abhängigkeiten
|
||||
#015
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Keine automatische Übernahme lokaler Konten über den Namen** – sonst übernähme, wer beim IdP
|
||||
„admin“ heißt, den lokalen Admin. Bei Namensgleichheit bekommt das SSO-Konto „name-2“.
|
||||
- **IdP maßgeblich für die Rolle:** bei jeder Anmeldung aus den Gruppen; Wechsel kommt ins Audit-Log.
|
||||
- Ablaufzustand (Verifier, State, Nonce, Ziel) in einem kurzlebigen signierten Cookie
|
||||
(`pd_oidc`, 10 min, nur `/api/v1/auth/oidc`, SameSite=Lax) statt in der Datenbank.
|
||||
- `redirect_uri` und die Rückruf-URL werden aus `APP_URL` gebildet – hinter dem Reverse-Proxy
|
||||
trägt die Anfrage den internen Host.
|
||||
- Client-Anmeldung `client_secret_post` (Standard von openid-client); authentik und Keycloak können beides.
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
# #017 Branding-Assets
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.6.0
|
||||
**Labels:** design
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 17 min
|
||||
|
||||
## Ziel
|
||||
Alle Logo-Varianten, Banner, Favicons, Manifest-Icons nach Vorgabe.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640
|
||||
- [ ] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons per Skript aus SVG
|
||||
- [x] symbol(-light), icon-small, avatar, logo(-light), banner 1280×640 (+ banner.png für die Social Preview)
|
||||
- [x] Favicon 16/32, Apple-Touch-Icon, Manifest-Icons (192, 512, maskierbar) per Skript aus SVG (`pnpm marke`)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Darstellung in beiden Themes geprüft
|
||||
- [x] Darstellung in beiden Themes geprüft (Vorschau-Tafel dunkel/hell, Favicons auf hellem und dunklem Tab)
|
||||
|
||||
## Abhängigkeiten
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Texte als Pfade:** Wortmarke und Banner sehen überall gleich aus, auch ohne installierte Schrift.
|
||||
Die TTFs (JetBrains Mono 2.304, Space Grotesk 2.0.0, OFL) lädt `pnpm marke` mit SHA-256-Prüfung
|
||||
nach `node_modules/.cache`; eingecheckt werden nur die Ergebnisse.
|
||||
- opentype.js 1.3.5 kennt die Ligatur-Regeln von JetBrains Mono nicht und schreibt in `toPathData`
|
||||
gelegentlich „NaN“ – deshalb Glyph für Glyph gesetzt und Pfaddaten selbst geschrieben.
|
||||
- `icon-small.svg` passt sich per `prefers-color-scheme` an; die PNG-Favicons sind dicke Linien auf
|
||||
Teal, weil 16 px mit den dünnen Linien des Symbols nicht lesbar waren.
|
||||
- Unterzeile im Banner 32 statt 34 px – sonst breiter als die Ränder.
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# #018 README und Dokumentation
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.6.0
|
||||
**Labels:** doku
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 02 min (überwiegend parallel zu #019, während der Installer lief)
|
||||
|
||||
## Ziel
|
||||
README mit Banner, Screenshots, Installation, CLI, Proxmox-Rechten, neuer Distribution, eigenem Profil.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Screenshots aus dem gebauten Stand
|
||||
- [ ] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
||||
- [x] Screenshots aus dem gebauten Stand (`scripts/screenshots.ts`, dunkel, 1440 px und 390 px, echte Daten von pve-demo)
|
||||
- [x] Hinweis: unabhängiges Tool, nicht von Proxmox Server Solutions
|
||||
- [x] Banner oben, Funktionen, Installation (Compose, LXC), Erste Schritte, CLI, OIDC, Katalog erweitern
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Anleitungen einmal Schritt für Schritt nachvollzogen
|
||||
- [x] Anleitungen einmal Schritt für Schritt nachvollzogen: Compose (eigene Env-Datei, frische DB), LXC-Installer auf pve-demo, „Erste Schritte“ in der frisch installierten Instanz (Server anlegen, testen, Token, CLI, Dry-Run, echter LXC). „Neue Distribution“ ist der Weg, auf dem Ubuntu 26.04 dazukam (#010); „Eigenes Profil“ prüft der Playwright-Ablauf `profil-editor`.
|
||||
|
||||
## Abhängigkeiten
|
||||
#013
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Beim Erstellen der Screenshots fiel auf: die Übersicht zeigte seit Etappe 1 nur einen Platzhalter
|
||||
(„Noch keine Deployments“) – jetzt Server mit Nodes, CPUs und freiem RAM sowie die letzten Aufträge.
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
# #019 Docker-Image und LXC-Installer
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.6.0
|
||||
**Labels:** infrastruktur
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Dockerfile (standalone, non-root), Compose mit migrate
|
||||
- [ ] `deploy/install-lxc.sh` (auf dem Proxmox-Host), Update-Pfad
|
||||
- [x] Dockerfile (standalone, non-root), Compose mit migrate – lokal komplett hochgefahren (migrate → app healthy, worker bereit, Anmeldung)
|
||||
- [x] `deploy/install-lxc.sh` (auf dem Proxmox-Host) + `deploy/im-container.sh`, Update-Pfad (`--aktualisieren`, drei Fassungen zum Zurückgehen)
|
||||
- [x] Gemeinsames Laufzeit-Paket `scripts/paket-bauen.sh` für beide Wege
|
||||
- [x] Migrationen ohne Prisma-CLI (`src/betrieb/migrieren.ts`), kompatibel zu `_prisma_migrations`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Installer in echtem LXC durchgelaufen
|
||||
- [x] Installer in echtem LXC durchgelaufen (pve-demo, CT 109 `pd-installer`); darin Server angelegt, Verbindungstest, CLI-Login, Dry-Run und ein echter LXC-Auftrag
|
||||
|
||||
## Abhängigkeiten
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- **Eigener Migrationslauf statt `prisma migrate deploy`:** spart Prisma-CLI und Schema-Engine im
|
||||
Betrieb; schreibt `_prisma_migrations` wie Prisma (Test prüft `prisma migrate status`).
|
||||
- **worker als esbuild-Bundle** mit nur den Paketen, die er importiert (aus der Metadatei, exakte
|
||||
Versionen aus der Entwicklung): 103 MB statt 741 MB.
|
||||
- **Node im LXC von nodejs.org** mit SHA-256-Prüfung (Debian 13 hat nur Node 20).
|
||||
- `ADMIN_PASSWORT` wird nach dem ersten Start aus der Env-Datei entfernt; das Passwort steht nur in der Ausgabe.
|
||||
- Image 559 MB (node:24.21.0-trixie-slim), läuft als UID 1000; PostgreSQL 17.11.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 19 min + 0 h 35 min Abnahme
|
||||
|
||||
## Ziel
|
||||
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
||||
@@ -15,7 +15,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
||||
- [x] Template-Bau auf pve-demo mit abgeschaltetem SSH (2026-10-12, Bau-Adresse 10.0.10.180): Template 9003 tpl-debian-12; daraus VM 111 pd-deb12 ebenfalls ohne Node-SSH ausgerollt, Agent aus dem Template, eigene Maschinen-ID
|
||||
|
||||
## Abhängigkeiten
|
||||
#007
|
||||
@@ -25,3 +25,9 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||
- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt.
|
||||
- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung).
|
||||
- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH.
|
||||
- **Abnahme fand einen echten Fehler:** Das cloud-init-Laufwerk hing als `ide2` an der q35-Maschine
|
||||
(also am SATA-Controller). Der „cloud“-Kernel von Debian 12 hat dafür keinen Treiber – cloud-init
|
||||
fand keine Datenquelle, die VM bootete als „localhost“ ohne Adresse. Debian 13 und Ubuntu kennen
|
||||
den Controller, deshalb fiel es bisher nicht auf. Neue Templates hängen das Laufwerk an `scsi1`
|
||||
(virtio-SCSI wie die System-Disk). Vorhandene Templates bleiben, wie sie sind.
|
||||
- Bau-Adresse per Ping- und ARP-Probe vom Testhost gewählt (10.0.10.150–199 stumm, Florians Freigabe „such dir eine IP“).
|
||||
|
||||
@@ -17,12 +17,12 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | erledigt | v0.3.0 | 6 h | 0 h 10 min |
|
||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | erledigt | v0.3.0 | 3 h | 0 h 11 min |
|
||||
| [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min |
|
||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | offen | v0.5.0 | 4 h | – |
|
||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | offen | v0.5.0 | 6 h | – |
|
||||
| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – |
|
||||
| [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – |
|
||||
| [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – |
|
||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||
| [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | 1 h 21 min |
|
||||
| [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | 0 h 10 min |
|
||||
| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min |
|
||||
| [#017](017-branding.md) | Branding-Assets | erledigt | v0.6.0 | 4 h | 0 h 17 min |
|
||||
| [#018](018-readme.md) | README und Dokumentation | erledigt | v0.6.0 | 4 h | 0 h 02 min |
|
||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | erledigt | v0.6.0 | 6 h | 0 h 20 min |
|
||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | erledigt | v0.3.0 | 3 h | 0 h 54 min |
|
||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
|
||||
|
||||
@@ -43,5 +43,7 @@ export default defineConfig([
|
||||
"playwright-report/**",
|
||||
"dist/**",
|
||||
"cli/**",
|
||||
// Laufzeit-Paket aus scripts/paket-bauen.sh.
|
||||
"paket/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxdeploy",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||
@@ -28,7 +28,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"worker": "tsx src/worker/main.ts",
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"cli:pruefen": "scripts/cli-pruefen.sh"
|
||||
"cli:pruefen": "scripts/cli-pruefen.sh",
|
||||
"marke": "tsx scripts/marke-erzeugen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
@@ -40,6 +41,7 @@
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "1.47.0",
|
||||
"next": "16.4.0",
|
||||
"openid-client": "6.8.8",
|
||||
"pg": "^8.23.0",
|
||||
"pg-boss": "12.37.1",
|
||||
"pino": "10.4.0",
|
||||
@@ -54,8 +56,10 @@
|
||||
"@commitlint/cli": "^21.2.3",
|
||||
"@commitlint/config-conventional": "^21.2.3",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/opentype.js": "^1.3.10",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
@@ -67,6 +71,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.5.1",
|
||||
"opentype.js": "1.3.5",
|
||||
"prettier": "^3.9.9",
|
||||
"prisma": "7.10.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -193,6 +193,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.4.0
|
||||
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
openid-client:
|
||||
specifier: 6.8.8
|
||||
version: 6.8.8
|
||||
pg:
|
||||
specifier: ^8.23.0
|
||||
version: 8.23.1
|
||||
@@ -230,12 +233,18 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
'@resvg/resvg-js':
|
||||
specifier: 2.6.2
|
||||
version: 2.6.2
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3
|
||||
'@types/node':
|
||||
specifier: ^24.13.6
|
||||
version: 24.19.2
|
||||
'@types/opentype.js':
|
||||
specifier: ^1.3.10
|
||||
version: 1.3.10
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -269,6 +278,9 @@ importers:
|
||||
lint-staged:
|
||||
specifier: ^17.5.1
|
||||
version: 17.6.0
|
||||
opentype.js:
|
||||
specifier: 1.3.5
|
||||
version: 1.3.5
|
||||
prettier:
|
||||
specifier: ^3.9.9
|
||||
version: 3.9.10
|
||||
@@ -1354,6 +1366,86 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||
resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1625,6 +1717,9 @@ packages:
|
||||
'@types/node@24.19.2':
|
||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||
|
||||
'@types/opentype.js@1.3.10':
|
||||
resolution: {integrity: sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
@@ -3183,6 +3278,9 @@ packages:
|
||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
oauth4webapi@3.8.8:
|
||||
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3226,6 +3324,14 @@ packages:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
openid-client@6.8.8:
|
||||
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
|
||||
|
||||
opentype.js@1.3.5:
|
||||
resolution: {integrity: sha512-thKDiELidAApOvXlncrpwDZKJCa9fXLEKM4+FoEWI+qTLDeNb+h7EkN+8a7KQODsB1GZ+Exz9KknkoPrEdXZDw==}
|
||||
deprecated: This version should be ignored, it was meant to be published as 2.0.0 but got published as 1.3.5 by mistake. As of May 6th 2026 the latest v1 release is v1.3.4
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4879,6 +4985,57 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
optionalDependencies:
|
||||
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.13':
|
||||
optional: true
|
||||
|
||||
@@ -5069,6 +5226,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/opentype.js@1.3.10': {}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
@@ -6784,6 +6943,8 @@ snapshots:
|
||||
|
||||
non-error@0.1.0: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -6832,6 +6993,13 @@ snapshots:
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
openid-client@6.8.8:
|
||||
dependencies:
|
||||
jose: 6.2.12
|
||||
oauth4webapi: 3.8.8
|
||||
|
||||
opentype.js@1.3.5: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenAb" TIMESTAMP(3);
|
||||
@@ -33,6 +33,8 @@ model Benutzer {
|
||||
oidcSubjekt String? @unique
|
||||
rolle Rolle @default(OPERATOR)
|
||||
aktiv Boolean @default(true)
|
||||
/// Sitzungen, die vorher ausgestellt wurden, gelten nicht mehr (Passwortwechsel, Sperre).
|
||||
sitzungenAb DateTime?
|
||||
/// Persönliche Standardwerte im Assistenten.
|
||||
standards Json?
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#2DD4BF"/>
|
||||
<g transform="translate(7.039999999999999 7.039999999999999) scale(0.78)">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#11161C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#11161C" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 887 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<style>.h{stroke:#E6EDF3}.p{stroke:#2DD4BF}@media (prefers-color-scheme: light){.h{stroke:#11161C}.p{stroke:#0F766E}}</style>
|
||||
<path class="h" d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke-width="5.5" stroke-linejoin="round"/>
|
||||
<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#11161C"/>
|
||||
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#0F766E"/>
|
||||
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#0F766E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 282 64" width="282" height="64" fill="none">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M83.46 43L80.76 43L80.76 21.1L87.81 21.1Q90.87 21.1 92.69 22.86Q94.5 24.61 94.5 27.55L94.5 27.55Q94.5 30.49 92.69 32.25Q90.87 34 87.81 34L87.81 34L83.46 34L83.46 43M83.46 23.53L83.46 31.57L87.81 31.57Q89.58 31.57 90.65 30.48Q91.71 29.38 91.71 27.55L91.71 27.55Q91.71 25.69 90.65 24.61Q89.58 23.53 87.81 23.53L87.81 23.53L83.46 23.53 M102.06 43L99.36 43L99.36 26.5L102.06 26.5L102.06 29.65L102.12 29.65Q102.33 28.06 103.47 27.13Q104.61 26.2 106.53 26.2L106.53 26.2Q109.11 26.2 110.51 27.75Q111.9 29.29 111.9 32.14L111.9 32.14L111.9 33.55L109.2 33.55L109.2 32.14Q109.2 28.54 105.69 28.54L105.69 28.54Q103.92 28.54 102.99 29.56Q102.06 30.58 102.06 32.5L102.06 32.5L102.06 43 M123 43.24L123 43.24Q120.03 43.24 118.28 41.5Q116.52 39.76 116.52 36.64L116.52 36.64L116.52 32.86Q116.52 29.71 118.26 27.99Q120 26.26 123 26.26L123 26.26Q126 26.26 127.74 27.99Q129.48 29.71 129.48 32.86L129.48 32.86L129.48 36.64Q129.48 39.76 127.73 41.5Q125.97 43.24 123 43.24M123 40.84L123 40.84Q124.77 40.84 125.78 39.85Q126.78 38.86 126.78 36.94L126.78 36.94L126.78 32.56Q126.78 30.64 125.78 29.65Q124.77 28.66 123 28.66L123 28.66Q121.26 28.66 120.24 29.65Q119.22 30.64 119.22 32.56L119.22 32.56L119.22 36.94Q119.22 38.86 120.24 39.85Q121.26 40.84 123 40.84 M136.68 43L133.5 43L139.35 34.51L133.86 26.5L137.04 26.5L140.31 31.6Q140.52 31.93 140.72 32.32Q140.91 32.71 141.03 32.95L141.03 32.95Q141.12 32.71 141.32 32.32Q141.51 31.93 141.72 31.6L141.72 31.6L144.99 26.5L148.17 26.5L142.68 34.54L148.5 43L145.32 43L141.78 37.6Q141.57 37.27 141.38 36.84Q141.18 36.4 141.03 36.13L141.03 36.13Q140.91 36.4 140.69 36.84Q140.46 37.27 140.22 37.6L140.22 37.6L136.68 43" fill="#E6EDF3"/>
|
||||
<path d="M158.76 43L152.25 43L152.25 21.1L158.76 21.1Q160.98 21.1 162.62 21.96Q164.25 22.81 165.15 24.34Q166.05 25.87 166.05 27.94L166.05 27.94L166.05 36.13Q166.05 38.2 165.15 39.74Q164.25 41.29 162.62 42.15Q160.98 43 158.76 43L158.76 43M156 24.46L156 39.64L158.76 39.64Q160.38 39.64 161.34 38.7Q162.3 37.75 162.3 36.13L162.3 36.13L162.3 27.94Q162.3 26.35 161.34 25.41Q160.38 24.46 158.76 24.46L158.76 24.46L156 24.46 M177.03 43.3L177.03 43.3Q174.93 43.3 173.37 42.49Q171.81 41.68 170.96 40.2Q170.1 38.71 170.1 36.7L170.1 36.7L170.1 32.8Q170.1 30.79 170.96 29.31Q171.81 27.82 173.37 27.01Q174.93 26.2 177.03 26.2L177.03 26.2Q179.1 26.2 180.65 27.01Q182.19 27.82 183.05 29.28Q183.9 30.73 183.9 32.68L183.9 32.68L183.9 35.68L173.7 35.68L173.7 36.82Q173.7 38.5 174.57 39.35Q175.44 40.21 177.03 40.21L177.03 40.21Q178.2 40.21 179.03 39.81Q179.85 39.4 180 38.62L180 38.62L183.72 38.62Q183.33 40.75 181.5 42.03Q179.67 43.3 177.03 43.3M173.7 32.68L173.7 32.68L173.7 33.25L180.3 33.22L180.3 32.65Q180.3 30.94 179.46 30.01Q178.62 29.08 177.03 29.08L177.03 29.08Q175.38 29.08 174.54 30.03Q173.7 30.97 173.7 32.68 M192.06 48.4L188.31 48.4L188.31 26.5L191.97 26.5L191.97 29.53Q192.42 27.97 193.59 27.09Q194.76 26.2 196.5 26.2L196.5 26.2Q198.09 26.2 199.31 26.98Q200.52 27.76 201.2 29.19Q201.87 30.61 201.87 32.5L201.87 32.5L201.87 36.97Q201.87 39.82 200.4 41.56Q198.93 43.3 196.5 43.3L196.5 43.3Q194.76 43.3 193.58 42.4Q192.39 41.5 191.94 39.88L191.94 39.88L192.06 43.78L192.06 48.4M195.09 40.06L195.09 40.06Q196.53 40.06 197.33 39.28Q198.12 38.5 198.12 36.88L198.12 36.88L198.12 32.62Q198.12 31 197.33 30.22Q196.53 29.44 195.09 29.44L195.09 29.44Q193.65 29.44 192.86 30.31Q192.06 31.18 192.06 32.77L192.06 32.77L192.06 36.73Q192.06 38.32 192.86 39.19Q193.65 40.06 195.09 40.06 M220.77 43L215.67 43Q213.21 43 211.73 41.53Q210.24 40.06 210.24 37.66L210.24 37.66L210.24 24.49L204.81 24.49L204.81 21.1L213.99 21.1L213.99 37.57Q213.99 38.5 214.52 39.06Q215.04 39.61 215.94 39.61L215.94 39.61L220.77 39.61L220.77 43 M231 43.27L231 43.27Q227.82 43.27 225.96 41.49Q224.1 39.7 224.1 36.67L224.1 36.67L224.1 32.83Q224.1 29.77 225.96 28Q227.82 26.23 231 26.23L231 26.23Q234.18 26.23 236.04 28Q237.9 29.77 237.9 32.83L237.9 32.83L237.9 36.67Q237.9 39.7 236.04 41.49Q234.18 43.27 231 43.27M231 40L231 40Q232.5 40 233.33 39.17Q234.15 38.35 234.15 36.79L234.15 36.79L234.15 32.71Q234.15 31.15 233.33 30.33Q232.5 29.5 231 29.5L231 29.5Q229.5 29.5 228.68 30.33Q227.85 31.15 227.85 32.71L227.85 32.71L227.85 36.79Q227.85 38.35 228.68 39.17Q229.5 40 231 40 M248.88 48.4L244.95 48.4L247.29 42.1L241.08 26.5L245.04 26.5L248.22 34.93Q248.49 35.68 248.76 36.57Q249.03 37.45 249.15 38.2L249.15 38.2Q249.24 37.45 249.5 36.57Q249.75 35.68 250.02 34.93L250.02 34.93L252.96 26.5L256.92 26.5L248.88 48.4" fill="#2DD4BF"/>
|
||||
<rect x="261.60" y="19.30" width="15.60" height="27.90" fill="#2DD4BF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#11161C" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#0F766E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#0F766E" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 411 B After Width: | Height: | Size: 434 B |
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Erzeugt alle Marken-Dateien unter public/brand/ aus dem Symbol der Vorgabe:
|
||||
*
|
||||
* symbol.svg, symbol-light.svg, icon-small.svg, avatar.svg, logo.svg, logo-light.svg, banner.svg,
|
||||
* favicon-16.png, favicon-32.png, apple-touch-icon.png, icon-192.png, icon-512.png,
|
||||
* icon-maskable-512.png
|
||||
*
|
||||
* Texte (Wortmarke, Banner) werden zu Pfaden – die SVGs sehen damit überall gleich aus, auch ohne
|
||||
* installierte Schrift (README auf Gitea, Social Preview). Die Schriften (JetBrains Mono 2.304,
|
||||
* Space Grotesk 2.0.0, beide SIL OFL) werden dafür einmal geladen, per SHA-256 geprüft und unter
|
||||
* node_modules/.cache abgelegt; eingecheckt werden nur die Ergebnisse.
|
||||
*
|
||||
* pnpm marke
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import opentype from "opentype.js";
|
||||
|
||||
const ZIEL = "public/brand";
|
||||
const CACHE = "node_modules/.cache/proxdeploy-schriften";
|
||||
|
||||
const FARBE = {
|
||||
ink: "#11161C",
|
||||
panel: "#1A2129",
|
||||
linie: "#2A333D",
|
||||
muted: "#8B98A5",
|
||||
paper: "#E6EDF3",
|
||||
teal: "#2DD4BF",
|
||||
tealDunkel: "#0F766E",
|
||||
};
|
||||
|
||||
// ── Schriften ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ARCHIVE = {
|
||||
jetbrains: {
|
||||
url: "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip",
|
||||
sha256: "6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf",
|
||||
},
|
||||
grotesk: {
|
||||
url: "https://github.com/floriankarsten/space-grotesk/releases/download/2.0.0/SpaceGrotesk-2.0.0.zip",
|
||||
sha256: "53b415577d4139248555300710bea0d268c7a5be67b93de53b716a9736cabffd",
|
||||
},
|
||||
};
|
||||
|
||||
async function archivHolen(name: keyof typeof ARCHIVE): Promise<string> {
|
||||
const { url, sha256 } = ARCHIVE[name];
|
||||
mkdirSync(CACHE, { recursive: true });
|
||||
const pfad = join(CACHE, `${name}.zip`);
|
||||
if (!existsSync(pfad)) {
|
||||
const antwort = await fetch(url);
|
||||
if (!antwort.ok) throw new Error(`${url}: HTTP ${antwort.status}`);
|
||||
writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
|
||||
}
|
||||
const ist = createHash("sha256").update(readFileSync(pfad)).digest("hex");
|
||||
if (ist !== sha256) throw new Error(`${pfad}: Prüfsumme ${ist} statt ${sha256}`);
|
||||
return pfad;
|
||||
}
|
||||
|
||||
function schriftAus(archiv: string, datei: string): opentype.Font {
|
||||
const daten = execFileSync("unzip", ["-p", archiv, datei], { maxBuffer: 16 * 1024 * 1024 });
|
||||
return opentype.parse(daten.buffer.slice(daten.byteOffset, daten.byteOffset + daten.byteLength));
|
||||
}
|
||||
|
||||
type Schriften = { mono: opentype.Font; monoFett: opentype.Font; grotesk: opentype.Font };
|
||||
|
||||
async function schriftenLaden(): Promise<Schriften> {
|
||||
const jb = await archivHolen("jetbrains");
|
||||
const sg = await archivHolen("grotesk");
|
||||
return {
|
||||
mono: schriftAus(jb, "fonts/ttf/JetBrainsMono-Regular.ttf"),
|
||||
monoFett: schriftAus(jb, "fonts/ttf/JetBrainsMono-Bold.ttf"),
|
||||
grotesk: schriftAus(sg, "SpaceGrotesk-2.0.0/ttf/static/SpaceGrotesk-Regular.ttf"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfaddaten selbst schreiben: `toPathData` von opentype.js 1.3.5 liefert an manchen Positionen
|
||||
* „NaN“ (beim Runden), und resvg bricht den Pfad dort ab.
|
||||
*/
|
||||
function pfadDaten(pfad: opentype.Path): string {
|
||||
const z = (wert: number) => {
|
||||
if (!Number.isFinite(wert)) throw new Error(`Ungültige Koordinate ${wert}`);
|
||||
return String(Math.round(wert * 100) / 100);
|
||||
};
|
||||
return pfad.commands
|
||||
.map((b) => {
|
||||
switch (b.type) {
|
||||
case "M":
|
||||
case "L":
|
||||
return `${b.type}${z(b.x)} ${z(b.y)}`;
|
||||
case "Q":
|
||||
return `Q${z(b.x1)} ${z(b.y1)} ${z(b.x)} ${z(b.y)}`;
|
||||
case "C":
|
||||
return `C${z(b.x1)} ${z(b.y1)} ${z(b.x2)} ${z(b.y2)} ${z(b.x)} ${z(b.y)}`;
|
||||
default:
|
||||
return "Z";
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Text als Pfad; liefert Pfaddaten und die Breite. */
|
||||
function text(schrift: opentype.Font, inhalt: string, x: number, y: number, groesse: number) {
|
||||
for (const zeichen of inhalt) {
|
||||
if (schrift.charToGlyph(zeichen).index === 0 && zeichen !== " ") {
|
||||
throw new Error(`${schrift.names.fullName?.en}: kein Glyph für „${zeichen}“`);
|
||||
}
|
||||
}
|
||||
// Glyph für Glyph: opentype.js 1.3.5 scheitert an den kontextuellen Ersetzungen (Ligaturen)
|
||||
// von JetBrains Mono; Logo und Banner brauchen keine.
|
||||
const faktor = groesse / schrift.unitsPerEm;
|
||||
let lauf = x;
|
||||
const teile: string[] = [];
|
||||
for (const zeichen of inhalt) {
|
||||
const glyph = schrift.charToGlyph(zeichen);
|
||||
teile.push(pfadDaten(glyph.getPath(lauf, y, groesse)));
|
||||
lauf += (glyph.advanceWidth ?? 0) * faktor;
|
||||
}
|
||||
return { d: teile.filter(Boolean).join(" "), breite: lauf - x };
|
||||
}
|
||||
|
||||
// ── Symbol ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SECHSECK = "M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z";
|
||||
|
||||
function symbolInhalt(rahmen: string, akzent: string): string {
|
||||
return [
|
||||
`<path d="${SECHSECK}" stroke="${rahmen}" stroke-width="3.5" stroke-linejoin="round"/>`,
|
||||
`<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="${akzent}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
`<path d="M24 47 H40" stroke="${akzent}" stroke-width="4" stroke-linecap="round"/>`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
const svg = (breite: number, hoehe: number, inhalt: string, extra = "") =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${breite} ${hoehe}" width="${breite}" height="${hoehe}" fill="none"${extra}>\n ${inhalt}\n</svg>\n`;
|
||||
|
||||
/** Favicon-Variante: ohne Sockel, dicke Linien, passt sich dem hellen oder dunklen Browser an. */
|
||||
function iconKlein(): string {
|
||||
return svg(
|
||||
64,
|
||||
64,
|
||||
[
|
||||
`<style>.h{stroke:${FARBE.paper}}.p{stroke:${FARBE.teal}}@media (prefers-color-scheme: light){.h{stroke:${FARBE.ink}}.p{stroke:${FARBE.tealDunkel}}}</style>`,
|
||||
`<path class="h" d="${SECHSECK}" stroke-width="5.5" stroke-linejoin="round"/>`,
|
||||
`<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
].join("\n "),
|
||||
);
|
||||
}
|
||||
|
||||
/** Avatar/App-Icon: abgerundetes Quadrat in Teal, Symbol in Ink. `randlos` für maskierbare Icons. */
|
||||
function avatar(randlos = false): string {
|
||||
const hintergrund = randlos
|
||||
? `<rect width="64" height="64" fill="${FARBE.teal}"/>`
|
||||
: `<rect width="64" height="64" rx="14" fill="${FARBE.teal}"/>`;
|
||||
// Maskierbar: Inhalt in der sicheren Zone (80 % in der Mitte).
|
||||
const massstab = randlos ? 0.62 : 0.78;
|
||||
const versatz = 32 * (1 - massstab);
|
||||
return svg(
|
||||
64,
|
||||
64,
|
||||
`${hintergrund}\n <g transform="translate(${versatz} ${versatz}) scale(${massstab})">\n ${symbolInhalt(FARBE.ink, FARBE.ink)}\n </g>`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Favicon als Rasterbild (16/32 px): dicke Linien ohne Sockel auf Teal – auf jedem Tab-Hintergrund lesbar. */
|
||||
function faviconRaster(): string {
|
||||
return svg(
|
||||
64,
|
||||
64,
|
||||
[
|
||||
`<rect width="64" height="64" rx="12" fill="${FARBE.teal}"/>`,
|
||||
`<g transform="translate(4 4) scale(0.875)">`,
|
||||
`<path d="${SECHSECK}" stroke="${FARBE.ink}" stroke-width="6" stroke-linejoin="round"/>`,
|
||||
`<path d="M32 43 V18 M22 28 L32 18 L42 28" stroke="${FARBE.ink}" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
`</g>`,
|
||||
].join("\n "),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logo: Symbol + Wortmarke ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wortmarke(s: Schriften, x: number, grundlinie: number, groesse: number, hell: boolean) {
|
||||
const prox = text(s.mono, "Prox", x, grundlinie, groesse);
|
||||
const deploy = text(s.monoFett, "Deploy", x + prox.breite, grundlinie, groesse);
|
||||
const versalhoehe = (s.mono.tables["os2"]?.sCapHeight ?? 730) / s.mono.unitsPerEm;
|
||||
const cursorX = x + prox.breite + deploy.breite + groesse * 0.12;
|
||||
const cursorBreite = groesse * 0.52;
|
||||
const cursorOben = grundlinie - versalhoehe * groesse - groesse * 0.06;
|
||||
const cursorHoehe = versalhoehe * groesse + groesse * 0.2;
|
||||
const text1 = hell ? FARBE.ink : FARBE.paper;
|
||||
const akzent = hell ? FARBE.tealDunkel : FARBE.teal;
|
||||
return {
|
||||
inhalt: [
|
||||
`<path d="${prox.d}" fill="${text1}"/>`,
|
||||
`<path d="${deploy.d}" fill="${akzent}"/>`,
|
||||
`<rect x="${cursorX.toFixed(2)}" y="${cursorOben.toFixed(2)}" width="${cursorBreite.toFixed(2)}" height="${cursorHoehe.toFixed(2)}" fill="${akzent}"/>`,
|
||||
].join("\n "),
|
||||
ende: cursorX + cursorBreite,
|
||||
};
|
||||
}
|
||||
|
||||
function logo(s: Schriften, hell: boolean): string {
|
||||
const w = wortmarke(s, 78, 43, 30, hell);
|
||||
const breite = Math.ceil(w.ende + 4);
|
||||
return svg(
|
||||
breite,
|
||||
64,
|
||||
`${symbolInhalt(hell ? FARBE.ink : FARBE.paper, hell ? FARBE.tealDunkel : FARBE.teal)}\n ${w.inhalt}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner 1280×640 ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function banner(s: Schriften): string {
|
||||
const teile: string[] = [`<rect width="1280" height="640" fill="${FARBE.ink}"/>`];
|
||||
// Großes, sehr transparentes Sechseck im Hintergrund.
|
||||
teile.push(
|
||||
`<g transform="translate(700 -60) scale(12)" opacity="0.07"><path d="${SECHSECK}" stroke="${FARBE.paper}" stroke-width="1.2" stroke-linejoin="round"/></g>`,
|
||||
);
|
||||
// Logo groß, links.
|
||||
const massstab = 2;
|
||||
teile.push(
|
||||
`<g transform="translate(96 120) scale(${massstab})">\n ${symbolInhalt(FARBE.paper, FARBE.teal)}\n ${wortmarke(s, 78, 43, 30, false).inhalt}\n </g>`,
|
||||
);
|
||||
// Unterzeile in Space Grotesk.
|
||||
const zeile = text(
|
||||
s.grotesk,
|
||||
"VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||
96,
|
||||
330,
|
||||
32,
|
||||
);
|
||||
if (zeile.breite > 1280 - 2 * 96) throw new Error(`Unterzeile zu breit (${zeile.breite})`);
|
||||
teile.push(`<path d="${zeile.d}" fill="${FARBE.paper}"/>`);
|
||||
// Tags als Outline-Pills.
|
||||
let x = 96;
|
||||
for (const tag of ["Web-GUI", "CLI", "cloud-init", "LXC", "Debian", "Ubuntu"]) {
|
||||
const t = text(s.mono, tag, 0, 0, 20);
|
||||
const breite = t.breite + 36;
|
||||
teile.push(
|
||||
`<rect x="${x}" y="378" width="${breite.toFixed(1)}" height="40" rx="20" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||
`<path d="${text(s.mono, tag, x + 18, 405, 20).d}" fill="${FARBE.muted}"/>`,
|
||||
);
|
||||
x += breite + 12;
|
||||
}
|
||||
// Kleine Terminal-Karte rechts unten.
|
||||
const karte = { x: 744, y: 470, b: 440, h: 110 };
|
||||
teile.push(
|
||||
`<rect x="${karte.x}" y="${karte.y}" width="${karte.b}" height="${karte.h}" rx="14" fill="${FARBE.panel}" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||||
`<path d="${text(s.mono, "# proxdeploy lxc dns01 --profile base", karte.x + 24, karte.y + 44, 18).d}" fill="${FARBE.muted}"/>`,
|
||||
);
|
||||
const haken = text(s.mono, "✓", karte.x + 24, karte.y + 80, 18);
|
||||
teile.push(
|
||||
`<path d="${haken.d}" fill="${FARBE.teal}"/>`,
|
||||
`<path d="${text(s.mono, " dns01 bereit", karte.x + 24 + haken.breite, karte.y + 80, 18).d}" fill="${FARBE.paper}"/>`,
|
||||
);
|
||||
return svg(1280, 640, teile.join("\n "));
|
||||
}
|
||||
|
||||
// ── PNG ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function png(quelle: string, breite: number, datei: string): void {
|
||||
const bild = new Resvg(quelle, { fitTo: { mode: "width", value: breite } }).render();
|
||||
writeFileSync(join(ZIEL, datei), bild.asPng());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const s = await schriftenLaden();
|
||||
mkdirSync(ZIEL, { recursive: true });
|
||||
const dateien: Record<string, string> = {
|
||||
"symbol.svg": svg(64, 64, symbolInhalt(FARBE.paper, FARBE.teal)),
|
||||
"symbol-light.svg": svg(64, 64, symbolInhalt(FARBE.ink, FARBE.tealDunkel)),
|
||||
"icon-small.svg": iconKlein(),
|
||||
"avatar.svg": avatar(),
|
||||
"logo.svg": logo(s, false),
|
||||
"logo-light.svg": logo(s, true),
|
||||
"banner.svg": banner(s),
|
||||
};
|
||||
for (const [name, inhalt] of Object.entries(dateien)) writeFileSync(join(ZIEL, name), inhalt);
|
||||
|
||||
// Raster-Icons aus dem Avatar: auf hellem und dunklem Grund gleich gut sichtbar.
|
||||
png(faviconRaster(), 16, "favicon-16.png");
|
||||
png(faviconRaster(), 32, "favicon-32.png");
|
||||
png(dateien["avatar.svg"]!, 192, "icon-192.png");
|
||||
png(dateien["avatar.svg"]!, 512, "icon-512.png");
|
||||
png(avatar(true), 180, "apple-touch-icon.png");
|
||||
png(avatar(true), 512, "icon-maskable-512.png");
|
||||
// Vorschau des Banners (Social Preview verlangt ein Rasterbild).
|
||||
png(dateien["banner.svg"]!, 1280, "banner.png");
|
||||
console.log(`${Object.keys(dateien).length} SVG und 7 PNG unter ${ZIEL}/ geschrieben`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut das Laufzeit-Paket von ProxDeploy – dieselbe Struktur für Docker-Image und LXC-Installer:
|
||||
#
|
||||
# paket/
|
||||
# app/ Next standalone (server.js, .next, node_modules), dazu public/, catalog/,
|
||||
# prisma/migrations/ – Arbeitsverzeichnis für app und worker
|
||||
# worker/worker.mjs worker als ein Bundle (esbuild); Pakete aus worker/node_modules
|
||||
# worker/migrieren.mjs Migrationen ohne Prisma-CLI
|
||||
# worker/node_modules/ Produktions-Abhängigkeiten, flach (ohne Symlinks)
|
||||
# VERSION
|
||||
#
|
||||
# scripts/paket-bauen.sh [version] → paket/
|
||||
# scripts/paket-bauen.sh 0.6.0 --tar → zusätzlich proxdeploy-0.6.0.tar.gz
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
version="${1:-$(node -p 'require("./package.json").version')}"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo unbekannt)"
|
||||
|
||||
rm -rf paket
|
||||
mkdir -p paket/worker
|
||||
|
||||
# 1. Web-App (standalone). Version und Commit landen im Build (Seitenleiste, /api/v1/ich).
|
||||
NEXT_PUBLIC_APP_VERSION="$version" NEXT_PUBLIC_GIT_SHA="$commit" pnpm build >/dev/null
|
||||
cp -a .next/standalone paket/app
|
||||
cp -a .next/static paket/app/.next/static
|
||||
cp -a public paket/app/public
|
||||
cp -a catalog paket/app/catalog
|
||||
mkdir -p paket/app/prisma && cp -a prisma/migrations paket/app/prisma/migrations
|
||||
|
||||
# 2. worker und Migrationslauf als Bundles; eigener Code (inkl. Prisma-Client aus src/generated)
|
||||
# wird gebündelt, Pakete bleiben extern.
|
||||
for eintrag in "src/worker/main.ts:worker.mjs" "src/betrieb/migrieren-start.ts:migrieren.mjs"; do
|
||||
pnpm exec esbuild "${eintrag%%:*}" --bundle --platform=node --format=esm --target=node24 \
|
||||
--packages=external --log-level=warning \
|
||||
--define:process.env.NEXT_PUBLIC_APP_VERSION="\"$version\"" \
|
||||
--define:process.env.NEXT_PUBLIC_GIT_SHA="\"$commit\"" \
|
||||
--metafile="paket/worker/${eintrag##*:}.meta.json" \
|
||||
--outfile="paket/worker/${eintrag##*:}"
|
||||
done
|
||||
|
||||
# 3. Nur die Pakete, die die Bundles wirklich importieren (aus den esbuild-Metadateien), in den
|
||||
# Versionen aus package.json – flach, ohne Build-Skripte. Next, React & Co. braucht der worker nicht.
|
||||
tmp="$(mktemp -d)"
|
||||
node - "$tmp" paket/worker/*.meta.json <<'JS'
|
||||
const fs = require("node:fs");
|
||||
const [ziel, ...metas] = process.argv.slice(2);
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||
const benoetigt = new Set();
|
||||
for (const datei of metas) {
|
||||
const meta = JSON.parse(fs.readFileSync(datei, "utf8"));
|
||||
for (const eingabe of Object.values(meta.inputs))
|
||||
for (const imp of eingabe.imports ?? [])
|
||||
if (imp.external && !imp.path.startsWith("node:")) {
|
||||
const teile = imp.path.split("/");
|
||||
benoetigt.add(imp.path.startsWith("@") ? teile.slice(0, 2).join("/") : teile[0]);
|
||||
}
|
||||
}
|
||||
const abhaengigkeiten = {};
|
||||
for (const name of [...benoetigt].sort()) {
|
||||
if (!pkg.dependencies[name]) throw new Error(`${name} wird vom worker importiert, steht aber nicht in dependencies`);
|
||||
// Exakt die Version, die in der Entwicklung installiert ist (aus dem Lockfile aufgelöst).
|
||||
abhaengigkeiten[name] = JSON.parse(fs.readFileSync(`node_modules/${name}/package.json`, "utf8")).version;
|
||||
}
|
||||
fs.writeFileSync(`${ziel}/package.json`, JSON.stringify({ name: "proxdeploy-worker", private: true, type: "module", dependencies: abhaengigkeiten }, null, 2));
|
||||
console.log(`worker braucht: ${Object.entries(abhaengigkeiten).map(([n, v]) => `${n}@${v}`).join(", ")}`);
|
||||
JS
|
||||
rm -f paket/worker/*.meta.json
|
||||
cp pnpm-workspace.yaml "$tmp/"
|
||||
(cd "$tmp" && pnpm install --prod --ignore-scripts --config.node-linker=hoisted >/dev/null)
|
||||
mv "$tmp/node_modules" paket/worker/node_modules
|
||||
rm -rf "$tmp"
|
||||
|
||||
echo "$version+$commit" > paket/VERSION
|
||||
echo "paket/ gebaut ($version+$commit, $(du -sh paket | cut -f1))"
|
||||
|
||||
if [ "${2:-}" = "--tar" ]; then
|
||||
tar -czf "proxdeploy-$version.tar.gz" -C paket .
|
||||
sha256sum "proxdeploy-$version.tar.gz"
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Screenshots für das README aus dem gebauten Stand (`scripts/neu-starten.sh`, Port 3310), dunkles
|
||||
* Theme, 1440 px und 390 px. Ergebnis: docs/bilder/*.png
|
||||
*
|
||||
* pnpm exec tsx scripts/screenshots.ts [auftrag-kurzid]
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, type Page } from "@playwright/test";
|
||||
|
||||
const BASIS = process.env["SCREENSHOT_URL"] ?? "http://127.0.0.1:3310";
|
||||
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
||||
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
||||
const ZIEL = "docs/bilder";
|
||||
|
||||
async function anmelden(seite: Page) {
|
||||
await seite.goto(`${BASIS}/anmelden`);
|
||||
await seite.getByLabel("Benutzername").fill(BENUTZER);
|
||||
await seite.getByLabel("Passwort").fill(PASSWORT);
|
||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||
await seite.waitForURL((url) => !url.pathname.startsWith("/anmelden"));
|
||||
}
|
||||
|
||||
async function bild(seite: Page, name: string, ganz = false) {
|
||||
await seite.waitForLoadState("networkidle");
|
||||
// Blinkender Cursor und Spinner sollen nicht zufällig halb im Bild stehen.
|
||||
await seite.addStyleTag({ content: "*{animation:none!important;transition:none!important}" });
|
||||
await seite.screenshot({ path: `${ZIEL}/${name}.png`, fullPage: ganz });
|
||||
console.log(`${ZIEL}/${name}.png`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const auftrag = process.argv[2];
|
||||
if (!auftrag) throw new Error("Kurz-ID eines erfolgreichen Auftrags angeben (für die Live-Ansicht).");
|
||||
mkdirSync(ZIEL, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const kontext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await kontext.addCookies([{ name: "pd_theme", value: "dunkel", url: BASIS }]);
|
||||
const seite = await kontext.newPage();
|
||||
await anmelden(seite);
|
||||
|
||||
await seite.goto(`${BASIS}/`);
|
||||
await bild(seite, "uebersicht");
|
||||
|
||||
await seite.goto(`${BASIS}/auftraege/${auftrag}`);
|
||||
await bild(seite, "live-ansicht");
|
||||
|
||||
await seite.goto(`${BASIS}/auftraege`);
|
||||
await bild(seite, "verlauf");
|
||||
|
||||
await seite.goto(`${BASIS}/server/demo`);
|
||||
await bild(seite, "server", true);
|
||||
|
||||
await seite.goto(`${BASIS}/katalog`);
|
||||
await bild(seite, "katalog");
|
||||
|
||||
// Assistent bis zur Zusammenfassung – mit echtem Dry-Run gegen den Server.
|
||||
await seite.goto(`${BASIS}/ausrollen`);
|
||||
await seite.waitForLoadState("networkidle");
|
||||
const weiter = async (bis: number) => {
|
||||
await seite.getByRole("button", { name: "Weiter" }).click();
|
||||
await seite.getByText(`Schritt ${bis} von 9`).waitFor();
|
||||
};
|
||||
for (let s = 2; s <= 4; s++) await weiter(s);
|
||||
await seite.getByLabel("Name (Hostname)").fill("web01");
|
||||
for (let s = 5; s <= 7; s++) await weiter(s);
|
||||
// Ohne hinterlegten Schlüssel einen angeben (öffentlicher Beispielschlüssel).
|
||||
const feld = seite.locator("textarea#schluessel");
|
||||
if ((await feld.inputValue()) === "")
|
||||
await feld.fill(
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@laptop",
|
||||
);
|
||||
for (let s = 8; s <= 9; s++) await weiter(s);
|
||||
await seite.getByText("Geplante Proxmox-Aufrufe").waitFor({ timeout: 60_000 });
|
||||
await bild(seite, "assistent-zusammenfassung");
|
||||
|
||||
const handy = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
|
||||
await handy.addCookies([{ name: "pd_theme", value: "dunkel", url: BASIS }]);
|
||||
const klein = await handy.newPage();
|
||||
await anmelden(klein);
|
||||
await klein.goto(`${BASIS}/auftraege/${auftrag}`);
|
||||
await bild(klein, "handy-live-ansicht");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Code } from "@/components/formular";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||
import { TerminalKarte } from "@/components/terminal-karte";
|
||||
import { apiAufruf } from "@/lib/api-client";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
|
||||
|
||||
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||
@@ -19,13 +21,15 @@ export function LiveAnsicht({
|
||||
anfang,
|
||||
anfangsLog,
|
||||
version,
|
||||
abbrechenErlaubt,
|
||||
darfAusrollen,
|
||||
}: {
|
||||
anfang: AuftragSicht;
|
||||
anfangsLog: LogZeile[];
|
||||
version: string;
|
||||
abbrechenErlaubt: boolean;
|
||||
darfAusrollen: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [aktionFehler, setAktionFehler] = useState<string | null>(null);
|
||||
const [auftrag, setAuftrag] = useState(anfang);
|
||||
const [log, setLog] = useState(anfangsLog);
|
||||
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
|
||||
@@ -90,6 +94,25 @@ export function LiveAnsicht({
|
||||
const ergebnis = auftrag.abschluss;
|
||||
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
|
||||
|
||||
async function aufraeumen() {
|
||||
const ziel = auftrag.aufraeumbar;
|
||||
if (!ziel) return;
|
||||
const wort = ziel.typ === "LXC" ? "Container" : "VM";
|
||||
const frage =
|
||||
`${wort} ${ziel.vmid}${ziel.knoten ? ` auf ${ziel.knoten}` : ""} stoppen und mit allen Disks löschen?\n\n` +
|
||||
`ProxDeploy löscht nur, wenn die Beschreibung des Gastes auf ${auftrag.kurzId} verweist.`;
|
||||
if (!window.confirm(frage)) return;
|
||||
try {
|
||||
const neu = await apiAufruf<{ kurzId: string }>(
|
||||
"POST",
|
||||
`/api/v1/auftraege/${auftrag.kurzId}/aufraeumen`,
|
||||
);
|
||||
router.push(`/auftraege/${neu.kurzId}`);
|
||||
} catch (fehler) {
|
||||
setAktionFehler(fehler instanceof ApiFehler ? fehler.message : "Aufräumen ließ sich nicht starten.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
@@ -105,9 +128,22 @@ export function LiveAnsicht({
|
||||
: (auftrag.name ?? "Ausrollen")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<Status art={art}>{text}</Status>
|
||||
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
|
||||
{darfAusrollen && auftrag.aufraeumbar ? (
|
||||
<Knopf variante="gefahr" onClick={() => void aufraeumen()}>
|
||||
Aufräumen
|
||||
</Knopf>
|
||||
) : null}
|
||||
{darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? (
|
||||
<Link
|
||||
href={`/ausrollen?wiederholen=${auftrag.kurzId}`}
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Wiederholen
|
||||
</Link>
|
||||
) : null}
|
||||
{darfAusrollen && !ENDE.has(auftrag.status) ? (
|
||||
<Knopf
|
||||
variante="gefahr"
|
||||
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
|
||||
@@ -118,6 +154,8 @@ export function LiveAnsicht({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{aktionFehler ? <Status art="fehler">{aktionFehler}</Status> : null}
|
||||
|
||||
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
|
||||
|
||||
{typeof ssh === "string" ? (
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function AuftragSeite({ params }: { params: Promise<{ kurzI
|
||||
anfang={auftrag}
|
||||
anfangsLog={log}
|
||||
version={versionText()}
|
||||
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
|
||||
darfAusrollen={darf(benutzer.rolle, "ausrollen")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
|
||||
type Auswahl = Array<{ wert: string; text: string }>;
|
||||
|
||||
/**
|
||||
* Filter des Verlaufs. Der Stand steht in der URL – Neuladen und Weitergeben behalten ihn; jede
|
||||
* Änderung springt auf Seite 1.
|
||||
*/
|
||||
export function FilterLeiste({ server, distributionen }: { server: Auswahl; distributionen: Auswahl }) {
|
||||
const router = useRouter();
|
||||
const pfad = usePathname();
|
||||
const parameter = useSearchParams();
|
||||
|
||||
const setze = (name: string, wert: string) => {
|
||||
const neu = new URLSearchParams(parameter);
|
||||
if (wert) neu.set(name, wert);
|
||||
else neu.delete(name);
|
||||
neu.delete("seite");
|
||||
const text = neu.toString();
|
||||
router.push(text ? `${pfad}?${text}` : pfad);
|
||||
};
|
||||
|
||||
const felder: Array<{ name: string; label: string; optionen: Auswahl }> = [
|
||||
{ name: "server", label: "Server", optionen: server },
|
||||
{
|
||||
name: "typ",
|
||||
label: "Typ",
|
||||
optionen: [
|
||||
{ wert: "vm", text: "VM" },
|
||||
{ wert: "lxc", text: "LXC" },
|
||||
{ wert: "template", text: "Template" },
|
||||
{ wert: "aufraeumen", text: "Aufräumen" },
|
||||
],
|
||||
},
|
||||
{ name: "distribution", label: "Distribution", optionen: distributionen },
|
||||
{
|
||||
name: "status",
|
||||
label: "Status",
|
||||
optionen: [
|
||||
{ wert: "LAEUFT", text: "läuft" },
|
||||
{ wert: "WARTEND", text: "wartet" },
|
||||
{ wert: "ERFOLGREICH", text: "erfolgreich" },
|
||||
{ wert: "FEHLGESCHLAGEN", text: "fehlgeschlagen" },
|
||||
{ wert: "ABGEBROCHEN", text: "abgebrochen" },
|
||||
{ wert: "AUFGERAEUMT", text: "aufgeräumt" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const aktiv = felder.some((f) => parameter.get(f.name));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:flex lg:items-end">
|
||||
{felder.map((f) => (
|
||||
<Feld key={f.name} id={`filter-${f.name}`} label={f.label}>
|
||||
<select
|
||||
id={`filter-${f.name}`}
|
||||
className={`${EINGABE_KLASSE} lg:w-44`}
|
||||
value={parameter.get(f.name) ?? ""}
|
||||
onChange={(e) => setze(f.name, e.target.value)}
|
||||
>
|
||||
<option value="">alle</option>
|
||||
{f.optionen.map((o) => (
|
||||
<option key={o.wert} value={o.wert}>
|
||||
{o.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
))}
|
||||
{aktiv ? (
|
||||
<Knopf variante="sekundaer" className="col-span-2 sm:col-span-1" onClick={() => router.push(pfad)}>
|
||||
Filter zurücksetzen
|
||||
</Knopf>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,25 +3,56 @@ import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { zeitpunkt } from "@/lib/formate";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { auftraegeListe } from "@/server/services/auftraege";
|
||||
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
import { FilterLeiste } from "./filter-leiste";
|
||||
|
||||
export const metadata: Metadata = { title: "Verlauf" };
|
||||
|
||||
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
|
||||
export default async function Verlauf() {
|
||||
type Suche = Record<string, string | string[] | undefined>;
|
||||
|
||||
/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */
|
||||
export default async function Verlauf({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||
await seiteSchuetzen("lesen", "/auftraege");
|
||||
const auftraege = await auftraegeListe({ grenze: 100 });
|
||||
const suche = await searchParams;
|
||||
const filter = verlaufFilterAus(suche);
|
||||
const [{ eintraege, gesamt, seite, seiten }, server, distributionen] = await Promise.all([
|
||||
auftraegeSuchen(filter),
|
||||
prisma.server.findMany({ select: { name: true, titel: true }, orderBy: { name: "asc" } }),
|
||||
distributionenListe(),
|
||||
]);
|
||||
|
||||
const seitenLink = (ziel: number) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||
if (ziel > 1) p.set("seite", String(ziel));
|
||||
const text = p.toString();
|
||||
return text ? `/auftraege?${text}` : "/auftraege";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
|
||||
<p className="text-muted text-sm">
|
||||
{gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"}
|
||||
</p>
|
||||
</header>
|
||||
<FilterLeiste
|
||||
server={server.map((s) => ({ wert: s.name, text: s.titel }))}
|
||||
distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))}
|
||||
/>
|
||||
<Karte>
|
||||
{auftraege.length === 0 ? (
|
||||
<Status art="offen">Noch keine Aufträge.</Status>
|
||||
{eintraege.length === 0 ? (
|
||||
<Status art="offen">
|
||||
{gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."}
|
||||
</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{auftraege.map((a) => {
|
||||
{eintraege.map((a) => {
|
||||
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||
return (
|
||||
<li key={a.id}>
|
||||
@@ -34,9 +65,15 @@ export default async function Verlauf() {
|
||||
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||
<span className="text-muted text-sm">
|
||||
{" "}
|
||||
· {ART_TEXT[a.art]} · {a.server.name}
|
||||
·{" "}
|
||||
{a.art === "AUSROLLEN" && a.gastTyp
|
||||
? a.gastTyp === "LXC"
|
||||
? "LXC"
|
||||
: "VM"
|
||||
: ART_TEXT[a.art]}{" "}
|
||||
· {a.server.name}
|
||||
{a.distributionId ? ` · ${a.distributionId}` : ""}
|
||||
{a.vmid ? ` · ${a.vmid}` : ""}
|
||||
{a.vmid ? ` · ${a.vmid}` : ""} · {a.erstelltVon}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
|
||||
@@ -50,6 +87,27 @@ export default async function Verlauf() {
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
{seiten > 1 ? (
|
||||
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||
{seite > 1 ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||
← Neuere
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-muted">
|
||||
Seite {seite} von {seiten}
|
||||
</span>
|
||||
{seite < seiten ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||
Ältere →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollAnfrage, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||
|
||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -204,6 +204,53 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen früheren Auftrag in die Formularwerte übernehmen („Wiederholen“). Name, VMID und eine
|
||||
* statische IP gehören zum alten Gast und werden geleert – sie muss man neu angeben.
|
||||
*/
|
||||
function wiederholungUebernehmen(w: Werte, a: AusrollAnfrage, daten: AssistentDaten): Werte {
|
||||
const hinterlegt = new Set(daten.schluessel.map((x) => x.oeffentlich));
|
||||
const gewaehlt = new Set(a.zugang.sshSchluessel);
|
||||
const variablen: Werte["variablen"] = {};
|
||||
for (const [profil, werte] of Object.entries(a.variablen)) {
|
||||
variablen[profil] = Object.fromEntries(
|
||||
Object.entries(werte).map(([k, v]) => [k, typeof v === "boolean" ? v : String(v)]),
|
||||
);
|
||||
}
|
||||
const server = daten.server.some((x) => x.name === a.server) ? a.server : w.server;
|
||||
return {
|
||||
...w,
|
||||
server,
|
||||
knoten: a.knoten ?? "",
|
||||
typ: a.typ,
|
||||
distribution: a.distribution,
|
||||
name: "",
|
||||
vmid: "",
|
||||
tags: a.tags.join(","),
|
||||
cores: String(a.cores),
|
||||
speicherMb: String(a.speicherMb),
|
||||
diskGb: String(a.diskGb),
|
||||
storage: a.storage,
|
||||
swapMb: String(a.swapMb),
|
||||
unprivilegiert: a.unprivilegiert,
|
||||
nesting: a.features.nesting,
|
||||
bridge: a.netz.bridge,
|
||||
vlan: a.netz.vlan ? String(a.netz.vlan) : "",
|
||||
modus: a.netz.modus,
|
||||
ip: "",
|
||||
gateway: a.netz.gateway ?? "",
|
||||
dns: a.netz.dns.join(","),
|
||||
suchdomain: a.netz.suchdomain ?? "",
|
||||
benutzer: a.zugang.benutzer,
|
||||
schluessel: a.zugang.sshSchluessel.filter((k) => !hinterlegt.has(k)).join("\n"),
|
||||
ohneSchluessel: daten.schluessel.filter((x) => !gewaehlt.has(x.oeffentlich)).map((x) => x.fingerabdruck),
|
||||
profile: a.profile,
|
||||
variablen,
|
||||
templateBauen: a.templateBauen,
|
||||
vorlage: a.vorlage ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||
const a = anfrageAus(w, []);
|
||||
return {
|
||||
@@ -231,7 +278,16 @@ function werteZuVorlage(w: Werte): VorlagenParameter {
|
||||
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
|
||||
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
|
||||
*/
|
||||
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
|
||||
export function Assistent({
|
||||
daten,
|
||||
vorauswahl,
|
||||
wiederholung = null,
|
||||
}: {
|
||||
daten: AssistentDaten;
|
||||
vorauswahl: string | null;
|
||||
/** `?wiederholen=<kurzId>`: Anfrage eines früheren Auftrags als Vorbelegung. */
|
||||
wiederholung?: { kurzId: string; anfrage: AusrollAnfrage } | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pfad = usePathname();
|
||||
const parameter = useSearchParams();
|
||||
@@ -279,6 +335,8 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
const vorlage = !parameter.get("d")
|
||||
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
|
||||
: undefined;
|
||||
if (!parameter.get("d") && wiederholung)
|
||||
return wiederholungUebernehmen(ausUrl, wiederholung.anfrage, daten);
|
||||
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
|
||||
});
|
||||
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
|
||||
@@ -421,6 +479,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
||||
<p className="text-muted text-sm">
|
||||
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
|
||||
</p>
|
||||
{wiederholung ? (
|
||||
<Status art="offen" className="mt-2 text-sm">
|
||||
Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben.
|
||||
</Status>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { versionText } from "@/lib/version";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
@@ -15,7 +16,11 @@ import { Assistent, type AssistentDaten } from "./assistent";
|
||||
export const metadata: Metadata = { title: "Ausrollen" };
|
||||
|
||||
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
|
||||
export default async function Ausrollen() {
|
||||
export default async function Ausrollen({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ wiederholen?: string }>;
|
||||
}) {
|
||||
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
|
||||
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
|
||||
const mitTest = server.filter((s) => s.letzterTest);
|
||||
@@ -87,5 +92,22 @@ export default async function Ausrollen() {
|
||||
daten.server.map((s) => s.name),
|
||||
(await cookies()).get(SERVER_COOKIE)?.value,
|
||||
);
|
||||
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
|
||||
return (
|
||||
<Assistent
|
||||
daten={daten}
|
||||
vorauswahl={vorauswahl}
|
||||
wiederholung={await wiederholungLaden((await searchParams).wiederholen)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Anfrage eines früheren Ausroll-Auftrags – nur, wenn sie noch zum heutigen Schema passt. */
|
||||
async function wiederholungLaden(kennung: string | undefined) {
|
||||
if (!kennung) return null;
|
||||
const auftrag = await prisma.auftrag.findFirst({
|
||||
where: { OR: [{ kurzId: kennung }, { id: kennung }], art: "AUSROLLEN" },
|
||||
select: { kurzId: true, anfrage: true },
|
||||
});
|
||||
const anfrage = AusrollAnfrage.safeParse(auftrag?.anfrage);
|
||||
return auftrag && anfrage.success ? { kurzId: auftrag.kurzId, anfrage: anfrage.data } : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
type Benutzer = {
|
||||
id: string;
|
||||
benutzername: string;
|
||||
anzeigename: string;
|
||||
email: string | null;
|
||||
rolle: "ADMIN" | "OPERATOR";
|
||||
aktiv: boolean;
|
||||
passwort: boolean;
|
||||
oidc: boolean;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type Meldung = { art: "erledigt" | "fehler"; text: string; felder?: Record<string, string> } | null;
|
||||
|
||||
const leer = { benutzername: "", anzeigename: "", email: "", rolle: "OPERATOR" as const, passwort: "" };
|
||||
|
||||
/**
|
||||
* Benutzer anlegen, Rolle ändern, sperren, Passwort neu setzen. Admin: Server, Katalog, Benutzer;
|
||||
* Operator: ansehen und ausrollen. Das eigene Konto lässt sich nicht sperren, der letzte Admin
|
||||
* nicht herabstufen – das prüft der Server.
|
||||
*/
|
||||
export function BenutzerVerwaltung({ anfang, ich }: { anfang: Benutzer[]; ich: string }) {
|
||||
const router = useRouter();
|
||||
const [neu, setNeu] = useState<{
|
||||
benutzername: string;
|
||||
anzeigename: string;
|
||||
email: string;
|
||||
rolle: "ADMIN" | "OPERATOR";
|
||||
passwort: string;
|
||||
}>(leer);
|
||||
const [meldung, setMeldung] = useState<Meldung>(null);
|
||||
const [passwortFuer, setPasswortFuer] = useState<{ id: string; passwort: string } | null>(null);
|
||||
|
||||
const fehlerZeigen = (fehler: unknown) =>
|
||||
setMeldung({
|
||||
art: "fehler",
|
||||
text:
|
||||
fehler instanceof ApiFehler ? [fehler.message, fehler.hinweis].filter(Boolean).join(" ") : "Fehler.",
|
||||
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||
});
|
||||
|
||||
async function aendern(b: Benutzer, aenderung: Partial<Pick<Benutzer, "rolle" | "aktiv">>, text: string) {
|
||||
try {
|
||||
await apiAufruf("PATCH", `/api/v1/benutzer/${b.id}`, aenderung);
|
||||
setMeldung({ art: "erledigt", text });
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
fehlerZeigen(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
async function anlegen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
try {
|
||||
await apiAufruf("POST", "/api/v1/benutzer", neu);
|
||||
setMeldung({ art: "erledigt", text: `${neu.benutzername} angelegt.` });
|
||||
setNeu(leer);
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
fehlerZeigen(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
async function passwortSetzen(ereignis: React.FormEvent, b: Benutzer) {
|
||||
ereignis.preventDefault();
|
||||
if (!passwortFuer) return;
|
||||
try {
|
||||
await apiAufruf("POST", `/api/v1/benutzer/${b.id}/passwort`, { passwort: passwortFuer.passwort });
|
||||
setPasswortFuer(null);
|
||||
setMeldung({
|
||||
art: "erledigt",
|
||||
text: `Passwort von ${b.benutzername} gesetzt – alle Sitzungen beendet.`,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
fehlerZeigen(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Karte titel="Benutzer">
|
||||
<div className="flex flex-col gap-5">
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{anfang.map((b) => (
|
||||
<li key={b.id} className="flex flex-col gap-3 py-3 first:pt-0">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{b.anzeigename} <span className="text-muted font-mono text-sm">· {b.benutzername}</span>
|
||||
{b.benutzername === ich ? <span className="text-muted text-sm"> · du</span> : null}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
{[
|
||||
b.email,
|
||||
b.passwort ? "Passwort" : null,
|
||||
b.oidc ? "OIDC" : null,
|
||||
b.tokens > 0 ? `${b.tokens} API-Token${b.tokens === 1 ? "" : "s"}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "–"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!b.aktiv ? <Status art="fehler">gesperrt</Status> : null}
|
||||
<label className="sr-only" htmlFor={`rolle-${b.id}`}>
|
||||
Rolle von {b.benutzername}
|
||||
</label>
|
||||
<select
|
||||
id={`rolle-${b.id}`}
|
||||
className={`${EINGABE_KLASSE} w-36`}
|
||||
value={b.rolle}
|
||||
onChange={(e) =>
|
||||
void aendern(
|
||||
b,
|
||||
{ rolle: e.target.value as Benutzer["rolle"] },
|
||||
`${b.benutzername} ist jetzt ${e.target.value === "ADMIN" ? "Admin" : "Operator"}.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="OPERATOR">Operator</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
</select>
|
||||
<Knopf
|
||||
variante="sekundaer"
|
||||
onClick={() =>
|
||||
setPasswortFuer(passwortFuer?.id === b.id ? null : { id: b.id, passwort: "" })
|
||||
}
|
||||
aria-label={`Passwort von ${b.benutzername} setzen`}
|
||||
>
|
||||
Passwort
|
||||
</Knopf>
|
||||
{b.benutzername !== ich ? (
|
||||
<Knopf
|
||||
variante={b.aktiv ? "gefahr" : "sekundaer"}
|
||||
onClick={() =>
|
||||
void aendern(
|
||||
b,
|
||||
{ aktiv: !b.aktiv },
|
||||
b.aktiv
|
||||
? `${b.benutzername} gesperrt – Sitzungen beendet.`
|
||||
: `${b.benutzername} entsperrt.`,
|
||||
)
|
||||
}
|
||||
aria-label={`${b.benutzername} ${b.aktiv ? "sperren" : "entsperren"}`}
|
||||
>
|
||||
{b.aktiv ? "Sperren" : "Entsperren"}
|
||||
</Knopf>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{passwortFuer?.id === b.id ? (
|
||||
<form
|
||||
onSubmit={(e) => void passwortSetzen(e, b)}
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
>
|
||||
<Feld
|
||||
id={`pw-${b.id}`}
|
||||
label={`Neues Passwort für ${b.benutzername}`}
|
||||
hilfe="Mindestens 12 Zeichen"
|
||||
>
|
||||
<input
|
||||
id={`pw-${b.id}`}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={`${EINGABE_KLASSE} sm:w-72`}
|
||||
value={passwortFuer.passwort}
|
||||
onChange={(e) => setPasswortFuer({ id: b.id, passwort: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Knopf type="submit" disabled={passwortFuer.passwort.length < 12}>
|
||||
Setzen
|
||||
</Knopf>
|
||||
</form>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => void anlegen(e)} className="border-linie flex flex-col gap-4 border-t pt-5">
|
||||
<p className="font-semibold">Neuer Benutzer</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="b-name" label="Benutzername" fehler={meldung?.felder?.["benutzername"]}>
|
||||
<input
|
||||
id="b-name"
|
||||
autoComplete="off"
|
||||
className={EINGABE_KLASSE}
|
||||
value={neu.benutzername}
|
||||
onChange={(e) => setNeu({ ...neu, benutzername: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="b-anzeige" label="Anzeigename" fehler={meldung?.felder?.["anzeigename"]}>
|
||||
<input
|
||||
id="b-anzeige"
|
||||
className={EINGABE_KLASSE}
|
||||
value={neu.anzeigename}
|
||||
onChange={(e) => setNeu({ ...neu, anzeigename: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="b-email" label="E-Mail (optional)" fehler={meldung?.felder?.["email"]}>
|
||||
<input
|
||||
id="b-email"
|
||||
type="email"
|
||||
className={EINGABE_KLASSE}
|
||||
value={neu.email}
|
||||
onChange={(e) => setNeu({ ...neu, email: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="b-rolle" label="Rolle">
|
||||
<select
|
||||
id="b-rolle"
|
||||
className={EINGABE_KLASSE}
|
||||
value={neu.rolle}
|
||||
onChange={(e) => setNeu({ ...neu, rolle: e.target.value as Benutzer["rolle"] })}
|
||||
>
|
||||
<option value="OPERATOR">Operator – ansehen und ausrollen</option>
|
||||
<option value="ADMIN">Admin – auch Server, Katalog, Benutzer</option>
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld
|
||||
id="b-passwort"
|
||||
label="Erstes Passwort"
|
||||
hilfe="Mindestens 12 Zeichen; der Benutzer kann es in den Einstellungen ändern."
|
||||
fehler={meldung?.felder?.["passwort"]}
|
||||
>
|
||||
<input
|
||||
id="b-passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={EINGABE_KLASSE}
|
||||
value={neu.passwort}
|
||||
onChange={(e) => setNeu({ ...neu, passwort: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
<div>
|
||||
<Knopf type="submit" disabled={!neu.benutzername || !neu.anzeigename || neu.passwort.length < 12}>
|
||||
Benutzer anlegen
|
||||
</Knopf>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { benutzerListe } from "@/server/services/benutzer";
|
||||
import { schluesselFuer } from "@/server/services/schluessel";
|
||||
import { tokensVon } from "@/server/services/tokens";
|
||||
import { BenutzerVerwaltung } from "./benutzer-verwaltung";
|
||||
import { PasswortAendern } from "./passwort-aendern";
|
||||
import { SchluesselVerwaltung } from "./schluessel-verwaltung";
|
||||
import { TokenVerwaltung } from "./token-verwaltung";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
/** Einstellungen. Heute: SSH-Schlüssel und CLI-Tokens. Benutzer und Standardwerte folgen mit #015. */
|
||||
export default async function Einstellungen() {
|
||||
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */
|
||||
export default async function Einstellungen({ searchParams }: { searchParams: Promise<{ sso?: string }> }) {
|
||||
const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
|
||||
const sso = oidcEinstellungen();
|
||||
const ssoFehler = (await searchParams).sso;
|
||||
const admin = darf(benutzer.rolle, "benutzerVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||
<p className="text-muted text-sm">Angemeldet als {benutzer.benutzername}</p>
|
||||
<p className="text-muted text-sm">
|
||||
Angemeldet als {benutzer.benutzername} · {benutzer.rolle === "ADMIN" ? "Admin" : "Operator"}
|
||||
</p>
|
||||
</header>
|
||||
<SchluesselVerwaltung
|
||||
anfang={await schluesselFuer(benutzer)}
|
||||
teamErlaubt={darf(benutzer.rolle, "benutzerVerwalten")}
|
||||
/>
|
||||
<SchluesselVerwaltung anfang={await schluesselFuer(benutzer)} teamErlaubt={admin} />
|
||||
<TokenVerwaltung
|
||||
anfang={await tokensVon(benutzer.id)}
|
||||
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
|
||||
/>
|
||||
{benutzer.passwortHash ? <PasswortAendern /> : null}
|
||||
{sso ? (
|
||||
<Karte titel="Anmeldung per SSO">
|
||||
<div className="flex flex-col gap-3">
|
||||
{ssoFehler === "schon_verknuepft" ? (
|
||||
<Status art="fehler">Dieses SSO-Konto gehört schon zu einem anderen Benutzer.</Status>
|
||||
) : null}
|
||||
{benutzer.oidcSubjekt ? (
|
||||
<Status art="erledigt">
|
||||
Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen.
|
||||
</Status>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-sm">
|
||||
Dein Konto ist noch nicht mit {sso.anzeigename} verknüpft. Danach kannst du dich auch dort
|
||||
anmelden; die Rolle kommt dann aus den Gruppen beim Identity Provider.
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="/api/v1/auth/oidc/start?verknuepfen=1"
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Mit {sso.anzeigename} verknüpfen
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
) : null}
|
||||
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
|
||||
export function PasswortAendern() {
|
||||
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
|
||||
const [meldung, setMeldung] = useState<{
|
||||
art: "erledigt" | "fehler";
|
||||
text: string;
|
||||
felder?: Record<string, string>;
|
||||
} | null>(null);
|
||||
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
|
||||
|
||||
async function speichern(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
try {
|
||||
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
|
||||
setWerte({ alt: "", neu: "", wiederholt: "" });
|
||||
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
|
||||
} catch (fehler) {
|
||||
setMeldung({
|
||||
art: "fehler",
|
||||
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
|
||||
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Karte titel="Passwort">
|
||||
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
|
||||
<input
|
||||
id="pw-alt"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className={EINGABE_KLASSE}
|
||||
value={werte.alt}
|
||||
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld
|
||||
id="pw-neu"
|
||||
label="Neues Passwort"
|
||||
hilfe="Mindestens 12 Zeichen"
|
||||
fehler={meldung?.felder?.["neu"]}
|
||||
>
|
||||
<input
|
||||
id="pw-neu"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={EINGABE_KLASSE}
|
||||
value={werte.neu}
|
||||
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld
|
||||
id="pw-wieder"
|
||||
label="Neues Passwort wiederholen"
|
||||
fehler={abweichend ? "Stimmt nicht überein." : undefined}
|
||||
>
|
||||
<input
|
||||
id="pw-wieder"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={EINGABE_KLASSE}
|
||||
value={werte.wiederholt}
|
||||
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Knopf
|
||||
type="submit"
|
||||
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
|
||||
>
|
||||
Passwort ändern
|
||||
</Knopf>
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import Link from "next/link";
|
||||
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
|
||||
import { bytes, zeitpunkt } from "@/lib/formate";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { auftraegeSuchen } from "@/server/services/auftraege";
|
||||
import { serverListe } from "@/server/services/server";
|
||||
import { ServerStatus } from "./server/server-status";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
/** Server mit Status und freien Ressourcen (aus dem letzten Verbindungstest), letzte Aufträge. */
|
||||
export default async function Uebersicht() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/");
|
||||
const server = await serverListe();
|
||||
const [server, { eintraege }] = await Promise.all([serverListe(), auftraegeSuchen({ seite: 1 })]);
|
||||
const letzte = eintraege.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -32,23 +37,73 @@ export default async function Uebersicht() {
|
||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{server.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.name}
|
||||
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
||||
<span className="font-medium">{eintrag.titel}</span>{" "}
|
||||
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
||||
</Link>
|
||||
<ServerStatus server={eintrag} />
|
||||
</li>
|
||||
))}
|
||||
{server.map((eintrag) => {
|
||||
const knoten = eintrag.letzterTest?.knoten ?? [];
|
||||
const online = knoten.filter((k) => k.online);
|
||||
const frei = online.reduce(
|
||||
(s, k) => s + ((k.speicherGesamt ?? 0) - (k.speicherBelegt ?? 0)),
|
||||
0,
|
||||
);
|
||||
const cpus = online.reduce((s, k) => s + (k.cpus ?? 0), 0);
|
||||
return (
|
||||
<li key={eintrag.name} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href={`/server/${eintrag.name}`} className="min-w-0 hover:underline">
|
||||
<span className="font-medium">{eintrag.titel}</span>{" "}
|
||||
<span className="text-muted font-mono text-sm">{eintrag.name}</span>
|
||||
</Link>
|
||||
<ServerStatus server={eintrag} />
|
||||
</div>
|
||||
{knoten.length > 0 ? (
|
||||
<p className="text-muted text-xs">
|
||||
{online.length} von {knoten.length} {knoten.length === 1 ? "Node" : "Nodes"} online
|
||||
{online.length > 0 ? ` · ${cpus} CPUs · ${bytes(frei)} RAM frei` : ""}
|
||||
{eintrag.letzterTest?.cluster ? ` · Cluster ${eintrag.letzterTest.cluster.name}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
<Karte titel="Letzte Deployments">
|
||||
<Status art="offen">Noch keine Deployments.</Status>
|
||||
<Karte
|
||||
titel="Letzte Deployments"
|
||||
aktion={
|
||||
<Link href="/auftraege" className="text-akzent text-sm">
|
||||
Verlauf
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{letzte.length === 0 ? (
|
||||
<Status art="offen">Noch keine Deployments.</Status>
|
||||
) : (
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{letzte.map((a) => {
|
||||
const [art, text] = STATUS_ANZEIGE[a.status];
|
||||
return (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/auftraege/${a.kurzId}`}
|
||||
className="hover:bg-panel-hoch/60 -mx-2 flex items-center justify-between gap-3 rounded-lg px-2 py-2"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
|
||||
<span className="text-muted text-sm">
|
||||
{" "}
|
||||
{a.art !== "AUSROLLEN" && a.name ? `· ${ART_TEXT[a.art]} ` : ""}· {a.server.name}
|
||||
{a.vmid ? ` · ${a.vmid}` : ""} · {zeitpunkt(a.erstelltAm)}
|
||||
</span>
|
||||
</span>
|
||||
<Status art={art} className="shrink-0 text-sm">
|
||||
{text}
|
||||
</Status>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { EINGABE_KLASSE, Feld } from "@/components/formular";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
type Suche = Record<string, string | string[] | undefined>;
|
||||
|
||||
const QUELLEN = { WEB: "Web", CLI: "CLI", API: "API", SYSTEM: "System" } as const;
|
||||
|
||||
/**
|
||||
* Audit-Log: jede Aktion mit Benutzer, Quelle und Adresse. Nur lesbar – die Datenbank verweigert
|
||||
* Ändern und Löschen per Trigger. Der Filter ist ein schlichtes GET-Formular, der Stand steht in der URL.
|
||||
*/
|
||||
export default async function Protokoll({ searchParams }: { searchParams: Promise<Suche> }) {
|
||||
await seiteSchuetzen("protokollLesen", "/protokoll");
|
||||
const filter = protokollFilterAus(await searchParams);
|
||||
const [{ eintraege, gesamt, seite, seiten }, benutzer] = await Promise.all([
|
||||
protokollSuchen(filter),
|
||||
prisma.benutzer.findMany({ select: { benutzername: true }, orderBy: { benutzername: "asc" } }),
|
||||
]);
|
||||
const seitenLink = (ziel: number) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
|
||||
if (ziel > 1) p.set("seite", String(ziel));
|
||||
return `/protokoll${p.size > 0 ? `?${p.toString()}` : ""}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Audit-Log</h1>
|
||||
<p className="text-muted text-sm">{gesamt} Einträge</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
method="get"
|
||||
action="/protokoll"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:flex lg:items-end"
|
||||
>
|
||||
<Feld id="p-suche" label="Suche" hilfe="Aktion oder Ziel">
|
||||
<input
|
||||
id="p-suche"
|
||||
name="suche"
|
||||
defaultValue={filter.suche ?? ""}
|
||||
className={`${EINGABE_KLASSE} lg:w-48`}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="p-benutzer" label="Benutzer">
|
||||
<select
|
||||
id="p-benutzer"
|
||||
name="benutzer"
|
||||
defaultValue={filter.benutzer ?? ""}
|
||||
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||
>
|
||||
<option value="">alle</option>
|
||||
{benutzer.map((b) => (
|
||||
<option key={b.benutzername}>{b.benutzername}</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld id="p-quelle" label="Quelle">
|
||||
<select
|
||||
id="p-quelle"
|
||||
name="quelle"
|
||||
defaultValue={filter.quelle ?? ""}
|
||||
className={`${EINGABE_KLASSE} lg:w-32`}
|
||||
>
|
||||
<option value="">alle</option>
|
||||
{Object.entries(QUELLEN).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Feld>
|
||||
<Feld id="p-von" label="Von">
|
||||
<input
|
||||
id="p-von"
|
||||
name="von"
|
||||
type="date"
|
||||
defaultValue={filter.von ?? ""}
|
||||
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="p-bis" label="Bis">
|
||||
<input
|
||||
id="p-bis"
|
||||
name="bis"
|
||||
type="date"
|
||||
defaultValue={filter.bis ?? ""}
|
||||
className={`${EINGABE_KLASSE} lg:w-40`}
|
||||
/>
|
||||
</Feld>
|
||||
<div className="flex items-end gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold hover:opacity-90 sm:h-10"
|
||||
>
|
||||
Filtern
|
||||
</button>
|
||||
<Link href="/protokoll" className="text-muted hover:text-text text-sm">
|
||||
Zurücksetzen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Karte>
|
||||
{eintraege.length === 0 ? (
|
||||
<Status art="offen">Keine Einträge.</Status>
|
||||
) : (
|
||||
<ol className="divide-linie flex flex-col divide-y" aria-label="Einträge">
|
||||
{eintraege.map((e) => (
|
||||
<li key={e.id} className="flex flex-col gap-1 py-3 first:pt-0 sm:flex-row sm:gap-4">
|
||||
<time className="text-muted shrink-0 font-mono text-xs sm:w-36 sm:pt-0.5" dateTime={e.zeit}>
|
||||
{new Date(e.zeit).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" })}
|
||||
</time>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
<span className="font-medium">{e.aktion}</span>
|
||||
{e.ziel ? (
|
||||
<span className="text-muted font-mono text-sm break-all"> · {e.ziel}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
{e.benutzer ?? "–"} · {QUELLEN[e.quelle]}
|
||||
{e.ip ? ` · ${e.ip}` : ""}
|
||||
</p>
|
||||
{e.details ? (
|
||||
<details className="mt-1">
|
||||
<summary className="text-muted cursor-pointer text-xs">Details</summary>
|
||||
<pre className="bg-grund border-linie mt-1 overflow-x-auto rounded-lg border p-2 font-mono text-xs">
|
||||
{JSON.stringify(e.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{seiten > 1 ? (
|
||||
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
|
||||
{seite > 1 ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
|
||||
← Neuere
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-muted">
|
||||
Seite {seite} von {seiten}
|
||||
</span>
|
||||
{seite < seiten ? (
|
||||
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
|
||||
Ältere →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Status } from "@/components/status";
|
||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||
import { oidcEinstellungen } from "@/server/auth/oidc";
|
||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||
import { Anmeldeformular } from "./anmeldeformular";
|
||||
|
||||
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
|
||||
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||
}
|
||||
|
||||
const SSO_MELDUNGEN: Record<string, string> = {
|
||||
abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.",
|
||||
idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.",
|
||||
keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.",
|
||||
gesperrt: "Dein Konto ist gesperrt.",
|
||||
name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.",
|
||||
aus: "Die Anmeldung per SSO ist nicht eingerichtet.",
|
||||
};
|
||||
|
||||
export default async function Anmelden({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const ziel = sicheresZiel((await searchParams)["ziel"]);
|
||||
const parameter = await searchParams;
|
||||
const ziel = sicheresZiel(parameter["ziel"]);
|
||||
const sso = oidcEinstellungen();
|
||||
const ssoFehler =
|
||||
typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null;
|
||||
if (await aktuellerBenutzer()) redirect(ziel);
|
||||
|
||||
return (
|
||||
@@ -29,7 +44,27 @@ export default async function Anmelden({
|
||||
</h1>
|
||||
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
||||
</div>
|
||||
{ssoFehler ? (
|
||||
<Status art="fehler" className="mb-4 text-sm">
|
||||
{ssoFehler}
|
||||
</Status>
|
||||
) : null}
|
||||
<Anmeldeformular ziel={ziel} />
|
||||
{sso ? (
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
|
||||
<span className="bg-linie h-px flex-1" />
|
||||
oder
|
||||
<span className="bg-linie h-px flex-1" />
|
||||
</div>
|
||||
<a
|
||||
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
|
||||
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
|
||||
>
|
||||
Mit {sso.anzeigename} anmelden
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { aufraeumenStarten } from "@/server/services/auftraege";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen → neuer Auftrag (202). */
|
||||
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
|
||||
const auftrag = await aufraeumenStarten((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
|
||||
});
|
||||
@@ -3,12 +3,13 @@ import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { ausrollenStarten } from "@/server/services/ausrollen";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { auftraegeListe } from "@/server/services/auftraege";
|
||||
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
|
||||
|
||||
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
|
||||
/** Verlauf: `?server=&typ=vm|lxc|template|aufraeumen&distribution=&status=&seite=`. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "lesen");
|
||||
return NextResponse.json(await auftraegeListe());
|
||||
const filter = verlaufFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||
return NextResponse.json(await auftraegeSuchen(filter));
|
||||
});
|
||||
|
||||
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse, istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import {
|
||||
OIDC_COOKIE,
|
||||
oidcAbschliessen,
|
||||
oidcEinstellungen,
|
||||
OidcFehler,
|
||||
RUECKRUF_PFAD,
|
||||
} from "@/server/auth/oidc";
|
||||
|
||||
function cookieLesen(kopf: string | null, name: string): string | undefined {
|
||||
for (const teil of (kopf ?? "").split(";")) {
|
||||
const [schluessel, ...wert] = teil.trim().split("=");
|
||||
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich: Rückkehr vom Identity Provider. Erfolg → Sitzungscookie und Weiterleitung zum Ziel;
|
||||
* Fehler → Anmeldeseite mit Code (`?sso=keine_gruppe` …), nie eine Fehlerseite mit Interna.
|
||||
*/
|
||||
export async function GET(anfrage: Request): Promise<Response> {
|
||||
const herkunft = new URL(anfrage.url).origin;
|
||||
const e = oidcEinstellungen();
|
||||
const zurAnmeldung = (code: string) =>
|
||||
NextResponse.redirect(oeffentlicheAdresse(`/anmelden?sso=${code}`, herkunft), 302);
|
||||
if (!e) return zurAnmeldung("aus");
|
||||
|
||||
// Die öffentliche Adresse: genau die, die als redirect_uri beim IdP registriert ist.
|
||||
const aktuell = oeffentlicheAdresse(RUECKRUF_PFAD + new URL(anfrage.url).search, herkunft);
|
||||
let antwort: NextResponse;
|
||||
try {
|
||||
const { benutzer, ziel } = await oidcAbschliessen(
|
||||
e,
|
||||
aktuell,
|
||||
cookieLesen(anfrage.headers.get("cookie"), OIDC_COOKIE),
|
||||
aufruferAdresse(anfrage.headers),
|
||||
);
|
||||
antwort = NextResponse.redirect(oeffentlicheAdresse(ziel, herkunft), 302);
|
||||
antwort.cookies.set(
|
||||
SITZUNG_COOKIE,
|
||||
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "oidc" }),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/",
|
||||
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||
},
|
||||
);
|
||||
} catch (fehler) {
|
||||
if (!(fehler instanceof OidcFehler)) logger.error({ err: fehler }, "OIDC-Rückruf fehlgeschlagen");
|
||||
const code = fehler instanceof OidcFehler ? fehler.code : "idp";
|
||||
// Beim Verknüpfen ist man schon angemeldet – die Meldung gehört dann in die Einstellungen.
|
||||
antwort =
|
||||
code === "schon_verknuepft"
|
||||
? NextResponse.redirect(oeffentlicheAdresse(`/einstellungen?sso=${code}`, herkunft), 302)
|
||||
: zurAnmeldung(code);
|
||||
}
|
||||
antwort.cookies.set(OIDC_COOKIE, "", { path: "/api/v1/auth/oidc", maxAge: 0 });
|
||||
return antwort;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { istHttps, oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { api } from "@/server/api";
|
||||
import { anfrageAufrufer } from "@/server/auth/zugang";
|
||||
import { OIDC_COOKIE, oidcEinstellungen, oidcStarten, RUECKRUF_PFAD } from "@/server/auth/oidc";
|
||||
import { AnwendungsFehler, nichtAngemeldet } from "@/server/fehler";
|
||||
|
||||
/** Nur relative Ziele – sonst wäre `?ziel=` eine offene Weiterleitung. */
|
||||
function sicheresZiel(ziel: string | null): string {
|
||||
return ziel && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich: leitet zum Identity Provider. `?verknuepfen=1` (angemeldet) hängt das SSO-Konto an
|
||||
* das eigene lokale Konto statt anzumelden.
|
||||
*/
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const e = oidcEinstellungen();
|
||||
if (!e) throw new AnwendungsFehler(404, "oidc_aus", "Die Anmeldung per SSO ist nicht eingerichtet.");
|
||||
const abfrage = new URL(anfrage.url).searchParams;
|
||||
let verknuepfen: string | null = null;
|
||||
if (abfrage.get("verknuepfen") === "1") {
|
||||
const aufrufer = await anfrageAufrufer(anfrage);
|
||||
if (!aufrufer) throw nichtAngemeldet();
|
||||
verknuepfen = aufrufer.benutzer.id;
|
||||
}
|
||||
const herkunft = new URL(anfrage.url).origin;
|
||||
const { weiter, cookie } = await oidcStarten(
|
||||
e,
|
||||
oeffentlicheAdresse(RUECKRUF_PFAD, herkunft),
|
||||
sicheresZiel(abfrage.get("ziel")),
|
||||
verknuepfen,
|
||||
);
|
||||
const antwort = NextResponse.redirect(weiter, 302);
|
||||
antwort.cookies.set(OIDC_COOKIE, cookie, {
|
||||
httpOnly: true,
|
||||
// Lax: der Rückruf ist eine Top-Level-Navigation vom IdP zurück, da kommt das Cookie mit.
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/api/v1/auth/oidc",
|
||||
maxAge: 600,
|
||||
});
|
||||
return antwort;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { passwortZuruecksetzen } from "@/server/services/benutzer";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Admin setzt ein neues Passwort `{ passwort }`; alle Sitzungen des Benutzers enden. */
|
||||
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||
await passwortZuruecksetzen(
|
||||
(await params).id,
|
||||
await jsonLesen(anfrage),
|
||||
aufrufer,
|
||||
aufruferAdresse(anfrage.headers),
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { benutzerAendern } from "@/server/services/benutzer";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
/** `{ anzeigename?, email?, rolle?, aktiv? }` – Sperren beendet alle Sitzungen. */
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||
return NextResponse.json(
|
||||
await benutzerAendern(
|
||||
(await params).id,
|
||||
await jsonLesen(anfrage),
|
||||
aufrufer,
|
||||
aufruferAdresse(anfrage.headers),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { benutzerAnlegen, benutzerListe } from "@/server/services/benutzer";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||
return NextResponse.json(await benutzerListe());
|
||||
});
|
||||
|
||||
/** `{ benutzername, anzeigename, email?, rolle, passwort }` */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "benutzerVerwalten");
|
||||
return NextResponse.json(
|
||||
await benutzerAnlegen(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)),
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse, istHttps } from "@/lib/adressen";
|
||||
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { eigenesPasswortAendern } from "@/server/services/benutzer";
|
||||
|
||||
/**
|
||||
* Eigenes Passwort ändern `{ alt, neu }`. Alle anderen Sitzungen enden; die aufrufende Web-Sitzung
|
||||
* bekommt ein frisches Cookie, damit man angemeldet bleibt.
|
||||
*/
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "eigeneZugaenge");
|
||||
await eigenesPasswortAendern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers));
|
||||
const antwort = new NextResponse(null, { status: 204 });
|
||||
if (aufrufer.quelle === "WEB") {
|
||||
antwort.cookies.set(
|
||||
SITZUNG_COOKIE,
|
||||
await sitzungErzeugen({ benutzerId: aufrufer.benutzer.id, herkunft: "passwort" }),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/",
|
||||
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||
},
|
||||
);
|
||||
}
|
||||
return antwort;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { protokollFilterAus, protokollSuchen } from "@/server/services/protokoll";
|
||||
|
||||
/** Audit-Log, neueste zuerst: `?benutzer=&quelle=&suche=&von=YYYY-MM-DD&bis=YYYY-MM-DD&seite=`. Nur Admins. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "protokollLesen");
|
||||
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||
return NextResponse.json(await protokollSuchen(filter));
|
||||
});
|
||||
@@ -8,7 +8,15 @@ import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
|
||||
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||
icons: { icon: "/brand/symbol.svg" },
|
||||
icons: {
|
||||
// SVG passt sich hellem und dunklem Browser an; PNG für Browser ohne SVG-Favicons.
|
||||
icon: [
|
||||
{ url: "/brand/icon-small.svg", type: "image/svg+xml" },
|
||||
{ url: "/brand/favicon-32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/brand/favicon-16.png", sizes: "16x16", type: "image/png" },
|
||||
],
|
||||
apple: { url: "/brand/apple-touch-icon.png", sizes: "180x180" },
|
||||
},
|
||||
};
|
||||
|
||||
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/** Web-App-Manifest unter /manifest.webmanifest – öffentlich (siehe proxy.ts). */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "ProxDeploy",
|
||||
short_name: "ProxDeploy",
|
||||
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#11161C",
|
||||
theme_color: "#11161C",
|
||||
lang: "de",
|
||||
icons: [
|
||||
{ src: "/brand/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/brand/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/brand/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Einstieg für den Betrieb: `node migrieren.mjs` (Docker: Dienst „migrate“, LXC: vor app und worker).
|
||||
* Liest DATABASE_URL und den Ordner der Migrationen (Standard: prisma/migrations neben dem Aufruf).
|
||||
*/
|
||||
import { migrieren } from "./migrieren";
|
||||
|
||||
const url = process.env["DATABASE_URL"];
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL fehlt.");
|
||||
process.exit(2);
|
||||
}
|
||||
const ordner = process.env["MIGRATIONEN"] ?? "prisma/migrations";
|
||||
try {
|
||||
const { eingespielt, vorhanden } = await migrieren(url, ordner, (text) => console.log(text));
|
||||
console.log(
|
||||
eingespielt.length > 0
|
||||
? `${eingespielt.length} Migration(en) eingespielt, ${vorhanden} waren schon da.`
|
||||
: `Datenbank aktuell (${vorhanden} Migrationen).`,
|
||||
);
|
||||
} catch (fehler) {
|
||||
console.error((fehler as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import pg from "pg";
|
||||
|
||||
/**
|
||||
* Spielt die Migrationen aus `prisma/migrations/` ein – im Betrieb statt `prisma migrate deploy`,
|
||||
* damit weder Docker-Image noch LXC das Prisma-CLI samt Schema-Engine brauchen.
|
||||
*
|
||||
* Kompatibel zu Prisma: dieselbe Tabelle `_prisma_migrations`, Prüfsumme = SHA-256 der
|
||||
* migration.sql. In der Entwicklung bleibt `prisma migrate dev` also benutzbar, und beide
|
||||
* erkennen, was schon eingespielt ist.
|
||||
*
|
||||
* Jede Migration läuft in einer eigenen Transaktion; eine Sperre (`pg_advisory_lock`) verhindert,
|
||||
* dass app und worker gleichzeitig migrieren. Eine eingespielte Migration, deren Datei sich
|
||||
* geändert hat, ist ein Fehler – dann stimmt der Codestand nicht zur Datenbank.
|
||||
*/
|
||||
const SPERRE = 724_196_331; // beliebig, aber fest: „proxdeploy-migration“
|
||||
|
||||
export type MigrationsErgebnis = { eingespielt: string[]; vorhanden: number };
|
||||
|
||||
export async function migrieren(
|
||||
datenbankUrl: string,
|
||||
ordner: string,
|
||||
log: (text: string) => void = () => {},
|
||||
): Promise<MigrationsErgebnis> {
|
||||
const migrationen = readdirSync(ordner, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
const client = new pg.Client({ connectionString: datenbankUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock($1)", [SPERRE]);
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
|
||||
"id" VARCHAR(36) PRIMARY KEY NOT NULL,
|
||||
"checksum" VARCHAR(64) NOT NULL,
|
||||
"finished_at" TIMESTAMPTZ,
|
||||
"migration_name" VARCHAR(255) NOT NULL,
|
||||
"logs" TEXT,
|
||||
"rolled_back_at" TIMESTAMPTZ,
|
||||
"started_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
|
||||
)`);
|
||||
const { rows } = await client.query<{
|
||||
migration_name: string;
|
||||
checksum: string;
|
||||
finished_at: Date | null;
|
||||
}>(`SELECT migration_name, checksum, finished_at FROM "_prisma_migrations" WHERE rolled_back_at IS NULL`);
|
||||
const bekannt = new Map(rows.map((r) => [r.migration_name, r]));
|
||||
const unfertig = rows.filter((r) => !r.finished_at);
|
||||
if (unfertig.length > 0) {
|
||||
throw new Error(
|
||||
`Migration ${unfertig.map((r) => r.migration_name).join(", ")} ist unvollständig eingespielt – bitte prüfen.`,
|
||||
);
|
||||
}
|
||||
|
||||
const eingespielt: string[] = [];
|
||||
for (const name of migrationen) {
|
||||
const sql = readFileSync(join(ordner, name, "migration.sql"));
|
||||
const pruefsumme = createHash("sha256").update(sql).digest("hex");
|
||||
const vorhanden = bekannt.get(name);
|
||||
if (vorhanden) {
|
||||
if (vorhanden.checksum !== pruefsumme) {
|
||||
throw new Error(`Migration ${name} wurde nach dem Einspielen verändert (Prüfsumme weicht ab).`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
log(`Migration ${name} …`);
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
const id = randomUUID();
|
||||
await client.query(
|
||||
`INSERT INTO "_prisma_migrations" (id, checksum, migration_name, started_at) VALUES ($1, $2, $3, now())`,
|
||||
[id, pruefsumme, name],
|
||||
);
|
||||
await client.query(sql.toString("utf8"));
|
||||
await client.query(
|
||||
`UPDATE "_prisma_migrations" SET finished_at = now(), applied_steps_count = 1 WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
} catch (fehler) {
|
||||
await client.query("ROLLBACK");
|
||||
throw new Error(`Migration ${name} fehlgeschlagen: ${(fehler as Error).message}`);
|
||||
}
|
||||
eingespielt.push(name);
|
||||
}
|
||||
return { eingespielt, vorhanden: migrationen.length - eingespielt.length };
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1)", [SPERRE]).catch(() => {});
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LogOut,
|
||||
Menu,
|
||||
Rocket,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Layers,
|
||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<
|
||||
vorlagen: FileStack,
|
||||
katalog: BookOpen,
|
||||
server: Server,
|
||||
protokoll: ScrollText,
|
||||
einstellungen: Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||
|
||||
/**
|
||||
* Aufräumen nach einem fehlgeschlagenen oder abgebrochenen Auftrag: den halb angelegten Gast
|
||||
* stoppen und löschen. Gelöscht wird nur, was **nachweislich zu diesem Auftrag gehört** – die
|
||||
* Beschreibung trägt „(Auftrag <kurzId>)“, gesetzt schon beim Anlegen bzw. Klonen. Templates
|
||||
* werden nie gelöscht. Passt etwas nicht, bricht der Ablauf ab, ohne etwas anzufassen.
|
||||
*/
|
||||
export type AufraeumEingabe = {
|
||||
client: ProxmoxClient;
|
||||
typ: "VM" | "LXC";
|
||||
vmid: number;
|
||||
/** Node laut Auftrag; liegt der Gast inzwischen woanders (Migration), gilt der tatsächliche. */
|
||||
knoten: string | null;
|
||||
/** Kurz-ID des Auftrags, der den Gast angelegt hat. */
|
||||
auftragKurzId: string;
|
||||
};
|
||||
|
||||
export type AufraeumStand = { knoten?: string; vorhanden?: boolean; laeuft?: boolean; name?: string };
|
||||
|
||||
type Ressource = {
|
||||
vmid?: number;
|
||||
node?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
name?: string;
|
||||
template?: number;
|
||||
};
|
||||
|
||||
export function aufraeumSchritte(e: AufraeumEingabe, stand: AufraeumStand = {}): PhasenSchritt[] {
|
||||
const { client } = e;
|
||||
const art = e.typ === "LXC" ? "lxc" : "qemu";
|
||||
const wort = e.typ === "LXC" ? "Container" : "VM";
|
||||
const gastPfad = () => pfad`/nodes/${stand.knoten!}/${art}/${e.vmid}`;
|
||||
|
||||
return [
|
||||
{
|
||||
schluessel: "pruefungen",
|
||||
titel: "Prüfungen",
|
||||
ausfuehren: async () => {
|
||||
const ressourcen = await client.get<Ressource[]>("/cluster/resources", { type: "vm" });
|
||||
const gast = ressourcen.find((r) => r.vmid === e.vmid);
|
||||
if (!gast) {
|
||||
stand.vorhanden = false;
|
||||
return { detail: `${wort} ${e.vmid} gibt es nicht mehr – nichts zu löschen` };
|
||||
}
|
||||
if (gast.type !== art) {
|
||||
throw new AblaufFehler(
|
||||
`Unter ${e.vmid} liegt kein ${wort}, sondern ${gast.type === "lxc" ? "ein Container" : "eine VM"}.`,
|
||||
"Nichts gelöscht. Die ID wurde inzwischen anders vergeben – von Hand prüfen.",
|
||||
);
|
||||
}
|
||||
stand.knoten = gast.node!;
|
||||
const konfig = await client.get<Record<string, unknown>>(`${gastPfad()}/config`);
|
||||
if (gast.template === 1 || konfig["template"] === 1) {
|
||||
throw new AblaufFehler(
|
||||
`${e.vmid} ist ein Template.`,
|
||||
"Templates räumt ProxDeploy nie auf. Unter „Templates“ bzw. in Proxmox entfernen.",
|
||||
);
|
||||
}
|
||||
const beschreibung = String(konfig["description"] ?? "");
|
||||
if (!beschreibung.includes(`(Auftrag ${e.auftragKurzId})`)) {
|
||||
throw new AblaufFehler(
|
||||
`${wort} ${e.vmid} gehört nicht erkennbar zu Auftrag ${e.auftragKurzId}.`,
|
||||
"Nichts gelöscht. In Proxmox prüfen und von Hand entfernen, falls es der halb angelegte Gast ist.",
|
||||
);
|
||||
}
|
||||
if (konfig["lock"]) {
|
||||
throw new AblaufFehler(
|
||||
`${wort} ${e.vmid} ist gesperrt (${String(konfig["lock"])}).`,
|
||||
`Läuft dort noch eine Aufgabe? Sonst auf dem Node „${e.typ === "LXC" ? "pct" : "qm"} unlock ${e.vmid}“ und erneut aufräumen.`,
|
||||
);
|
||||
}
|
||||
stand.vorhanden = true;
|
||||
stand.laeuft = gast.status === "running";
|
||||
stand.name = gast.name ?? String(konfig["name"] ?? konfig["hostname"] ?? "");
|
||||
return {
|
||||
detail: `${wort} ${e.vmid}${stand.name ? ` (${stand.name})` : ""} auf ${stand.knoten} gehört zu ${e.auftragKurzId}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "stoppen",
|
||||
titel: "Stoppen",
|
||||
ausfuehren: async () => {
|
||||
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||
if (!stand.laeuft) return { uebersprungen: true, detail: "läuft nicht" };
|
||||
await aufgabeAbwarten(client, await client.post<string>(`${gastPfad()}/status/stop`), {
|
||||
zeitlimitMs: 5 * 60 * 1000,
|
||||
});
|
||||
return { detail: `${wort} ${e.vmid} gestoppt` };
|
||||
},
|
||||
},
|
||||
{
|
||||
schluessel: "loeschen",
|
||||
titel: "Löschen",
|
||||
ausfuehren: async () => {
|
||||
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
|
||||
await aufgabeAbwarten(
|
||||
client,
|
||||
await client.delete<string>(gastPfad(), { "destroy-unreferenced-disks": true }),
|
||||
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||
);
|
||||
return { detail: `${wort} ${e.vmid} mit Disks gelöscht` };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -470,7 +470,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
serial0: "socket",
|
||||
vga: "serial0",
|
||||
agent: "enabled=1,fstrim_cloned_disks=1",
|
||||
ide2: `${e.storage}:cloudinit`,
|
||||
// cloud-init-Laufwerk am virtio-SCSI-Controller, nicht an ide2: q35 hängt IDE an SATA (AHCI),
|
||||
// und der „cloud“-Kernel von Debian 12 hat keinen Treiber dafür – cloud-init fände keine
|
||||
// Datenquelle und bliebe stumm (gefunden bei der Abnahme von #021).
|
||||
scsi1: `${e.storage}:cloudinit`,
|
||||
ipconfig0: "ip=dhcp",
|
||||
ciupgrade: false,
|
||||
tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"),
|
||||
|
||||
@@ -92,6 +92,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
const { client, anfrage: a, distribution: d } = e;
|
||||
const warten = e.warten ?? schlafen;
|
||||
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
|
||||
const beschreibung = () =>
|
||||
`Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`;
|
||||
const templateStand: TemplateBauStand = {};
|
||||
|
||||
const schritte: PhasenSchritt[] = [
|
||||
@@ -238,6 +240,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
|
||||
newid: vmid,
|
||||
name: a.name,
|
||||
// Schon beim Klonen: daran erkennt „Aufräumen“, dass die VM zu diesem Auftrag gehört.
|
||||
description: beschreibung(),
|
||||
full: true,
|
||||
target: stand.knoten!,
|
||||
storage: a.storage,
|
||||
@@ -284,7 +288,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
|
||||
onboot: a.startBeiBoot,
|
||||
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
||||
description: beschreibung(),
|
||||
});
|
||||
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
|
||||
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ssh2 from "ssh2";
|
||||
|
||||
/**
|
||||
* Ein ed25519-Schlüsselpaar im OpenSSH-Format, das ssh2 auch selbst wieder lesen kann.
|
||||
*
|
||||
* `ssh2.utils.generateKeyPairSync("ed25519")` liefert in etwa 4 von 1000 Fällen einen privaten
|
||||
* Schlüssel, an dem der eigene Parser mit „Malformed OpenSSH private key“ scheitert (gemessen mit
|
||||
* ssh2 1.17.0). Gespeichert wäre so ein Server-Schlüssel dauerhaft unbrauchbar – deshalb wird
|
||||
* geprüft und notfalls neu erzeugt.
|
||||
*/
|
||||
export function ed25519Erzeugen(kommentar?: string): { privat: string; oeffentlich: string } {
|
||||
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", kommentar ? { comment: kommentar } : {});
|
||||
if (!(ssh2.utils.parseKey(paar.private) instanceof Error)) {
|
||||
return { privat: paar.private, oeffentlich: paar.public.trim() };
|
||||
}
|
||||
}
|
||||
throw new Error("Kein lesbarer ed25519-Schlüssel erzeugt.");
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "
|
||||
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
|
||||
* und CLI dieselben Zeilen zeigen.
|
||||
*/
|
||||
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
|
||||
export function abschlussAus(a: Omit<AuftragSicht, "abschluss" | "aufraeumbar">): Abschluss | null {
|
||||
if (!ENDE.has(a.status)) return null;
|
||||
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
|
||||
const gesamt =
|
||||
@@ -53,5 +53,10 @@ export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | nu
|
||||
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
|
||||
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
|
||||
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
|
||||
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
|
||||
return {
|
||||
ok: true,
|
||||
titel: `${name} ${a.art === "AUFRAEUMEN" ? "gelöscht" : "bereit"}`,
|
||||
dauer: gesamt,
|
||||
zeilen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ export type NavigationsEintrag = {
|
||||
| "vorlagen"
|
||||
| "katalog"
|
||||
| "server"
|
||||
| "protokoll"
|
||||
| "einstellungen";
|
||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
|
||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge" | "protokollLesen";
|
||||
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||
ab?: string;
|
||||
};
|
||||
@@ -28,13 +29,8 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
titel: "Einstellungen",
|
||||
symbol: "einstellungen",
|
||||
recht: "eigeneZugaenge",
|
||||
ab: "#015",
|
||||
},
|
||||
{ pfad: "/protokoll", titel: "Audit-Log", symbol: "protokoll", recht: "protokollLesen" },
|
||||
{ pfad: "/einstellungen", titel: "Einstellungen", symbol: "einstellungen", recht: "eigeneZugaenge" },
|
||||
];
|
||||
|
||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
||||
|
||||
@@ -164,6 +164,9 @@ export const AuftragSicht = z.looseObject({
|
||||
gestartetAm: Zeit.nullable(),
|
||||
beendetAm: Zeit.nullable(),
|
||||
abschluss: Abschluss.nullable(),
|
||||
aufraeumbar: z
|
||||
.looseObject({ typ: z.enum(["VM", "LXC"]), vmid: z.number().int(), knoten: z.string().nullable() })
|
||||
.nullable(),
|
||||
phasen: z.array(PhaseSicht),
|
||||
});
|
||||
|
||||
@@ -190,3 +193,10 @@ export const Plan = z.looseObject({
|
||||
automatisch: z.array(z.string()),
|
||||
ok: z.boolean(),
|
||||
});
|
||||
|
||||
export const Verlauf = z.looseObject({
|
||||
eintraege: z.array(AuftragSicht),
|
||||
gesamt: z.number().int(),
|
||||
seite: z.number().int(),
|
||||
seiten: z.number().int(),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ export type AuftragSicht = {
|
||||
beendetAm: string | null;
|
||||
/** Abschlussbox, sobald der Auftrag fertig ist. */
|
||||
abschluss: Abschluss | null;
|
||||
/** Bei Fehlschlag/Abbruch: was „Aufräumen“ löschen würde. */
|
||||
aufraeumbar: { typ: "VM" | "LXC"; vmid: number; knoten: string | null } | null;
|
||||
phasen: Array<{
|
||||
schluessel: string;
|
||||
titel: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||
*/
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.4.0";
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.6.1";
|
||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||
|
||||
export function versionText(): string {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import * as client from "openid-client";
|
||||
import type { Benutzer, Rolle } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und
|
||||
* Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich.
|
||||
*
|
||||
* Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den
|
||||
* Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin
|
||||
* übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus.
|
||||
*/
|
||||
export type OidcEinstellungen = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
anzeigename: string;
|
||||
gruppenClaim: string;
|
||||
gruppeAdmin: string;
|
||||
gruppeOperator: string;
|
||||
};
|
||||
|
||||
export function oidcEinstellungen(
|
||||
umgebung: Record<string, string | undefined> = process.env,
|
||||
): OidcEinstellungen | null {
|
||||
if (umgebung["OIDC_AKTIV"] !== "true") return null;
|
||||
const issuer = umgebung["OIDC_ISSUER"]?.trim();
|
||||
const clientId = umgebung["OIDC_CLIENT_ID"]?.trim();
|
||||
if (!issuer || !clientId) {
|
||||
logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus.");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "",
|
||||
anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO",
|
||||
gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups",
|
||||
gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "",
|
||||
gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
let konfiguration: { schluessel: string; wert: Promise<client.Configuration> } | undefined;
|
||||
|
||||
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
|
||||
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
|
||||
const schluessel = `${e.issuer}|${e.clientId}`;
|
||||
if (konfiguration?.schluessel !== schluessel) {
|
||||
const url = new URL(e.issuer);
|
||||
const wert = client.discovery(
|
||||
url,
|
||||
e.clientId,
|
||||
e.clientSecret || undefined,
|
||||
e.clientSecret ? undefined : client.None(),
|
||||
// http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https.
|
||||
url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
|
||||
? { execute: [client.allowInsecureRequests] }
|
||||
: undefined,
|
||||
);
|
||||
konfiguration = { schluessel, wert };
|
||||
wert.catch(() => {
|
||||
if (konfiguration?.wert === wert) konfiguration = undefined;
|
||||
});
|
||||
}
|
||||
return await konfiguration.wert;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function oidcZwischenspeicherLeeren(): void {
|
||||
konfiguration = undefined;
|
||||
}
|
||||
|
||||
// ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ───────────────────────────
|
||||
|
||||
export const OIDC_COOKIE = "pd_oidc";
|
||||
const OIDC_LAUFZEIT_S = 600;
|
||||
|
||||
type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null };
|
||||
|
||||
function geheimnis(): Uint8Array {
|
||||
return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? "");
|
||||
}
|
||||
|
||||
export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf";
|
||||
|
||||
/** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */
|
||||
export async function oidcStarten(
|
||||
e: OidcEinstellungen,
|
||||
rueckruf: URL,
|
||||
ziel: string,
|
||||
verknuepfen: string | null,
|
||||
): Promise<{ weiter: URL; cookie: string }> {
|
||||
const config = await konfigurationHolen(e);
|
||||
const verifier = client.randomPKCECodeVerifier();
|
||||
const ablauf: Ablauf = {
|
||||
verifier,
|
||||
state: client.randomState(),
|
||||
nonce: client.randomNonce(),
|
||||
ziel,
|
||||
verknuepfen,
|
||||
};
|
||||
const weiter = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: rueckruf.href,
|
||||
scope: "openid profile email",
|
||||
code_challenge: await client.calculatePKCECodeChallenge(verifier),
|
||||
code_challenge_method: "S256",
|
||||
state: ablauf.state,
|
||||
nonce: ablauf.nonce,
|
||||
});
|
||||
const cookie = await new SignJWT(ablauf)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setAudience("proxdeploy-oidc")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${OIDC_LAUFZEIT_S}s`)
|
||||
.sign(geheimnis());
|
||||
return { weiter, cookie };
|
||||
}
|
||||
|
||||
export class OidcFehler extends Error {
|
||||
constructor(
|
||||
readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft",
|
||||
nachricht: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
this.name = "OidcFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Ansprueche = Record<string, unknown> & { sub: string };
|
||||
|
||||
export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null {
|
||||
const roh = ansprueche[e.gruppenClaim];
|
||||
const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : [];
|
||||
if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN";
|
||||
if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR";
|
||||
// Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“.
|
||||
if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */
|
||||
async function freierName(ansprueche: Ansprueche): Promise<string> {
|
||||
const quelle = String(
|
||||
ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "",
|
||||
);
|
||||
let basis = quelle
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9._-]/g, "-")
|
||||
.replace(/^[^a-z]+/, "")
|
||||
.slice(0, 28);
|
||||
if (basis.length < 2) basis = "sso-benutzer";
|
||||
for (let n = 0; n < 50; n++) {
|
||||
const name = n === 0 ? basis : `${basis}-${n + 1}`;
|
||||
if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name;
|
||||
}
|
||||
throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto
|
||||
* finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen.
|
||||
*/
|
||||
export async function oidcAbschliessen(
|
||||
e: OidcEinstellungen,
|
||||
aktuelleUrl: URL,
|
||||
cookie: string | undefined,
|
||||
ip: string | null,
|
||||
): Promise<{ benutzer: Benutzer; ziel: string }> {
|
||||
let ablauf: Ablauf;
|
||||
try {
|
||||
const { payload } = await jwtVerify(cookie ?? "", geheimnis(), {
|
||||
algorithms: ["HS256"],
|
||||
audience: "proxdeploy-oidc",
|
||||
});
|
||||
ablauf = payload as unknown as Ablauf;
|
||||
} catch {
|
||||
throw new OidcFehler(
|
||||
"abgelaufen",
|
||||
"Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = await konfigurationHolen(e);
|
||||
let ansprueche: Ansprueche;
|
||||
try {
|
||||
const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, {
|
||||
pkceCodeVerifier: ablauf.verifier,
|
||||
expectedState: ablauf.state,
|
||||
expectedNonce: ablauf.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
ansprueche = antwort.claims() as Ansprueche;
|
||||
} catch (fehler) {
|
||||
logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt");
|
||||
throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt.");
|
||||
}
|
||||
|
||||
const rolle = rolleAusGruppen(e, ansprueche);
|
||||
const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice(
|
||||
0,
|
||||
80,
|
||||
);
|
||||
const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null;
|
||||
|
||||
// Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an.
|
||||
if (ablauf.verknuepfen) {
|
||||
const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||
if (vorhanden && vorhanden.id !== ablauf.verknuepfen) {
|
||||
throw new OidcFehler(
|
||||
"schon_verknuepft",
|
||||
`Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`,
|
||||
);
|
||||
}
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.update({
|
||||
where: { id: ablauf.verknuepfen! },
|
||||
data: { oidcSubjekt: ansprueche.sub },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "SSO-Konto verknüpft",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: "/einstellungen" };
|
||||
}
|
||||
|
||||
if (!rolle) {
|
||||
await protokollieren({
|
||||
benutzerId: null,
|
||||
quelle: "WEB",
|
||||
aktion: "SSO-Anmeldung ohne passende Gruppe",
|
||||
ziel: `oidc:${ansprueche.sub}`,
|
||||
ip,
|
||||
});
|
||||
throw new OidcFehler(
|
||||
"keine_gruppe",
|
||||
"Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).",
|
||||
);
|
||||
}
|
||||
|
||||
const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
|
||||
if (bekannt) {
|
||||
if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt.");
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.update({
|
||||
where: { id: bekannt.id },
|
||||
data: { rolle, anzeigename, ...(email ? { email } : {}) },
|
||||
});
|
||||
if (bekannt.rolle !== rolle) {
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "Rolle aus SSO-Gruppen übernommen",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
details: { rolle: [bekannt.rolle, rolle] },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: ablauf.ziel };
|
||||
}
|
||||
|
||||
const benutzername = await freierName(ansprueche);
|
||||
const benutzer = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.create({
|
||||
data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: b.id,
|
||||
quelle: "WEB",
|
||||
aktion: "Benutzer per SSO angelegt",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
details: { rolle },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return b;
|
||||
});
|
||||
return { benutzer, ziel: ablauf.ziel };
|
||||
}
|
||||
@@ -19,7 +19,10 @@ async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | n
|
||||
const sitzung = await sitzungLesen(jwt);
|
||||
if (!sitzung) return null;
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
return benutzer?.aktiv ? benutzer : null;
|
||||
if (!benutzer?.aktiv) return null;
|
||||
// Nach Passwortwechsel oder Sperre gelten ältere Sitzungen nicht mehr.
|
||||
if (benutzer.sitzungenAb && sitzung.ausgestelltAm * 1000 < benutzer.sitzungenAb.getTime()) return null;
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/** Für Server Components. */
|
||||
|
||||
@@ -29,6 +29,7 @@ const KOMPONENTEN: Record<string, Schema> = {
|
||||
AuftragErgebnis: A.AuftragErgebnis,
|
||||
Abschluss: A.Abschluss,
|
||||
Auftrag: A.AuftragSicht,
|
||||
Verlauf: A.Verlauf,
|
||||
LogZeile: A.LogZeile,
|
||||
GeplanterAufruf: A.GeplanterAufruf,
|
||||
Plan: A.Plan,
|
||||
@@ -126,6 +127,23 @@ const PFADE: Record<string, Record<string, Operation>> = {
|
||||
},
|
||||
},
|
||||
"/auftraege": {
|
||||
get: {
|
||||
operationId: "verlauf",
|
||||
summary: "Verlauf, neueste zuerst, 25 je Seite",
|
||||
parameters: [
|
||||
{ name: "server", in: "query", required: false, schema: { type: "string" } },
|
||||
{
|
||||
name: "typ",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["vm", "lxc", "template", "aufraeumen"] },
|
||||
},
|
||||
{ name: "distribution", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "status", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "seite", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
|
||||
],
|
||||
responses: { "200": { description: "Seite des Verlaufs", ...json(ref("Verlauf")) }, default: FEHLER },
|
||||
},
|
||||
post: {
|
||||
operationId: "ausrollen",
|
||||
summary: "VM oder LXC ausrollen (Auftrag)",
|
||||
@@ -181,6 +199,17 @@ const PFADE: Record<string, Record<string, Operation>> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}/aufraeumen": {
|
||||
post: {
|
||||
operationId: "auftragAufraeumen",
|
||||
summary: "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
|
||||
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
|
||||
responses: {
|
||||
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
|
||||
default: FEHLER,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/auftraege/{id}/abbrechen": {
|
||||
post: {
|
||||
operationId: "auftragAbbrechen",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { abschlussAus } from "@/lib/auftrag-text";
|
||||
import { randomInt } from "node:crypto";
|
||||
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
||||
import { abschlussAus } from "@/lib/auftrag-text";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||
import { melden } from "@/server/ereignisse/senke";
|
||||
@@ -91,9 +92,21 @@ const MIT = {
|
||||
|
||||
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
|
||||
|
||||
/** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */
|
||||
function aufraeumbarAus(
|
||||
a: Pick<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
|
||||
): AuftragSicht["aufraeumbar"] {
|
||||
if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null;
|
||||
const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown };
|
||||
if (typeof angelegt.vmid !== "number") return null;
|
||||
const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM";
|
||||
const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten;
|
||||
return { typ, vmid: angelegt.vmid, knoten };
|
||||
}
|
||||
|
||||
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||
const sicht: Omit<AuftragSicht, "abschluss"> = {
|
||||
const sicht: Omit<AuftragSicht, "abschluss" | "aufraeumbar"> = {
|
||||
id: a.id,
|
||||
kurzId: a.kurzId,
|
||||
art: a.art,
|
||||
@@ -124,7 +137,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||
beendetAm: iso(p.beendetAm),
|
||||
})),
|
||||
};
|
||||
return { ...sicht, abschluss: abschlussAus(sicht) };
|
||||
return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) };
|
||||
}
|
||||
|
||||
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
|
||||
@@ -137,16 +150,66 @@ export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
|
||||
return auftragSicht(auftrag);
|
||||
}
|
||||
|
||||
export async function auftraegeListe(
|
||||
filter: { serverId?: string; grenze?: number } = {},
|
||||
): Promise<AuftragSicht[]> {
|
||||
const zeilen = await prisma.auftrag.findMany({
|
||||
where: filter.serverId ? { serverId: filter.serverId } : {},
|
||||
include: MIT,
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: filter.grenze ?? 50,
|
||||
});
|
||||
return zeilen.map(auftragSicht);
|
||||
/** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */
|
||||
export const VerlaufFilter = z.object({
|
||||
server: z.string().trim().optional(),
|
||||
typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(),
|
||||
distribution: z.string().trim().optional(),
|
||||
status: z
|
||||
.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"])
|
||||
.optional(),
|
||||
seite: z.coerce.number().int().min(1).max(10_000).default(1),
|
||||
});
|
||||
export type VerlaufFilter = z.infer<typeof VerlaufFilter>;
|
||||
|
||||
export const VERLAUF_PRO_SEITE = 25;
|
||||
|
||||
/** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */
|
||||
export function verlaufFilterAus(parameter: Record<string, string | string[] | undefined>): VerlaufFilter {
|
||||
const roh: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parameter)) {
|
||||
const wert = Array.isArray(v) ? v[0] : v;
|
||||
if (wert) roh[k] = wert;
|
||||
}
|
||||
const geprueft: Record<string, unknown> = {};
|
||||
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
|
||||
const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]);
|
||||
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||
}
|
||||
return VerlaufFilter.parse(geprueft);
|
||||
}
|
||||
|
||||
export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{
|
||||
eintraege: AuftragSicht[];
|
||||
gesamt: number;
|
||||
seite: number;
|
||||
seiten: number;
|
||||
}> {
|
||||
const where: Prisma.AuftragWhereInput = {
|
||||
...(filter.server ? { server: { name: filter.server } } : {}),
|
||||
...(filter.distribution ? { distributionId: filter.distribution } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}),
|
||||
...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}),
|
||||
...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}),
|
||||
...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}),
|
||||
};
|
||||
const [gesamt, zeilen] = await prisma.$transaction([
|
||||
prisma.auftrag.count({ where }),
|
||||
prisma.auftrag.findMany({
|
||||
where,
|
||||
include: MIT,
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
skip: (filter.seite - 1) * VERLAUF_PRO_SEITE,
|
||||
take: VERLAUF_PRO_SEITE,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
eintraege: zeilen.map(auftragSicht),
|
||||
gesamt,
|
||||
seite: filter.seite,
|
||||
seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
|
||||
@@ -167,6 +230,50 @@ export async function logAb(auftragId: string, abId: bigint, grenze = 500): Prom
|
||||
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
|
||||
|
||||
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
|
||||
/**
|
||||
* Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw.
|
||||
* abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox.
|
||||
*/
|
||||
export async function aufraeumenStarten(
|
||||
kennung: string,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<Auftrag> {
|
||||
const sicht = await auftragHolen(kennung);
|
||||
if (!sicht.aufraeumbar) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"nicht_aufraeumbar",
|
||||
sicht.status === "AUFGERAEUMT"
|
||||
? "Dieser Auftrag ist schon aufgeräumt."
|
||||
: "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.",
|
||||
);
|
||||
}
|
||||
const laufend = await prisma.auftrag.findFirst({
|
||||
where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } },
|
||||
select: { kurzId: true },
|
||||
});
|
||||
if (laufend) {
|
||||
throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`);
|
||||
}
|
||||
const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } });
|
||||
const { typ, vmid, knoten } = sicht.aufraeumbar;
|
||||
return await auftragAnlegen(
|
||||
{
|
||||
art: "AUFRAEUMEN",
|
||||
serverId: original.serverId,
|
||||
...(knoten ? { knoten } : {}),
|
||||
gastTyp: typ,
|
||||
...(original.name ? { name: original.name } : {}),
|
||||
...(original.distributionId ? { distributionId: original.distributionId } : {}),
|
||||
bezugId: original.id,
|
||||
anfrage: { auftrag: original.kurzId, typ, vmid, knoten },
|
||||
},
|
||||
aufrufer,
|
||||
ip,
|
||||
);
|
||||
}
|
||||
|
||||
export async function auftragAbbrechen(
|
||||
kennung: string,
|
||||
aufrufer: Aufrufer,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { Benutzer, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (nur Admins) und das eigene Passwort. Zwei Schutzregeln: der letzte aktive
|
||||
* Admin bleibt Admin und aktiv, und niemand sperrt sich selbst aus. Passwortwechsel und Sperre
|
||||
* beenden alle bestehenden Sitzungen (`sitzungenAb`); API-Tokens prüfen den Aktiv-Status je Anfrage.
|
||||
*/
|
||||
export type BenutzerSicht = {
|
||||
id: string;
|
||||
benutzername: string;
|
||||
anzeigename: string;
|
||||
email: string | null;
|
||||
rolle: "ADMIN" | "OPERATOR";
|
||||
aktiv: boolean;
|
||||
/** Lokales Passwort gesetzt (sonst nur OIDC). */
|
||||
passwort: boolean;
|
||||
oidc: boolean;
|
||||
erstelltAm: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
const Passwort = z
|
||||
.string()
|
||||
.min(PASSWORT_MINDESTLAENGE, `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
|
||||
.max(1024, "Höchstens 1024 Zeichen.");
|
||||
|
||||
export const BenutzerAnlegen = z.object({
|
||||
benutzername: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(
|
||||
/^[a-z][a-z0-9._-]{1,31}$/,
|
||||
"2–32 Zeichen: Kleinbuchstaben, Ziffern, Punkt, _ und -; vorne ein Buchstabe.",
|
||||
),
|
||||
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80),
|
||||
email: z
|
||||
.email("Keine gültige E-Mail-Adresse.")
|
||||
.trim()
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
rolle: z.enum(["ADMIN", "OPERATOR"]).default("OPERATOR"),
|
||||
passwort: Passwort,
|
||||
});
|
||||
|
||||
export const BenutzerAendern = z.object({
|
||||
anzeigename: z.string().trim().min(1, "Anzeigename fehlt.").max(80).optional(),
|
||||
email: z
|
||||
.email("Keine gültige E-Mail-Adresse.")
|
||||
.trim()
|
||||
.nullable()
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => null)),
|
||||
rolle: z.enum(["ADMIN", "OPERATOR"]).optional(),
|
||||
aktiv: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const MIT_TOKENS = { _count: { select: { tokens: true } } } as const;
|
||||
|
||||
function sicht(b: Benutzer & { _count: { tokens: number } }): BenutzerSicht {
|
||||
return {
|
||||
id: b.id,
|
||||
benutzername: b.benutzername,
|
||||
anzeigename: b.anzeigename,
|
||||
email: b.email,
|
||||
rolle: b.rolle,
|
||||
aktiv: b.aktiv,
|
||||
passwort: Boolean(b.passwortHash),
|
||||
oidc: Boolean(b.oidcSubjekt),
|
||||
erstelltAm: b.erstelltAm.toISOString(),
|
||||
tokens: b._count.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** Auf volle Sekunden: JWT-`iat` hat Sekunden, eine im selben Augenblick neu ausgestellte Sitzung gilt. */
|
||||
const jetztSekunde = () => new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
|
||||
export async function benutzerListe(): Promise<BenutzerSicht[]> {
|
||||
const liste = await prisma.benutzer.findMany({ include: MIT_TOKENS, orderBy: { benutzername: "asc" } });
|
||||
return liste.map(sicht);
|
||||
}
|
||||
|
||||
export async function benutzerAnlegen(
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<BenutzerSicht> {
|
||||
const e = BenutzerAnlegen.parse(roh);
|
||||
try {
|
||||
const neu = await prisma.$transaction(async (tx) => {
|
||||
const b = await tx.benutzer.create({
|
||||
data: {
|
||||
benutzername: e.benutzername,
|
||||
anzeigename: e.anzeigename,
|
||||
email: e.email ?? null,
|
||||
rolle: e.rolle,
|
||||
passwortHash: await passwortHashen(e.passwort),
|
||||
},
|
||||
include: MIT_TOKENS,
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Benutzer angelegt",
|
||||
ziel: `benutzer:${b.benutzername}`,
|
||||
details: { rolle: b.rolle },
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return b;
|
||||
});
|
||||
return sicht(neu);
|
||||
} catch (fehler) {
|
||||
if ((fehler as { code?: string }).code === "P2002") {
|
||||
throw new AnwendungsFehler(409, "vergeben", `„${e.benutzername}“ gibt es schon.`, undefined, {
|
||||
benutzername: "Benutzername schon vergeben.",
|
||||
});
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
async function letzterAdmin(tx: Prisma.TransactionClient, ohne: string): Promise<boolean> {
|
||||
return (await tx.benutzer.count({ where: { rolle: "ADMIN", aktiv: true, id: { not: ohne } } })) === 0;
|
||||
}
|
||||
|
||||
export async function benutzerAendern(
|
||||
id: string,
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<BenutzerSicht> {
|
||||
const e = BenutzerAendern.parse(roh);
|
||||
const ergebnis = await prisma.$transaction(async (tx) => {
|
||||
// Sperre auf die Admin-Zeilen: zwei gleichzeitige Herabstufungen dürfen nicht beide durchgehen.
|
||||
await tx.$queryRaw`SELECT id FROM "Benutzer" WHERE rolle = 'ADMIN' FOR UPDATE`;
|
||||
const alt = await tx.benutzer.findUnique({ where: { id } });
|
||||
if (!alt) throw nichtGefunden("Den Benutzer");
|
||||
const selbst = alt.id === aufrufer.benutzer.id;
|
||||
if (selbst && e.aktiv === false) {
|
||||
throw new AnwendungsFehler(409, "selbst", "Das eigene Konto lässt sich nicht sperren.");
|
||||
}
|
||||
const verliertAdmin = alt.rolle === "ADMIN" && alt.aktiv && (e.rolle === "OPERATOR" || e.aktiv === false);
|
||||
if (verliertAdmin && (await letzterAdmin(tx, alt.id))) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"letzter_admin",
|
||||
"Das ist der letzte aktive Admin.",
|
||||
"Erst einen anderen Benutzer zum Admin machen.",
|
||||
);
|
||||
}
|
||||
const gesperrt = e.aktiv === false && alt.aktiv;
|
||||
const neu = await tx.benutzer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(e.anzeigename !== undefined ? { anzeigename: e.anzeigename } : {}),
|
||||
...(e.email !== undefined ? { email: e.email } : {}),
|
||||
...(e.rolle !== undefined ? { rolle: e.rolle } : {}),
|
||||
...(e.aktiv !== undefined ? { aktiv: e.aktiv } : {}),
|
||||
...(gesperrt ? { sitzungenAb: jetztSekunde() } : {}),
|
||||
},
|
||||
include: MIT_TOKENS,
|
||||
});
|
||||
const aenderungen: Record<string, unknown> = {};
|
||||
if (e.rolle !== undefined && e.rolle !== alt.rolle) aenderungen["rolle"] = [alt.rolle, e.rolle];
|
||||
if (e.aktiv !== undefined && e.aktiv !== alt.aktiv) aenderungen["aktiv"] = [alt.aktiv, e.aktiv];
|
||||
if (e.anzeigename !== undefined && e.anzeigename !== alt.anzeigename) aenderungen["anzeigename"] = true;
|
||||
if (e.email !== undefined && e.email !== alt.email) aenderungen["email"] = true;
|
||||
if (Object.keys(aenderungen).length > 0) {
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: gesperrt
|
||||
? "Benutzer gesperrt"
|
||||
: e.aktiv === true && !alt.aktiv
|
||||
? "Benutzer entsperrt"
|
||||
: "Benutzer geändert",
|
||||
ziel: `benutzer:${alt.benutzername}`,
|
||||
details: aenderungen as Prisma.InputJsonValue,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
return neu;
|
||||
});
|
||||
return sicht(ergebnis);
|
||||
}
|
||||
|
||||
/** Admin setzt ein neues Passwort (z. B. vergessen); alle Sitzungen des Benutzers enden. */
|
||||
export async function passwortZuruecksetzen(
|
||||
id: string,
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<void> {
|
||||
const { passwort } = z.object({ passwort: Passwort }).parse(roh);
|
||||
const ziel = await prisma.benutzer.findUnique({ where: { id } });
|
||||
if (!ziel) throw nichtGefunden("Den Benutzer");
|
||||
const hashWert = await passwortHashen(passwort);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.benutzer.update({
|
||||
where: { id },
|
||||
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Passwort zurückgesetzt",
|
||||
ziel: `benutzer:${ziel.benutzername}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Eigenes Passwort ändern – mit dem alten. Andere Sitzungen enden; die aufrufende stellt der Handler neu aus. */
|
||||
export async function eigenesPasswortAendern(
|
||||
roh: unknown,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<void> {
|
||||
const e = z
|
||||
.object({ alt: z.string().min(1, "Bisheriges Passwort fehlt.").max(1024), neu: Passwort })
|
||||
.parse(roh);
|
||||
const ich = await prisma.benutzer.findUniqueOrThrow({ where: { id: aufrufer.benutzer.id } });
|
||||
if (!ich.passwortHash || !(await passwortPruefen(ich.passwortHash, e.alt))) {
|
||||
throw new AnwendungsFehler(400, "ungueltig", "Das bisherige Passwort stimmt nicht.", undefined, {
|
||||
alt: "Stimmt nicht.",
|
||||
});
|
||||
}
|
||||
if (e.alt === e.neu) {
|
||||
throw new AnwendungsFehler(400, "ungueltig", "Das neue Passwort ist das alte.", undefined, {
|
||||
neu: "Bitte ein anderes Passwort wählen.",
|
||||
});
|
||||
}
|
||||
const hashWert = await passwortHashen(e.neu);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.benutzer.update({
|
||||
where: { id: ich.id },
|
||||
data: { passwortHash: hashWert, sitzungenAb: jetztSekunde() },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: ich.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Eigenes Passwort geändert",
|
||||
ziel: `benutzer:${ich.benutzername}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import ssh2 from "ssh2";
|
||||
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
|
||||
@@ -18,8 +18,8 @@ export async function bereitstellungsschluessel(): Promise<Bereitstellungsschlue
|
||||
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
||||
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
||||
}
|
||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
|
||||
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
|
||||
const paar = ed25519Erzeugen(KOMMENTAR);
|
||||
const neu = { privat: verschluesseln(paar.privat), oeffentlich: paar.oeffentlich };
|
||||
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
||||
await prisma.einstellung.upsert({
|
||||
where: { schluessel: SCHLUESSEL },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Quelle } from "@/generated/prisma/enums";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -28,3 +29,92 @@ export async function protokollieren(eintrag: ProtokollEintrag, client: Client =
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Filter der Audit-Log-Ansicht (`/protokoll?…`, `GET /api/v1/protokoll?…`). */
|
||||
export const ProtokollFilter = z.object({
|
||||
benutzer: z.string().trim().optional(),
|
||||
quelle: z.enum(["WEB", "CLI", "API", "SYSTEM"]).optional(),
|
||||
/** Teilwort in Aktion oder Ziel, z. B. „Server“ oder „d-7f3k2“. */
|
||||
suche: z.string().trim().max(100).optional(),
|
||||
von: z.iso.date().optional(),
|
||||
bis: z.iso.date().optional(),
|
||||
seite: z.coerce.number().int().min(1).max(100_000).default(1),
|
||||
});
|
||||
export type ProtokollFilter = z.infer<typeof ProtokollFilter>;
|
||||
|
||||
export const PROTOKOLL_PRO_SEITE = 50;
|
||||
|
||||
export type ProtokollZeile = {
|
||||
id: string;
|
||||
zeit: string;
|
||||
benutzer: string | null;
|
||||
quelle: Quelle;
|
||||
aktion: string;
|
||||
ziel: string | null;
|
||||
details: unknown;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
/** Wie beim Verlauf: Gültiges übernehmen, Unsinn still fallen lassen. */
|
||||
export function protokollFilterAus(
|
||||
parameter: Record<string, string | string[] | undefined>,
|
||||
): ProtokollFilter {
|
||||
const geprueft: Record<string, unknown> = {};
|
||||
for (const schluessel of Object.keys(ProtokollFilter.shape) as Array<keyof ProtokollFilter>) {
|
||||
const roh = parameter[schluessel];
|
||||
const wert = Array.isArray(roh) ? roh[0] : roh;
|
||||
if (!wert) continue;
|
||||
const feld = ProtokollFilter.shape[schluessel].safeParse(wert);
|
||||
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
|
||||
}
|
||||
return ProtokollFilter.parse(geprueft);
|
||||
}
|
||||
|
||||
export async function protokollSuchen(filter: ProtokollFilter): Promise<{
|
||||
eintraege: ProtokollZeile[];
|
||||
gesamt: number;
|
||||
seite: number;
|
||||
seiten: number;
|
||||
}> {
|
||||
const zeit: Prisma.DateTimeFilter = {};
|
||||
if (filter.von) zeit.gte = new Date(`${filter.von}T00:00:00`);
|
||||
if (filter.bis) zeit.lt = new Date(new Date(`${filter.bis}T00:00:00`).getTime() + 86_400_000);
|
||||
const where: Prisma.ProtokollWhereInput = {
|
||||
...(filter.benutzer ? { benutzer: { benutzername: filter.benutzer } } : {}),
|
||||
...(filter.quelle ? { quelle: filter.quelle } : {}),
|
||||
...(filter.suche
|
||||
? {
|
||||
OR: [
|
||||
{ aktion: { contains: filter.suche, mode: "insensitive" as const } },
|
||||
{ ziel: { contains: filter.suche, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(filter.von || filter.bis ? { zeit } : {}),
|
||||
};
|
||||
const [gesamt, zeilen] = await prisma.$transaction([
|
||||
prisma.protokoll.count({ where }),
|
||||
prisma.protokoll.findMany({
|
||||
where,
|
||||
include: { benutzer: { select: { benutzername: true } } },
|
||||
orderBy: { id: "desc" },
|
||||
skip: (filter.seite - 1) * PROTOKOLL_PRO_SEITE,
|
||||
take: PROTOKOLL_PRO_SEITE,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
eintraege: zeilen.map((z) => ({
|
||||
id: z.id.toString(),
|
||||
zeit: z.zeit.toISOString(),
|
||||
benutzer: z.benutzer?.benutzername ?? null,
|
||||
quelle: z.quelle,
|
||||
aktion: z.aktion,
|
||||
ziel: z.ziel,
|
||||
details: z.details,
|
||||
ip: z.ip,
|
||||
})),
|
||||
gesamt,
|
||||
seite: filter.seite,
|
||||
seiten: Math.max(1, Math.ceil(gesamt / PROTOKOLL_PRO_SEITE)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ssh2 from "ssh2";
|
||||
import { Prisma, type Server } from "@/generated/prisma/client";
|
||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||
import { ed25519Erzeugen } from "@/engine/ssh/schluessel";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -180,13 +180,13 @@ export async function sshSchluesselErzeugen(
|
||||
ip: string | null,
|
||||
): Promise<ServerSicht> {
|
||||
const server = await serverHolen(name);
|
||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||
const paar = ed25519Erzeugen(`proxdeploy@${server.name}`);
|
||||
const neu = await prisma.$transaction(async (tx) => {
|
||||
const aktualisiert = await tx.server.update({
|
||||
where: { id: server.id },
|
||||
data: {
|
||||
sshPrivat: verschluesseln(paar.private),
|
||||
sshOeffentlich: paar.public.trim(),
|
||||
sshPrivat: verschluesseln(paar.privat),
|
||||
sshOeffentlich: paar.oeffentlich,
|
||||
// Ein neuer Schlüssel macht gepinnte Host-Keys nicht ungültig, aber der alte ist auf
|
||||
// den Nodes noch hinterlegt – das sagt die Oberfläche dazu.
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
|
||||
/** Meldet alle Abläufe beim worker an. */
|
||||
import "./ablaeufe/ausrollen";
|
||||
import "./ablaeufe/template";
|
||||
import "./ablaeufe/aufraeumen";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||
import { aufraeumSchritte, type AufraeumStand } from "@/engine/ablaeufe/aufraeumen";
|
||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { melden } from "@/server/ereignisse/senke";
|
||||
import { verbindungAus } from "@/server/services/server";
|
||||
import { ablaufRegistrieren } from "../ausfuehren";
|
||||
import { aufrufLogger } from "../aufruf-log";
|
||||
|
||||
const Anfrage = z.object({
|
||||
auftrag: z.string(),
|
||||
typ: z.enum(["VM", "LXC"]),
|
||||
vmid: z.number().int(),
|
||||
knoten: z.string().nullable(),
|
||||
});
|
||||
|
||||
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke, abbruchGewuenscht }) => {
|
||||
const anfrage = Anfrage.parse(auftrag.anfrage);
|
||||
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
|
||||
await prisma.auftrag.update({
|
||||
where: { id: auftrag.id },
|
||||
data: {
|
||||
vmid: anfrage.vmid,
|
||||
kopf: `Server ${server.name}${anfrage.knoten ? ` · Node ${anfrage.knoten}` : ""} · ${
|
||||
anfrage.typ === "LXC" ? "CT" : "VM"
|
||||
} ${anfrage.vmid} aus ${anfrage.auftrag}`,
|
||||
},
|
||||
});
|
||||
await melden(auftrag.id);
|
||||
|
||||
const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) });
|
||||
const stand: AufraeumStand = {};
|
||||
try {
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
aufraeumSchritte(
|
||||
{
|
||||
client,
|
||||
typ: anfrage.typ,
|
||||
vmid: anfrage.vmid,
|
||||
knoten: anfrage.knoten,
|
||||
auftragKurzId: anfrage.auftrag,
|
||||
},
|
||||
stand,
|
||||
),
|
||||
senke,
|
||||
{ abbruchGewuenscht },
|
||||
);
|
||||
if (!ergebnis.ok) return ergebnis;
|
||||
// Der ursprüngliche Auftrag ist damit erledigt – im Verlauf als „aufgeräumt“ sichtbar.
|
||||
if (auftrag.bezugId) {
|
||||
await prisma.auftrag.update({ where: { id: auftrag.bezugId }, data: { status: "AUFGERAEUMT" } });
|
||||
await melden(auftrag.bezugId);
|
||||
}
|
||||
return { ok: true, ergebnis: { vmid: anfrage.vmid, ...(stand.knoten ? { knoten: stand.knoten } : {}) } };
|
||||
} finally {
|
||||
await client.schliessen();
|
||||
}
|
||||
});
|
||||