kundencenter/apps/worker/src/index.ts

91 lines
6.2 KiB
TypeScript
Raw Normal View History

import http from 'node:http';
import { env } from './env.js';
import { pool } from '@kc/platform/db';
import { discordEnabled, discordReady, reconcileTicketChannels, syncDiscord, stopDiscord } from './discord.js';
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
import { checkMailbox } from './mailbox.js';
const log = (m: string) => console.log(JSON.stringify({ t: new Date().toISOString(), svc: 'worker', msg: m }));
let lastLoop = Date.now();
let stopping = false;
// Health: Liveness (Schleife lebt) + Readiness (DB), Discord-Status nur informativ
http.createServer(async (req, res) => {
const alive = Date.now() - lastLoop < 60_000;
let db = false; try { await pool.query('SELECT 1'); db = true; } catch { /* db down */ }
const ok = alive && db;
res.writeHead(req.url === '/health' ? (alive ? 200 : 503) : (ok ? 200 : 503), { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: ok ? 'ok' : 'degraded', loop: alive, db, discord: discordEnabled() ? (discordReady() ? 'connected' : 'connecting') : 'disabled' }));
}).listen(env.healthPort, '127.0.0.1');
await recoverStale(log);
void syncDiscord(log);
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
// Discord-Ticketkanäle: offene Tickets bekommen einen Kanal, geschlossene verlieren ihn (nur im Kategorie-Modus)
setInterval(() => void reconcileTicketChannels(log).catch((e) => log(`Ticket-Kanäle: Abgleich fehlgeschlagen: ${(e as Error).message}`)), 60_000);
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
const schedule = () => scheduleDueSyncs((t, p, o) => enqueue(t, p, o)).catch((e) => log(`Planung fehlgeschlagen: ${(e as Error).message}`));
setInterval(schedule, 30_000); void schedule();
// Verträge: Kündigungen wirksam machen, verlängern, auslaufen lassen (idempotent)
const lifecycle = () => processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)).then((r) => { if (r.ended || r.renewed) log(`Verträge: ${r.ended} beendet, ${r.renewed} verlängert`); }).catch((e) => log(`Vertragslauf fehlgeschlagen: ${(e as Error).message}`));
setInterval(lifecycle, 60_000); void lifecycle();
// Backup-Frische: fehlt ein erfolgreiches Backup seit >26 h, wird gemeldet (höchstens alle 12 h, ohne Nutzdaten)
import { readFile } from 'node:fs/promises';
const checkBackup = async () => {
try {
const st = JSON.parse(await readFile(process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json', 'utf8'));
const age = st.lastRun?.at ? (Date.now() - new Date(st.lastRun.at).getTime()) / 3600000 : Infinity;
if (age > 26) await enqueue('discord.notify', { event: 'backup.stale', detail: Number.isFinite(age) ? `letzter Lauf vor ${Math.round(age)} Stunden` : 'noch nie gelaufen' }, { idempotencyKey: `backup.stale:${Math.floor(Date.now() / (12 * 3600000))}` });
} catch { /* Backup nicht eingerichtet: keine Meldung */ }
};
setInterval(() => void checkBackup(), 3600_000);
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>
2026-10-01 10:48:48 +02:00
// 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();
// Vertrags-Erinnerung: ~30 Tage vor Laufzeitende Mail mit "Behalten"/"Kündigen"-Link (einmal je Vertrag,
// markiert über renewal_reminder_sent_at). Unabhängig von processContractLifecycle oben, die nur zum
// tatsächlichen Laufzeitende selbst verlängert/beendet.
import { sendRenewalReminders } from '@kc/platform/contractRenewal';
const checkRenewals = async () => {
try { const r = await sendRenewalReminders(); if (r.sent || r.skipped) log(`Vertrags-Erinnerungen: ${r.sent} gesendet, ${r.skipped} übersprungen`); }
catch (e) { log(`Vertrags-Erinnerung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void checkRenewals(), 24 * 3600_000); void checkRenewals();
feat(audit): Aufbewahrungs-Policy mit Checkpoint-Löschung und CSV-Export Zweiter Teil von #34: Aufbewahrungsfristen und Export waren offen. Die DSGVO selbst schreibt keine feste Zahl vor (nur den Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1 lit. e) - daher eine eigene, änderbare Policy statt eines hart codierten Gesetzeswerts. Voreinstellung 180 Tage als verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten. - Migration 034: audit_settings (retention_days, Default 180) und audit_retention_checkpoints. - purgeAuditRetention() (@kc/platform/audit): löscht Einträge jenseits der Frist. Da jede Zeile die vorherige mit hasht, würde einfaches Löschen die Kette brechen - ein Checkpoint (Hash der zuletzt gelöschten Zeile) macht sie trotzdem weiter überprüfbar. Bricht nur ab, wenn die Kette vorher schon fehlerhaft ist, oder bei einer Lücke zwischen gelöschten und verbleibenden Zeilen (unerwartete Zeitstempel- Reihenfolge). audit_events ist per Trigger unveränderlich (Migration 033) - der DELETE-Trigger wird dafür kurz entfernt und sofort wieder angelegt, alles unter derselben Sperre wie audit() (re-entrant über dieselbe Verbindung, sonst Deadlock). Die Aufräumung selbst wird als eigener Audit-Eintrag protokolliert. - verifyAuditChain() beginnt nach einer Aufräumung beim Checkpoint-Hash statt der Genesis-Null - nur wenn die neue erste Zeile auch wirklich genau darauf verweist, sonst bliebe eine echte Manipulation unentdeckt. - Worker: täglicher automatischer Lauf. CLI: audit-purge zum manuellen Anstoßen. API: GET/PUT /admin/audit/settings, POST /admin/audit/purge (settings.write), GET /admin/audit/export.csv (audit.read, mit demselben Formel-Injection-Schutz wie der Rechnungs-Export). Web: Einstellungs-Card unter Audit-Protokoll mit Erklärtext zur DSGVO- Rechtsgrundlage, Frist-Eingabe, "Jetzt aufräumen" und Export-Link. Verifiziert: Testsuite (59/59), echter Lauf mit künstlich vordatierten (aber korrekt verketteten) Testzeilen gegen die Testdatenbank - Kette vor und nach der Aufräumung intakt, Checkpoint korrekt genutzt, zweiter Lauf idempotent. Echte HTTP-Endpunkte (inkl. CSRF) gegen Produktion getestet. Volles Backup+Wiederherstellungstest danach weiterhin grün (201 Einträge). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 12:01:00 +02:00
// 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.
const purgeAudit = async () => {
try {
const [row] = (await pool.query('SELECT retention_days FROM audit_settings WHERE id = 1')) as any;
const { purgeAuditRetention } = await import('@kc/platform/audit');
const r = await purgeAuditRetention(row[0]?.retention_days ?? 180);
if (r.purged) log(`Audit-Aufbewahrung: ${r.purged} Einträge gelöscht (älter als ${r.retentionDays} Tage)`);
} catch (e) { log(`Audit-Aufbewahrung fehlgeschlagen: ${(e as Error).message}`); }
};
setInterval(() => void purgeAudit(), 24 * 3600_000); void purgeAudit();
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));
for (const sig of ['SIGTERM', 'SIGINT'] as const) process.on(sig, async () => { stopping = true; await stopDiscord(); await pool.end(); process.exit(0); });
log('Worker gestartet');
while (!stopping) {
try { lastLoop = Date.now(); const worked = await runOnce(log); if (!worked) await new Promise((r) => setTimeout(r, 2000)); }
catch (e) { log(`Schleifenfehler: ${(e as Error).message}`); await new Promise((r) => setTimeout(r, 5000)); }
}