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,300 @@
import { createHash } from 'node:crypto';
import { Agent, fetch as undiciFetch } from 'undici';
import {
ConnectorError, CONTRACT_VERSION, httpJson, maskKey,
type ActionName, type Capability, type CatalogItem, type ChildAccess, type ChildKind, type Connector, type ImportableCustomer, type NewCatalogSpec, type ConnectorContext, type HttpOptions, type NormalizedChild, type NormalizedResource, type ProvisionContext,
} from '@kc/connector-sdk';
/**
* KeyHelp-Connector auf Basis der offiziellen OpenAPI-Definition "KeyHelp RESTful API 2.15" (docs/api-specs/).
* Provider-Rohmodelle bleiben in dieser Datei. Einheiten von Speicher/Traffic sind in der Definition nicht angegeben:
* angenommen wird Byte (an der Instanz zu prüfen).
*/
interface RawClient {
id: number; status?: number; username: string; email?: string; language?: string; notes?: string; id_hosting_plan?: number; created_at?: string; document_root?: string;
is_suspended?: boolean; suspend_on?: string | null; delete_on?: string | null; contact_data?: { company?: string; first_name?: string; last_name?: string };
permissions?: Record<string, boolean>; resource_limits?: Record<string, number>;
}
type Stat = { value: number; max: number };
interface RawPlan { id: number; name: string; resources?: Record<string, number>; permissions?: Record<string, boolean> }
type Row = Record<string, any>;
export interface KeyHelpDeps extends HttpOptions { now?: () => Date }
const STATUS_LABEL: Record<number, string> = { 0: 'unbekannt', 1: 'ok', 2: 'Fehler', 3: 'Konfiguration neu', 4: 'Konfiguration wird aktualisiert' };
const PERM_LABEL: Record<string, string> = { ftp: 'FTP', php: 'PHP', ssh: 'SSH', backup: 'Backup', panel_access: 'Panel-Zugang', file_manager: 'Dateimanager', dns_editor: 'DNS-Editor', certificate_management: 'Zertifikate', domain_email: 'E-Mail-Domains', applications: 'Anwendungen' };
const RES_LABEL: Record<string, string> = { disk_space: 'Speicher', traffic: 'Traffic', domains: 'Domains', subdomains: 'Subdomains', email_accounts: 'Postfächer', email_addresses: 'E-Mail-Adressen', databases: 'Datenbanken', ftp_users: 'FTP-Benutzer', scheduled_tasks: 'Cronjobs' };
// ---- Eingabeprüfung für Unterobjekte (Kundeneingaben laufen bis zum Panel: strikt prüfen) ----
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i;
const LOCAL_RE = /^[a-z0-9._+-]{1,64}$/i;
const DBNAME_RE = /^[a-z0-9_]{1,64}$/;
const FTPUSER_RE = /^[a-z0-9._-]{1,32}$/i;
const bad = (m: string) => new ConnectorError('INVALID_INPUT', m);
const str = (v: unknown, name: string, max = 200): string => { if (typeof v !== 'string' || !v.trim() || v.length > max || /[\0\r\n]/.test(v)) throw bad(`${name} fehlt oder ist ungültig`); return v.trim(); };
const optStr = (v: unknown, name: string, max = 200) => (v === undefined || v === null || v === '' ? undefined : str(v, name, max));
const password = (s: Record<string, string> | undefined): string => { const p = s?.password; if (!p || p.length > 128 || /[\0\r\n]/.test(p)) throw bad('Passwort fehlt oder ist ungültig'); return p; };
const homeDir = (v: unknown): string | undefined => { const h = optStr(v, 'Verzeichnis', 300); if (h === undefined) return undefined; if (!/^\/(www|files)(\/|$)/.test(h) || h.split('/').includes('..')) throw bad('Das Verzeichnis muss mit /www/ oder /files beginnen'); return h; };
/** Zusammengehörigkeit prüfen: Der Schlüssel des Panels ist ein Administratorzugang, die Mandantentrennung muss deshalb HIER passieren. */
const owned = (row: Row | null | undefined, clientId: string): Row => { if (!row || String(row.id_user) !== clientId) throw new ConnectorError('NOT_FOUND'); return row; };
/** KeyHelp liefert Zeitstempel ohne Zeitzone: einheitlich als UTC gelesen (Annahme, an der Instanz zu prüfen). */
const iso = (s: string | null | undefined): string | null => { if (!s) return null; const t = /[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.trim().replace(' ', 'T')}Z`; const d = new Date(t); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
const digits = (s: string | undefined) => (s ?? '').replace(/\D/g, '');
export const usernameFor = (c: ProvisionContext | undefined, key: string): string =>
c?.contractNumber && digits(c.contractNumber) ? `kc${digits(c.contractNumber)}` : `kc${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
const noteFor = (c: ProvisionContext) => `Kundencenter ${c.customerNumber} ${c.contractNumber}`;
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length); let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]!); } }));
return out;
}
const fmtRes = (k: string, v: number) => (v < 0 ? 'unbegrenzt' : k === 'disk_space' || k === 'traffic' ? `${Math.round((v / 1024 / 1024 / 1024) * 10) / 10} GB` : String(v));
/** Tarif → Produktvorlage (Limits und Rechte). */
function planItem(p: RawPlan): CatalogItem {
return {
externalRef: `plan:${p.id}`, name: p.name, description: null, category: 'hosting', fixed: true, providerActive: true, program: 'KeyHelp',
features: [...Object.entries(p.resources ?? {}).filter(([k]) => RES_LABEL[k]).map(([k, v]) => `${RES_LABEL[k]}: ${fmtRes(k, v)}`), ...Object.entries(p.permissions ?? {}).filter(([k, v]) => v && PERM_LABEL[k]).map(([k]) => PERM_LABEL[k]!)],
meta: Object.fromEntries(Object.entries(p.resources ?? {}).map(([k, v]) => [k, v])),
provisioning: { hostingPlanId: p.id, language: 'de', createSystemDomain: true, sendLoginCredentials: true },
};
}
export function createKeyHelpConnector(deps: KeyHelpDeps = {}): Connector {
const now = deps.now ?? (() => new Date());
const agents = new Map<string, Agent>();
const base = (ctx: ConnectorContext) => {
const u = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '').replace(/\/api\/v2$/, '');
if (!/^https:\/\//.test(u)) throw new ConnectorError('BAD_CONFIG', 'baseUrl muss mit https:// beginnen');
return u;
};
const apiKey = (ctx: ConnectorContext) => { const k = ctx.secrets.apiKey; if (!k) throw new ConnectorError('BAD_CONFIG', 'API-Schlüssel fehlt');
const bad = [...k].find((c) => c.charCodeAt(0) < 33 || c.charCodeAt(0) > 126);
if (bad !== undefined) throw new ConnectorError('BAD_CONFIG', `API-Schlüssel enthält ein ungültiges Zeichen „${bad}“ (Leerzeichen, Zeilenumbruch oder „…“ vom Kürzen?). Bitte den vollständigen Schlüssel neu kopieren.`);
return k; };
/** TLS: Standard ist volle Prüfung. Für selbstsignierte Zertifikate: Fingerabdruck festlegen (bevorzugt) oder Prüfung abschalten (unsicher). */
function fetchFor(ctx: ConnectorContext): typeof fetch {
if (deps.fetchImpl) return deps.fetchImpl;
const fp = String(ctx.config.tlsFingerprint ?? '').replace(/[:\s]/g, '').toLowerCase();
const verify = String(ctx.config.verifyTls ?? 'true').toLowerCase() !== 'false';
if (verify && !fp) return fetch;
if (fp && !/^[0-9a-f]{64}$/.test(fp)) throw new ConnectorError('BAD_CONFIG', 'tlsFingerprint muss ein SHA-256-Fingerabdruck sein (64 Hex-Zeichen)');
const key = `${base(ctx)}|${fp}|${verify}`; let a = agents.get(key);
if (!a) {
a = new Agent({ connect: { rejectUnauthorized: false, ...(fp ? { checkServerIdentity: (_h: string, cert: { fingerprint256?: string }) => ((cert.fingerprint256 ?? '').replace(/:/g, '').toLowerCase() === fp ? undefined : new Error('TLS-Fingerabdruck stimmt nicht überein')) } : {}) } });
agents.set(key, a);
}
const agent = a; return ((url: string, init: RequestInit) => undiciFetch(url, { ...(init as object), dispatcher: agent } as never)) as unknown as typeof fetch;
}
/** Aufruf mit Wiederholung bei Webserver-Neustart (500/503) für Lesen und Ziel-Zustands-Änderungen; Anlage (POST) wird nie automatisch wiederholt. */
function call<T>(ctx: ConnectorContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown): Promise<T> {
return httpJson<T>(method, `${base(ctx)}/api/v2${path}`, { headers: { 'x-api-key': apiKey(ctx) }, body },
{ ...deps, fetchImpl: fetchFor(ctx), retries: deps.retries ?? 3, baseDelayMs: deps.baseDelayMs ?? 1500, retryWrites: method === 'PUT' || method === 'DELETE' });
}
const optional = async <T>(p: Promise<T>): Promise<T | null> => { try { return await p; } catch (e) { if (e instanceof ConnectorError && e.code === 'NOT_FOUND') return null; throw e; } };
// ---- Normalisierung ------------------------------------------------------------------
const clientName = (c: RawClient) => `${c.contact_data?.company?.trim() || c.username} · ${c.username}`;
function mapClient(c: RawClient, stats: Record<string, Stat> | null, plans: Map<number, string>): NormalizedResource {
const usage: Record<string, number | string | null> = {}; const limits: Record<string, number | string | null> = {};
for (const [k, v] of Object.entries(stats ?? {})) { if (v && typeof v.value === 'number') usage[k] = v.value; if (v && typeof v.max === 'number') limits[k] = v.max < 0 ? null : v.max; } // negative Grenze = unbegrenzt (angenommen)
const perms = Object.entries(c.permissions ?? {}).filter(([, v]) => v).map(([k]) => PERM_LABEL[k]).filter(Boolean);
return {
externalRef: String(c.id), type: 'hosting_account', name: clientName(c), state: c.is_suspended ? 'suspended' : c.status === 2 ? 'error' : 'active',
validFrom: iso(c.created_at), validUntil: null, limits, usage,
details: { username: c.username, plan: plans.get(Number(c.id_hosting_plan)) ?? (c.id_hosting_plan ? `Tarif ${c.id_hosting_plan}` : null), planId: c.id_hosting_plan ?? null, providerStatus: STATUS_LABEL[c.status ?? 0] ?? null,
scheduledSuspendOn: c.suspend_on || null, scheduledDeleteOn: c.delete_on || null, features: perms, units: 'Speicher/Traffic in Byte (angenommen)' },
};
}
const state = (r: Row): NormalizedChild['state'] => (r.is_disabled ? 'disabled' : r.status === 2 ? 'error' : r.status === 3 || r.status === 4 ? 'pending' : 'active');
function mapChild(kind: ChildKind, r: Row): NormalizedChild {
if (kind === 'domain') return { id: String(r.id), kind, name: r.domain_utf8 || r.domain, state: state(r), details: { subdomain: !!r.is_subdomain, system: !!r.is_system_domain, phpVersion: r.php_version || 'System', letsEncrypt: !!r.security?.lets_encrypt, forceHttps: !!r.security?.force_https, hsts: !!r.security?.is_hsts, emailDomain: !!r.is_email_domain, parentId: r.id_parent_domain ? String(r.id_parent_domain) : null } };
if (kind === 'email') return { id: String(r.id), kind, name: r.email_utf8 || r.email, state: state(r), details: { sizeBytes: r.size ?? null, maxSizeBytes: r.max_size ?? null, aliases: r.aliases_utf8 ?? r.aliases ?? [], forwardings: r.forwardings_utf8 ?? r.forwardings ?? [], catchAll: !!r.catch_all, description: r.description ?? '' } };
if (kind === 'database') return { id: String(r.id), kind, name: r.database_name, state: 'active', details: { user: r.database_username, sizeBytes: r.size ?? null, description: r.description ?? '', remoteHosts: r.remote_hosts ?? [] } };
if (kind === 'ftp') return { id: String(r.id), kind, name: r.username, state: state(r), details: { home: r.home_directory, description: r.description ?? '' } };
const until = iso(r.valid_till);
return { id: String(r.id), kind, name: r.name, state: until && new Date(until) < now() ? 'expired' : 'active', validUntil: until, details: { issuer: r.issuer ?? null, securedDomains: r.secured_domains ?? [], usedByDomains: r.usage?.domain_count ?? null } };
}
const PATH: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp-users', certificate: 'certificates' };
const LISTKEY: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp_users', certificate: 'certificates' };
const NAMEKEY: Record<ChildKind, string> = { domain: 'domain', email: 'email', database: 'database_name', ftp: 'username', certificate: 'name' };
async function getClient(ctx: ConnectorContext, id: string): Promise<RawClient> { return call<RawClient>(ctx, 'GET', `/clients/${encodeURIComponent(id)}`); }
async function plansMap(ctx: ConnectorContext): Promise<Map<number, string>> { try { return new Map((await call<RawPlan[]>(ctx, 'GET', '/hosting-plans')).map((p) => [p.id, p.name])); } catch { return new Map(); } }
// ---- Unterobjekte ------------------------------------------------------------------
const children: ChildAccess = {
async kinds(ctx, parentRef) {
const c = await getClient(ctx, parentRef); const p = c.permissions ?? {};
return [{ kind: 'domain', canWrite: true }, { kind: 'email', canWrite: true }, { kind: 'database', canWrite: true }, { kind: 'ftp', canWrite: p.ftp !== false }, { kind: 'certificate', canWrite: false }];
},
async list(ctx, parentRef, kind) {
const res = await call<Record<string, Row[]>>(ctx, 'GET', `/clients/${encodeURIComponent(parentRef)}/resources`);
const rows = res?.[LISTKEY[kind]]; if (!Array.isArray(rows)) throw new ConnectorError('INVALID_RESPONSE');
return rows.filter((r) => String(r.id_user) === parentRef || r.id_user === undefined).map((r) => mapChild(kind, r)); // doppelt abgesichert: nur Objekte dieses Kunden
},
async act(ctx, req) {
const { parentRef: cid, kind, op, id } = req; const d = req.data ?? {}; const path = PATH[kind];
if (kind === 'certificate') throw new ConnectorError('UNSUPPORTED', 'Zertifikate sind nur lesbar');
const fetchOwned = async (): Promise<Row> => owned(await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)), cid);
if (op === 'delete') {
const row = await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)); if (!row) return {}; // schon weg: Ziel erreicht (idempotent)
owned(row, cid);
if (kind === 'domain' && row.is_system_domain) throw bad('Die System-Domain kann nicht gelöscht werden');
await call(ctx, 'DELETE', `/${path}/${encodeURIComponent(String(row.id))}`); return {};
}
if (op === 'update') {
const row = await fetchOwned(); const body: Row = {};
if (kind === 'domain') { if (d.phpVersion !== undefined) body.php_version = optStr(d.phpVersion, 'PHP-Version', 20) ?? ''; const sec: Row = {}; for (const [k, f] of [['letsEncrypt', 'lets_encrypt'], ['forceHttps', 'force_https'], ['hsts', 'is_hsts']] as const) if (typeof d[k] === 'boolean') sec[f] = d[k]; if (Object.keys(sec).length) body.security = sec; if (typeof d.disabled === 'boolean') body.is_disabled = d.disabled; }
if (kind === 'email') { if (req.secrets?.password) body.password = password(req.secrets); if (d.maxSizeBytes !== undefined) { const n = Number(d.maxSizeBytes); if (!Number.isInteger(n) || n < 0) throw bad('Postfachgröße ungültig'); body.max_size = n; } if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; if (typeof d.catchAll === 'boolean') body.catch_all = d.catchAll;
if (Array.isArray(d.forwardings)) body.forwardings = d.forwardings.map((x) => str(x, 'Weiterleitung', 254)); if (Array.isArray(d.aliases)) body.aliases = d.aliases.map((x) => str(x, 'Alias', 254)); }
if (kind === 'database') { if (req.secrets?.password) body.password = password(req.secrets); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (kind === 'ftp') { if (req.secrets?.password) body.password = password(req.secrets); if (d.home !== undefined) body.home_directory = homeDir(d.home); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (!Object.keys(body).length) throw bad('Keine Änderung angegeben');
await call(ctx, 'PUT', `/${path}/${encodeURIComponent(String(row.id))}`, body);
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(String(row.id))}`)) };
}
// ---- anlegen: id_user wird IMMER auf den Kunden gesetzt; existiert das Objekt schon (Wiederholung), wird es übernommen ----
const existingBy = async (name: string) => { const r = await optional(call<Row>(ctx, 'GET', `/${path}/name/${encodeURIComponent(name)}`)); if (r && String(r.id_user) !== cid) throw new ConnectorError('CONFLICT', 'Der Name ist bereits vergeben'); return r; };
let body: Row; let natural: string | undefined;
if (kind === 'domain') {
const name = str(d.domain, 'Domain', 253).toLowerCase(); if (!DOMAIN_RE.test(name)) throw bad('Ungültiger Domainname'); natural = name;
let parent = 0; if (d.parentDomainId !== undefined && d.parentDomainId !== null && d.parentDomainId !== 0) { const p = owned(await optional(call<Row>(ctx, 'GET', `/domains/${encodeURIComponent(str(String(d.parentDomainId), 'Hauptdomain', 20))}`)), cid); parent = Number(p.id); if (!name.endsWith(`.${String(p.domain).toLowerCase()}`)) throw bad('Die Subdomain muss zur Hauptdomain passen'); }
body = { id_user: Number(cid), id_parent_domain: parent, domain: name, ...(d.phpVersion ? { php_version: str(d.phpVersion, 'PHP-Version', 20) } : {}), ...(typeof d.emailDomain === 'boolean' ? { is_email_domain: d.emailDomain } : {}), ...(typeof d.letsEncrypt === 'boolean' ? { security: { lets_encrypt: d.letsEncrypt, force_https: !!d.forceHttps } } : {}) };
} else if (kind === 'email') {
const local = str(d.local, 'Postfach', 64); const dom = str(d.domain, 'Domain', 253).toLowerCase(); if (!LOCAL_RE.test(local) || !DOMAIN_RE.test(dom)) throw bad('Ungültige E-Mail-Adresse'); natural = `${local}@${dom}`.toLowerCase();
const dRow = owned(await optional(call<Row>(ctx, 'GET', `/domains/name/${encodeURIComponent(dom)}`)), cid); void dRow; // Domain muss dem Kunden gehören
body = { id_user: Number(cid), email: natural, password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}), ...(d.maxSizeBytes !== undefined ? { max_size: Number(d.maxSizeBytes) } : {}) };
} else if (kind === 'database') {
const n = optStr(d.name, 'Datenbankname', 64); if (n && !DBNAME_RE.test(n)) throw bad('Datenbankname: nur a-z, 0-9 und Unterstrich'); natural = n;
body = { id_user: Number(cid), ...(n ? { database_name: n, database_username: n } : {}), password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
} else {
const u = optStr(d.username, 'FTP-Benutzer', 32); if (u && !FTPUSER_RE.test(u)) throw bad('FTP-Benutzername ungültig'); natural = u;
body = { id_user: Number(cid), ...(u ? { username: u } : {}), password: password(req.secrets), home_directory: homeDir(d.home) ?? '/www/', ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
}
if (natural) { const ex = await existingBy(natural); if (ex) return { child: mapChild(kind, ex) }; }
const created = await call<{ id: number }>(ctx, 'POST', `/${path}`, body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine ID');
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${created.id}`)) };
},
};
void NAMEKEY;
const capabilities: Capability[] = ['customers.list', 'catalog.write', 'catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.create', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.terminate', 'plan.change', 'sso.login', 'children.read', 'children.write'];
return {
contractVersion: CONTRACT_VERSION, key: 'keyhelp', displayName: 'KeyHelp (Webhosting)',
configFields: [
{ name: 'baseUrl', label: 'KeyHelp-Adresse', required: true, placeholder: 'https://keyhelp.example.de', help: 'Die Adresse, unter der Sie KeyHelp im Browser öffnen (mit https://).' },
{ name: 'apiKey', label: 'API-Schlüssel', secret: true, required: true, help: 'In KeyHelp unter Konfiguration → API anlegen und auf die IP-Adresse dieses Servers beschränken.' },
{ name: 'verifyTls', label: 'Zertifikat von KeyHelp prüfen', advanced: true, options: [{ value: 'true', label: 'Ja, prüfen (empfohlen)' }, { value: 'false', label: 'Nein, nicht prüfen (unsicher)' }], help: 'Nur ändern, wenn KeyHelp ein selbstsigniertes Zertifikat verwendet. Besser ist es, statt „nicht prüfen“ den Fingerabdruck unten einzutragen.' },
{ name: 'tlsFingerprint', label: 'Zertifikat-Fingerabdruck (SHA-256)', advanced: true, placeholder: 'AB:CD:EF:…', help: 'Nur bei selbstsigniertem Zertifikat: Das Kundencenter vertraut dann genau diesem Zertifikat. Mit „Vom Server holen“ eintragen und mit KeyHelp abgleichen.' },
],
capabilities: () => capabilities,
async healthCheck(ctx) {
const t0 = Date.now();
try {
await call(ctx, 'GET', '/ping'); // reines Erreichbarkeits-Signal (kein Wiederholen bei Ausfall)
let version: string | undefined; try { const s = await call<{ meta?: { panel_version?: string; api_version?: string } }>(ctx, 'GET', '/server'); version = s?.meta?.panel_version ? `KeyHelp ${s.meta.panel_version} (API ${s.meta.api_version ?? '?'})` : undefined; } catch { /* Version nur informativ */ }
return { ok: true, latencyMs: Date.now() - t0, version };
} catch (e) { return { ok: false, latencyMs: Date.now() - t0, message: e instanceof ConnectorError ? e.message : 'Unbekannter Fehler' }; }
},
/** Alle Kunden mit Nutzung (Statistik je Kunde, begrenzt parallel). Ausfall einzelner Statistiken bricht den Abgleich nicht ab. */
async listResources(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
return mapLimit(clients, 4, async (c) => { const st = await call<Record<string, Stat>>(ctx, 'GET', `/clients/${c.id}/stats`).catch(() => null); return mapClient(c, st, plans); });
},
/** Hosting-Tarife als Produktvorlagen (Limits und Rechte des Tarifs). */
async listCatalog(ctx) {
const plans = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans'); if (!Array.isArray(plans)) throw new ConnectorError('INVALID_RESPONSE');
return plans.map(planItem);
},
/** Kunden (Hosting-Konten) mit Kontaktdaten und Tarif für die Übernahme. */
async listCustomers(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
const t = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
return clients.map((c): ImportableCustomer => {
const cd = (c.contact_data ?? {}) as Row; const person = [t(cd.first_name), t(cd.last_name)].filter(Boolean).join(' ');
return { externalRef: String(c.id), displayName: t(cd.company) ?? (person || c.username), company: t(cd.company), firstName: t(cd.first_name), lastName: t(cd.last_name), email: t(c.email), phone: t(cd.telephone),
address: { street: t(cd.address), zip: t(cd.zip), city: t(cd.city), state: t(cd.state), country: t(cd.country) }, legacyNumber: t(cd.client_id),
planRef: c.id_hosting_plan ? `plan:${c.id_hosting_plan}` : null, planName: plans.get(Number(c.id_hosting_plan)) ?? null, state: c.is_suspended ? 'suspended' : 'active', createdAt: iso(c.created_at) };
});
},
/**
* Neuen Hosting-Tarif anlegen. Größen werden als Byte gesendet (Annahme, an der Instanz prüfen). "Unbegrenzt" gibt es nur, wenn die Instanz
* es bereits kennt (negativer Wert in bestehenden Tarifen/Konten); sonst wird abgelehnt statt zu raten.
*/
async createCatalogItem(ctx, spec) {
const name = str(spec.name, 'Name', 100); const existing = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans');
if (existing.some((p) => p.name.toLowerCase() === name.toLowerCase())) throw new ConnectorError('CONFLICT', 'Es gibt bereits einen Tarif mit diesem Namen');
const clients = await call<RawClient[]>(ctx, 'GET', '/clients').catch(() => [] as RawClient[]);
const unlimitedKnown = existing.some((p) => Object.values(p.resources ?? {}).some((v) => v < 0)) || clients.some((c) => Object.values(c.resource_limits ?? {}).some((v) => v < 0));
const GB = 1024 ** 3; const map: [keyof NewCatalogSpec['limits'], string, boolean][] = [['diskSpaceGb', 'disk_space', true], ['trafficGb', 'traffic', true], ['domains', 'domains', false], ['subdomains', 'subdomains', false], ['emailAccounts', 'email_accounts', false], ['emailAddresses', 'email_addresses', false], ['emailForwardings', 'email_forwardings', false], ['databases', 'databases', false], ['ftpUsers', 'ftp_users', false], ['scheduledTasks', 'scheduled_tasks', false]];
const resources: Record<string, number> = {};
for (const [k, api, isGb] of map) {
const v = spec.limits?.[k]; if (v === undefined) continue;
if (v === null) { if (!unlimitedKnown) throw bad(`"Unbegrenzt" (${RES_LABEL[api]}) wird von dieser Instanz noch nicht erkannt. Bitte eine Zahl angeben.`); resources[api] = -1; continue; }
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1_000_000) throw bad(`${RES_LABEL[api]}: ungültiger Wert`);
resources[api] = isGb ? Math.round(v * GB) : Math.round(v);
}
const permissions: Record<string, boolean> = {}; for (const [k, v] of Object.entries(spec.permissions ?? {})) if (/^[a-z_]{2,40}$/.test(k) && typeof v === 'boolean') permissions[k] = v;
const created = await call<{ id: number }>(ctx, 'POST', '/hosting-plans', { name, ...(Object.keys(resources).length ? { resources } : {}), ...(Object.keys(permissions).length ? { permissions } : {}) });
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Tarif-ID');
return planItem(await call<RawPlan>(ctx, 'GET', `/hosting-plans/${created.id}`));
},
validateProvisioning(p) {
if (p.hostingPlanId !== undefined && p.hostingPlanId !== null && (!Number.isInteger(p.hostingPlanId) || (p.hostingPlanId as number) < 1)) return 'hostingPlanId muss eine ganze Zahl ≥ 1 sein';
if (p.language !== undefined && !/^[a-z]{2}(_[A-Z]{2})?$/.test(String(p.language))) return 'language muss z. B. "de" oder "de_DE" sein';
for (const k of ['createSystemDomain', 'sendLoginCredentials']) if (p[k] !== undefined && typeof p[k] !== 'boolean') return `${k} muss true oder false sein`;
return null;
},
/**
* Legt ein Hosting-Konto an. Benutzername = "kc" + Ziffern der Vertragsnummer (stabil): Existiert er schon und trägt unseren Vermerk,
* wird das Konto übernommen (sichere Wiederholung nach unklarem Ausgang). Das vom Panel erzeugte Passwort wird NIE gelesen oder gespeichert
* (KeyHelp schickt die Zugangsdaten an die Kunden-E-Mail; zusätzlich gibt es den Panel-Login).
*/
async provision(ctx, req) {
const err = this.validateProvisioning!(req.params); if (err) throw new ConnectorError('BAD_CONFIG', err);
const c = req.context; if (!c?.customerEmail) throw new ConnectorError('BAD_CONFIG', 'Für das Hosting-Konto wird die E-Mail-Adresse des Kunden benötigt');
const username = usernameFor(c, req.idempotencyKey); const plans = await plansMap(ctx);
const existing = await optional(call<RawClient>(ctx, 'GET', `/clients/name/${encodeURIComponent(username)}`));
if (existing) {
if (!String(existing.notes ?? '').includes(noteFor(c))) throw new ConnectorError('CONFLICT', `Der Benutzername ${username} ist bereits an ein anderes Konto vergeben`);
return { resource: mapClient(existing, null, plans) }; // bereits angelegt (Wiederholung)
}
const [first, ...rest] = (c.contactName ?? '').trim().split(/\s+/).filter(Boolean);
const body: Row = { username, email: c.customerEmail, language: (req.params.language as string) ?? 'de', ...(req.params.hostingPlanId ? { id_hosting_plan: req.params.hostingPlanId } : {}), create_system_domain: req.params.createSystemDomain ?? true, send_login_credentials: req.params.sendLoginCredentials ?? true,
notes: noteFor(c), contact_data: { company: c.customerName, ...(first ? { first_name: first, last_name: rest.join(' ') || first } : {}) } };
const created = await call<{ id: number }>(ctx, 'POST', '/clients', body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Kunden-ID');
const client = await getClient(ctx, String(created.id));
if (client.username !== username) throw new ConnectorError('INVALID_RESPONSE', `Konto ${created.id} mit unerwartetem Benutzernamen angelegt`);
return { resource: mapClient(client, null, plans) };
},
/** Sperren/Entsperren setzen einen Zielzustand; Tarifwechsel setzt den Tarif; Löschen ist endgültig (nur manuell bestätigt, nie automatisch wiederholt). */
async execute(ctx, req) {
const id = encodeURIComponent(req.externalRef); const plans = await plansMap(ctx);
const act: ActionName = req.action;
if (act === 'suspend' || act === 'unsuspend') { await call(ctx, 'PUT', `/clients/${id}`, { is_suspended: act === 'suspend' }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'change_plan') { const pid = Number(req.params?.planId); if (!Number.isInteger(pid) || pid < 1) throw new ConnectorError('BAD_CONFIG', 'planId fehlt'); await call(ctx, 'PUT', `/clients/${id}`, { id_hosting_plan: pid }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'terminate') { await optional(call(ctx, 'DELETE', `/clients/${id}`)); return {}; } // 404 = schon gelöscht
throw new ConnectorError('UNSUPPORTED', 'Diese Aktion gibt es bei KeyHelp nicht');
},
/** Offizieller Login-Link (60 Minuten gültig, Brute-Force-Schutz im Panel). */
async loginUrl(ctx, ref) {
const r = await call<{ url?: string }>(ctx, 'GET', `/login/${encodeURIComponent(ref)}`);
if (!r?.url || !/^https:\/\//.test(r.url)) throw new ConnectorError('INVALID_RESPONSE', 'kein Login-Link');
return { url: r.url, validForSec: 3600 };
},
children,
};
}
export const keyhelpConnector = createKeyHelpConnector();
void maskKey;