Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,7 @@
# ADR 0001: MariaDB statt PostgreSQL
Status: angenommen (26.09.2026, Vorgabe des Betreibers: MariaDB 11.8 ist bereitgestellt)
**Entscheidung:** MariaDB ist die führende Datenbank, utf8mb4_unicode_ci, Zugriff über `mysql2` mit eigenen SQL-Migrationen.
**Vorteile:** keine zweite Datenbank im Betrieb, vorhandenes Backup/Know-how. **Nachteile:** kein Row-Level-Security, DDL nicht transaktional.
**Folgen:** Mandantentrennung erfolgt in der Policy-Schicht und in Abfragen (`canInOrg`) und wird per Integrationstest geprüft.
Audit-Unveränderlichkeit: Hash-Kette (`audit_events`); zusätzlich soll die Anwendung später eine DB-Rolle mit nur INSERT/SELECT auf dieser Tabelle nutzen (offen).
Alle DB-Verbindungen laufen in UTC (`SET time_zone='+00:00'`).

View file

@ -0,0 +1,6 @@
# ADR 0002: Modularer Monolith (Fastify + Next.js + Worker)
Status: angenommen
**Entscheidung:** Drei Prozesse (API, Worker, Web) in einem pnpm-Monorepo. Fachlichkeit in Modulen (`KcModule`) mit eigenen Routen und Rechten.
Fastify statt NestJS: weniger Abhängigkeiten, gleiche Modulgrenzen durch eigenes Modulmanifest. Jobs zunächst in MariaDB (`jobs`, `FOR UPDATE SKIP LOCKED`); Redis ist nicht nötig und kann später ergänzt werden.
**Regeln:** Module importieren einander nicht; Kommunikation über `core/*` und Jobs. Connectoren werden als eigene Module/Pakete hinter einem Connectorvertrag ergänzt (Ticket KUNDENCENT-6).
**Bekannte Abweichung:** Config-Lader ist in `apps/api` und `apps/worker` dupliziert; bei weiterem Wachstum in ein Paket `packages/config` auslagern.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,77 @@
# Betrieb: Backup und Wiederherstellung
## Neu: Passwort und Ziele in der Oberfläche
Unter **Einstellungen → Backup** (nur Superadministratoren dürfen ändern):
- **Backup-Passwort**: selbst wählbar, mindestens 11 Zeichen (länger als 10), mit Wiederholung und Bestätigung durch das Anmeldepasswort. Neue Sicherungen werden damit per **gpg (AES-256, Integritätsschutz)** verschlüsselt (`*.tar.gz.gpg`). Das Passwort liegt mit dem Master-Schlüssel verschlüsselt in der Datenbank, wird nie angezeigt oder protokolliert und lässt sich **nicht wiederherstellen**. Nach einer Änderung bleiben ältere Sicherungen mit dem alten Passwort verschlüsselt. Ohne Passwort und ohne age-Schlüssel wird nichts gesichert (nie unverschlüsselt).
- **Externe Ziele**: SFTP (Passwort oder Schlüssel; Server-Schlüssel wird beim ersten Test gemerkt und angezeigt, ein anderer Server wird danach abgelehnt), FTP/FTPS, Google Drive (Token aus `rclone authorize`), Ordner/Netzlaufwerk (eingehängt, z. B. `/mnt/nas/...`). Zugangsdaten werden verschlüsselt gespeichert und nie wieder angezeigt. „Testen“ legt den Ordner an, schreibt und löscht eine Testdatei. Ziele lassen sich deaktivieren oder entfernen (bereits abgelegte Sicherungen bleiben).
- Alte Sicherungen mit `.age` bleiben mit der Schlüsseldatei lesbar; der Wiederherstellungstest erkennt die Art an der Dateiendung.
**Entschlüsseln im Notfall (nur mit Standardwerkzeug):**
```bash
gpg -d -o archive.tar.gz kundencenter-DATUM-....tar.gz.gpg # fragt nach dem Backup-Passwort
```
## Überblick
- **Was wird gesichert:** Datenbank `kundencenter` (Dump) + Konfiguration mit dem Master-Schlüssel (`app.env`, `db.env`, `discord.env`). Ohne `KC_SECRET_KEY` wären 2FA- und Verbindungs-Geheimnisse in einem Backup unlesbar, deshalb ist er enthalten. **Nicht** enthalten: der private Backup-Schlüssel, Plane-Zugang, das Lizenzsystem (eigenes System, eigenes Backup).
- **Verschlüsselung:** `age` (öffentlicher Schlüssel `BACKUP_AGE_RECIPIENT`). Ziele (NAS, FTP, Google Drive) sehen nur verschlüsselte Dateien.
- **Zeitplan:** täglich ca. 02:30 (`kc-backup.timer`), Wiederherstellungstest sonntags ca. 04:30 (`kc-restore-test.timer`). Verpasste Läufe werden nachgeholt (`Persistent`).
- **Aufbewahrung** (lokal und auf jedem Ziel): 14 tägliche, 8 wöchentliche, 12 monatliche Sicherungen; immer mindestens 3 bleiben erhalten. Fremde Dateien werden nie gelöscht.
- **Ablage lokal:** `/var/backups/kundencenter/` (Rechte 700). Das ersetzt kein externes Ziel: bei Verlust des Servers ist es weg.
- **Kontrolle:** Übersicht (Personal) zeigt letzten Lauf, Ziele und Wiederherstellungstest. Bei fehlgeschlagenem Lauf oder mehr als 26 h ohne Erfolg geht eine Meldung an Discord (ohne Nutzdaten, sobald der Bot eingerichtet ist). Status: `/var/lib/kundencenter/backup-status.json`, Logs: `journalctl -u kc-backup`.
- **Wiederherstellungstest** (automatisch): entschlüsselt die neueste Sicherung, spielt sie in die Wegwerf-Datenbank `kundencenter_restoretest` ein und prüft Prüfsummen, Zeilenzahlen aller Kerntabellen, Migrationen, Audit-Hash-Kette und ob gesicherte Geheimnisse mit dem gesicherten Schlüssel lesbar sind. Produktivdaten bleiben unberührt.
## WICHTIG: privaten Schlüssel extern sichern
Der private Schlüssel liegt in `/etc/kundencenter/backup-age-key.txt` (Rechte 600). **Ohne ihn sind alle Backups wertlos.** Bitte einmalig eine Kopie an einem sicheren Ort ablegen (Passwortmanager, Tresor), **nicht** im selben Cloud-Ziel wie die Backups:
```bash
cat /etc/kundencenter/backup-age-key.txt # Inhalt (zwei Kommentarzeilen + Schlüsselzeile AGE-SECRET-KEY-...) sicher kopieren
```
Der Schlüssel liegt auf dem Server, damit der automatische Wiederherstellungstest laufen kann. Wer den Server kontrolliert, hat ohnehin Zugriff auf die Datenbank.
## Backup-Ziele per Kommandozeile (Alternative zur Oberfläche)
Zugangsdaten der Ziele stehen in `/var/lib/kundencenter/rclone.conf` (nur root). Alle Befehle mit `--config /var/lib/kundencenter/rclone.conf`.
**SFTP (empfohlen: Schlüssel statt Passwort)**
```bash
ssh-keygen -t ed25519 -N "" -f /var/lib/kundencenter/backup_ed25519
cat /var/lib/kundencenter/backup_ed25519.pub # auf dem Ziel in ~/.ssh/authorized_keys eintragen
ssh-keyscan -H ZIELHOST > /var/lib/kundencenter/known_hosts
rclone config create nas sftp host=ZIELHOST user=BENUTZER key_file=/var/lib/kundencenter/backup_ed25519 known_hosts_file=/var/lib/kundencenter/known_hosts --config /var/lib/kundencenter/rclone.conf
rclone mkdir nas:kundencenter --config /var/lib/kundencenter/rclone.conf && rclone lsd nas: --config /var/lib/kundencenter/rclone.conf
```
**FTP/FTPS**
```bash
rclone config create ftpziel ftp host=ZIELHOST user=BENUTZER pass="$(rclone obscure 'PASSWORT')" explicit_tls=true --config /var/lib/kundencenter/rclone.conf
```
(Nur unverschlüsseltes FTP möglich? Dann `explicit_tls=false`; die Backups sind trotzdem verschlüsselt, Zugangsdaten gehen aber im Klartext über das Netz.)
**Google Drive** (OAuth, einmalig auf einem Rechner mit Browser)
```bash
# auf dem Rechner mit Browser (rclone dort installieren):
rclone authorize "drive" "eyJzY29wZSI6ImRyaXZlLmZpbGUifQ" # Scope drive.file: nur von rclone angelegte Dateien
# ausgegebenen Token kopieren, dann auf dem Server:
rclone config create gdrive drive scope=drive.file token='{"access_token":...}' --config /var/lib/kundencenter/rclone.conf
```
Empfehlung: eigene OAuth-Client-ID (Google Cloud) verwenden (`client_id=`/`client_secret=`), sonst gelten geteilte Limits.
**Aktivieren und testen**
```bash
# in /etc/kundencenter/backup.env: BACKUP_REMOTES=nas:kundencenter,gdrive:Backups/kundencenter
systemctl start kc-backup && journalctl -u kc-backup -n 30 --no-pager -o cat # Ziele müssen "ok": true zeigen
```
Ein ausgefallenes Ziel lässt den Lauf als Fehler zählen (Meldung), das lokale Backup bleibt erhalten.
## Wiederherstellung (Notfall)
1. Neuer/repariertes Server mit MariaDB, Node und `age`; Kundencenter-Code nach `/srv/kundencenter` (Repo), `pnpm install && pnpm build`.
2. Sicherungsdatei holen (lokal, Ziel) und den **privaten Schlüssel** bereitlegen.
3. Entschlüsseln und entpacken:
```bash
age -d -i backup-age-key.txt -o archive.tar.gz kundencenter-DATUM-....tar.gz.age
mkdir restore && tar -xzf archive.tar.gz -C restore # enthält db.sql, config/*.env, manifest.json
```
4. Konfiguration zurück: `cp restore/config/*.env /etc/kundencenter/` (chmod 600), Datenbank und Benutzer anlegen (nur Rechte auf `kundencenter`).
5. Datenbank einspielen: `mysql -u… -p kundencenter < restore/db.sql`
6. Dienste starten (`kc-api`, `kc-worker`, `kc-web`), prüfen: `/v1/ready`, Anmeldung, Übersicht, **Audit-Protokoll → „Integrität prüfen“**.
7. Erwartung: Datenverlust höchstens bis zum letzten Backup (bis zu 24 h), Wiederherstellung ca. 30 Minuten.
## Vor größeren Änderungen
Vor Migrationen oder Updates manuell sichern: `systemctl start kc-backup`. Rollback einer fehlerhaften Migration = Wiederherstellung (Abschnitt oben) in eine Wegwerf-Datenbank prüfen, dann produktiv einspielen.

