Source: Router/orgaSettings/acmeDns.js

// @ts-check
/**
 * acme-dns — Delegation für Kunden-Zonen
 *
 * ## Warum das hier liegt und nicht im Bot
 *
 * Ein Wildcard-Zertifikat (`*.app.kunde.de`) kann nur per **DNS-01** geprüft
 * werden: Let's Encrypt verlangt einen TXT unter `_acme-challenge.app.kunde.de`.
 * Der Wert wechselt bei jeder Ausstellung, der Kunde kann ihn also nicht einmal
 * eintragen. Deshalb delegiert er den Challenge-Namen **einmal** an einen
 * Dienst, den wir kontrollieren:
 *
 *     _acme-challenge.app.kunde.de.   CNAME   <subdomain>.<acme-dns-host>.
 *
 * **Die Konten entstehen hier, nicht im Bot.** Ein Konto, das der Bot beim ersten
 * Ausstellungsversuch anlegt, wäre für den Kunden unerreichbar: der CNAME zeigt
 * auf einen Zielnamen, den er nie gesehen hat — und der Dienst gibt denselben
 * Namen nie wieder heraus. Die Registrierung gehört deshalb zu dem Vorgang, der
 * dem Kunden die Records **zeigt**: das Anlegen der Domain. Der Bot liest die
 * Konten nur noch und setzt den TXT-Wert.
 *
 * ## Ein Konto pro Challenge-Namen
 *
 * Der CNAME-**Zielname** entscheidet, welcher TXT geantwortet wird. Zwei Namen
 * auf dasselbe Ziel hiesse: beide Prüfungen sehen denselben Wert — und der
 * wechselt. Deshalb ist `acmeDns` eine Map `{ challengeName: konto }`.
 *
 * `app.kunde.de` und `*.app.kunde.de` ergeben dagegen **denselben**
 * Challenge-Namen: ein Konto, ein CNAME, zwei Werte — aber **nacheinander**,
 * weil acme-dns pro Konto genau einen Wert hält.
 *
 * ## Die Namensregeln sind ein Vertrag mit dem Bot
 *
 * `challengeNameFor` und `zoneSans` werden in `basic-bots`
 * (`traefikZones/acmeDns.js`, `traefikZones/render.js`) gespiegelt: der Bot
 * rechnet beim Ausstellen dieselben Namen aus. Ändert sich eine Regel, muss sie
 * in **beiden** Repos geändert werden — sonst zeigt der CNAME auf ein Konto,
 * das der Bot nicht findet, und die Ausstellung scheitert ohne erkennbaren
 * Grund. Beide Seiten haben dafür Tests.
 */

/** acme-dns-Pfad der Registrierung. */
const REGISTER_PATH = '/register';

/**
 * Der DNS-Name, unter dem eine Zertifikats-Autorität geprüft wird.
 *
 * Das `*.` fällt weg: DNS-01 prüft immer den Namen **ohne** Wildcard-Label.
 *
 * @param {unknown} san - `app.kunde.de` oder `*.app.kunde.de`
 * @returns {string|null}
 */
export const challengeNameFor = (san) => {
    if (typeof san !== 'string') return null;
    const value = san.trim().toLowerCase().replace(/\.$/, '').replace(/^\*\./, '');
    if (!value || value.includes('*') || !value.includes('.')) return null;
    return `_acme-challenge.${value}`;
};

/**
 * Die SANs einer Zone — Spiegel der Regel in `render.js` (`basic-bots`).
 *
 * Ein Wildcard pro Label, **kein** `*.zone` zusätzlich: die Zone bedient
 * `<praefix>.<label>.<zone>`, `db.app.kunde.de` ist von `*.app.kunde.de`
 * gedeckt. Ein zusätzliches `*.kunde.de` verlangte `_acme-challenge.kunde.de` —
 * einen CNAME, den der Kunde nie anlegt, und dessen Fehlen die **ganze** Order
 * scheitern lässt. Ohne Labels liegen die Hosts eine Ebene tiefer (`*.zone`).
 *
 * @param {unknown} zone
 * @param {string[]} labels
 * @returns {string[]}
 */
export const zoneSans = (zone, labels = []) => {
    if (typeof zone !== 'string') return [];
    const value = zone.trim().toLowerCase().replace(/\.$/, '');
    if (!value) return [];
    const list = Array.isArray(labels) ? labels.filter(Boolean) : [];
    if (list.length === 0) return [`*.${value}`];
    return list.map((label) => `*.${String(label).trim().toLowerCase()}.${value}`);
};

/**
 * Die **verschiedenen** Challenge-Namen einer Zone, stabil sortiert.
 *
 * @param {unknown} zone
 * @param {string[]} labels
 * @returns {string[]}
 */
