348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
/**
|
|
* 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 };
|