71
docs/connector-keyhelp.md Normal file
View file

@ -0,0 +1,71 @@
# KeyHelp-Connector: API-Fähigkeiten und Abbildung
## Umsetzungsstand (27.09.2026)
Paket `packages/connector-keyhelp`, getestet gegen einen nach der Definition nachgebauten Server (`test/fake.ts`), **nicht gegen eine echte Instanz**. Zugang: KeyHelp-Adresse + API-Schlüssel (IP-beschränkt); die Version wird über `GET /server` erkannt.
- **Lesen/Abgleich**: Kunden mit Nutzung und Limits (Statistik je Kunde), Tarife, Health mit Version. Der Abgleich arbeitet rein lesend.
- **Kunden übernehmen** (Kunden → Aus Verbindung übernehmen): Kontaktdaten → Kunde und Rechnungsanschrift, Inhaber als „eingeladen“ **ohne E-Mail-Versand** (Einladung später gezielt), Konto wird zugeordnet, optional **Bestandsvertrag** zum passenden Produkt (Beginn wählbar, Laufzeit wird in die Zukunft fortgeschrieben, keine Rechnung). Doppelte Übernahme ist ausgeschlossen (`organizations.legacy_ref`); Verknüpfung mit vorhandenem Kunden möglich.
- **Tarife/Produkte**: vorhandene Tarife über „Produkte → Aus Verbindung übernehmen“; **neue Tarife** über „Neuen Hosting-Tarif anlegen“ (legt den Tarif in KeyHelp an und das Produkt im Kundencenter). Name muss frei sein, „unbegrenzt“ nur, wenn die Instanz es bereits kennt.
- **Bereitstellung** (Bestellung): Konto anlegen, Benutzername `kc` + Ziffern der Vertragsnummer (stabil, bei Wiederholung wird das eigene Konto übernommen), Vermerk „Kundencenter K-… V-…“, Zugangsdaten gehen per KeyHelp an die Kunden-E-Mail (Passwort wird nie gelesen/gespeichert).
- **Aktionen**: Sperren/Entsperren, Tarifwechsel (Connector; UI folgt mit Upgrade/Downgrade), Löschen nur manuell und nie automatisch wiederholt.
- **Kundenpanel-Login** (SSO, 60 Min., nicht gespeichert, im Audit).
- **Hosting-Ressourcen** je Konto: Domains, E-Mail, Datenbanken, FTP lesen und ändern (als Auftrag, Passwörter nur verschlüsselt bis zur Ausführung), SSL-Zertifikate nur lesen. Freigabe pro Produkt/Ressource (`domain.manage`, `email.manage`, `database.manage`, `ftp.manage`, `panel.login`) plus Rolle Inhaber/Admin.
- **Mandantentrennung**: Der API-Schlüssel ist ein Administratorzugang, deshalb prüft der Connector vor jeder Änderung, dass das Objekt dem Konto gehört; neue Objekte bekommen immer die Kunden-ID; Namenskonflikte mit fremden Objekten werden abgelehnt (getestet).
### Annahmen, die an der echten Instanz zu prüfen sind
1. Speicher/Traffic in **Byte** (Definition gibt keine Einheit an; Anzeige und Tarif-Anlage gehen davon aus).
2. **Zeitstempel ohne Zeitzone** werden als UTC gelesen.
3. Negative Limits = **unbegrenzt** (nur beim Anlegen genutzt, wenn die Instanz es schon kennt).
4. Pflichtfelder von `POST /clients` (E-Mail, Benutzername; Passwort wird vom Panel erzeugt) und Benutzernamensregeln (`kc` + Ziffern).
5. FTP-Anlage setzt ein vollständig eingerichtetes System-Konto voraus (Panel meldet sonst 400).
6. Verhalten bei Tarifwechsel, wenn neue Limits unterschritten werden.
7. Listen liefern alles (kein Paging): bei sehr vielen Kunden dauert der Abgleich (Statistik je Kunde, 4 parallel).
### Noch nicht umgesetzt
DNS-Editor, Cronjobs, Verzeichnisschutz, PHP-Einstellungen je Kunde, Zertifikate hochladen, Fail2Ban-Entsperren, Backup-Liste anzeigen; Hauptdomain bei der Bestellung erfragen; Kündigung mit `delete_on` statt Sperre; Tarifwechsel in der Oberfläche.
# (Auswertung der API-Definition)
Quelle: offizielle OpenAPI-Definition **KeyHelp RESTful API 2.15** (geändert 25.09.2026), Kopie: `docs/api-specs/keyhelp-api-2.15.openapi.json`. Stand: nur die Definition gelesen, **nicht** gegen eine echte KeyHelp-Instanz geprüft (kein Testzugang, Version der Instanz unbekannt).
## Grundregeln der API
- URL `https://<host>/api/v2/<Endpunkt>`, Anmeldung per Header `X-API-Key`. Formate: JSON (Standard), XML, YAML.
- **API ist standardmäßig aus.** Schlüssel legt man in KeyHelp an (Admin-Bereich → Konfiguration → API) und **soll auf IP/IP-Bereich beschränkt** werden. Es gibt keine Rechte-Stufen pro Schlüssel: Ein Schlüssel ist praktisch ein Administratorzugang → höchste Vertraulichkeit (verschlüsselt speichern, nur Superadmin).
- Objekte sind per numerischer ID **oder Name** ansprechbar (`/clients/{id}` und `/clients/name/{name}`).
- Listen liefern **alles auf einmal** (nur `sort`/`order`, kein Paging). Bei sehr vielen Kunden Antwortzeit beachten.
- Fehlerformat `{code, message}`, Statuscodes 200/201/202/204/400/401/404.
- Beim Neuladen des Webservers antwortet KeyHelp kurz mit **500/503** → Aufrufer muss nach einigen Sekunden erneut versuchen.
- Objekte haben ein Feld `status`: 0 unbekannt, 1 ok, 2 Fehler, 3 Konfiguration neu, 4 Konfiguration wird aktualisiert (→ Zustand „in Bearbeitung“).
- Keine Webhooks, keine Paginierung, keine Rechnungs-/Ticket-Funktionen.
## Abbildung auf den Connector-Vertrag
| Fähigkeit | KeyHelp | Bemerkung |
|---|---|---|
| Health / Version | `GET /ping`, `GET /server` | Server-Info mit Komponenten (Apache, PHP, MariaDB, Postfix …), Auslastung, Ports |
| Ressourcen lesen | `GET /clients`, `/clients/{id}`, `/clients/{id}/resources` | Kunde = Hosting-Konto; Ressourcen-Liste enthält die Unterobjekte |
| Nutzung | `GET /clients/{id}/stats`, `/traffic` | Speicher und Traffic (`value`/`max`), Anzahl Domains, Postfächer, DBs, FTP, Cronjobs; Traffic je Protokoll (HTTP, FTP, POP3, IMAP, SMTP) |
| Limits/Tarif | `client.resource_limits` (nur lesend), `GET /hosting-plans` | **Limits nur über den Tarif** änderbar, keine Einzelabweichung pro Kunde per API (dafür eigenen Tarif anlegen) |
| `lifecycle.create` | `POST /clients` → 201 `{id}` | Pflicht/Optionen: Benutzername, E-Mail, Passwort oder generiert, `id_hosting_plan` (sonst Standardtarif), Kontaktdaten, `create_system_domain`, `send_login_credentials`. Natürliche Idempotenz über den eindeutigen Benutzernamen (vorher `GET /clients/name/{name}`) |
| `lifecycle.suspend/unsuspend` | `PUT /clients/{id}` `{is_suspended}` | Zielzustand → idempotent. Zusätzlich **geplant**: `suspend_on` und `delete_on` (Datum oder „+30 days“) → passt zu Kündigung, Kulanz- und Sperrregeln |
| `lifecycle.terminate` | `DELETE /clients/{id}` | **Löscht Konto und ALLE Ressourcen** (destruktiv): nie automatisch wiederholen, immer manuelle Bestätigung; besser `delete_on` mit Kulanzfrist |
| `plan.change` | `PUT /clients/{id}` `{id_hosting_plan}` | Up-/Downgrade über Tarifwechsel (Verhalten bei Überschreitung der neuen Limits an der Instanz prüfen) |
| Passwort/Zugang | `PUT /clients/{id}` `{password}` | Optional Zugangsdaten per E-Mail (`send_login_credentials`) |
| `sso.login` | `GET /login/{id}` bzw. `/login/name/{name}` | **Offiziell unterstützt**: URL 60 Minuten gültig, mit Brute-Force-Schutz. Passt zu „sicherer Panel-Login“ |
| Domains | `/domains` (CRUD), `php_version`, `is_disabled`, `delete_on`, `security` (Let’s Encrypt, HTTPS erzwingen, HSTS), `is_email_domain` | Haupt- und Subdomains, System-Domains filterbar |
| DNS | `/dns/{id}` lesen/ersetzen/zurücksetzen | Datensätze, DKIM |
| SSL | `/certificates` (CRUD) | `valid_till`, `issuer`, `secured_domains` → **Ablaufdaten** verfügbar |
| E-Mail | `/emails` (CRUD) | Postfachgröße/-limit, Aliase, Weiterleitungen, Catch-all, Passwort |
| Datenbanken | `/databases` (CRUD) | Name, Benutzer, Passwort, Größe, Remote-Hosts |
| FTP | `/ftp-users` (CRUD) | Benutzer, Heimverzeichnis |
| Weiteres | `/scheduled-tasks` (Cronjobs), `/directory-protections`, `/backups/operations` (nur Liste), `/fail2ban/banned-ips` (Liste, Entsperren) | Backup-Wiederherstellung ist nicht per API möglich |
| Nicht vorhanden | Webhooks, Paging, Limits pro Kunde, Rechnungen/Tickets | Abgleich daher per Polling (Sync-Job) |
| Nie freigeben | `/admins/*`, `POST /server/reboot` | Administratorkonten und Server-Neustart gehören nicht in das Kundencenter |
## Vorschlag für den Umfang
1. **Lesend zuerst:** Health, Kunden mit Nutzung/Limits/Status, Domains, SSL-Ablauf, Übernahme der **Hosting-Tarife als Produktvorlagen** (wie beim Lizenzsystem: Limits aus `hosting-plans`).
2. **Lebenszyklus:** anlegen, sperren/entsperren, Tarifwechsel, geplantes Löschen (`delete_on`) statt sofortigem `DELETE`.
3. **Panel-Login (SSO)** und Unterobjekte (E-Mail, Datenbanken, FTP) für Kunden gemäß Produktregeln.
## Benötigt für die Umsetzung
- KeyHelp-URL und **Version** (Definition kennt 2.1–2.15; Abweichungen kapselt der Connector).
- Ein **Test-API-Schlüssel**, auf die IP des Kundencenter-Servers beschränkt (idealerweise gegen eine Testinstanz; sonst Schlüssel mit Bedacht, da er Administratorrechte hat).
- Entscheidung, welche Tarife als Produkte angeboten werden und ob Kunden Unterobjekte selbst verwalten dürfen.

