Wird geladen …
; + return (<> +Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).
+ {err &&Installations-ID: {s.instanceId} (bleibt stabil, zählt sonst als neues Gerät).
+ >); +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 395c6b8..2aa3820 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -42,6 +42,17 @@ const checkBackup = async () => { }; setInterval(() => void checkBackup(), 3600_000); +// Eigene Lizenz (licensing.flessinglabs.com): "beim Start prüfen, dann periodisch alle 6-24h" laut +// INTEGRATION.md (Rate-Limit 30/min erlaubt deutlich mehr, aber unnötig). checkLicenseNow() ist ein No-Op- +// Fehlschlag, solange kein Programm-Key/Lizenzschlüssel hinterlegt ist (siehe LicenseCheckError) - dann +// einfach weiter versuchen, keine Alarmierung (Betreiber hat die Lizenzierung schlicht noch nicht eingerichtet). +import { checkLicenseNow, LicenseCheckError } from '@kc/platform/license'; +const checkLicense = async () => { + try { await checkLicenseNow(); } + catch (e) { if (!(e instanceof LicenseCheckError)) log(`Lizenzprüfung fehlgeschlagen: ${(e as Error).message}`); } +}; +setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense(); + // Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default // spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch, // mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig. diff --git a/migrations/035_license_state.sql b/migrations/035_license_state.sql new file mode 100644 index 0000000..1f06b6f --- /dev/null +++ b/migrations/035_license_state.sql @@ -0,0 +1,37 @@ +-- Kundencenter-Lizenzierung (Meilenstein #113-#117): eigenes Lizenzsystem-Konto (licensing.flessinglabs.com), +-- nicht zu verwechseln mit dem bestehenden "licensing"-Connector (der verwaltet KUNDEN-Lizenzen, die das +-- Kundencenter weiterverkauft). Dies hier ist die Lizenz der Kundencenter-Installation selbst. +CREATE TABLE license_state ( + id TINYINT PRIMARY KEY DEFAULT 1, + license_key_enc TEXT NULL, -- verschlüsselt (wie andere Secrets), vom Betreiber selbst eingetragen + instance_id CHAR(36) NOT NULL, -- einmal erzeugt, bleibt stabil (zählt sonst als neues Gerät) + -- zuletzt erfolgreich geprüfter (und signaturgeprüfter) Entitlement-Snapshot + valid BOOLEAN NULL, + message VARCHAR(300) NULL, + plan VARCHAR(100) NULL, + modules_json JSON NULL, + expires_at DATETIME NULL, + user_limit INT NULL, + customer_limit INT NULL, + entitlement_version INT NULL, + is_trial BOOLEAN NOT NULL DEFAULT FALSE, + addons_json JSON NULL, + server_time DATETIME NULL, + checked_at DATETIME(3) NULL, -- Zeitpunkt der letzten ERFOLGREICHEN Online-Prüfung (Basis fürs Offline-Fenster) + last_attempt_at DATETIME(3) NULL, + last_error VARCHAR(500) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +); +INSERT INTO license_state (id, instance_id) VALUES (1, UUID()); + +-- Öffentliche Signaturschlüssel (GET /license/signing-keys), lokal zwischengespeichert statt bei jeder Prüfung +-- abgerufen - sonst könnte ein kompromittierter/vorgetäuschter Server zu einer gefälschten Antwort auch gleich +-- einen passenden Schlüssel mitliefern. Werden nur ergänzt, nie anhand der Liste selbst gelöscht. +CREATE TABLE license_signing_keys ( + kid VARCHAR(100) PRIMARY KEY, + alg VARCHAR(20) NOT NULL, + n_hex TEXT NOT NULL, + e_hex VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + fetched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +); diff --git a/packages/platform/src/config.ts b/packages/platform/src/config.ts index 935f186..ca54f85 100644 --- a/packages/platform/src/config.ts +++ b/packages/platform/src/config.ts @@ -27,6 +27,11 @@ export const config = { /** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */ allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]), secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'), + // Identifiziert das Programm "Kundencenter" selbst gegenüber licensing.flessinglabs.com (kein echtes + // Geheimnis, steckt in jeder Installation gleich). Der je Installation unterschiedliche Lizenzschlüssel + // liegt dagegen verschlüsselt in der DB (Einstellungen > Lizenz), nicht hier. + licenseProgramKey: process.env.LICENSE_PROGRAM_KEY || null, + licenseApiBaseUrl: process.env.LICENSE_API_BASE_URL || 'https://licensing.flessinglabs.com/api', db: { host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3306), diff --git a/packages/platform/src/license.ts b/packages/platform/src/license.ts new file mode 100644 index 0000000..a52ffc7 --- /dev/null +++ b/packages/platform/src/license.ts @@ -0,0 +1,163 @@ +import { createPublicKey, createVerify } from 'node:crypto'; +import type { PoolConnection } from 'mysql2/promise'; +import { config } from './config.js'; +import { one, query, run } from './db.js'; +import { encrypt, decrypt } from './crypto.js'; + +/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com (Meilenstein + * "Kundencenter lizenzierbar machen"). Nicht zu verwechseln mit dem "licensing"-Connector, der + * KUNDEN-Lizenzen verwaltet, die das Kundencenter weiterverkauft. */ + +const OFFLINE_GRACE_DAYS = Number(process.env.LICENSE_OFFLINE_GRACE_DAYS ?? 7); + +export interface VerifyResult { + valid: boolean; message: string | null; plan: string | null; modules: string[]; + expiresAt: string | null; userLimit: number | null; customerLimit: number | null; + entitlementVersion: number | null; trial: boolean; addons: unknown[]; serverTime: string | null; + signature: string | null; keyId: string | null; +} +interface SigningKey { kid: string; alg: string; n: string; e: string; status: string } + +// Buffer.from(hex, 'hex') verwirft bei ungerader Länge stillschweigend die letzte Ziffer (z. B. würde der +// Exponent "10001" = 65537 fälschlich zu 0x1000 statt 0x010001) - deshalb vorher auf gerade Länge auffüllen. +const hexToB64url = (hex: string): string => Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex').toString('base64url'); + +/** FLS1.