feat(license): Kundencenter-Lizenzierung gegen licensing.flessinglabs.com (#116)
Milestone "Kundencenter lizenzierbar machen": die Installation prüft jetzt ihre eigene Lizenz beim Lizenzsystem (licensing.flessinglabs.com, Programm "Kundencenter") und setzt die Kundengrenze serverseitig durch - nicht zu verwechseln mit dem bestehenden "licensing"-Connector, der KUNDEN-Lizenzen verwaltet, die das Kundencenter weiterverkauft. - Migration 035: license_state (ein Lizenzschlüssel, verschlüsselt wie andere Secrets, stabile instance_id, zuletzt geprüfter Entitlement- Snapshot), license_signing_keys (öffentliche Signaturschlüssel, werden nur ergänzt, nie anhand einer Serverantwort gelöscht). - @kc/platform/license: checkLicenseNow() ruft POST /license/verify mit X-Program-Key auf, prüft die FLS1-Signatur (RSA-2048, PKCS#1 v1.5, SHA-256) und schreibt das Ergebnis in license_state. getEntitlement() liefert den nutzbaren Stand inkl. Offline-Fenster (Standard 7 Tage, verlängert nie eine tatsächlich abgelaufene Lizenz). assertCustomerCapacity() sperrt neue/reaktivierte Kunden transaktional (GET_LOCK gegen den letzten freien Platz) bei erreichter Kundengrenze oder ungültiger Lizenz - aber NICHT, solange die Lizenzierung noch gar nicht konfiguriert oder noch nie geprüft wurde (sonst wäre jede frische Installation von Anfang an blockiert). - Durchsetzung an allen drei Stellen, die einen neuen Kundenplatz belegen: Kundenanlage, Reaktivierung (closed→active) und KeyHelp-Import neuer (nicht verknüpfter) Kunden. - Worker prüft alle 12 h automatisch (zusätzlich zur Prüfung beim Speichern eines neuen Schlüssels). API: GET/PUT /admin/license/settings, POST /admin/license/check. UI: Einstellungen → Lizenz (Status, Module, Kundengrenze, Schlüssel eingeben, manuell prüfen). Beim Testen einen echten Bug gefunden und behoben: Buffer.from(hex,'hex') verwirft bei ungerader Hex-Länge stillschweigend die letzte Ziffer (der Exponent "10001"/65537 wurde fälschlich zu 0x1000) - hätte jede Signatur- prüfung unbemerkt scheitern lassen. Mit geradem Padding gegen echte Serverantworten (gültig und manipuliert) verifiziert. Gegen die echte Produktions-API getestet (Program-Key aus LICENSE_PROGRAM_KEY): Status/Settings/Check-Endpunkte, Signaturprüfung mit echten Schlüsseln erfolgreich, Kundenanlage korrekt blockiert bei geprüfter ungültiger Lizenz, danach wieder sauber in den unkonfigurierten (nicht blockierenden) Zustand zurückgesetzt, da noch kein echter Lizenzschlüssel für diese Installation vorliegt. Testsuite 58/59 (eine vorbestehende, unabhängige datumsabhängige Flakiness in orders.test.ts, auch ohne diese Änderungen reproduzierbar). Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün. Offen: Lizenzschlüssel für diese Installation selbst anlegen (Admin-Panel licensing.flessinglabs.com) und unter Einstellungen > Lizenz eintragen. Modul-Gates (billing/domains/provisioning/discord/automation) an den einzelnen Funktionsbereichen sowie die Lizenzübersicht-Admin-UI (#117) sind noch zu bauen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3240e6df79
commit
d033088223
10 changed files with 340 additions and 1 deletions
1
apps/api/src/core/license.ts
Normal file
1
apps/api/src/core/license.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '@kc/platform/license';
|
||||||
|
|
@ -5,6 +5,7 @@ import { one, query, run, tx } from '../../core/db.js';
|
||||||
import { audit } from '../../core/audit.js';
|
import { audit } from '../../core/audit.js';
|
||||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||||
|
import { assertCustomerCapacity } from '../../core/license.js';
|
||||||
import { canInOrg } from '../../core/policy.js';
|
import { canInOrg } from '../../core/policy.js';
|
||||||
import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js';
|
import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js';
|
||||||
import { enqueue } from '../../core/jobs.js';
|
import { enqueue } from '../../core/jobs.js';
|
||||||
|
|
@ -84,6 +85,8 @@ export const customersModule: KcModule = {
|
||||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
|
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
|
||||||
const orgId = randomUUID();
|
const orgId = randomUUID();
|
||||||
const res = await tx(async (c) => {
|
const res = await tx(async (c) => {
|
||||||
|
const cap = await assertCustomerCapacity(c);
|
||||||
|
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
|
||||||
const number = await nextCustomerNumber(c);
|
const number = await nextCustomerNumber(c);
|
||||||
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
|
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
|
||||||
const bp = b.billing;
|
const bp = b.billing;
|
||||||
|
|
@ -145,6 +148,10 @@ export const customersModule: KcModule = {
|
||||||
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
|
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
|
||||||
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
|
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
|
||||||
await tx(async (c) => {
|
await tx(async (c) => {
|
||||||
|
if (b.status === 'active' && before.status === 'closed') {
|
||||||
|
const cap = await assertCustomerCapacity(c);
|
||||||
|
if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
|
||||||
|
}
|
||||||
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
|
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
|
||||||
const keys = Object.keys(patch);
|
const keys = Object.keys(patch);
|
||||||
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
|
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
|
||||||
|
|
@ -221,6 +228,8 @@ export const customersModule: KcModule = {
|
||||||
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
|
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
|
||||||
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
|
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
|
||||||
} else {
|
} else {
|
||||||
|
const cap = await assertCustomerCapacity(cn);
|
||||||
|
if (!cap.allowed) throw badRequest(cap.reason!, 'CUSTOMER_LIMIT_REACHED');
|
||||||
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
|
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
|
||||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
|
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
|
||||||
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }
|
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { ordersModule } from './orders/index.js';
|
||||||
import { backupModule } from './backup/index.js';
|
import { backupModule } from './backup/index.js';
|
||||||
import { mailModule } from './mail/index.js';
|
import { mailModule } from './mail/index.js';
|
||||||
import { discordModule } from './discord/index.js';
|
import { discordModule } from './discord/index.js';
|
||||||
|
import { licenseModule } from './license/index.js';
|
||||||
|
|
||||||
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
||||||
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule];
|
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule];
|
||||||
|
|
|
||||||
47
apps/api/src/modules/license/index.ts
Normal file
47
apps/api/src/modules/license/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { one } from '../../core/db.js';
|
||||||
|
import { audit } from '../../core/audit.js';
|
||||||
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
|
import { badRequest } from '../../core/errors.js';
|
||||||
|
import { checkLicenseNow, getEntitlement, setLicenseKey, LicenseCheckError } from '../../core/license.js';
|
||||||
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
|
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
|
||||||
|
* dem "licensing"-Connector (Einstellungen > Verbindungen), der KUNDEN-Lizenzen verwaltet. */
|
||||||
|
export const licenseModule: KcModule = {
|
||||||
|
name: 'license',
|
||||||
|
permissions: {
|
||||||
|
staff: { admin: ['license.read'], superadmin: ['license.read', 'settings.write'] },
|
||||||
|
},
|
||||||
|
register(app: FastifyInstance) {
|
||||||
|
app.get('/admin/license/status', async (req) => {
|
||||||
|
requirePermission(req, 'license.read');
|
||||||
|
const ent = await getEntitlement();
|
||||||
|
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
|
||||||
|
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin/license/settings', async (req) => {
|
||||||
|
const a = requirePermission(req, 'settings.write');
|
||||||
|
const b = z.object({ licenseKey: z.string().trim().min(8).max(200) }).parse(req.body);
|
||||||
|
await setLicenseKey(b.licenseKey);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.settings.update', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { licenseKey: '***' } });
|
||||||
|
let checked = false; let error: string | undefined;
|
||||||
|
try { await checkLicenseNow(); checked = true; } catch (e) { error = (e as Error).message; }
|
||||||
|
return { ok: true, checked, error };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/license/check', async (req) => {
|
||||||
|
const a = requirePermission(req, 'settings.write');
|
||||||
|
try {
|
||||||
|
const r = await checkLicenseNow();
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'license.check', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { valid: r.valid, plan: r.plan } });
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LicenseCheckError) throw badRequest(e.message, 'LICENSE_CHECK_FAILED');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
|
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
|
||||||
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
|
{ href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') },
|
||||||
{ href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') },
|
{ href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') },
|
||||||
|
{ href: '/einstellungen/lizenz', label: 'Lizenz', show: can('license.read') },
|
||||||
].filter((t) => t.show);
|
].filter((t) => t.show);
|
||||||
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
|
||||||
return (<>
|
return (<>
|
||||||
|
|
|
||||||
64
apps/web/src/app/(app)/einstellungen/lizenz/page.tsx
Normal file
64
apps/web/src/app/(app)/einstellungen/lizenz/page.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
'use client';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { Alert, Field, fmt } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
|
||||||
|
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
|
||||||
|
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
|
||||||
|
}
|
||||||
|
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
|
||||||
|
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
|
||||||
|
|
||||||
|
export default function LizenzPage() {
|
||||||
|
const { can } = useSession(); const w = can('settings.write');
|
||||||
|
const [s, setS] = useState<Status | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
|
||||||
|
const load = useCallback(() => api<Status>('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
async function saveKey(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setErr(''); setOk(''); setBusy(true);
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
try {
|
||||||
|
const r = await api<{ ok: boolean; checked: boolean; error?: string }>('PUT', '/admin/license/settings', { licenseKey: String(f.get('licenseKey') ?? '').trim() });
|
||||||
|
setOk(r.checked ? 'Lizenzschlüssel gespeichert und erfolgreich geprüft.' : `Lizenzschlüssel gespeichert, Prüfung fehlgeschlagen: ${r.error}`);
|
||||||
|
e.currentTarget.reset(); void load();
|
||||||
|
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function checkNow() {
|
||||||
|
setErr(''); setOk(''); setBusy(true);
|
||||||
|
try { await api('POST', '/admin/license/check'); setOk('Lizenz geprüft.'); void load(); }
|
||||||
|
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||||
|
return (<>
|
||||||
|
<h2>Lizenz</h2>
|
||||||
|
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
|
||||||
|
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||||
|
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
|
||||||
|
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
|
||||||
|
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Status</h3>
|
||||||
|
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
|
||||||
|
<div><dt className="small muted">Plan</dt><dd>{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}</dd></div>
|
||||||
|
<div><dt className="small muted">Module</dt><dd>{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}</dd></div>
|
||||||
|
<div><dt className="small muted">Kundengrenze</dt><dd>{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}</dd></div>
|
||||||
|
<div><dt className="small muted">Nutzer-/Slotlimit</dt><dd>{s.userLimit === null ? 'unbegrenzt' : s.userLimit}</dd></div>
|
||||||
|
<div><dt className="small muted">Gültig bis</dt><dd>{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}</dd></div>
|
||||||
|
<div><dt className="small muted">Zuletzt geprüft</dt><dd>{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})</dd></div>
|
||||||
|
</dl>
|
||||||
|
{w && <button className="btn" disabled={busy || !s.hasKey} onClick={checkNow}>Jetzt prüfen</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{w && <form className="card" onSubmit={saveKey}>
|
||||||
|
<h3>Lizenzschlüssel</h3>
|
||||||
|
<Field id="licenseKey" label="Lizenzschlüssel" hint="vom Lizenzsystem (licensing.flessinglabs.com), z. B. nach einem Plan-Wechsel"><input id="licenseKey" name="licenseKey" placeholder={s.hasKey ? '•••••••• (hinterlegt, zum Ändern neu eingeben)' : ''} /></Field>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>Speichern & prüfen</button>
|
||||||
|
</form>}
|
||||||
|
<p className="muted small">Installations-ID: <span className="mono">{s.instanceId}</span> (bleibt stabil, zählt sonst als neues Gerät).</p>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,17 @@ const checkBackup = async () => {
|
||||||
};
|
};
|
||||||
setInterval(() => void checkBackup(), 3600_000);
|
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
|
// 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,
|
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
|
||||||
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.
|
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.
|
||||||
|
|
|
||||||
37
migrations/035_license_state.sql
Normal file
37
migrations/035_license_state.sql
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
|
|
@ -27,6 +27,11 @@ export const config = {
|
||||||
/** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */
|
/** 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)]),
|
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'),
|
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: {
|
db: {
|
||||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||||
port: Number(process.env.DB_PORT ?? 3306),
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
|
|
||||||
163
packages/platform/src/license.ts
Normal file
163
packages/platform/src/license.ts
Normal file
|
|
@ -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.<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Wie assertCustomerCapacity: solange Lizenzierung nicht konfiguriert/geprüft ist, nichts einschränken. */
|
||||||
|
export async function hasModule(key: string): Promise<boolean> {
|
||||||
|
const e = await getEntitlement();
|
||||||
|
if (e.source === 'unconfigured' || e.source === 'unchecked') return true;
|
||||||
|
return e.valid && 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.
|
||||||
|
*
|
||||||
|
* Solange die Lizenzierung gar nicht konfiguriert ist (kein LICENSE_PROGRAM_KEY/-schlüssel) oder noch nie
|
||||||
|
* erfolgreich geprüft wurde, wird NICHT durchgesetzt - sonst wäre jede frische Installation (und jede Test-
|
||||||
|
* umgebung) ab dem ersten Kunden blockiert. Erst eine tatsächlich geprüfte, ungültige/überschrittene Lizenz
|
||||||
|
* sperrt neue Kunden. */
|
||||||
|
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
|
||||||
|
const ent = await getEntitlement();
|
||||||
|
if (ent.source === 'unconfigured' || ent.source === 'unchecked') return { allowed: true, count: 0, limit: null };
|
||||||
|
if (!ent.valid) return { allowed: false, reason: ent.message ?? 'Keine gültige Lizenz', count: 0, limit: null };
|
||||||
|
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 (ent.customerLimit !== null && count >= ent.customerLimit) return { allowed: false, reason: `Kundengrenze erreicht (${count}/${ent.customerLimit}). Bitte Lizenz upgraden.`, count, limit: ent.customerLimit };
|
||||||
|
return { allowed: true, count, limit: ent.customerLimit };
|
||||||
|
} finally {
|
||||||
|
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue