/** * Technitium DNS Server adapter * Requires config: url, token * * API docs: https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md * Record ID format: "type||name||content||priority" */ import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import { withDiagLogging } from "../../services/diagLog.js"; export interface TechnitiumConfig { url: string; token: string; } export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter { function base() { return config.url.replace(/\/$/, ""); } function authHeader() { return `Bearer ${config.token}`; } function handleResponse(data: any) { if (data.status === "invalid-token") throw new Error("Invalid or expired Technitium API token"); if (data.status === "error") throw new Error(data.errorMessage ?? "Technitium API error"); if (data.status !== "ok") throw new Error(`Unexpected Technitium status: ${data.status}`); return data.response; } async function apiGet(path: string, params: Record = {}) { const qs = new URLSearchParams(params).toString(); const res = await fetch(`${base()}${path}${qs ? "?" + qs : ""}`, { headers: { Authorization: authHeader(), Accept: "application/json" }, }); return handleResponse(await res.json()); } async function apiPost(path: string, params: Record = {}) { const res = await fetch(`${base()}${path}`, { method: "POST", headers: { Authorization: authHeader(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: new URLSearchParams(params).toString(), }); return handleResponse(await res.json()); } function extractContent(r: any): string | null { const rd = r.rData ?? {}; switch (r.type) { case "A": case "AAAA": return rd.ipAddress ?? null; case "CNAME": return (rd.cname ?? "").replace(/\.$/, "") || null; case "MX": return (rd.exchange ?? "").replace(/\.$/, "") || null; case "TXT": return rd.text ?? null; case "NS": return (rd.nameServer ?? "").replace(/\.$/, "") || null; case "PTR": return (rd.ptrName ?? "").replace(/\.$/, "") || null; default: return null; } } function buildTypeParams(type: string, content: string, priority?: number): Record { switch (type) { case "A": case "AAAA": return { ipAddress: content }; case "CNAME": return { cname: content }; case "MX": return { exchange: content, preference: String(priority ?? 10) }; case "TXT": return { text: content }; case "NS": return { nameServer: content }; case "PTR": return { ptrName: content }; default: throw new Error(`Unsupported record type for Technitium: ${type}`); } } function makeId(type: string, name: string, content: string, priority?: number | null) { return `${type}||${name}||${content}||${priority ?? ""}`; } function parseId(recordId: string) { const [type, name, content, priorityStr] = recordId.split("||"); return { type, name, content, priority: priorityStr ? parseInt(priorityStr, 10) : undefined }; } async function listZones(): Promise { const resp = await apiGet("/api/zones/list"); const zones = resp?.zones ?? []; return zones.filter((z: any) => !z.internal).map((z: any) => ({ id: z.name, name: z.name })); } async function listRecords(zone: string): Promise { const resp = await apiGet("/api/zones/records/get", { domain: zone, listZone: "true" }); const records = resp?.records ?? []; const SKIP = new Set(["SOA", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "DS", "ANAME", "FWD", "APP"]); return records .filter((r: any) => r.name && !SKIP.has(r.type)) .map((r: any) => { const content = extractContent(r); if (content === null) return null; const priority = r.type === "MX" ? (r.rData?.preference ?? 10) : null; return { id: makeId(r.type, r.name, content, priority), type: r.type, name: r.name, content, ttl: r.ttl ?? 3600, priority }; }) .filter(Boolean) as DnsRecord[]; } async function addRecord(zone: string, record: DnsRecordInput): Promise { const typeParams = buildTypeParams(record.type, record.content, record.priority); await apiPost("/api/zones/records/add", { domain: record.name, zone, type: record.type, ttl: String(record.ttl ?? 3600), overwrite: "false", ...typeParams, }); return { id: makeId(record.type, record.name, record.content, record.priority), type: record.type, name: record.name, content: record.content, ttl: record.ttl ?? 3600, priority: record.priority ?? null, }; } async function updateRecord(zone: string, recordId: string, record: DnsRecordInput): Promise { await deleteRecord(zone, recordId); return addRecord(zone, record); } async function deleteRecord(zone: string, recordId: string): Promise { const { type, name, content, priority } = parseId(recordId); const typeParams = buildTypeParams(type, content, priority); await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams }); } return withDiagLogging("technitium", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); }