/** * 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"; import { withDiagLogging } from "../../services/diagLog.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 { 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 { 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 | 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 { 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 { 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 { 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 { 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 { 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 withDiagLogging("azure", { listZones, listRecords, addRecord, updateRecord, deleteRecord }); }