Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,216 @@
/**
* Connector-Vertrag v1. Jeder Provider (KeyHelp, Plesk, Lizenzsystem, Mock, …) implementiert `Connector`.
* Provider-spezifische Datenmodelle dürfen den Connector nicht verlassen: nach außen gehen nur normalisierte Typen.
*/
export const CONTRACT_VERSION = 1 as const;
/** Fähigkeiten. Backend und UI bieten nur an, was der Connector meldet. */
export type Capability =
| 'customers.list' | 'catalog.write' | 'children.read' | 'children.write' | 'secret.reveal' | 'license.customer_info' | 'catalog.list' | 'license.key_prefix' | 'license.expiry' | 'resources.list' | 'resources.get' | 'status.read' | 'usage.read'
| 'lifecycle.create' | 'lifecycle.suspend' | 'lifecycle.unsuspend' | 'lifecycle.terminate' | 'lifecycle.extend'
| 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks';
/** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */
export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan';
export const ACTION_CAPABILITY: Record<ActionName, Capability> = {
suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change',
};
/** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv). */
export const DESTRUCTIVE_ACTIONS: ReadonlySet<ActionName> = new Set(['terminate']);
export type ResourceType = 'license' | 'hosting_account' | 'domain' | 'server';
export type ResourceState = 'active' | 'suspended' | 'expired' | 'error' | 'unknown';
export interface NormalizedResource {
externalRef: string; // stabile ID beim Provider
type: ResourceType;
name: string;
state: ResourceState;
validFrom?: string | null; // ISO 8601 UTC
validUntil?: string | null;
limits?: Record<string, number | string | null>;
usage?: Record<string, number | string | null>;
/** Anzeigedaten. Geheimnisse (z. B. vollständige Lizenzschlüssel) sind bereits maskiert. */
details?: Record<string, unknown>;
}
/** Angebot/Produktvorlage beim Anbieter (z. B. ein Programm im Lizenzsystem), Grundlage für die Produktübernahme. Enthält keine Geheimnisse. */
export interface CatalogItem {
externalRef: string; name: string; description?: string | null; category: 'hosting' | 'license' | 'addon' | 'service';
/** Optionale Angaben des Anbieters, die die Übernahme vorbelegen. */
/** true: Der Anbieter legt die Provisionierung fest (nicht änderbar bei der Übernahme). */
fixed?: boolean;
group?: string; program?: string; features?: string[]; badge?: string | null; providerActive?: boolean;
price?: { cents: number; currency: string }; interval?: 'once' | 'monthly' | 'yearly'; meta?: Record<string, string | number | null>;
/** Standard-Provisionierungsparameter (Grundlage für Produkte). */
provisioning: Record<string, unknown>;
/** Vorschläge aus dem Bestand des Anbieters (z. B. übliche Laufzeit/Limit bestehender Lizenzen). */
hints?: { label: string; provisioning: Record<string, unknown>; count: number }[];
}
/** Angaben zum Kunden und Vorgang, die bei der Anlage an den Anbieter gemeldet werden können (nur wenn dieser sie unterstützt). */
export interface ProvisionContext {
source: 'kundencenter'; customerNumber: string; customerName: string; contactName: string | null; customerEmail: string | null; orderNumber: string; contractNumber: string;
}
// ---- Kunden des Anbieters (Übernahme) und neue Angebote ----
/** Ein Kunde/Konto beim Anbieter, das ins Kundencenter übernommen werden kann. Enthält keine Geheimnisse. */
export interface ImportableCustomer {
externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; phone: string | null;
address: { street: string | null; zip: string | null; city: string | null; state: string | null; country: string | null };
/** Kundennummer beim Anbieter, falls vorhanden. */ legacyNumber: string | null;
planRef: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
}
/** Neues Angebot (z. B. Hosting-Tarif) beim Anbieter anlegen. null = unbegrenzt. Größen in GB. */
export interface NewCatalogSpec {
name: string;
limits: { diskSpaceGb?: number | null; trafficGb?: number | null; domains?: number | null; subdomains?: number | null; emailAccounts?: number | null; emailAddresses?: number | null; emailForwardings?: number | null; databases?: number | null; ftpUsers?: number | null; scheduledTasks?: number | null };
permissions?: Record<string, boolean>;
}
// ---- Unterobjekte (z. B. Domains, Postfächer, Datenbanken eines Hosting-Kontos) ----
export type ChildKind = 'domain' | 'email' | 'database' | 'ftp' | 'certificate';
export type ChildOp = 'create' | 'update' | 'delete';
export interface NormalizedChild {
id: string; kind: ChildKind; name: string; state: 'active' | 'disabled' | 'pending' | 'error' | 'expired';
validUntil?: string | null;
/** Anzeigedaten ohne Geheimnisse (Passwörter werden nie geliefert). */
details: Record<string, unknown>;
}
export interface ChildAccess {
/** Welche Arten der Anbieter für dieses Objekt kennt und ob Schreiben möglich ist. */
kinds(ctx: ConnectorContext, parentRef: string): Promise<{ kind: ChildKind; canWrite: boolean }[]>;
/** Liest die Unterobjekte NUR des angegebenen Elternobjekts (Mandantentrennung liegt im Connector). */
list(ctx: ConnectorContext, parentRef: string, kind: ChildKind): Promise<NormalizedChild[]>;
/**
* Ändert ein Unterobjekt. Betrifft nur Objekte des Elternobjekts (Zugehörigkeit wird vor jeder Änderung geprüft).
* `secrets` (z. B. Passwörter) laufen getrennt von `data`, damit sie nie protokolliert werden.
*/
act(ctx: ConnectorContext, req: { parentRef: string; kind: ChildKind; op: ChildOp; id?: string; data: Record<string, unknown>; secrets?: Record<string, string>; idempotencyKey: string }): Promise<{ child?: NormalizedChild }>;
}
export interface Health { ok: boolean; latencyMs: number; message?: string; version?: string }
export interface ConnectorContext {
config: Record<string, unknown>;
secrets: Record<string, string>;
correlationId: string;
signal?: AbortSignal;
}
export interface Connector {
readonly contractVersion: typeof CONTRACT_VERSION;
readonly key: string;
readonly displayName: string;
/** Beschreibt Konfigurationsfelder (für die Admin-UI); secret=true wird verschlüsselt gespeichert. */
readonly configFields: { name: string; label: string; secret?: boolean; required?: boolean; placeholder?: string; /** Erklärungstext unter dem Feld */ help?: string; /** Erweiterte Einstellung (in der Oberfläche eingeklappt) */ advanced?: boolean; /** Auswahlfeld statt Freitext */ options?: { value: string; label: string }[] }[];
capabilities(ctx: ConnectorContext): Promise<Capability[]> | Capability[];
healthCheck(ctx: ConnectorContext): Promise<Health>;
listResources(ctx: ConnectorContext): Promise<NormalizedResource[]>;
/**
* Liefert Zugangsdaten/Schlüssel einer Ressource im Klartext (z. B. den vollständigen Lizenzschlüssel) für die berechtigte Anzeige auf Abruf.
* Die Werte werden nie gespeichert oder protokolliert (Capability `secret.reveal`).
*/
/** Kurzlebiger Login-Link ins Kundenpanel (Capability `sso.login`). Wird nie gespeichert. */
loginUrl?(ctx: ConnectorContext, externalRef: string): Promise<{ url: string; validForSec: number }>;
/** Unterobjekte eines Objekts (Capability `children.read`/`children.write`). */
children?: ChildAccess;
reveal?(ctx: ConnectorContext, externalRef: string): Promise<{ label: string; value: string }[]>;
/** Liest die Kunden des Anbieters für die Übernahme (Capability `customers.list`). */
listCustomers?(ctx: ConnectorContext): Promise<ImportableCustomer[]>;
/** Legt ein neues Angebot beim Anbieter an und liefert es als Vorlage zurück (Capability `catalog.write`). */
createCatalogItem?(ctx: ConnectorContext, spec: NewCatalogSpec): Promise<CatalogItem>;
/** Liest Angebote/Produktvorlagen des Anbieters aus (Capability `catalog.list`). */
listCatalog?(ctx: ConnectorContext): Promise<CatalogItem[]>;
/** Prüft Provisionierungsparameter eines Produkts (beim Speichern). Liefert eine Fehlermeldung oder null. */
validateProvisioning?(params: Record<string, unknown>): string | null;
/**
* Legt beim Provider ein neues Objekt an (Capability `lifecycle.create`). NICHT idempotent beim Provider:
* Der Aufrufer wiederholt nur bei Fehlern, bei denen die Anfrage sicher nicht verarbeitet wurde (UNREACHABLE, RATE_LIMITED).
*/
provision?(ctx: ConnectorContext, req: { params: Record<string, unknown>; label: string; idempotencyKey: string; context?: ProvisionContext }): Promise<{ resource: NormalizedResource }>;
/** Idempotent: derselbe idempotencyKey darf beim Provider nie zu einer Doppelausführung führen. */
execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record<string, unknown>; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>;
}
// ---- Normalisierte Fehler --------------------------------------------------
export type ErrorCode = 'UNREACHABLE' | 'TIMEOUT' | 'AUTH_FAILED' | 'RATE_LIMITED' | 'NOT_FOUND' | 'INVALID_RESPONSE' | 'UNSUPPORTED' | 'CONFLICT' | 'UPSTREAM_ERROR' | 'BAD_CONFIG' | 'INVALID_INPUT';
/** Fehler, bei denen die Anfrage sicher NICHT beim Provider angekommen ist (Anlage darf wiederholt werden). */
export const NOT_SENT: ReadonlySet<ErrorCode> = new Set<ErrorCode>(['RATE_LIMITED']);
const RETRYABLE: ReadonlySet<ErrorCode> = new Set(['UNREACHABLE', 'TIMEOUT', 'RATE_LIMITED', 'UPSTREAM_ERROR']);
const MESSAGES: Record<ErrorCode, string> = {
UNREACHABLE: 'Der Dienst ist nicht erreichbar.', TIMEOUT: 'Der Dienst hat nicht rechtzeitig geantwortet.', AUTH_FAILED: 'Anmeldung beim Dienst fehlgeschlagen.',
RATE_LIMITED: 'Zu viele Anfragen, bitte später erneut versuchen.', NOT_FOUND: 'Objekt beim Dienst nicht gefunden.', INVALID_RESPONSE: 'Unerwartete Antwort des Dienstes.',
UNSUPPORTED: 'Diese Aktion wird nicht unterstützt.', CONFLICT: 'Konflikt mit dem Zustand beim Dienst.', UPSTREAM_ERROR: 'Der Dienst meldet einen Fehler.', BAD_CONFIG: 'Die Verbindung ist unvollständig konfiguriert.', INVALID_INPUT: 'Die Eingaben wurden vom Dienst abgelehnt.',
};
export class ConnectorError extends Error {
readonly retryable: boolean;
constructor(public code: ErrorCode, public detail?: string, public retryAfterMs?: number) {
super(detail ? `${MESSAGES[code]} (${detail})` : MESSAGES[code]);
this.retryable = RETRYABLE.has(code);
}
/** Verständliche Meldung für die Oberfläche (ohne technische Details). */
/** Die Anfrage ist sicher NICHT beim Provider verarbeitet worden (Verbindung verweigert, abgelehnt, Konfiguration falsch). */
get notSent(): boolean {
if (['RATE_LIMITED', 'AUTH_FAILED', 'BAD_CONFIG', 'UNSUPPORTED', 'NOT_FOUND', 'CONFLICT', 'INVALID_INPUT'].includes(this.code)) return true;
return this.code === 'UNREACHABLE' && /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH/.test(this.detail ?? '');
}
/** Nach diesem Fehler ist unklar, ob der Provider die Anfrage ausgeführt hat (Timeout, 5xx, ungültige Antwort). */
get ambiguous(): boolean { return !this.notSent; }
get userMessage(): string { return MESSAGES[this.code]; }
/** Wahrscheinliche Ursache und Abhilfe in einfachen Worten (aus Fehlercode und technischem Detail). */
get hint(): string {
const d = this.detail ?? '';
if (/ENOTFOUND|EAI_AGAIN/.test(d)) return 'Der Servername wird nicht aufgelöst. Adresse auf Tippfehler prüfen und ob der Name vom Kundencenter-Server aus auflösbar ist.';
if (/ECONNREFUSED/.test(d)) return 'Der Server ist erreichbar, aber an dieser Adresse/diesem Port läuft nichts. Port und https/http prüfen.';
if (/EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/.test(d)) return 'Keine Netzwerkverbindung zum Server. Firewall/VLAN-Regeln prüfen (kann das Kundencenter den Server erreichen?).';
if (/CERT|SELF.SIGNED|UNABLE_TO_VERIFY|ERR_TLS|SSL/i.test(d)) return 'Das HTTPS-Zertifikat wird nicht als vertrauenswürdig akzeptiert. Bei selbstsigniertem Zertifikat unter „Erweiterte Einstellungen“ den Fingerabdruck eintragen.';
if (this.code === 'BAD_CONFIG') return 'Die gespeicherten Einstellungen sind unvollständig oder fehlerhaft (siehe technische Details). Unter „Zugangsdaten / Einstellungen ändern“ korrigieren.';
if (this.code === 'AUTH_FAILED' || /Anmeldung beim Dienst|HTTP 40[13]/.test(d)) return 'Der API-Schlüssel wurde abgelehnt. Schlüssel prüfen und ob er auf die IP-Adresse des Kundencenter-Servers erlaubt ist.';
if (this.code === 'NOT_FOUND' || /nicht gefunden/.test(d)) return 'Die Adresse antwortet, aber die API wurde nicht gefunden. Ist die Adresse die des Panels (ohne /api/v2)?';
if (this.code === 'TIMEOUT' || /nicht rechtzeitig/.test(d)) return 'Keine Antwort innerhalb der Wartezeit. Server oder Netzwerk überlastet, oder Firewall verwirft Pakete.';
if (this.code === 'INVALID_RESPONSE' || /kein JSON|ungültige Antwort/.test(d)) return 'Die Adresse antwortet, aber nicht wie eine API (evtl. falsche Adresse oder Weiterleitung auf eine Webseite).';
if (this.code === 'UPSTREAM_ERROR' || /HTTP 5\d\d/.test(d)) return 'Der Dienst meldet einen eigenen Fehler. Dort im Protokoll nachsehen.';
return '';
}
}
// ---- HTTP-Helfer: Timeout, Backoff, Rate-Limit -----------------------------
export interface HttpOptions { timeoutMs?: number; retries?: number; baseDelayMs?: number; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise<void> }
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** JSON-Anfrage mit Timeout. Wiederholungen mit exponentiellem Backoff nur für GET (idempotent) bzw. wenn `retryWrites`. */
export async function httpJson<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, init: { headers?: Record<string, string>; body?: unknown; form?: Record<string, string> } = {}, o: HttpOptions & { retryWrites?: boolean } = {}): Promise<T> {
const f = o.fetchImpl ?? fetch; const sleep = o.sleep ?? defaultSleep;
const attempts = (method === 'GET' || o.retryWrites ? (o.retries ?? 2) : 0) + 1;
let last: ConnectorError | undefined;
for (let i = 0; i < attempts; i++) {
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? 10_000);
try {
const headers: Record<string, string> = { accept: 'application/json', ...(init.headers ?? {}) };
let body: string | undefined;
if (init.form) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(init.form).toString(); }
else if (init.body !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(init.body); }
const res = await f(url, { method, headers, body, signal: ctl.signal });
if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`);
if (res.status === 404) throw new ConnectorError('NOT_FOUND');
if (res.status === 400) { const b = await res.json().catch(() => null) as { message?: string } | null; throw new ConnectorError('INVALID_INPUT', typeof b?.message === 'string' ? b.message.slice(0, 200) : undefined); }
if (res.status === 409) throw new ConnectorError('CONFLICT');
if (res.status === 429) { const ra = Number(res.headers.get('retry-after')); throw new ConnectorError('RATE_LIMITED', undefined, Number.isFinite(ra) && ra > 0 ? ra * 1000 : undefined); }
if (res.status >= 500) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
if (!res.ok) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
if (res.status === 204) return undefined as T;
try { return (await res.json()) as T; } catch { throw new ConnectorError('INVALID_RESPONSE', 'kein JSON'); }
} catch (e) {
last = e instanceof ConnectorError ? e
: (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT')
: new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message);
if (!last.retryable || i === attempts - 1) throw last;
await sleep(last.retryAfterMs ?? Math.min(10_000, (o.baseDelayMs ?? 300) * 2 ** i) + Math.floor(Math.random() * 100));
} finally { clearTimeout(timer); }
}
throw last!;
}
/** Maskiert Geheimnisse für Anzeige/Logs: "4e59…7174". */
export const maskKey = (k: string): string => (k.length <= 8 ? '****' : `${k.slice(0, 4)}…${k.slice(-4)}`);