// @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;
};