34
docs/connector-vertrag.md Normal file
View file

@ -0,0 +1,34 @@
# Connector-Vertrag v1
Code: `packages/connector-sdk/src/index.ts`. Jeder Provider implementiert `Connector` und liefert **nur normalisierte Daten** (`NormalizedResource`). Provider-Rohmodelle bleiben im Connector-Paket.
| Element | Beschreibung |
|---|---|
| `capabilities(ctx)` | Fähigkeiten (`resources.list`, `lifecycle.suspend`, …). Backend/UI bieten nur Gemeldetes an. Das Lizenz-Connector meldet Schreibfähigkeiten nur mit konfiguriertem API-Benutzer. |
| `healthCheck(ctx)` | Erreichbarkeit + Latenz. Ergebnis wird an der Instanz gespeichert (`health`, `last_ok_at`). |
| `listResources(ctx)` | Abgleich. Ergebnis wird in `resources` gespiegelt; nicht mehr gelieferte Objekte werden mit `missing_since` markiert, nie gelöscht. |
| `execute(ctx, {action, externalRef, params, idempotencyKey})` | Schreibende Aktion. Läuft ausschließlich als persistenter Auftrag (`connector.execute`), Idempotenzschlüssel = Job-ID. Aktionen setzen **absolute Zielzustände** (z. B. `until`), damit Wiederholung wirkungsgleich ist. |
| Fehler | `ConnectorError` mit Codes `UNREACHABLE, TIMEOUT, AUTH_FAILED, RATE_LIMITED, NOT_FOUND, INVALID_RESPONSE, UNSUPPORTED, CONFLICT, UPSTREAM_ERROR, BAD_CONFIG`; `retryable` und `userMessage` (ohne technische Details). |
| HTTP | `httpJson`: Timeout, exponentielles Backoff mit Jitter, `Retry-After`; Wiederholung nur für GET, Schreibzugriffe nie automatisch im Connector. |
## Aufträge
Status: geplant → wird ausgeführt → erfolgreich | wird wiederholt → manuelle Prüfung erforderlich / fehlgeschlagen. Retries begrenzt (`max_attempts`, Backoff 30 s · 2ⁿ, max. 1 h).
**Destruktive Aktionen (`terminate`) werden nie automatisch wiederholt**, auch nicht nach Worker-Absturz (`needs_review`), und lassen sich nicht über „Erneut ausführen“ starten.
## Neuer Connector
1. Paket `packages/connector-<name>` mit `Connector`-Implementierung, `configFields` (Secrets als `secret: true`).
2. Vertragstest: `runContract(...)` aus `@kc/connector-sdk/dist/contract.js` plus Fixtures mit anonymisierten Antworten.
3. In `packages/connectors/src/index.ts` registrieren. Keine weitere Änderung in API/Worker/UI nötig.
## Vorhandene Connectoren
- `mock` – Testdaten (`[Mock]`), simuliert Ausfälle (`simulateOutage`).
- `licensing` – eigenes Lizenzsystem. Erkennt Erweiterungen über `GET /` → `features`:
- Zugang bevorzugt per **Service-Token** (`token`), alternativ API-Benutzer/Passwort. Ohne Zugang nur lesend.
- `catalog.list`: Produktkatalog des Lizenzsystems (Programm → Gruppe → Produkt mit Preis, Dauer, Features) als Übernahmevorlagen; Produkt- und Programmschlüssel werden nie ausgegeben. Ältere Systeme: Programme mit Vorschlägen aus bestehenden Lizenzen.
- `lifecycle`: Sperren/Entsperren/Verlängern über die Endpunkte `/licenses/{id}/suspend|unsuspend|extend` (Zielzustand, protokolliert); sonst Rückfall auf `PUT`.
- Zustand berücksichtigt `is_active`, `blocked`, `status`, `suspended_at`, `revoked_at` und Ablauf. Bei `utc_timestamps` werden Zeiten als UTC gelesen, sonst als Ortszeit.
- Anlage (`lifecycle.create`) mit `product_id` (Plan/Edition des Lizenzsystems); Produkt und Präfix dürfen nie stillschweigend verloren gehen (Antwort wird geprüft).
- KeyHelp, Plesk: **offen** (keine Testzugänge; siehe Plane).
## Zugangsdaten
Secrets werden pro Verbindung mit AES-256-GCM verschlüsselt (`connector_instances.secrets_enc`), nur Superadmins dürfen Verbindungen anlegen/ändern, die UI zeigt sie nie wieder an, das Audit-Protokoll maskiert sie.