export const challengeNamesForZone = (zone, labels) => {
    const names = new Set();
    for (const san of zoneSans(zone, labels)) {
        const name = challengeNameFor(san);
        if (name) names.add(name);
    }
    return [...names].sort();
};

/**
 * Die CNAME-Records, die der Kunde setzen muss — einer pro Challenge-Namen.
 *
 * Namen ohne Konto fehlen im Ergebnis: ein Record ohne Ziel wäre eine Anweisung
 * ins Leere.
 *
 * @param {string[]} challengeNames
 * @param {Record<string, { fulldomain?: string }>} accounts
 * @returns {{name: string, type: 'CNAME', value: string}[]}
 */
export const delegationRecords = (challengeNames, accounts) => {
    const records = [];
    for (const name of challengeNames ?? []) {
        const target = accounts?.[name]?.fulldomain;
        if (!target) continue;
        records.push({ name, type: 'CNAME', value: String(target) });
    }
    return records;
};

/**
 * Welche Challenge-Namen noch kein Konto haben.
 *
 * Nur diese werden registriert: der CNAME des Kunden zeigt auf den **alten**
 * Zielnamen, ein neues Konto wäre ab dem Moment unerreichbar.
 *
 * @param {string[]} challengeNames
 * @param {Record<string, unknown>} accounts
 * @returns {string[]}
 */
export const missingAccounts = (challengeNames, accounts) =>
    (challengeNames ?? []).filter((name) => !accounts?.[name]?.fulldomain);

/** Frist für einen Aufruf an acme-dns. */
const DEFAULT_TIMEOUT_MS = 15000;

/**
 * `fetch` mit Frist — ohne sie hinge das Speichern an einem Dienst, der nicht
 * antwortet, und der Admin sähe einen drehenden Knopf.
 *
 * @param {string} url
 * @param {RequestInit} init
 * @param {number} timeoutMs
 * @returns {Promise<Response>}
 */
const fetchWithTimeout = async (url, init, timeoutMs) => {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
        return await fetch(url, { ...init, signal: controller.signal });
    } finally {
        clearTimeout(timer);
    }
};

/**
 * Fehler mit dem, was der Dienst geantwortet hat.
 *
 * acme-dns erklärt Fehler nur im Body (`"registration disabled"`) — ohne ihn
 * bliebe im Log ein nackter Status.
 *
 * @param {string} action
 * @param {Response} response
 * @returns {Promise<Error>}
 */
const responseError = async (action, response) => {
    let body = '';
    try {
        body = (await response.text()).slice(0, 300);
    } catch {
        // Body nicht lesbar — der Status genügt.
    }
    return Object.assign(
        new Error(`acme-dns: ${action} failed (${response.status})${body ? `: ${body}` : ''}`),
        { status: response.status },
    );
};

/**
 * Die Basis-URL der Instanz (`ACME_DNS_API_BASE`), oder `null`.
 *
 * Nicht fest verdrahtet: Dev und Prod zeigen auf verschiedene Instanzen, und
 * eine Konstante wäre der Grund, warum eine Testdelegation im Kundensystem
 * landet.
 *
 * @returns {string|null}
 */
export const acmeDnsApiBase = () => {
    const value = String(process.env.ACME_DNS_API_BASE || '').trim();
    return value ? value.replace(/\/+$/, '') : null;
};

/**
 * Registriert ein Konto für einen Challenge-Namen.
 *
 * `allowFrom` beschränkt, **wer** den TXT setzen darf (CIDR-Liste); leer heißt
 * „von überall", der Default des Dienstes. Die Zugangsdaten gehen im Klartext
 * über HTTPS — sie sind der Schlüssel zu genau einem TXT-Namen, nicht zur Zone.
 *
 * @param {object} options
 * @param {string} options.apiBase
 * @param {string[]} [options.allowFrom]
 * @param {number} [options.timeoutMs]
 * @returns {Promise<{username: string, password: string, subdomain: string, fulldomain: string}>}
 */
export const registerAccount = async ({ apiBase, allowFrom = [], timeoutMs = DEFAULT_TIMEOUT_MS }) => {
    const response = await fetchWithTimeout(
        `${apiBase}${REGISTER_PATH}`,
        {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(allowFrom.length ? { allowfrom: allowFrom } : {}),
        },
        timeoutMs,
    );
    if (!response.ok) throw await responseError('register', response);

    const account = await response.json();
    for (const field of ['username', 'password', 'subdomain', 'fulldomain']) {
        if (!account?.[field]) throw new Error(`acme-dns: register answered without "${field}".`);
    }
    return account;
};