initial commit
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user