22
docs/domains.md Normal file
View file

@ -0,0 +1,22 @@
# Domains: Preise, Prüfung, Aufstellung
**Modul:** `apps/api/src/modules/domains` · Migrationen 010–013 · Seiten `/domains`, `/admin/produkte/domains`, `/admin/domains`, Abschnitt „Domains“ auf der Kundenseite.
## Preisberechnung
- Einkaufsliste (KCS/supportdomain): Endung, Laufzeit, 4 Staffeln (0–49, 50–199, 200–999, 1000+), Setup. Die Liste gilt als **Bruttopreis inkl. USt** (Einstellung `cost_basis`, umstellbar auf netto).
- Verkauf = Einkauf + Aufschlag (Prozent in Basispunkten oder Festbetrag; je Endung, sonst global). Kein Aufschlag → kein Verkaufspreis („auf Anfrage“).
- Rundung (optional, Standard an): Cent-Anteil 00–50 → ,50; 51–99 → ,99.
- Bei Brutto-Liste wird Netto herausgerechnet (`brutto / (1 + USt)`), es wird nie zweimal USt aufgeschlagen. Setup ohne Aufschlag.
- Steuersatz: „Regelsteuersatz“ aus `tax_rates`.
## Verfügbarkeit
RDAP (IANA-Bootstrap, .de zusätzlich über rdap.denic.de), Rückfall DNS mit Hinweis „ohne Gewähr“. Endgültige Prüfung erst bei der Registrierung beim Registrar.
## Aufstellung (`domain_records`)
Preis-Schnappschuss je Domain (EK brutto/netto, VK brutto/netto, Gewinn netto) und Beschaffungsstatus (offen / bei KCS bestellt / anderer Anbieter). CSV-Export als KCS-Bestellliste und Gesamtaufstellung.
## Import der Preisliste
Oberfläche „Preisliste einlesen“ oder `pnpm --filter @kc/api cli import-domains --file=<datei>` (Format siehe `data/domain-preise.txt`). Aufschläge und Aktiv-Status bleiben erhalten.
## Offen
Registrar-API von KCS (unbekannt, Anfrage nötig), Domain als Position in der Bestellung.

