Sloth Manager tracked every API call made to DNS providers for connectivity troubleshooting. Port that here, generalized to cover every outbound integration this app makes, not just DNS -- Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up too, since a broken API token or unreachable host on any of them is just as worth diagnosing. New services/diagLog.ts: a generic withDiagLogging(source, adapter) wraps every async method of any adapter object with timing + success/failure recording, without touching a single adapter's request/error-handling internals -- every DNS and integration adapter interface here is already just a flat set of async methods, so this one wrapper works for all twelve of them. Applied it at each adapter factory's own return statement (one line each) rather than at the route layer, so background jobs that construct adapters directly (the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox lookups) get logged too, not just requests through routes/integrations.ts. New diag_log table (ring-buffered to the last 500 rows, mirroring Sloth Manager's approach -- this is for live troubleshooting, not a durable record) and admin-only GET/DELETE /api/diag-log routes, source/ result filters, pagination. New admin-only Diagnostic Log page: filterable, paginated table with a Clear button. Also introduces the shared useSortable hook + SortableTh component used here for the first time -- a follow-up commit applies the same sorting (and CSV export) to the rest of the app's tables, per the same request. Verified end-to-end against a temp SQLite DB with real migrations: a fake wrapped adapter's successful and failing calls both land correctly in the log with the right source/operation/latency/error, and the source/ok filters and clear-log operation all behave correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<any> {
|
|
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<string, string> | 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<DnsZone[]> {
|
|
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<DnsRecord[]> {
|
|
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<DnsRecord> {
|
|
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<DnsRecord> {
|
|
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<void> {
|
|
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 });
|
|
}
|