Add DNS module ported from Sloth Manager

Ports zone/record management across Cloudflare, Loopia, Pi-hole, Azure DNS,
cPanel, and Technitium onto the new stack. Unlike the original (one instance
per provider configured via env vars), providers are now configured through
the UI and support multiple named instances per type, with API
credentials encrypted at rest via the integration_credentials table.

- server/src/dns/adapters/*: each provider ported to a config-based factory
  (no more process.env reads), preserving each provider's original quirks
  (Pi-hole session auth, Azure record-set merging, Loopia XML-RPC, cPanel
  UAPI/API2 fallbacks, Technitium composite record IDs).
- server/src/routes/dns.ts: provider CRUD (admin), a "test connection"
  endpoint, and zone/record browsing+sync+CRUD (operator+), all audit-logged.
- dns_zones_cache/dns_records_cache tables replace Sloth Manager's
  dns-cache.json file, keeping the same "cache is the source of truth for
  display, sync fetches fresh from the provider" behavior.
- web: a DNS page with provider management, zone browsing, and a record
  editor, plus a reusable dynamic provider-config form.

Verified: full build (tsc + vite) passes; a scripted HTTP-layer test against
a running server exercises auth, role gating (403 for viewer), validation
(400 on missing config fields), provider CRUD, credential non-leakage in
list responses, and adapter error propagation (502 against an unreachable
host) — all passing. Real provider connectivity still needs to be checked
against the user's actual DNS accounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-14 23:00:30 +02:00
1 parent 89919fdbff
commit 39b1d1fe2e
24 files changed
+2726 -33

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
/**
* Azure DNS adapter — uses the Azure Resource Manager REST API.
* Requires config: tenantId, clientId, clientSecret, subscriptionId
*
* The service principal needs the "DNS Zone Contributor" role (or higher)
* on the subscription or resource group containing the DNS zones.
*
* Zone IDs are encoded as "resourceGroup::zoneName" so we know which
* resource group to target for record operations.
*/
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
const ARM_BASE = "https://management.azure.com";
const API_VERSION = "2018-05-01";
export interface AzureConfig {
tenantId: string;
clientId: string;
clientSecret: string;
subscriptionId: string;
}
export function createAzureAdapter(config: AzureConfig): DnsAdapter {
let tokenCache: { token: string; expiresAt: number } | null = null;
async function getToken(): Promise<string> {
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token;
const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
scope: "https://management.azure.com/.default",
});
const res = await fetch(url, { method: "POST", body });
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(`Azure auth failed: ${data.error_description ?? data.error ?? res.statusText}`);
}
tokenCache = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
return tokenCache.token;
}
async function armFetch(path: string, options: RequestInit = {}): Promise<any> {
const token = await getToken();
const url = `${ARM_BASE}${path}${path.includes("?") ? "&" : "?"}api-version=${API_VERSION}`;
const res = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(options.headers as Record<string, string> | undefined),
},
});
if (res.status === 204) return null;
const text = await res.text();
if (!text || !text.trim()) return null;
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Azure returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`);
}
if (!res.ok) {
throw new Error(data?.error?.message ?? `Azure API error ${res.status}`);
}
return data;
}
function encodeZoneId(resourceGroup: string, zoneName: string) {
return `${resourceGroup}::${zoneName}`;
}
function decodeZoneId(zoneId: string) {
const idx = zoneId.indexOf("::");
return { resourceGroup: zoneId.slice(0, idx), zoneName: zoneId.slice(idx + 2) };
}
async function listZones(): Promise<DnsZone[]> {
const data = await armFetch(
`/subscriptions/${config.subscriptionId}/providers/Microsoft.Network/dnsZones`,
);
return (data.value ?? []).map((z: any) => {
const rgMatch = z.id.match(/resourceGroups\/([^/]+)\//i);
const rg = rgMatch ? rgMatch[1] : "unknown";
return { id: encodeZoneId(rg, z.name), name: z.name };
});
}
function extractRecords(type: string, props: any): string[] {
switch (type) {
case "A":
return (props.ARecords ?? []).map((r: any) => r.ipv4Address);
case "AAAA":
return (props.AAAARecords ?? []).map((r: any) => r.ipv6Address);
case "CNAME":
return props.CNAMERecord ? [props.CNAMERecord.cname] : [];
case "MX":
return (props.MXRecords ?? []).map((r: any) => r.exchange);
case "NS":
return (props.NSRecords ?? []).map((r: any) => r.nsdname);
case "TXT":
return (props.TXTRecords ?? []).map((r: any) => r.value.join(""));
case "SRV":
return (props.SRVRecords ?? []).map((r: any) => `${r.priority} ${r.weight} ${r.port} ${r.target}`);
case "CAA":
return (props.caaRecords ?? []).map((r: any) => `${r.flags} ${r.tag} ${r.value}`);
case "PTR":
return (props.PTRRecords ?? []).map((r: any) => r.ptrdname);
default:
return [];
}
}
function extractMxPriority(props: any): number | null {
return props.MXRecords?.[0]?.preference ?? null;
}
async function listRecords(zoneId: string): Promise<DnsRecord[]> {
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
const path = `/subscriptions/${config.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/recordSets`;
const data = await armFetch(path);
const records: DnsRecord[] = [];
for (const rs of data.value ?? []) {
const type = rs.type.split("/").pop();
const name = rs.name === "@" ? zoneName : `${rs.name}.${zoneName}`;
const ttl = rs.properties.TTL;
const entries = extractRecords(type, rs.properties);
for (const content of entries) {
records.push({
id: `${rs.name}::${type}::${content}`,
type,
name,
content,
ttl,
priority: type === "MX" ? extractMxPriority(rs.properties) : null,
});
}
}
return records;
}
function appendToRecordSet(props: any, type: string, content: string, priority?: number) {
switch (type) {
case "A":
props.ARecords = [...(props.ARecords ?? []), { ipv4Address: content }];
break;
case "AAAA":
props.AAAARecords = [...(props.AAAARecords ?? []), { ipv6Address: content }];
break;
case "CNAME":
props.CNAMERecord = { cname: content };
break;
case "MX":
props.MXRecords = [...(props.MXRecords ?? []), { preference: Number(priority) || 10, exchange: content }];
break;
case "NS":
props.NSRecords = [...(props.NSRecords ?? []), { nsdname: content }];
break;
case "TXT":
props.TXTRecords = [...(props.TXTRecords ?? []), { value: [content] }];
break;
case "PTR":
props.PTRRecords = [...(props.PTRRecords ?? []), { ptrdname: content }];
break;
default:
throw new Error(`Record type ${type} is not supported for add via this adapter`);
}
}
function removeFromRecordSet(props: any, type: string, content: string) {
switch (type) {
case "A":
props.ARecords = (props.ARecords ?? []).filter((r: any) => r.ipv4Address !== content);
break;
case "AAAA":
props.AAAARecords = (props.AAAARecords ?? []).filter((r: any) => r.ipv6Address !== content);
break;
case "CNAME":
props.CNAMERecord = null;
break;
case "MX":
props.MXRecords = (props.MXRecords ?? []).filter((r: any) => r.exchange !== content);
break;
case "NS":
props.NSRecords = (props.NSRecords ?? []).filter((r: any) => r.nsdname !== content);
break;
case "TXT":
props.TXTRecords = (props.TXTRecords ?? []).filter((r: any) => r.value.join("") !== content);
break;
case "PTR":
props.PTRRecords = (props.PTRRecords ?? []).filter((r: any) => r.ptrdname !== content);
break;
}
}
function countRecords(props: any, type: string): number {
switch (type) {
case "A":
return (props.ARecords ?? []).length;
case "AAAA":
return (props.AAAARecords ?? []).length;
case "CNAME":
return props.CNAMERecord ? 1 : 0;
case "MX":
return (props.MXRecords ?? []).length;
case "NS":
return (props.NSRecords ?? []).length;
case "TXT":
return (props.TXTRecords ?? []).length;
case "PTR":
return (props.PTRRecords ?? []).length;
default:
return 0;
}
}
async function addRecord(zoneId: string, record: DnsRecordInput): Promise<DnsRecord> {
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
let relName = record.name;
if (relName.endsWith(`.${zoneName}`)) relName = relName.slice(0, -(zoneName.length + 1));
if (relName === zoneName) relName = "@";
const path = `/subscriptions/${config.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${record.type}/${relName}`;
let existing: any = null;
try {
existing = await armFetch(path);
} catch {
// 404 means it doesn't exist yet
}
const props = existing?.properties ?? { TTL: Number(record.ttl) || 3600 };
props.TTL = Number(record.ttl) || props.TTL || 3600;
appendToRecordSet(props, record.type, record.content, record.priority);
await armFetch(path, { method: "PUT", body: JSON.stringify({ properties: props }) });
return {
id: `${relName}::${record.type}::${record.content}`,
type: record.type,
name: relName === "@" ? zoneName : `${relName}.${zoneName}`,
content: record.content,
ttl: props.TTL,
priority: record.priority ?? null,
};
}
async function updateRecord(zoneId: string, recordId: string, record: DnsRecordInput): Promise<DnsRecord> {
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
const parts = recordId.split("::");
const oldRelName = parts[0];
const type = parts[1];
const oldContent = parts.slice(2).join("::");
let newRelName = record.name;
if (newRelName.endsWith(`.${zoneName}`)) newRelName = newRelName.slice(0, -(zoneName.length + 1));
if (newRelName === zoneName) newRelName = "@";
const oldPath = `/subscriptions/${config.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${oldRelName}`;
const newPath = `/subscriptions/${config.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${newRelName}`;
let existing: any = null;
try {
existing = await armFetch(oldPath);
} catch {
// doesn't exist in Azure
}
if (existing) {
const props = existing.properties;
removeFromRecordSet(props, type, oldContent);
props.TTL = Number(record.ttl) || props.TTL || 3600;
appendToRecordSet(props, type, record.content, record.priority);
if (oldRelName === newRelName) {
await armFetch(oldPath, { method: "PUT", body: JSON.stringify({ properties: props }) });
} else {
await armFetch(oldPath, { method: "DELETE" });
const newProps: any = { TTL: props.TTL };
appendToRecordSet(newProps, type, record.content, record.priority);
await armFetch(newPath, { method: "PUT", body: JSON.stringify({ properties: newProps }) });
}
} else {
await addRecord(zoneId, record);
}
return {
id: `${newRelName}::${type}::${record.content}`,
type,
name: newRelName === "@" ? zoneName : `${newRelName}.${zoneName}`,
content: record.content,
ttl: Number(record.ttl) || 3600,
priority: record.priority ?? null,
};
}
async function deleteRecord(zoneId: string, recordId: string): Promise<void> {
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
const parts = recordId.split("::");
const relName = parts[0];
const type = parts[1];
const content = parts.slice(2).join("::");
const path = `/subscriptions/${config.subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${relName}`;
const existing = await armFetch(path);
const props = existing.properties;
removeFromRecordSet(props, type, content);
const remaining = countRecords(props, type);
if (remaining === 0) {
await armFetch(path, { method: "DELETE" });
} else {
await armFetch(path, { method: "PUT", body: JSON.stringify({ properties: props }) });
}
}
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
}