View file

@ -0,0 +1,50 @@
# Lizenzsystem-Erweiterung: Edition (Schlüssel-Präfix) und festes Ablaufdatum
**Zweck:** Das Kundencenter soll Familytool-Stufen verkaufen (Premium, Unlimited, Lifetime, Trial). Das Familytool erkennt die Stufe am **Präfix des Lizenzschlüssels** (`PREMIUM-…`, `TRIAL-…`, `LIFETIME…`, sonst Unlimited). Das Lizenzsystem konnte bisher nur Schlüssel ohne Präfix erzeugen, jede Lizenz wäre also automatisch Unlimited gewesen.
**Wo einspielen:** auf der Instanz, gegen die das Kundencenter und das Familytool laufen, also **licensing.flessinglabs.com** (nicht auf dem lokalen Dienst dieses Servers). Ohne den Patch verweigert das Kundencenter Bestellungen mit Edition (das Produkt bleibt Entwurf, eine Bestellung würde nie eine falsche Lizenz anlegen).
Getestet an einer Kopie des Codes mit eigener Testdatenbank auf einem separaten Port: Präfix für alle drei Editionen, ungültiges Präfix → 422, festes Ablaufdatum, unveränderte Anlage ohne Präfix, `validate` mit Präfix-Schlüssel, Erkennung über `GET /`.
## Ablauf (ca. 10 Minuten, kurze Unterbrechung nur beim Neustart)
1. **Sichern** (DB und Code des Lizenzsystems auf dem Zielserver):
```bash
mysqldump --single-transaction <DBNAME> > licensing-vor-erweiterung.sql
cp -a <pfad>/backend/app app-vor-erweiterung
```
2. **Vorab prüfen: startet der Dienst nach einem Neustart korrekt?** (Wir hatten am 26.09. auf dem lokalen Dienst einen Ausfall, weil das venv neuer war als der laufende Prozess. Siehe LICENSING-11.)
```bash
<pfad>/backend/venv/bin/pip list | grep -iE "bcrypt|passlib|slowapi|starlette" # bcrypt muss < 5 sein (z. B. 4.0.1)
grep -n "def validate_license" -A3 <pfad>/backend/app/routers/licenses.py # Parameter muss "request: Request" heißen
```
Wenn `bcrypt` ≥ 5 ist oder die Schema-Parameter noch `request` heißen: **erst das beheben**, sonst fallen Login und `validate` beim Neustart aus.
3. **Datenbank erweitern** (Schlüssel werden mit Präfix länger als 36 Zeichen):
```sql
ALTER TABLE licenses MODIFY license_key VARCHAR(64) NOT NULL;
```
4. **Code anpassen** (erst Trockenlauf, dann anwenden):
```bash
python3 apply_erweiterung.py <pfad>/backend # zeigt nur, was geändert würde
python3 apply_erweiterung.py <pfad>/backend --apply # legt Sicherungen *.bak-<zeit> an
```
Weicht der Code ab, bricht das Skript ohne Änderung ab und nennt die Stelle.
5. **Dienst neu starten** (z. B. `systemctl restart licensing-backend`) und prüfen:
```bash
curl -s https://licensing.flessinglabs.com/api/ # muss "features":["key_prefix","explicit_expiry"] enthalten
```
Danach kurz testen: Login im Admin-Panel, eine Lizenz prüfen (`validate`) und im Familytool die Lizenzprüfung.
6. **Im Kundencenter**: Verbindungen → „Jetzt abgleichen“. Unter „Fähigkeiten“ erscheinen `license.key_prefix` und `license.expiry`. Danach lassen sich die Familytool-Produkte aktivieren.
## Zurückrollen
Dienst stoppen, `*.bak-<zeit>` zurückkopieren, Dienst starten. Die Spalte darf auf 64 Zeichen bleiben. Bereits mit Präfix angelegte Lizenzen bleiben gültig.
## API-Änderungen im Detail
- `POST /licenses/` Body: neues optionales Feld `key_prefix` (`PREMIUM` | `TRIAL` | `LIFETIME`). Antwort enthält den Schlüssel `<PRAEFIX>-<uuid>`.
- `POST /licenses/`: ein angegebenes `expires_at` (Ortszeit wie bisher) wird verwendet; ohne Angabe gilt die bisherige Berechnung aus `duration_type`.
- `GET /`: Feld `features` (Liste).
- Nicht geändert: `validate`, `register`, Shop, Login, alle bestehenden Felder.
## Bekannte Grenzen
- Ein Upgrade von Premium auf Unlimited bedeutet einen **neuen Schlüssel** (das Präfix steckt im Schlüssel). Das Kundencenter legt dann eine neue Lizenz an; die alte wird gesperrt. Eine spätere Lösung mit einem Feld `edition` im Lizenzsystem (statt Präfix) ist möglich.
- Das Ablaufdatum wird (wie im Lizenzsystem üblich) als Ortszeit gespeichert.

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Erweiterung des Lizenzsystems um "Edition" (Schlüssel-Präfix) und festes Ablaufdatum – für das Kundencenter.
Was der Patch tut (alles abwärtskompatibel, bestehende Clients ändern sich nicht):
1. POST /licenses/ akzeptiert optional key_prefix: "PREMIUM" | "TRIAL" | "LIFETIME"
-> Schlüssel lautet dann "<PRAEFIX>-<uuid>" (das Familytool erkennt daran die Stufe; ohne Präfix = Unlimited).
2. POST /licenses/ beachtet ein ausdrücklich angegebenes expires_at (bisher wurde es ignoriert).
3. GET / meldet "features": ["key_prefix", "explicit_expiry"] (daran erkennt das Kundencenter die Erweiterung).
4. models.License.license_key wird von 36 auf 64 Zeichen erweitert (DB-Änderung: siehe README, VORHER ausführen).
Nutzung (Standard = nur prüfen, nichts ändern):
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend # Trockenlauf
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend --apply # anwenden (mit Sicherungskopien *.bak-<zeit>)
Das Skript ist idempotent: bereits vorhandene Änderungen werden erkannt und übersprungen.
"""
import re, sys, time, py_compile, pathlib
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith('--')]
apply = '--apply' in sys.argv
if len(args) != 1:
print(__doc__); return 2
root = pathlib.Path(args[0]) / 'app'
files = {n: root / p for n, p in {'models': 'models.py', 'schemas': 'schemas.py', 'licenses': 'routers/licenses.py', 'main': 'main.py'}.items()}
for n, f in files.items():
if not f.exists():
print(f'FEHLER: {f} nicht gefunden – falscher Pfad? (erwartet: .../licensing-system/backend)'); return 1
src = {n: f.read_text() for n, f in files.items()}
new = dict(src); report = []
def step(name, key, old, repl, marker):
if marker in new[key]:
report.append(f' = {name}: bereits vorhanden'); return True
if old not in new[key]:
report.append(f' ! {name}: erwartete Stelle NICHT gefunden – bitte manuell prüfen (siehe README)'); return False
new[key] = new[key].replace(old, repl, 1); report.append(f' + {name}'); return True
ok = True
ok &= step('models: license_key 64 Zeichen', 'models',
'license_key = Column(String(36), unique=True, index=True, default=lambda: str(uuid.uuid4()))',
'license_key = Column(String(64), unique=True, index=True, default=lambda: str(uuid.uuid4()))', 'String(64), unique=True')
ok &= step('schemas: key_prefix in LicenseCreate', 'schemas',
'class LicenseCreate(LicenseBase):\n pass',
'class LicenseCreate(LicenseBase):\n # Optionales Schlüssel-Präfix (Edition): Schlüssel lautet "<PRAEFIX>-<uuid>". Ohne Präfix gilt die Lizenz im Familytool als UNLIMITED.\n key_prefix: Optional[Literal["PREMIUM", "TRIAL", "LIFETIME"]] = None',
'key_prefix: Optional[Literal')
if 'Literal' not in re.split(r'\nclass ', new['schemas'], maxsplit=1)[0]:
m = re.search(r'^from typing import (.+)$', new['schemas'], re.M)
new['schemas'] = new['schemas'].replace(m.group(0), f'from typing import Literal, {m.group(1)}', 1) if m else 'from typing import Literal\n' + new['schemas']
report.append(' + schemas: Literal importiert')
ok &= step('licenses: festes Ablaufdatum hat Vorrang', 'licenses',
' # Calculate expires_at based on duration_type\n expires_at = None\n if license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
' # Ein ausdrücklich angegebenes Ablaufdatum hat Vorrang; sonst wird es aus duration_type berechnet\n expires_at = license.expires_at\n if expires_at is None and license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
'expires_at = license.expires_at')
ok &= step('licenses: Schlüssel mit Präfix', 'licenses',
' db_license = models.License(\n program_id=license.program_id,',
' extra = {"license_key": f"{license.key_prefix}-{uuid.uuid4()}"} if license.key_prefix else {}\n db_license = models.License(\n **extra,\n program_id=license.program_id,',
'f"{license.key_prefix}-')
if not re.search(r'^import uuid$', new['licenses'], re.M):
new['licenses'] = re.sub(r'^import datetime$', 'import datetime\nimport uuid', new['licenses'], count=1, flags=re.M); report.append(' + licenses: uuid importiert')
ok &= step('main: features in GET /', 'main',
'return {"message": "Licensing System Backend is running!"}',
'return {"message": "Licensing System Backend is running!", "features": ["key_prefix", "explicit_expiry"]}', '"features"')
print('\n'.join(report))
if not ok:
print('\nABBRUCH: Nicht alle Stellen passen (Code weicht ab). Es wurde NICHTS geändert.'); return 1
changed = [n for n in files if new[n] != src[n]]
if not changed:
print('\nNichts zu tun – die Erweiterung ist bereits vollständig eingespielt.'); return 0
if not apply:
print(f'\nTROCKENLAUF: würde {", ".join(files[n].name for n in changed)} ändern. Mit --apply ausführen.'); return 0
ts = time.strftime('%Y%m%d-%H%M%S')
for n in changed:
files[n].with_name(files[n].name + f'.bak-{ts}').write_text(src[n])
files[n].write_text(new[n])
py_compile.compile(str(files[n]), doraise=True)
print(f'\nANGEWENDET: {", ".join(files[n].name for n in changed)} (Sicherungen: *.bak-{ts}). Jetzt Dienst neu starten (siehe README).')
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,39 @@
# Produkte, Bestellungen, Verträge
## Modell
- **Produkt** (`products`) + **unveränderliche Versionen** (`product_versions`): Preise in Cent (netto), Steuersatz (Basispunkte), Intervall (`once|monthly|yearly`), Mindestlaufzeit, Verlängerung, Kündigungsfrist, Provisionierungsparameter. Änderung = neue Version.
- **Bestellung** (`orders`/`order_items`): Preis wird **immer serverseitig** aus der aktuellen Version berechnet (`@kc/platform/pricing`) und als **Snapshot** in `order_items.snapshot_json` und `contracts.price_snapshot_json` festgeschrieben. Preisänderungen wirken nicht rückwirkend.
- **Vertrag** (`contracts`): je Bestellposition ein Vertrag, Verweis auf die bereitgestellte Ressource.
## Statusautomaten (`@kc/platform/statemachine`, mit Tabellentest)
- Bestellung: `pending_approval → approved → provisioning → completed | failed`, `rejected`, `cancelled`, `failed → provisioning` (Wiederholung).
- Vertrag: `pending → active ⇄ suspended → cancelled | expired`, `pending → failed`.
- Ungültige Übergänge liefern HTTP 409, nie eine stille Änderung.
## Abläufe
1. **Bestellung**: Kunde (Inhaber/Admin der Organisation) nur für Produkte mit „Selbstbestellung“, ohne Rabatt. Personal bestellt für Kunden inkl. Rabatt und gilt als freigegeben.
2. **Freigabe**: Produkte mit „manuelle Freigabe“ warten auf `orders.approve` (Admin). Kunden können ihre offene Bestellung zurückziehen.
3. **Bereitstellung** (Worker, Auftrag `order.provision`): Ressource beim Anbieter anlegen (`Connector.provision`), der Kunde bekommt sie zugeordnet, Vertrag wird aktiv, Laufzeitende wird gesetzt.
- Idempotent: bereits aktive Verträge werden übersprungen.
- **Anlage wird nicht blind wiederholt**: Nur bei sicher nicht gesendeter Anfrage (Verbindung verweigert, Rate-Limit) bis zu 5 Versuche; bei unklarem Ausgang (Timeout, 5xx, ungültige Antwort) sofort „fehlgeschlagen“ mit Hinweis, beim Anbieter zu prüfen. Neustart nur mit ausdrücklicher Bestätigung.
4. **Kündigung**: zum nächsten Laufzeitende unter Beachtung der Frist (`effectiveCancelDate`), widerrufbar bis zum Wirksamwerden; sofort nur durch Personal. Bei Wirksamwerden wird der Vertrag beendet und die Ressource **gesperrt** (nie automatisch gelöscht).
5. **Verlängerung**: automatisch um die Verlängerungslaufzeit, solange nicht gekündigt (Worker-Lauf jede Minute, idempotent).
## Kundenart
Privatkunden: Preise brutto, Verlängerung nur um 1 Monat und Kündigungsfrist max. 30 Tage (`consumerTerms`). Geschäftskunden: Preise netto (brutto in Klammern).
## Vor Produktivbetrieb rechtlich klären (Steuer-/Rechtsberatung)
- Verbraucherverträge: Widerrufsbelehrung/-recht, Button-Lösung, Preisangabenverordnung, Laufzeit-/Kündigungsregeln (Gesetz für faire Verbraucherverträge) – die Regel in `consumerTerms` ist eine Annahme.
- Steuer: B2B/B2C, Reverse-Charge, EU-Lieferort (One-Stop-Shop), USt-IdNr.-Prüfung.
- Rechnungsstellung ist **noch nicht** angebunden (KUNDENCENT-26); Verträge erzeugen bisher keine Rechnungen.
## Preisbasis (netto/brutto)
Jede Produktversion hat eine Preisbasis. **Brutto**: der Betrag ist der Endkundenpreis und bleibt exakt (z. B. 9,99 €); Netto und Steuer werden herausgerechnet (Netto kaufmännisch gerundet, Steuer = Brutto − Netto). **Netto**: Steuer wird aufgeschlagen. Der Snapshot in Bestellung und Vertrag enthält Basis, Netto, Steuer und Brutto.
## Vorlagenpakete (`/bundles/*.json`)
Pakete beschreiben mehrere Produkte einer Software (Editionen, Preise, Laufzeiten, Provisionierung). Auf der Übernahmeseite wählt man ein Programm der Verbindung und importiert Paketprodukte als **Entwürfe**. Vorhanden: `familytool.json` (Premium/Unlimited monatlich+jährlich, Lifetime, Trial 14 Tage; Preise brutto laut LICENSE_TIERS.md).
## Edition (Familytool) und Laufzeitpflege
- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl. Patch: `docs/lizenzsystem-erweiterung/`.
- Nach der Bereitstellung wird das Ablaufdatum der Lizenz an das Vertragsende angeglichen; bei jeder **Vertragsverlängerung** wird die Lizenz mit verlängert (Auftrag `extend`, idempotent). Bis zur Rechnungs-/Zahlungsanbindung geschieht das **ohne Zahlungsprüfung**.
- Rollierende Monatsverträge (Mindestlaufzeit 0, automatische Verlängerung) erhalten einen monatlichen Verlängerungstakt. Upgrade Premium → Unlimited bedeutet einen neuen Schlüssel.