/** * Azure DNS adapter — uses the Azure Resource Manager REST API. * * Requires env: * AZURE_TENANT_ID Azure AD tenant ID * AZURE_CLIENT_ID Service principal application (client) ID * AZURE_CLIENT_SECRET Service principal client secret * AZURE_SUBSCRIPTION_ID Azure subscription ID * * The service principal needs the "DNS Zone Contributor" role (or higher) * on the subscription or resource group containing your DNS zones. * * Zone IDs are encoded as "resourceGroup::zoneName" so we know which * resource group to target for record operations. */ const ARM_BASE = 'https://management.azure.com'; const API_VERSION = '2018-05-01'; // ─── Auth ──────────────────────────────────────────────────────────────────── let tokenCache = null; async function getToken() { if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token; const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env; const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`; const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: AZURE_CLIENT_ID, client_secret: AZURE_CLIENT_SECRET, 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, options = {}) { 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 ?? {}), }, }); 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) { const msg = data?.error?.message ?? `Azure API error ${res.status}`; throw new Error(msg); } return data; } // ─── Zone helpers ───────────────────────────────────────────────────────────── function encodeZoneId(resourceGroup, zoneName) { return `${resourceGroup}::${zoneName}`; } function decodeZoneId(zoneId) { const idx = zoneId.indexOf('::'); return { resourceGroup: zoneId.slice(0, idx), zoneName: zoneId.slice(idx + 2) }; } // ─── Public API ────────────────────────────────────────────────────────────── async function listZones() { const sub = process.env.AZURE_SUBSCRIPTION_ID; const data = await armFetch(`/subscriptions/${sub}/providers/Microsoft.Network/dnsZones`); return (data.value ?? []).map(z => { // Extract resource group from the zone's resource ID // e.g. /subscriptions/.../resourceGroups/myRG/providers/Microsoft.Network/dnsZones/example.com const rgMatch = z.id.match(/resourceGroups\/([^/]+)\//i); const rg = rgMatch ? rgMatch[1] : 'unknown'; return { id: encodeZoneId(rg, z.name), name: z.name, }; }); } async function listRecords(zoneId) { const { resourceGroup, zoneName } = decodeZoneId(zoneId); const sub = process.env.AZURE_SUBSCRIPTION_ID; const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/recordSets`; const data = await armFetch(path); const records = []; for (const rs of data.value ?? []) { const type = rs.type.split('/').pop(); // e.g. "Microsoft.Network/dnsZones/A" → "A" const name = rs.name === '@' ? zoneName : `${rs.name}.${zoneName}`; const ttl = rs.properties.TTL; // Each record set can contain multiple records — flatten them 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 extractRecords(type, props) { switch (type) { case 'A': return (props.ARecords ?? []).map(r => r.ipv4Address); case 'AAAA': return (props.AAAARecords ?? []).map(r => r.ipv6Address); case 'CNAME': return props.CNAMERecord ? [props.CNAMERecord.cname] : []; case 'MX': return (props.MXRecords ?? []).map(r => r.exchange); case 'NS': return (props.NSRecords ?? []).map(r => r.nsdname); case 'TXT': return (props.TXTRecords ?? []).map(r => r.value.join('')); case 'SRV': return (props.SRVRecords ?? []).map(r => `${r.priority} ${r.weight} ${r.port} ${r.target}`); case 'CAA': return (props.caaRecords ?? []).map(r => `${r.flags} ${r.tag} ${r.value}`); case 'PTR': return (props.PTRRecords ?? []).map(r => r.ptrdname); case 'SOA': return []; // skip SOA default: return []; } } function extractMxPriority(props) { return props.MXRecords?.[0]?.preference ?? null; } /** * Adds a DNS record. Azure groups records into record sets by name+type, * so we GET the existing set (if any), append the new value, then PUT. */ async function addRecord(zoneId, record) { const { resourceGroup, zoneName } = decodeZoneId(zoneId); const sub = process.env.AZURE_SUBSCRIPTION_ID; // Derive relative record name let relName = record.name; if (relName.endsWith(`.${zoneName}`)) relName = relName.slice(0, -(zoneName.length + 1)); if (relName === zoneName) relName = '@'; const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${record.type}/${relName}`; // Fetch existing record set so we can merge let existing = null; try { existing = await armFetch(path); } catch { // 404 means it doesn't exist yet — that's fine } 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, }; } function appendToRecordSet(props, type, content, priority) { 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`); } } /** * Updates a record in-place with a single GET → modify → PUT. * Falls back to a plain add if the record set no longer exists in Azure. * recordId format: "relName::type::oldContent" */ async function updateRecord(zoneId, recordId, record) { const { resourceGroup, zoneName } = decodeZoneId(zoneId); const sub = process.env.AZURE_SUBSCRIPTION_ID; const parts = recordId.split('::'); const oldRelName = parts[0]; const type = parts[1]; const oldContent = parts.slice(2).join('::'); // Derive the new relative name from the updated record let newRelName = record.name; if (newRelName.endsWith(`.${zoneName}`)) newRelName = newRelName.slice(0, -(zoneName.length + 1)); if (newRelName === zoneName) newRelName = '@'; const oldPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${oldRelName}`; const newPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${newRelName}`; // Try to fetch the existing record set let existing = null; try { existing = await armFetch(oldPath); } catch { /* doesn't exist in Azure */ } if (existing) { const props = existing.properties; // Remove old value and add new one removeFromRecordSet(props, type, oldContent); props.TTL = Number(record.ttl) || props.TTL || 3600; appendToRecordSet(props, type, record.content, record.priority); if (oldRelName === newRelName) { // Same name — just update in place await armFetch(oldPath, { method: 'PUT', body: JSON.stringify({ properties: props }) }); } else { // Name changed — delete old set, create new one await armFetch(oldPath, { method: 'DELETE' }); const newProps = { TTL: props.TTL }; appendToRecordSet(newProps, type, record.content, record.priority); await armFetch(newPath, { method: 'PUT', body: JSON.stringify({ properties: newProps }) }); } } else { // Record set gone from Azure — just add it fresh 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, }; } /** * Removes a single value from an Azure record set. * If the set becomes empty, the entire record set is deleted. * recordId format: "relName::type::content" */ async function deleteRecord(zoneId, recordId) { const { resourceGroup, zoneName } = decodeZoneId(zoneId); const sub = process.env.AZURE_SUBSCRIPTION_ID; const parts = recordId.split('::'); const relName = parts[0]; const type = parts[1]; const content = parts.slice(2).join('::'); // content may contain "::" const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${relName}`; // Fetch the record set and remove just this value const existing = await armFetch(path); const props = existing.properties; removeFromRecordSet(props, type, content); const remaining = countRecords(props, type); if (remaining === 0) { // Delete the entire record set await armFetch(path, { method: 'DELETE' }); } else { await armFetch(path, { method: 'PUT', body: JSON.stringify({ properties: props }), }); } } function removeFromRecordSet(props, type, content) { switch (type) { case 'A': props.ARecords = (props.ARecords ?? []).filter(r => r.ipv4Address !== content); break; case 'AAAA': props.AAAARecords = (props.AAAARecords ?? []).filter(r => r.ipv6Address !== content); break; case 'CNAME': props.CNAMERecord = null; break; case 'MX': props.MXRecords = (props.MXRecords ?? []).filter(r => r.exchange !== content); break; case 'NS': props.NSRecords = (props.NSRecords ?? []).filter(r => r.nsdname !== content); break; case 'TXT': props.TXTRecords = (props.TXTRecords ?? []).filter(r => r.value.join('') !== content); break; case 'PTR': props.PTRRecords = (props.PTRRecords ?? []).filter(r => r.ptrdname !== content); break; } } function countRecords(props, type) { 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; } } module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };