kundencenter/packages/platform/src/license.ts
Claude 17267b7c05 KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen
Testmodus (ohne gültige Lizenz): 1 Personal-Konto, 2 Kunden; Discord-Bot,
E-Mail-Posteingang (IMAP), DATEV-Export und Backups auf externe Ziele nur mit
Lizenz. Mit Lizenz gelten deren Grenzen und Module (Lizenz ohne Modulliste =
voller Umfang). Bestehende Daten bleiben bei Ablauf vollständig nutzbar, nur
Neuanlagen über die Grenzen und die Zusatzfunktionen pausieren.

- Edition zentral in @kc/platform/license (getEdition, hasFeature,
  assertCustomerCapacity, assertStaffCapacity), auch in der CLI durchgesetzt
- Einstellungen → Lizenz zeigt die Edition, Hinweisbanner im Testmodus
- README mit Installation, Update und Betrieb; systemd-Units unter ops/systemd
- .env.example bereinigt (Kommentare nicht mehr hinter Werten, veraltete
  SMTP/Discord-Variablen entfernt; beides wird in der Oberfläche eingestellt)
- LICENSE.md: Nutzungsbedingungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:53 +02:00

202 lines
14 KiB
TypeScript

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.<base64url(Payload)>.<base64url(Signatur)>, RSA-2048 PKCS#1 v1.5 SHA-256 über die ASCII-Bytes
* des Payload-Teils (siehe licensing INTEGRATION.md, Abschnitt "Signierte Antworten"). */
function verifyFls1(token: string, keys: SigningKey[]): { ok: boolean; payload?: Record<string, unknown> } {
const parts = token.split('.');
if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false };
const [, payloadB64, sigB64] = parts;
let payload: Record<string, unknown>;
try { payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString('utf8')); } catch { return { ok: false }; }
const key = keys.find((k) => k.kid === payload.kid);
if (!key) return { ok: false };
try {
const pub = createPublicKey({ key: { kty: 'RSA', n: hexToB64url(key.n), e: hexToB64url(key.e) }, format: 'jwk' });
const v = createVerify('RSA-SHA256'); v.update(payloadB64!, 'ascii'); v.end();
return { ok: v.verify(pub, Buffer.from(sigB64!, 'base64url')), payload };
} catch { return { ok: false }; }
}
async function loadSigningKeys(): Promise<SigningKey[]> {
const rows = await query('SELECT kid, alg, n_hex, e_hex, status FROM license_signing_keys');
return rows.map((r) => ({ kid: r.kid, alg: r.alg, n: r.n_hex, e: r.e_hex, status: r.status }));
}
/** Schlüssel nur ERGÄNZEN, nie anhand der Serverantwort löschen (sonst könnte ein vorgetäuschter
* Server die Liste auf nur noch seine eigenen Schlüssel "bereinigen"). */
async function refreshSigningKeys(): Promise<void> {
const r = await fetch(`${config.licenseApiBaseUrl}/license/signing-keys`, { signal: AbortSignal.timeout(10000) });
if (!r.ok) return;
const data = await r.json() as { format?: string; keys?: { kid: string; alg: string; n: string; e: string; status: string }[] };
if (data.format !== 'FLS1' || !Array.isArray(data.keys)) return;
for (const k of data.keys) await run('INSERT INTO license_signing_keys (kid, alg, n_hex, e_hex, status) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE alg=VALUES(alg), n_hex=VALUES(n_hex), e_hex=VALUES(e_hex), status=VALUES(status)', [k.kid, k.alg, k.n, k.e, k.status]);
}
export class LicenseCheckError extends Error {}
/** Fragt /license/verify live ab, prüft die Signatur und schreibt das Ergebnis in license_state.
* Wirft bei fehlender Konfiguration oder Netzwerkfehler (ruft NICHT selbst den gecachten Zustand ab -
* das macht getEntitlement()). */
export async function checkLicenseNow(): Promise<VerifyResult> {
if (!config.licenseProgramKey) throw new LicenseCheckError('LICENSE_PROGRAM_KEY ist nicht konfiguriert');
const row = await one('SELECT license_key_enc, instance_id FROM license_state WHERE id = 1');
if (!row) throw new LicenseCheckError('license_state fehlt (Migration nicht angewendet?)');
if (!row.license_key_enc) throw new LicenseCheckError('Kein Lizenzschlüssel hinterlegt (Einstellungen > Lizenz)');
const licenseKey = decrypt(row.license_key_enc);
await run('UPDATE license_state SET last_attempt_at = UTC_TIMESTAMP(3) WHERE id = 1');
let res: Response;
try {
res = await fetch(`${config.licenseApiBaseUrl}/license/verify`, {
method: 'POST', headers: { 'content-type': 'application/json', 'X-Program-Key': config.licenseProgramKey },
body: JSON.stringify({ licenseKey, instanceId: row.instance_id }), signal: AbortSignal.timeout(15000),
});
} catch (e) {
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [`Netzwerkfehler: ${(e as Error).message}`.slice(0, 500)]);
throw new LicenseCheckError(`Lizenzserver nicht erreichbar: ${(e as Error).message}`);
}
if (res.status === 401) { await run('UPDATE license_state SET last_error = ? WHERE id = 1', ['Program-Key ungültig (LICENSE_PROGRAM_KEY)']); throw new LicenseCheckError('Program-Key ungültig'); }
if (!res.ok) { const msg = `Lizenzserver: HTTP ${res.status}`; await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]); throw new LicenseCheckError(msg); }
const data = await res.json() as VerifyResult;
if (data.signature) {
let keys = await loadSigningKeys();
let v = verifyFls1(data.signature, keys);
if (!v.ok) { await refreshSigningKeys().catch(() => undefined); keys = await loadSigningKeys(); v = verifyFls1(data.signature, keys); } // evtl. rotierter, noch unbekannter Schlüssel
if (!v.ok || v.payload?.licenseKey !== licenseKey) {
const msg = 'Signaturprüfung der Lizenzantwort fehlgeschlagen';
await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]);
throw new LicenseCheckError(msg);
}
}
await run(
`UPDATE license_state SET valid=?, message=?, plan=?, modules_json=?, expires_at=?, user_limit=?, customer_limit=?,
entitlement_version=?, is_trial=?, addons_json=?, server_time=?, checked_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id=1`,
[data.valid, data.message ?? null, data.plan ?? null, JSON.stringify(data.modules ?? []), data.expiresAt ? new Date(data.expiresAt) : null,
data.userLimit ?? null, data.customerLimit ?? null, data.entitlementVersion ?? null, !!data.trial, JSON.stringify(data.addons ?? []),
data.serverTime ? new Date(data.serverTime) : null],
);
return data;
}
export interface Entitlement {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: Date | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: Date | null; message: string | null;
}
/** Liefert den aktuell nutzbaren Entitlement-Stand: zuletzt geprüfter (signaturgeprüfter) Snapshot, solange
* er innerhalb des Offline-Fensters liegt (min. Ablaufdatum der Lizenz, min. eigenes Offline-Fenster ab der
* letzten erfolgreichen Prüfung) - verlängert nie eine tatsächlich abgelaufene Lizenz. Ruft NICHT selbst
* die Live-API auf (das übernimmt der Worker periodisch bzw. checkLicenseNow() explizit). */
export async function getEntitlement(): Promise<Entitlement> {
if (!config.licenseProgramKey) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'LICENSE_PROGRAM_KEY nicht konfiguriert' };
const row = await one('SELECT * FROM license_state WHERE id = 1');
if (!row || !row.license_key_enc) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'Kein Lizenzschlüssel hinterlegt' };
if (!row.checked_at) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: true, source: 'unchecked', checkedAt: null, message: 'Noch nicht geprüft' };
const checkedAt = new Date(row.checked_at as Date);
const graceUntil = new Date(checkedAt.getTime() + OFFLINE_GRACE_DAYS * 86400000);
const expiresAt = row.expires_at ? new Date(row.expires_at as Date) : null;
const now = new Date();
const withinOfflineGrace = now <= graceUntil && (expiresAt === null || now <= expiresAt);
const modules = (typeof row.modules_json === 'string' ? JSON.parse(row.modules_json) : row.modules_json) ?? [];
return {
valid: !!row.valid && withinOfflineGrace, plan: row.plan, modules, customerLimit: row.customer_limit, userLimit: row.user_limit,
trial: !!row.is_trial, expiresAt, configured: true, source: withinOfflineGrace ? 'live' : 'offline-grace', checkedAt, message: row.message,
};
}
// ---- Edition: Testmodus ohne Lizenz, voller Umfang mit Lizenz ---------------------------------------------------
/** Funktionen, die eine Lizenz voraussetzen. Modul-Schlüssel im Lizenzsystem (Produkt/Lizenz "modules"). */
export const LICENSED_FEATURES = ['discord', 'imap', 'datev', 'backup_remote'] as const;
export type LicensedFeature = (typeof LICENSED_FEATURES)[number];
export const FEATURE_LABEL: Record<LicensedFeature, string> = { discord: 'Discord-Bot', imap: 'E-Mail-Posteingang (IMAP)', datev: 'DATEV-Export', backup_remote: 'Backups auf externe Ziele' };
/** Grenzen des Testmodus (ohne gültige Lizenz). Bestehende Daten bleiben unangetastet, nur Neuanlagen werden begrenzt. */
export const TRIAL_LIMITS = { staff: 1, customers: 2 } as const;
export interface Edition {
mode: 'licensed' | 'trial'; features: LicensedFeature[]; customerLimit: number | null; staffLimit: number | null;
/** Warum Testmodus (z. B. kein Schlüssel, abgelaufen); bei Lizenz der Plan. */ reason: string | null;
}
/** Maßgebliche Edition dieser Installation. Gültige Lizenz: deren Grenzen; eine Lizenz ohne Modulliste schaltet alle
* Funktionen frei (unbeschränkter Plan). Ohne gültige Lizenz (kein Schlüssel, ungeprüft, ungültig, abgelaufen oder
* länger als die Offline-Frist nicht geprüft): Testmodus mit TRIAL_LIMITS und ohne LICENSED_FEATURES. */
export async function getEdition(): Promise<Edition> {
// Integrationstests: voller Umfang (wie die abgeschalteten Rate-Limits im Testmodus)
if (config.env === 'test') return { mode: 'licensed', features: [...LICENSED_FEATURES], customerLimit: null, staffLimit: null, reason: 'test' };
const e = await getEntitlement();
if (e.valid) {
const mods = e.modules.filter((m): m is LicensedFeature => (LICENSED_FEATURES as readonly string[]).includes(m));
return { mode: 'licensed', features: e.modules.length ? mods : [...LICENSED_FEATURES], customerLimit: e.customerLimit, staffLimit: e.userLimit, reason: e.plan };
}
const reason = e.source === 'unconfigured' ? 'Kein Lizenzschlüssel hinterlegt' : e.source === 'unchecked' ? 'Lizenz noch nicht geprüft' : (e.message ?? 'Lizenz ungültig oder abgelaufen');
return { mode: 'trial', features: [], customerLimit: TRIAL_LIMITS.customers, staffLimit: TRIAL_LIMITS.staff, reason };
}
export async function hasFeature(f: LicensedFeature): Promise<boolean> { return (await getEdition()).features.includes(f); }
/** Fehlertext für gesperrte Funktionen (einheitlich in API und Worker). */
export const featureRequiresLicense = (f: LicensedFeature) => `${FEATURE_LABEL[f]} ist nur mit Lizenz verfügbar (Einstellungen → Lizenz).`;
/** Modul aus der Lizenz (allgemein, z. B. für künftige Zusatzmodule). */
export async function hasModule(key: string): Promise<boolean> {
const e = await getEntitlement();
return e.valid && (e.modules.length === 0 || e.modules.includes(key));
}
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
/** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung
* aufrufen. GET_LOCK serialisiert gegen gleichzeitige Anfragen auf den letzten freien Platz. Zählt aktive
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. Grenze aus der Edition
* (Lizenz oder Testmodus). */
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
const ed = await getEdition();
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
try {
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
const count = Number(row?.n ?? 0);
if (ed.customerLimit !== null && count >= ed.customerLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.customerLimit} Kunden. Für weitere Kunden bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Kundengrenze der Lizenz erreicht (${count}/${ed.customerLimit}). Bitte Lizenz upgraden.`;
return { allowed: false, reason, count, limit: ed.customerLimit };
}
return { allowed: true, count, limit: ed.customerLimit };
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
}
}
/** Grenze für Personal-Konten (aktive und eingeladene Mitarbeiter). */
export async function assertStaffCapacity(): Promise<CapacityCheck> {
const ed = await getEdition();
const row = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const count = Number(row?.n ?? 0);
if (ed.staffLimit !== null && count >= ed.staffLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.staffLimit} Personal-Konto. Für weitere Mitarbeiter bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Benutzergrenze der Lizenz erreicht (${count}/${ed.staffLimit}).`;
return { allowed: false, reason, count, limit: ed.staffLimit };
}
return { allowed: true, count, limit: ed.staffLimit };
}
export async function setLicenseKey(licenseKey: string): Promise<void> {
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);
}