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>
202 lines
14 KiB
TypeScript
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)]);
|
|
}
|