Files
sloth-manager/backend/src/adapters/azure.js
T
2026-06-02 01:00:27 +02:00

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 };