diff --git a/.gitignore b/.gitignore index f32b39d..8481219 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ build/ +*.tsbuildinfo .env data/*.sqlite data/*.sqlite-* diff --git a/package-lock.json b/package-lock.json index 25d9caa..c0568ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2074,6 +2074,16 @@ "@types/node": "*" } }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3723,6 +3733,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4718,6 +4737,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4743,6 +4784,7 @@ "express-session": "^1.18.1", "openid-client": "^6.1.7", "session-file-store": "^1.5.0", + "xml2js": "^0.6.2", "zod": "^3.24.1" }, "devDependencies": { @@ -4750,6 +4792,7 @@ "@types/express-session": "^1.18.1", "@types/node": "^22.10.5", "@types/session-file-store": "^1.2.5", + "@types/xml2js": "^0.4.14", "drizzle-kit": "^0.31.10", "tsx": "^4.19.2", "typescript": "^5.7.3" diff --git a/server/drizzle/0000_tricky_nocturne.sql b/server/drizzle/0000_colossal_violations.sql similarity index 87% rename from server/drizzle/0000_tricky_nocturne.sql rename to server/drizzle/0000_colossal_violations.sql index b46863e..1319f6b 100644 --- a/server/drizzle/0000_tricky_nocturne.sql +++ b/server/drizzle/0000_colossal_violations.sql @@ -25,16 +25,27 @@ CREATE TABLE `dns_providers` ( CREATE TABLE `dns_records_cache` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `provider_id` integer NOT NULL, - `zone` text NOT NULL, - `record_type` text NOT NULL, + `zone_id` text NOT NULL, + `record_id` text NOT NULL, + `type` text NOT NULL, `name` text NOT NULL, - `value` text NOT NULL, + `content` text NOT NULL, `ttl` integer, - `raw` text, - `synced_at` text DEFAULT (current_timestamp) NOT NULL, + `priority` integer, + `proxied` integer, FOREIGN KEY (`provider_id`) REFERENCES `dns_providers`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint +CREATE TABLE `dns_zones_cache` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `provider_id` integer NOT NULL, + `zone_id` text NOT NULL, + `zone_name` text NOT NULL, + `synced_at` text, + FOREIGN KEY (`provider_id`) REFERENCES `dns_providers`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `dns_zones_cache_provider_zone_idx` ON `dns_zones_cache` (`provider_id`,`zone_id`);--> statement-breakpoint CREATE TABLE `integration_credentials` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `name` text NOT NULL, diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json index 2fceebb..f323e0e 100644 --- a/server/drizzle/meta/0000_snapshot.json +++ b/server/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "1480fe39-d215-4e92-aee3-4de44905a67d", + "id": "19306643-f6f6-49dc-842a-ac2958e4d93a", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "audit_log": { @@ -184,15 +184,22 @@ "notNull": true, "autoincrement": false }, - "zone": { - "name": "zone", + "zone_id": { + "name": "zone_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "record_type": { - "name": "record_type", + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", "type": "text", "primaryKey": false, "notNull": true, @@ -205,8 +212,8 @@ "notNull": true, "autoincrement": false }, - "value": { - "name": "value", + "content": { + "name": "content", "type": "text", "primaryKey": false, "notNull": true, @@ -219,20 +226,19 @@ "notNull": false, "autoincrement": false }, - "raw": { - "name": "raw", - "type": "text", + "priority": { + "name": "priority", + "type": "integer", "primaryKey": false, "notNull": false, "autoincrement": false }, - "synced_at": { - "name": "synced_at", - "type": "text", + "proxied": { + "name": "proxied", + "type": "integer", "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "(current_timestamp)" + "notNull": false, + "autoincrement": false } }, "indexes": {}, @@ -255,6 +261,74 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "integration_credentials": { "name": "integration_credentials", "columns": { diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 2eca08e..d667c44 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1789415495200, - "tag": "0000_tricky_nocturne", + "when": 1789419241921, + "tag": "0000_colossal_violations", "breakpoints": true } ] diff --git a/server/package.json b/server/package.json index 81102da..74d59ae 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "express-session": "^1.18.1", "openid-client": "^6.1.7", "session-file-store": "^1.5.0", + "xml2js": "^0.6.2", "zod": "^3.24.1" }, "devDependencies": { @@ -24,6 +25,7 @@ "@types/express-session": "^1.18.1", "@types/node": "^22.10.5", "@types/session-file-store": "^1.2.5", + "@types/xml2js": "^0.4.14", "drizzle-kit": "^0.31.10", "tsx": "^4.19.2", "typescript": "^5.7.3" diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 6d1c492..4634bee 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; // ─── Users & roles ────────────────────────────────────────────────────────── @@ -119,20 +119,33 @@ export const dnsProviders = sqliteTable("dns_providers", { .default(sql`(current_timestamp)`), }); +export const dnsZonesCache = sqliteTable( + "dns_zones_cache", + { + id: integer("id").primaryKey({ autoIncrement: true }), + providerId: integer("provider_id") + .notNull() + .references(() => dnsProviders.id, { onDelete: "cascade" }), + zoneId: text("zone_id").notNull(), // provider-specific zone identifier + zoneName: text("zone_name").notNull(), + syncedAt: text("synced_at"), + }, + (table) => [uniqueIndex("dns_zones_cache_provider_zone_idx").on(table.providerId, table.zoneId)], +); + export const dnsRecordsCache = sqliteTable("dns_records_cache", { id: integer("id").primaryKey({ autoIncrement: true }), providerId: integer("provider_id") .notNull() .references(() => dnsProviders.id, { onDelete: "cascade" }), - zone: text("zone").notNull(), - recordType: text("record_type").notNull(), // A, AAAA, CNAME, TXT, MX, ... + zoneId: text("zone_id").notNull(), + recordId: text("record_id").notNull(), // provider-specific record identifier + type: text("type").notNull(), // A, AAAA, CNAME, TXT, MX, ... name: text("name").notNull(), - value: text("value").notNull(), + content: text("content").notNull(), ttl: integer("ttl"), - raw: text("raw"), // JSON: original provider payload for this record - syncedAt: text("synced_at") - .notNull() - .default(sql`(current_timestamp)`), + priority: integer("priority"), + proxied: integer("proxied", { mode: "boolean" }), }); // ─── Servers & scheduled tasks (ported from Schedule Task Manager) ───────── diff --git a/server/src/dns/adapters/azure.ts b/server/src/dns/adapters/azure.ts new file mode 100644 index 0000000..78d0d57 --- /dev/null +++ b/server/src/dns/adapters/azure.ts @@ -0,0 +1,330 @@ +/** + * 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"; + +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 { + 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 { + 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/adapters/cloudflare.ts b/server/src/dns/adapters/cloudflare.ts new file mode 100644 index 0000000..b09b30f --- /dev/null +++ b/server/src/dns/adapters/cloudflare.ts @@ -0,0 +1,98 @@ +/** + * Cloudflare adapter — uses the Cloudflare v4 REST API. + * Requires config: apiToken + */ +import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; + +const BASE = "https://api.cloudflare.com/client/v4"; + +export interface CloudflareConfig { + apiToken: string; +} + +export function createCloudflareAdapter(config: CloudflareConfig): DnsAdapter { + function headers() { + return { + Authorization: `Bearer ${config.apiToken}`, + "Content-Type": "application/json", + }; + } + + async function cfFetch(path: string, options: RequestInit = {}) { + const res = await fetch(`${BASE}${path}`, { ...options, headers: headers() }); + const data = await res.json(); + if (!data.success) { + const msg = data.errors?.map((e: { message: string }) => e.message).join(", ") || "Cloudflare API error"; + throw new Error(msg); + } + return data.result; + } + + async function listZones(): Promise { + let page = 1; + let allZones: any[] = []; + while (true) { + const result = await cfFetch(`/zones?per_page=50&page=${page}`); + allZones = allZones.concat(result); + if (result.length < 50) break; + page++; + } + return allZones.map((z) => ({ id: z.id, name: z.name })); + } + + async function listRecords(zoneId: string): Promise { + let page = 1; + let all: any[] = []; + while (true) { + const result = await cfFetch(`/zones/${zoneId}/dns_records?per_page=100&page=${page}`); + all = all.concat(result); + if (result.length < 100) break; + page++; + } + return all.map((r) => ({ + id: r.id, + type: r.type, + name: r.name, + content: r.content, + ttl: r.ttl, + priority: r.priority ?? null, + proxied: r.proxied ?? null, + })); + } + + function toBody(record: DnsRecordInput) { + const body: Record = { + type: record.type, + name: record.name, + content: record.content, + ttl: record.ttl || 1, + }; + if (record.priority !== undefined) body.priority = Number(record.priority); + if (record.proxied !== undefined) body.proxied = record.proxied; + return body; + } + + async function addRecord(zoneId: string, record: DnsRecordInput): Promise { + const body = toBody(record); + const result = await cfFetch(`/zones/${zoneId}/dns_records`, { + method: "POST", + body: JSON.stringify(body), + }); + return { id: result.id, ...record, ttl: record.ttl ?? null, priority: record.priority ?? null }; + } + + async function updateRecord(zoneId: string, recordId: string, record: DnsRecordInput): Promise { + const body = toBody(record); + const result = await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { + method: "PATCH", + body: JSON.stringify(body), + }); + return { id: result.id, ...record, ttl: record.ttl ?? null, priority: record.priority ?? null }; + } + + async function deleteRecord(zoneId: string, recordId: string): Promise { + await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" }); + } + + return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/adapters/cpanel.ts b/server/src/dns/adapters/cpanel.ts new file mode 100644 index 0000000..c192162 --- /dev/null +++ b/server/src/dns/adapters/cpanel.ts @@ -0,0 +1,277 @@ +/** + * cPanel adapter — uses the cPanel UAPI (user-level) and API 2 (for writes). + * Requires config: url, username, apiToken; optional: insecure (allow self-signed TLS) + * + * The cPanel account must own the domains being managed. API tokens are + * created under cPanel → Security → Manage API Tokens. + */ +import * as https from "node:https"; +import * as http from "node:http"; +import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; + +export interface CpanelConfig { + url: string; + username: string; + apiToken: string; + insecure?: boolean; +} + +interface RawResponse { + status: number; + text: () => string; +} + +function request(url: string, insecure: boolean, options: { method?: string; headers?: Record; body?: string } = {}): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === "https:" ? https : http; + + const req = lib.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers || {}, + rejectUnauthorized: !insecure, + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body })); + }, + ); + + req.on("error", reject); + if (options.body) req.write(options.body); + req.end(); + }); +} + +function parseResponse(res: RawResponse): any { + const text = res.text(); + if (text.trimStart().startsWith("<")) { + throw new Error( + `cPanel returned HTTP ${res.status} with an HTML page — check the URL, username, and API token. ` + + `If cPanel uses a self-signed certificate, enable "Allow self-signed certificate".`, + ); + } + const envelope = JSON.parse(text); + if (res.status >= 400) throw new Error(`cPanel HTTP ${res.status}`); + + if (envelope.message && !envelope.result) { + throw new Error(`cPanel blocked: ${envelope.message}`); + } + + const result = envelope.result ?? envelope; + const statusVal = result.status ?? result.result; + if (statusVal === 0 || statusVal === "0") { + const msg = result.errors?.join(", ") ?? result.error ?? result.message ?? "Unknown UAPI error"; + throw new Error(`cPanel UAPI error: ${msg}`); + } + return result.data; +} + +export function createCpanelAdapter(config: CpanelConfig): DnsAdapter { + const insecure = config.insecure === true; + + function base() { + return config.url.replace(/\/$/, ""); + } + + function authHeader() { + return `cpanel ${config.username}:${config.apiToken}`; + } + + async function uapiGet(module: string, func: string, params: Record = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${base()}/execute/${module}/${func}${qs ? "?" + qs : ""}`; + const res = await request(url, insecure, { headers: { Authorization: authHeader(), Accept: "application/json" } }); + return parseResponse(res); + } + + async function api2(func: string, params: Record = {}) { + const qs = new URLSearchParams({ + cpanel_jsonapi_module: "ZoneEdit", + cpanel_jsonapi_func: func, + cpanel_jsonapi_apiversion: "2", + ...params, + }).toString(); + const url = `${base()}/json-api/cpanel?${qs}`; + const res = await request(url, insecure, { headers: { Authorization: authHeader(), Accept: "application/json" } }); + const text = res.text(); + if (text.trimStart().startsWith("<")) throw new Error("cPanel returned HTML — check credentials"); + const data = JSON.parse(text); + const result = data?.cpanelresult?.data?.[0]; + if (result?.reason && result.reason !== "OK" && result?.status !== 1) { + throw new Error(`cPanel API 2 error: ${result.reason}`); + } + return result; + } + + async function listZones(): Promise { + let domains: string[] = []; + + try { + const data = await uapiGet("DNS", "list_zones"); + if (Array.isArray(data) && data.length > 0) { + const zones = data + .map((z: any) => { + const raw = typeof z === "string" ? z : (z.domain ?? z.zone ?? z.name ?? ""); + return raw.replace(/\.$/, ""); + }) + .filter(Boolean); + if (zones.length > 0) return zones.map((name: string) => ({ id: name, name })); + } + } catch { + // fall through + } + + try { + const info = await uapiGet("DomainInfo", "list_domains"); + if (info) { + const main = info.main_domain ? [info.main_domain] : []; + const addon = Array.isArray(info.addon_domains) ? info.addon_domains : []; + const parked = Array.isArray(info.parked_domains) ? info.parked_domains : []; + domains = [...new Set([...main, ...addon, ...parked])].filter(Boolean); + } + } catch { + // fall through + } + + if (domains.length === 0) { + try { + const data = await uapiGet("Zone", "listzone"); + if (Array.isArray(data)) { + domains = data + .map((z: any) => { + const raw = typeof z === "string" ? z : (z.domain ?? z.zone ?? z.name ?? ""); + return raw.replace(/\.$/, ""); + }) + .filter(Boolean); + } + } catch { + // no more fallbacks + } + } + + return domains.map((d) => ({ id: d, name: d })); + } + + function extractContent(r: any): string | null { + switch (r.type) { + case "A": + case "AAAA": + return r.address ?? null; + case "CNAME": + return (r.cname ?? "").replace(/\.$/, "") || null; + case "MX": + return (r.exchange ?? "").replace(/\.$/, "") || null; + case "TXT": + return Array.isArray(r.txtdata) ? r.txtdata.join("") : (r.txtdata ?? null); + case "NS": + return (r.nsdname ?? "").replace(/\.$/, "") || null; + case "PTR": + return (r.ptrdname ?? "").replace(/\.$/, "") || null; + default: + return null; + } + } + + async function listRecords(domain: string): Promise { + const qs = new URLSearchParams({ + cpanel_jsonapi_module: "ZoneEdit", + cpanel_jsonapi_func: "fetchzone_records", + cpanel_jsonapi_apiversion: "2", + domain, + }).toString(); + const url = `${base()}/json-api/cpanel?${qs}`; + const res = await request(url, insecure, { headers: { Authorization: authHeader(), Accept: "application/json" } }); + const envelope = JSON.parse(res.text()); + const records = envelope?.cpanelresult?.data ?? []; + + const SKIP_TYPES = new Set(["SOA", "NS", "$TTL", ":RAW"]); + + return records + .filter((r: any) => r.type && !SKIP_TYPES.has(r.type) && !r.type.startsWith("$") && !r.type.startsWith(":")) + .map((r: any) => { + const rawName = (r.name ?? "").replace(/\.$/, ""); + const name = rawName || domain; + const content = extractContent(r); + return { + id: `${r.Line}::${r.type}::${name}`, + type: r.type, + name, + content, + ttl: r.ttl ?? null, + priority: r.type === "MX" ? Number(r.preference) || null : null, + }; + }) + .filter((r: any) => r.content !== null); + } + + async function addRecord(domain: string, record: DnsRecordInput): Promise { + let name = record.name; + if (name.endsWith(`.${domain}`)) name = name.slice(0, -(domain.length + 1)); + if (name === domain) name = domain + "."; + + const params: Record = { domain, name, type: record.type, ttl: String(record.ttl || 3600) }; + + switch (record.type) { + case "A": + case "AAAA": + params.address = record.content; + break; + case "CNAME": + params.cname = record.content; + break; + case "MX": + params.exchange = record.content; + params.priority = String(record.priority ?? 10); + break; + case "TXT": + params.txtdata = record.content; + break; + case "NS": + params.nsdname = record.content; + break; + case "PTR": + params.ptrdname = record.content; + break; + default: + throw new Error(`Unsupported record type for cPanel add: ${record.type}`); + } + + await api2("add_zone_record", params); + + const all = await listRecords(domain); + const expectedName = name === domain + "." ? domain : `${name}.${domain}`; + const created = all.find((r) => r.type === record.type && r.content === record.content && r.name === expectedName); + + return ( + created ?? { + id: `new::${record.type}::${record.content}`, + type: record.type, + name: expectedName, + content: record.content, + ttl: record.ttl ?? 3600, + priority: record.priority ?? null, + } + ); + } + + async function updateRecord(domain: string, recordId: string, record: DnsRecordInput): Promise { + await deleteRecord(domain, recordId); + return addRecord(domain, record); + } + + async function deleteRecord(domain: string, recordId: string): Promise { + const lineIndex = parseInt(recordId.split("::")[0]); + await api2("remove_zone_record", { domain, line: String(lineIndex) }); + } + + return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/adapters/loopia.ts b/server/src/dns/adapters/loopia.ts new file mode 100644 index 0000000..28f5e99 --- /dev/null +++ b/server/src/dns/adapters/loopia.ts @@ -0,0 +1,183 @@ +/** + * Loopia adapter — uses the Loopia XML-RPC API. + * Requires config: username, password + * + * API docs: https://www.loopia.se/api/ + */ +import { parseStringPromise } from "xml2js"; +import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; + +const ENDPOINT = "https://api.loopia.se/RPCSERV"; + +export interface LoopiaConfig { + username: string; + password: string; +} + +function xmlEscape(s: string): string { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function valueXml(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "boolean") return `${v ? 1 : 0}`; + if (typeof v === "number") { + return Number.isInteger(v) ? `${v}` : `${v}`; + } + if (typeof v === "string") return `${xmlEscape(v)}`; + if (Array.isArray(v)) { + return `${v.map(valueXml).join("")}`; + } + if (typeof v === "object") { + const members = Object.entries(v as Record) + .map(([k, val]) => `${xmlEscape(k)}${valueXml(val)}`) + .join(""); + return `${members}`; + } + return `${xmlEscape(String(v))}`; +} + +function buildRequest(method: string, args: unknown[]): string { + const params = args.map((a) => `${valueXml(a)}`).join(""); + return `${method}${params}`; +} + +function parseValue(node: any): any { + if (node.string !== undefined) return Array.isArray(node.string) ? node.string[0] : node.string; + if (node.int !== undefined) return parseInt(Array.isArray(node.int) ? node.int[0] : node.int); + if (node.i4 !== undefined) return parseInt(Array.isArray(node.i4) ? node.i4[0] : node.i4); + if (node.boolean !== undefined) { + const b = Array.isArray(node.boolean) ? node.boolean[0] : node.boolean; + return b === "1" || b === 1 || b === true; + } + if (node.double !== undefined) return parseFloat(Array.isArray(node.double) ? node.double[0] : node.double); + if (node.array) { + const data = node.array[0]?.data?.[0]?.value ?? []; + return data.map(parseValue); + } + if (node.struct) { + const obj: Record = {}; + for (const member of node.struct[0].member ?? []) { + obj[member.name[0]] = parseValue(member.value[0]); + } + return obj; + } + const keys = Object.keys(node).filter((k) => k !== "_"); + if (keys.length > 0) { + const v = node[keys[0]]; + return Array.isArray(v) ? v[0] : v; + } + return node._ ?? null; +} + +async function xmlRpc(method: string, args: unknown[]): Promise { + const body = buildRequest(method, args); + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "text/xml; charset=utf-8" }, + body, + }); + + const text = await res.text(); + let parsed; + try { + parsed = await parseStringPromise(text, { explicitArray: true }); + } catch { + throw new Error(`Loopia returned invalid XML: ${text.slice(0, 300)}`); + } + + const fault = parsed?.methodResponse?.fault; + if (fault) { + const fv = parseValue(fault[0].value[0]); + throw new Error(`Loopia fault ${fv.faultCode}: ${fv.faultString}`); + } + + const param = parsed?.methodResponse?.params?.[0]?.param?.[0]?.value?.[0]; + const result = param ? parseValue(param) : null; + + if (typeof result === "string" && result !== "OK" && !/^\d+$/.test(result)) { + throw new Error(`Loopia returned status: ${result}`); + } + + return result; +} + +export function createLoopiaAdapter(config: LoopiaConfig): DnsAdapter { + function creds(): [string, string] { + return [config.username, config.password]; + } + + async function listZones(): Promise { + const domains = await xmlRpc("getDomains", creds()); + if (!Array.isArray(domains)) throw new Error(`getDomains returned unexpected value: ${JSON.stringify(domains)}`); + return domains.map((d: any) => ({ id: d.domain, name: d.domain })); + } + + async function listRecords(domain: string): Promise { + const subdomains = await xmlRpc("getSubdomains", [...creds(), domain]); + if (!Array.isArray(subdomains)) { + throw new Error(`getSubdomains returned unexpected value: ${JSON.stringify(subdomains)}`); + } + + const allRecords: DnsRecord[] = []; + for (const sub of subdomains) { + const records = await xmlRpc("getZoneRecords", [...creds(), domain, sub]); + if (!Array.isArray(records)) continue; + for (const r of records) { + allRecords.push({ + id: `${sub}::${r.record_id}`, + type: r.type, + name: sub === "@" ? domain : `${sub}.${domain}`, + content: r.rdata, + ttl: r.ttl, + priority: r.priority ?? null, + }); + } + } + return allRecords; + } + + async function addRecord(domain: string, record: DnsRecordInput): Promise { + let subdomain = record.name; + if (subdomain.endsWith(`.${domain}`)) subdomain = subdomain.slice(0, -(domain.length + 1)); + if (subdomain === domain || subdomain === "") subdomain = "@"; + + const entry = { + type: record.type, + ttl: Number(record.ttl) || 3600, + priority: Number(record.priority) || 0, + rdata: record.content, + }; + + await xmlRpc("addZoneRecord", [...creds(), domain, subdomain, entry]); + + const displayName = subdomain === "@" ? domain : `${subdomain}.${domain}`; + return { + id: `${subdomain}::new`, + type: record.type, + name: displayName, + content: record.content, + ttl: entry.ttl, + priority: entry.priority ?? null, + }; + } + + async function updateRecord(domain: string, recordId: string, record: DnsRecordInput): Promise { + await deleteRecord(domain, recordId); + return addRecord(domain, record); + } + + async function deleteRecord(domain: string, recordId: string): Promise { + const [subdomain, loopiaId] = recordId.split("::"); + await xmlRpc("removeZoneRecord", [...creds(), domain, subdomain, parseInt(loopiaId)]); + + if (subdomain !== "@") { + const remaining = await xmlRpc("getZoneRecords", [...creds(), domain, subdomain]); + if (!Array.isArray(remaining) || remaining.length === 0) { + await xmlRpc("removeSubdomain", [...creds(), domain, subdomain]); + } + } + } + + return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/adapters/pihole.ts b/server/src/dns/adapters/pihole.ts new file mode 100644 index 0000000..be8c8f7 --- /dev/null +++ b/server/src/dns/adapters/pihole.ts @@ -0,0 +1,142 @@ +/** + * Pi-hole v6 adapter — uses the Pi-hole v6 REST API. + * Supports custom A/AAAA records and CNAME records (Pi-hole local DNS). + * Requires config: url, password + * + * Note: Pi-hole local DNS has no zones or TTL — the whole instance is + * treated as a single zone. Record types are limited to A, AAAA, CNAME. + */ +import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; + +export interface PiholeConfig { + url: string; + password: string; +} + +export function createPiholeAdapter(config: PiholeConfig): DnsAdapter { + let cachedSid: string | null = null; + let sidExpiry = 0; + + function base() { + return config.url.replace(/\/$/, "") + "/api"; + } + + async function getSession(): Promise { + if (cachedSid && Date.now() < sidExpiry) return cachedSid; + + const res = await fetch(`${base()}/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: config.password }), + }); + + if (!res.ok) throw new Error(`Pi-hole auth failed: ${res.status} ${res.statusText}`); + const data = await res.json(); + + if (!data.session?.valid) { + throw new Error("Pi-hole authentication failed — check the web password"); + } + + cachedSid = data.session.sid; + sidExpiry = Date.now() + (data.session.validity ?? 300) * 1000 - 30_000; + return cachedSid!; + } + + async function phFetch(path: string, options: RequestInit = {}): Promise { + const sid = await getSession(); + const res = await fetch(`${base()}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "X-FTL-SID": sid, + ...(options.headers as Record | undefined), + }, + }); + + if (res.status === 401) { + cachedSid = null; + throw new Error("Pi-hole session expired — please retry"); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Pi-hole API error ${res.status}: ${text}`); + } + + if (res.status === 204) return null; + return res.json(); + } + + async function listZones(): Promise { + return [{ id: "local", name: config.url || "Pi-hole" }]; + } + + async function listRecords(_zoneId: string): Promise { + const [hostsData, cnameData] = await Promise.all([ + phFetch("/config/dns/hosts"), + phFetch("/config/dns/cnameRecords"), + ]); + + const records: DnsRecord[] = []; + + for (const entry of hostsData?.config?.dns?.hosts ?? []) { + const spaceIdx = entry.indexOf(" "); + if (spaceIdx === -1) continue; + const ip = entry.slice(0, spaceIdx).trim(); + const domain = entry.slice(spaceIdx + 1).trim(); + const type = ip.includes(":") ? "AAAA" : "A"; + records.push({ id: `dns::${domain}::${ip}`, type, name: domain, content: ip, ttl: null, priority: null }); + } + + for (const entry of cnameData?.config?.dns?.cnameRecords ?? []) { + const [alias, target] = String(entry).split(","); + if (!alias || !target) continue; + records.push({ + id: `cname::${alias.trim()}::${target.trim()}`, + type: "CNAME", + name: alias.trim(), + content: target.trim(), + ttl: null, + priority: null, + }); + } + + return records; + } + + async function addRecord(_zoneId: string, record: DnsRecordInput): Promise { + const type = record.type.toUpperCase(); + + if (type === "CNAME") { + const entry = encodeURIComponent(`${record.name},${record.content}`); + await phFetch(`/config/dns/cnameRecords/${entry}`, { method: "PUT" }); + return { id: `cname::${record.name}::${record.content}`, type: "CNAME", name: record.name, content: record.content, ttl: null, priority: null }; + } + + if (type === "A" || type === "AAAA") { + const entry = encodeURIComponent(`${record.content} ${record.name}`); + await phFetch(`/config/dns/hosts/${entry}`, { method: "PUT" }); + return { id: `dns::${record.name}::${record.content}`, type, name: record.name, content: record.content, ttl: null, priority: null }; + } + + throw new Error(`Pi-hole only supports A, AAAA, and CNAME records (got ${type})`); + } + + async function updateRecord(zoneId: string, recordId: string, record: DnsRecordInput): Promise { + await deleteRecord(zoneId, recordId); + return addRecord(zoneId, record); + } + + async function deleteRecord(_zoneId: string, recordId: string): Promise { + const [kind, part1, part2] = recordId.split("::"); + if (kind === "cname") { + const entry = encodeURIComponent(`${part1},${part2}`); + await phFetch(`/config/dns/cnameRecords/${entry}`, { method: "DELETE" }); + } else { + const entry = encodeURIComponent(`${part2} ${part1}`); + await phFetch(`/config/dns/hosts/${entry}`, { method: "DELETE" }); + } + } + + return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/adapters/technitium.ts b/server/src/dns/adapters/technitium.ts new file mode 100644 index 0000000..3d94349 --- /dev/null +++ b/server/src/dns/adapters/technitium.ts @@ -0,0 +1,152 @@ +/** + * Technitium DNS Server adapter + * Requires config: url, token + * + * API docs: https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md + * Record ID format: "type||name||content||priority" + */ +import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; + +export interface TechnitiumConfig { + url: string; + token: string; +} + +export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter { + function base() { + return config.url.replace(/\/$/, ""); + } + + function authHeader() { + return `Bearer ${config.token}`; + } + + function handleResponse(data: any) { + if (data.status === "invalid-token") throw new Error("Invalid or expired Technitium API token"); + if (data.status === "error") throw new Error(data.errorMessage ?? "Technitium API error"); + if (data.status !== "ok") throw new Error(`Unexpected Technitium status: ${data.status}`); + return data.response; + } + + async function apiGet(path: string, params: Record = {}) { + const qs = new URLSearchParams(params).toString(); + const res = await fetch(`${base()}${path}${qs ? "?" + qs : ""}`, { + headers: { Authorization: authHeader(), Accept: "application/json" }, + }); + return handleResponse(await res.json()); + } + + async function apiPost(path: string, params: Record = {}) { + const res = await fetch(`${base()}${path}`, { + method: "POST", + headers: { Authorization: authHeader(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams(params).toString(), + }); + return handleResponse(await res.json()); + } + + function extractContent(r: any): string | null { + const rd = r.rData ?? {}; + switch (r.type) { + case "A": + case "AAAA": + return rd.ipAddress ?? null; + case "CNAME": + return (rd.cname ?? "").replace(/\.$/, "") || null; + case "MX": + return (rd.exchange ?? "").replace(/\.$/, "") || null; + case "TXT": + return rd.text ?? null; + case "NS": + return (rd.nameServer ?? "").replace(/\.$/, "") || null; + case "PTR": + return (rd.ptrName ?? "").replace(/\.$/, "") || null; + default: + return null; + } + } + + function buildTypeParams(type: string, content: string, priority?: number): Record { + switch (type) { + case "A": + case "AAAA": + return { ipAddress: content }; + case "CNAME": + return { cname: content }; + case "MX": + return { exchange: content, preference: String(priority ?? 10) }; + case "TXT": + return { text: content }; + case "NS": + return { nameServer: content }; + case "PTR": + return { ptrName: content }; + default: + throw new Error(`Unsupported record type for Technitium: ${type}`); + } + } + + function makeId(type: string, name: string, content: string, priority?: number | null) { + return `${type}||${name}||${content}||${priority ?? ""}`; + } + + function parseId(recordId: string) { + const [type, name, content, priorityStr] = recordId.split("||"); + return { type, name, content, priority: priorityStr ? parseInt(priorityStr, 10) : undefined }; + } + + async function listZones(): Promise { + const resp = await apiGet("/api/zones/list"); + const zones = resp?.zones ?? []; + return zones.filter((z: any) => !z.internal).map((z: any) => ({ id: z.name, name: z.name })); + } + + async function listRecords(zone: string): Promise { + const resp = await apiGet("/api/zones/records/get", { domain: zone, listZone: "true" }); + const records = resp?.records ?? []; + const SKIP = new Set(["SOA", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "DS", "ANAME", "FWD", "APP"]); + + return records + .filter((r: any) => r.name && !SKIP.has(r.type)) + .map((r: any) => { + const content = extractContent(r); + if (content === null) return null; + const priority = r.type === "MX" ? (r.rData?.preference ?? 10) : null; + return { id: makeId(r.type, r.name, content, priority), type: r.type, name: r.name, content, ttl: r.ttl ?? 3600, priority }; + }) + .filter(Boolean) as DnsRecord[]; + } + + async function addRecord(zone: string, record: DnsRecordInput): Promise { + const typeParams = buildTypeParams(record.type, record.content, record.priority); + await apiPost("/api/zones/records/add", { + domain: record.name, + zone, + type: record.type, + ttl: String(record.ttl ?? 3600), + overwrite: "false", + ...typeParams, + }); + return { + id: makeId(record.type, record.name, record.content, record.priority), + type: record.type, + name: record.name, + content: record.content, + ttl: record.ttl ?? 3600, + priority: record.priority ?? null, + }; + } + + async function updateRecord(zone: string, recordId: string, record: DnsRecordInput): Promise { + await deleteRecord(zone, recordId); + return addRecord(zone, record); + } + + async function deleteRecord(zone: string, recordId: string): Promise { + const { type, name, content, priority } = parseId(recordId); + const typeParams = buildTypeParams(type, content, priority); + await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams }); + } + + return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; +} diff --git a/server/src/dns/loadProvider.ts b/server/src/dns/loadProvider.ts new file mode 100644 index 0000000..9fd82b7 --- /dev/null +++ b/server/src/dns/loadProvider.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { dnsProviders, integrationCredentials } from "../db/schema.js"; +import { decryptSecret } from "../crypto.js"; +import { createDnsAdapter } from "./registry.js"; +import type { DnsProviderConfig } from "./types.js"; + +export async function loadDnsProviderConfig(providerId: number) { + const [provider] = await db.select().from(dnsProviders).where(eq(dnsProviders.id, providerId)).limit(1); + if (!provider) return null; + + let secretFields: Record = {}; + if (provider.credentialId) { + const [cred] = await db + .select() + .from(integrationCredentials) + .where(eq(integrationCredentials.id, provider.credentialId)) + .limit(1); + if (cred) secretFields = JSON.parse(decryptSecret(cred.encryptedSecret)); + } + const nonSecretFields: Record = provider.config ? JSON.parse(provider.config) : {}; + const merged: DnsProviderConfig = { ...nonSecretFields, ...secretFields }; + + return { provider, config: merged }; +} + +export async function getDnsAdapterForProvider(providerId: number) { + const loaded = await loadDnsProviderConfig(providerId); + if (!loaded) return null; + return { provider: loaded.provider, adapter: createDnsAdapter(loaded.provider.providerType, loaded.config) }; +} diff --git a/server/src/dns/providerSchemas.ts b/server/src/dns/providerSchemas.ts new file mode 100644 index 0000000..7bbef6b --- /dev/null +++ b/server/src/dns/providerSchemas.ts @@ -0,0 +1,64 @@ +import type { DnsProviderType } from "../db/schema.js"; + +export interface ProviderField { + key: string; + label: string; + secret: boolean; + type?: "text" | "password" | "checkbox"; + placeholder?: string; +} + +export const DNS_PROVIDER_FIELDS: Record = { + cloudflare: [{ key: "apiToken", label: "API Token", secret: true, type: "password" }], + loopia: [ + { key: "username", label: "Username", secret: false }, + { key: "password", label: "Password", secret: true, type: "password" }, + ], + pihole: [ + { key: "url", label: "Pi-hole URL", secret: false, placeholder: "http://192.168.1.2" }, + { key: "password", label: "Web password", secret: true, type: "password" }, + ], + azure: [ + { key: "tenantId", label: "Tenant ID", secret: false }, + { key: "clientId", label: "Client (application) ID", secret: false }, + { key: "clientSecret", label: "Client secret", secret: true, type: "password" }, + { key: "subscriptionId", label: "Subscription ID", secret: false }, + ], + cpanel: [ + { key: "url", label: "cPanel URL", secret: false, placeholder: "https://host:2083" }, + { key: "username", label: "Username", secret: false }, + { key: "apiToken", label: "API Token", secret: true, type: "password" }, + { key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" }, + ], + technitium: [ + { key: "url", label: "Technitium URL", secret: false, placeholder: "http://192.168.1.1:5380" }, + { key: "token", label: "API Token", secret: true, type: "password" }, + ], +}; + +export function splitProviderConfig( + providerType: DnsProviderType, + input: Record, +): { secretFields: Record; nonSecretFields: Record } { + const fields = DNS_PROVIDER_FIELDS[providerType]; + const secretFields: Record = {}; + const nonSecretFields: Record = {}; + for (const field of fields) { + if (input[field.key] === undefined) continue; + if (field.secret) secretFields[field.key] = input[field.key]; + else nonSecretFields[field.key] = input[field.key]; + } + return { secretFields, nonSecretFields }; +} + +/** Returns the list of missing required field keys, or [] if the config is complete. */ +export function validateProviderConfig( + providerType: DnsProviderType, + merged: Record, +): string[] { + const fields = DNS_PROVIDER_FIELDS[providerType]; + return fields + .filter((f) => f.type !== "checkbox") + .filter((f) => merged[f.key] === undefined || merged[f.key] === "") + .map((f) => f.key); +} diff --git a/server/src/dns/registry.ts b/server/src/dns/registry.ts new file mode 100644 index 0000000..44c51de --- /dev/null +++ b/server/src/dns/registry.ts @@ -0,0 +1,25 @@ +import type { DnsProviderType } from "../db/schema.js"; +import type { DnsAdapter, DnsProviderConfig } from "./types.js"; +import { createCloudflareAdapter } from "./adapters/cloudflare.js"; +import { createLoopiaAdapter } from "./adapters/loopia.js"; +import { createPiholeAdapter } from "./adapters/pihole.js"; +import { createAzureAdapter } from "./adapters/azure.js"; +import { createCpanelAdapter } from "./adapters/cpanel.js"; +import { createTechnitiumAdapter } from "./adapters/technitium.js"; + +export function createDnsAdapter(providerType: DnsProviderType, config: DnsProviderConfig): DnsAdapter { + switch (providerType) { + case "cloudflare": + return createCloudflareAdapter(config as any); + case "loopia": + return createLoopiaAdapter(config as any); + case "pihole": + return createPiholeAdapter(config as any); + case "azure": + return createAzureAdapter(config as any); + case "cpanel": + return createCpanelAdapter(config as any); + case "technitium": + return createTechnitiumAdapter(config as any); + } +} diff --git a/server/src/dns/types.ts b/server/src/dns/types.ts new file mode 100644 index 0000000..f391a07 --- /dev/null +++ b/server/src/dns/types.ts @@ -0,0 +1,34 @@ +export interface DnsZone { + id: string; + name: string; +} + +export interface DnsRecord { + id: string; + type: string; + name: string; + content: string; + ttl: number | null; + priority: number | null; + proxied?: boolean | null; +} + +export interface DnsRecordInput { + type: string; + name: string; + content: string; + ttl?: number; + priority?: number; + proxied?: boolean; +} + +export interface DnsAdapter { + listZones(): Promise; + listRecords(zoneId: string): Promise; + addRecord(zoneId: string, record: DnsRecordInput): Promise; + updateRecord(zoneId: string, recordId: string, record: DnsRecordInput): Promise; + deleteRecord(zoneId: string, recordId: string): Promise; +} + +/** Combined, decrypted config passed to an adapter factory (secret + non-secret fields merged). */ +export type DnsProviderConfig = Record; diff --git a/server/src/index.ts b/server/src/index.ts index a15b5d4..c78bb50 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,7 @@ import { usersRouter } from "./routes/users.js"; import { auditLogRouter } from "./routes/auditLog.js"; import { secretsRouter } from "./routes/secrets.js"; import { ipamRouter } from "./routes/ipam.js"; +import { dnsRouter } from "./routes/dns.js"; warnIfAuthNotConfigured(); await runMigrations(); @@ -57,6 +58,7 @@ app.use("/api/users", usersRouter); app.use("/api/audit-log", auditLogRouter); app.use("/api/secrets", secretsRouter); app.use("/api/ipam", ipamRouter); +app.use("/api/dns", dnsRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts new file mode 100644 index 0000000..575a5ed --- /dev/null +++ b/server/src/routes/dns.ts @@ -0,0 +1,474 @@ +import { Router } from "express"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { dnsProviders, dnsProviderTypes, dnsZonesCache, dnsRecordsCache, integrationCredentials } from "../db/schema.js"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { encryptSecret } from "../crypto.js"; +import { + DNS_PROVIDER_FIELDS, + splitProviderConfig, + validateProviderConfig, +} from "../dns/providerSchemas.js"; +import { createDnsAdapter } from "../dns/registry.js"; +import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; + +export const dnsRouter = Router(); + +dnsRouter.use(requireAuth); + +// ─── Provider field schemas (for building the "add provider" form) ───────── + +dnsRouter.get("/provider-fields", (_req, res) => { + res.json({ fields: DNS_PROVIDER_FIELDS }); +}); + +// ─── Providers ─────────────────────────────────────────────────────────────── + +dnsRouter.get("/providers", async (_req, res) => { + const rows = await db + .select({ + id: dnsProviders.id, + providerType: dnsProviders.providerType, + name: dnsProviders.name, + enabled: dnsProviders.enabled, + createdAt: dnsProviders.createdAt, + }) + .from(dnsProviders); + res.json({ providers: rows }); +}); + +const configValueSchema = z.union([z.string(), z.boolean()]); + +const createProviderSchema = z.object({ + providerType: z.enum(dnsProviderTypes), + name: z.string().min(1).max(200), + config: z.record(configValueSchema), +}); + +dnsRouter.post("/providers", requireRole("admin"), async (req, res) => { + const parsed = createProviderSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + const { providerType, name, config } = parsed.data; + + const missing = validateProviderConfig(providerType, config); + if (missing.length > 0) { + return res.status(400).json({ error: "missing_fields", fields: missing }); + } + + const { secretFields, nonSecretFields } = splitProviderConfig(providerType, config); + + let credentialId: number | null = null; + if (Object.keys(secretFields).length > 0) { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${providerType}:${name}`, encryptedSecret: encryptSecret(JSON.stringify(secretFields)) }) + .returning(); + credentialId = cred.id; + } + + const [created] = await db + .insert(dnsProviders) + .values({ + providerType, + name, + credentialId, + config: JSON.stringify(nonSecretFields), + enabled: true, + }) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "create_provider", + targetType: "dns_provider", + targetId: created.id, + detail: { providerType, name }, + }); + + res.status(201).json({ + provider: { id: created.id, providerType: created.providerType, name: created.name, enabled: created.enabled, createdAt: created.createdAt }, + }); +}); + +const updateProviderSchema = z.object({ + name: z.string().min(1).max(200).optional(), + enabled: z.boolean().optional(), + config: z.record(configValueSchema).optional(), +}); + +dnsRouter.patch("/providers/:id", requireRole("admin"), async (req, res) => { + const id = Number(req.params.id); + const parsed = updateProviderSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const [existing] = await db.select().from(dnsProviders).where(eq(dnsProviders.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + let credentialId = existing.credentialId; + let configJson = existing.config; + + if (parsed.data.config) { + const loaded = await loadDnsProviderConfig(id); + const { secretFields, nonSecretFields } = splitProviderConfig(existing.providerType, parsed.data.config); + const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields }; + // Drop secret keys from the non-secret blob (they live in the credential row only) + for (const field of DNS_PROVIDER_FIELDS[existing.providerType]) { + if (field.secret) delete (mergedNonSecret as Record)[field.key]; + } + configJson = JSON.stringify(mergedNonSecret); + + if (Object.keys(secretFields).length > 0) { + const existingSecrets: Record = {}; + if (existing.credentialId) { + const [cred] = await db + .select() + .from(integrationCredentials) + .where(eq(integrationCredentials.id, existing.credentialId)) + .limit(1); + // existing secret fields were already merged into loaded.config; re-split to isolate them + if (cred) { + const fields = DNS_PROVIDER_FIELDS[existing.providerType].filter((f) => f.secret); + for (const f of fields) { + if (loaded?.config[f.key] !== undefined) existingSecrets[f.key] = loaded.config[f.key]!; + } + } + } + const mergedSecrets = { ...existingSecrets, ...secretFields }; + const encrypted = encryptSecret(JSON.stringify(mergedSecrets)); + if (existing.credentialId) { + await db + .update(integrationCredentials) + .set({ encryptedSecret: encrypted }) + .where(eq(integrationCredentials.id, existing.credentialId)); + } else { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${existing.providerType}:${existing.name}`, encryptedSecret: encrypted }) + .returning(); + credentialId = cred.id; + } + } + } + + const [updated] = await db + .update(dnsProviders) + .set({ + name: parsed.data.name ?? existing.name, + enabled: parsed.data.enabled ?? existing.enabled, + credentialId, + config: configJson, + }) + .where(eq(dnsProviders.id, id)) + .returning(); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "update_provider", + targetType: "dns_provider", + targetId: id, + detail: { name: updated.name }, + }); + + res.json({ + provider: { id: updated.id, providerType: updated.providerType, name: updated.name, enabled: updated.enabled, createdAt: updated.createdAt }, + }); +}); + +dnsRouter.delete("/providers/:id", requireRole("admin"), async (req, res) => { + const id = Number(req.params.id); + const [existing] = await db.select().from(dnsProviders).where(eq(dnsProviders.id, id)).limit(1); + if (!existing) { + return res.status(404).json({ error: "not_found" }); + } + + await db.delete(dnsProviders).where(eq(dnsProviders.id, id)); + if (existing.credentialId) { + await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId)); + } + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "delete_provider", + targetType: "dns_provider", + targetId: id, + detail: { name: existing.name }, + }); + + res.status(204).end(); +}); + +const testProviderSchema = z.object({ + providerType: z.enum(dnsProviderTypes), + config: z.record(configValueSchema), +}); + +dnsRouter.post("/providers/test", requireRole("admin"), async (req, res) => { + const parsed = testProviderSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + try { + const adapter = createDnsAdapter(parsed.data.providerType, parsed.data.config); + const zones = await adapter.listZones(); + res.json({ ok: true, zoneCount: zones.length }); + } catch (err) { + res.json({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } +}); + +// ─── Zones ─────────────────────────────────────────────────────────────────── + +dnsRouter.get("/providers/:id/zones", async (req, res) => { + const id = Number(req.params.id); + const found = await getDnsAdapterForProvider(id); + if (!found) return res.status(404).json({ error: "not_found" }); + if (!found.provider.enabled) return res.status(400).json({ error: "provider_disabled" }); + + try { + const zones = await found.adapter.listZones(); + const cacheRows = await db.select().from(dnsZonesCache).where(eq(dnsZonesCache.providerId, id)); + const cacheByZone = new Map(cacheRows.map((c) => [c.zoneId, c])); + + const recordCounts = new Map(); + const recordRows = await db.select().from(dnsRecordsCache).where(eq(dnsRecordsCache.providerId, id)); + for (const r of recordRows) { + recordCounts.set(r.zoneId, (recordCounts.get(r.zoneId) ?? 0) + 1); + } + + res.json({ + zones: zones.map((z) => ({ + ...z, + syncedAt: cacheByZone.get(z.id)?.syncedAt ?? null, + recordCount: recordCounts.get(z.id) ?? 0, + })), + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +}); + +// ─── Records (cached) ──────────────────────────────────────────────────────── + +dnsRouter.get("/providers/:id/zones/:zoneId/records", async (req, res) => { + const providerId = Number(req.params.id); + const zoneId = req.params.zoneId; + + const records = await db + .select() + .from(dnsRecordsCache) + .where(and(eq(dnsRecordsCache.providerId, providerId), eq(dnsRecordsCache.zoneId, zoneId))); + const [zoneCache] = await db + .select() + .from(dnsZonesCache) + .where(and(eq(dnsZonesCache.providerId, providerId), eq(dnsZonesCache.zoneId, zoneId))) + .limit(1); + + res.json({ records, syncedAt: zoneCache?.syncedAt ?? null }); +}); + +const syncBodySchema = z.object({ zoneName: z.string().optional() }); + +dnsRouter.post("/providers/:id/zones/:zoneId/sync", requireRole("operator"), async (req, res) => { + const providerId = Number(req.params.id); + const zoneId = req.params.zoneId; + const parsed = syncBodySchema.safeParse(req.body ?? {}); + const zoneName = parsed.success ? parsed.data.zoneName : undefined; + + const found = await getDnsAdapterForProvider(providerId); + if (!found) return res.status(404).json({ error: "not_found" }); + + try { + const records = await found.adapter.listRecords(zoneId); + const now = new Date().toISOString(); + + await db + .delete(dnsRecordsCache) + .where(and(eq(dnsRecordsCache.providerId, providerId), eq(dnsRecordsCache.zoneId, zoneId))); + if (records.length > 0) { + await db.insert(dnsRecordsCache).values( + records.map((r) => ({ + providerId, + zoneId, + recordId: r.id, + type: r.type, + name: r.name, + content: r.content, + ttl: r.ttl, + priority: r.priority, + proxied: r.proxied ?? null, + })), + ); + } + + const [existingZoneCache] = await db + .select() + .from(dnsZonesCache) + .where(and(eq(dnsZonesCache.providerId, providerId), eq(dnsZonesCache.zoneId, zoneId))) + .limit(1); + if (existingZoneCache) { + await db + .update(dnsZonesCache) + .set({ syncedAt: now, zoneName: zoneName ?? existingZoneCache.zoneName }) + .where(eq(dnsZonesCache.id, existingZoneCache.id)); + } else { + await db.insert(dnsZonesCache).values({ providerId, zoneId, zoneName: zoneName ?? zoneId, syncedAt: now }); + } + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "sync_zone", + targetType: "dns_zone", + targetId: `${providerId}:${zoneId}`, + detail: { recordCount: records.length }, + }); + + res.json({ records, syncedAt: now }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +}); + +const recordInputSchema = z.object({ + type: z.string().min(1), + name: z.string().min(1), + content: z.string().min(1), + ttl: z.number().optional(), + priority: z.number().optional(), + proxied: z.boolean().optional(), +}); + +dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"), async (req, res) => { + const providerId = Number(req.params.id); + const zoneId = req.params.zoneId; + const parsed = recordInputSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const found = await getDnsAdapterForProvider(providerId); + if (!found) return res.status(404).json({ error: "not_found" }); + + try { + const result = await found.adapter.addRecord(zoneId, parsed.data); + await db.insert(dnsRecordsCache).values({ + providerId, + zoneId, + recordId: result.id, + type: result.type, + name: result.name, + content: result.content, + ttl: result.ttl, + priority: result.priority, + proxied: result.proxied ?? null, + }); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "add_record", + targetType: "dns_record", + targetId: result.id, + detail: { providerId, zoneId, name: result.name, type: result.type }, + }); + + res.status(201).json({ record: result }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +}); + +dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { + const providerId = Number(req.params.id); + const { zoneId, recordId } = req.params; + const parsed = recordInputSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const found = await getDnsAdapterForProvider(providerId); + if (!found) return res.status(404).json({ error: "not_found" }); + + try { + const result = await found.adapter.updateRecord(zoneId, recordId, parsed.data); + + await db + .delete(dnsRecordsCache) + .where( + and( + eq(dnsRecordsCache.providerId, providerId), + eq(dnsRecordsCache.zoneId, zoneId), + eq(dnsRecordsCache.recordId, recordId), + ), + ); + await db.insert(dnsRecordsCache).values({ + providerId, + zoneId, + recordId: result.id, + type: result.type, + name: result.name, + content: result.content, + ttl: result.ttl, + priority: result.priority, + proxied: result.proxied ?? null, + }); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "update_record", + targetType: "dns_record", + targetId: result.id, + detail: { providerId, zoneId, name: result.name, type: result.type }, + }); + + res.json({ record: result }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +}); + +dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("operator"), async (req, res) => { + const providerId = Number(req.params.id); + const { zoneId, recordId } = req.params; + + const found = await getDnsAdapterForProvider(providerId); + if (!found) return res.status(404).json({ error: "not_found" }); + + try { + await found.adapter.deleteRecord(zoneId, recordId); + await db + .delete(dnsRecordsCache) + .where( + and( + eq(dnsRecordsCache.providerId, providerId), + eq(dnsRecordsCache.zoneId, zoneId), + eq(dnsRecordsCache.recordId, recordId), + ), + ); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "delete_record", + targetType: "dns_record", + targetId: recordId, + detail: { providerId, zoneId }, + }); + + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e4d9bbe..e157b0e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import Users from "./pages/Users"; import AuditLog from "./pages/AuditLog"; import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; +import Dns from "./pages/Dns"; import ComingSoon from "./pages/ComingSoon"; import AppShell from "./layout/AppShell"; @@ -57,7 +58,7 @@ export default function App() { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 892dcf7..dd7c17a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -76,6 +76,50 @@ export interface IpamInput { notes?: string; } +export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium"; + +export interface DnsProviderField { + key: string; + label: string; + secret: boolean; + type?: "text" | "password" | "checkbox"; + placeholder?: string; +} + +export interface DnsProviderSummary { + id: number; + providerType: DnsProviderType; + name: string; + enabled: boolean; + createdAt: string; +} + +export interface DnsZone { + id: string; + name: string; + syncedAt: string | null; + recordCount: number; +} + +export interface DnsRecord { + id: string; + type: string; + name: string; + content: string; + ttl: number | null; + priority: number | null; + proxied?: boolean | null; +} + +export interface DnsRecordInput { + type: string; + name: string; + content: string; + ttl?: number; + priority?: number; + proxied?: boolean; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -131,4 +175,56 @@ export const api = { request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/ipam/${id}`, { method: "DELETE" }), }, + dns: { + providerFields: () => + request<{ fields: Record }>("/api/dns/provider-fields"), + providers: { + list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"), + create: (data: { providerType: DnsProviderType; name: string; config: Record }) => + request<{ provider: DnsProviderSummary }>("/api/dns/providers", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: number, data: { name?: string; enabled?: boolean; config?: Record }) => + request<{ provider: DnsProviderSummary }>(`/api/dns/providers/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), + remove: (id: number) => request(`/api/dns/providers/${id}`, { method: "DELETE" }), + test: (data: { providerType: DnsProviderType; config: Record }) => + request<{ ok: boolean; zoneCount?: number; error?: string }>("/api/dns/providers/test", { + method: "POST", + body: JSON.stringify(data), + }), + }, + zones: { + list: (providerId: number) => request<{ zones: DnsZone[] }>(`/api/dns/providers/${providerId}/zones`), + }, + records: { + list: (providerId: number, zoneId: string) => + request<{ records: DnsRecord[]; syncedAt: string | null }>( + `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`, + ), + sync: (providerId: number, zoneId: string, zoneName?: string) => + request<{ records: DnsRecord[]; syncedAt: string }>( + `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/sync`, + { method: "POST", body: JSON.stringify({ zoneName }) }, + ), + create: (providerId: number, zoneId: string, data: DnsRecordInput) => + request<{ record: DnsRecord }>( + `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`, + { method: "POST", body: JSON.stringify(data) }, + ), + update: (providerId: number, zoneId: string, recordId: string, data: DnsRecordInput) => + request<{ record: DnsRecord }>( + `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`, + { method: "PUT", body: JSON.stringify(data) }, + ), + remove: (providerId: number, zoneId: string, recordId: string) => + request( + `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`, + { method: "DELETE" }, + ), + }, + }, }; diff --git a/web/src/components/DnsProviderForm.tsx b/web/src/components/DnsProviderForm.tsx new file mode 100644 index 0000000..4ab71c1 --- /dev/null +++ b/web/src/components/DnsProviderForm.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from "react"; +import { api, type DnsProviderField, type DnsProviderType } from "../api/client"; + +const PROVIDER_LABELS: Record = { + cloudflare: "Cloudflare", + loopia: "Loopia", + pihole: "Pi-hole", + azure: "Azure DNS", + cpanel: "cPanel", + technitium: "Technitium", +}; + +export default function DnsProviderForm({ + onCreated, + onCancel, +}: { + onCreated: () => void; + onCancel: () => void; +}) { + const [allFields, setAllFields] = useState | null>(null); + const [providerType, setProviderType] = useState("cloudflare"); + const [name, setName] = useState(""); + const [values, setValues] = useState>({}); + const [testResult, setTestResult] = useState<{ ok: boolean; zoneCount?: number; error?: string } | null>(null); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.dns.providerFields().then((res) => setAllFields(res.fields)); + }, []); + + useEffect(() => { + setValues({}); + setTestResult(null); + }, [providerType]); + + const fields = allFields?.[providerType] ?? []; + + function setField(key: string, value: string | boolean) { + setValues((v) => ({ ...v, [key]: value })); + } + + async function testConnection() { + setTesting(true); + setTestResult(null); + try { + const result = await api.dns.providers.test({ providerType, config: values }); + setTestResult(result); + } catch (err) { + setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } finally { + setTesting(false); + } + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSaving(true); + try { + await api.dns.providers.create({ providerType, name, config: values }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + return ( +
+
+

Add a DNS provider

+
+
+
+ {error && ( +
+
{error}
+
+ )} +
+ + +
+
+ + setName(e.target.value)} + /> +
+ + {fields.map((field) => ( +
+ {field.type === "checkbox" ? ( + + ) : ( + <> + + setField(field.key, e.target.value)} + /> + + )} +
+ ))} + + {testResult && ( +
+ {testResult.ok ? ( +
+ Connection OK — found {testResult.zoneCount} zone{testResult.zoneCount === 1 ? "" : "s"}. +
+ ) : ( +
Connection failed: {testResult.error}
+ )} +
+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx new file mode 100644 index 0000000..90186b7 --- /dev/null +++ b/web/src/pages/Dns.tsx @@ -0,0 +1,479 @@ +import { useEffect, useState } from "react"; +import { + api, + type CurrentUser, + type DnsProviderSummary, + type DnsProviderType, + type DnsRecord, + type DnsRecordInput, + type DnsZone, +} from "../api/client"; +import DnsProviderForm from "../components/DnsProviderForm"; + +const PROVIDER_LABELS: Record = { + cloudflare: "Cloudflare", + loopia: "Loopia", + pihole: "Pi-hole", + azure: "Azure DNS", + cpanel: "cPanel", + technitium: "Technitium", +}; + +const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined }; + +export default function Dns({ user }: { user: CurrentUser }) { + const isAdmin = user.role === "admin"; + const canEdit = user.role === "admin" || user.role === "operator"; + + const [providers, setProviders] = useState(null); + const [managingProviders, setManagingProviders] = useState(false); + const [selectedProviderId, setSelectedProviderId] = useState(null); + const [error, setError] = useState(null); + + const [zones, setZones] = useState(null); + const [zonesError, setZonesError] = useState(null); + const [selectedZone, setSelectedZone] = useState(null); + + const [records, setRecords] = useState(null); + const [syncedAt, setSyncedAt] = useState(null); + const [syncing, setSyncing] = useState(false); + + const [recordForm, setRecordForm] = useState(null); + const [editingRecordId, setEditingRecordId] = useState(null); + const [savingRecord, setSavingRecord] = useState(false); + + function loadProviders() { + api.dns.providers + .list() + .then((res) => { + setProviders(res.providers); + if (!selectedProviderId && res.providers.length > 0) setSelectedProviderId(res.providers[0].id); + }) + .catch((err) => setError(String(err))); + } + + useEffect(loadProviders, []); + + function loadZones(providerId: number) { + setZonesError(null); + setZones(null); + setSelectedZone(null); + setRecords(null); + api.dns.zones + .list(providerId) + .then((res) => setZones(res.zones)) + .catch((err) => setZonesError(err instanceof Error ? err.message : String(err))); + } + + useEffect(() => { + if (selectedProviderId && !managingProviders) loadZones(selectedProviderId); + }, [selectedProviderId, managingProviders]); + + function loadRecords(providerId: number, zone: DnsZone) { + api.dns.records + .list(providerId, zone.id) + .then((res) => { + setRecords(res.records); + setSyncedAt(res.syncedAt); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + } + + function selectZone(zone: DnsZone) { + setSelectedZone(zone); + setRecordForm(null); + setEditingRecordId(null); + if (selectedProviderId) loadRecords(selectedProviderId, zone); + } + + async function syncZone() { + if (!selectedProviderId || !selectedZone) return; + setSyncing(true); + setError(null); + try { + const res = await api.dns.records.sync(selectedProviderId, selectedZone.id, selectedZone.name); + setRecords(res.records); + setSyncedAt(res.syncedAt); + loadZones(selectedProviderId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSyncing(false); + } + } + + function startAddRecord() { + setEditingRecordId(null); + setRecordForm({ ...emptyRecordForm }); + } + + function startEditRecord(r: DnsRecord) { + setEditingRecordId(r.id); + setRecordForm({ + type: r.type, + name: r.name, + content: r.content, + ttl: r.ttl ?? undefined, + priority: r.priority ?? undefined, + proxied: r.proxied ?? undefined, + }); + } + + async function submitRecord(e: React.FormEvent) { + e.preventDefault(); + if (!selectedProviderId || !selectedZone || !recordForm) return; + setSavingRecord(true); + setError(null); + try { + if (editingRecordId) { + await api.dns.records.update(selectedProviderId, selectedZone.id, editingRecordId, recordForm); + } else { + await api.dns.records.create(selectedProviderId, selectedZone.id, recordForm); + } + setRecordForm(null); + setEditingRecordId(null); + loadRecords(selectedProviderId, selectedZone); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingRecord(false); + } + } + + async function removeRecord(r: DnsRecord) { + if (!selectedProviderId || !selectedZone) return; + if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return; + setError(null); + try { + await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id); + loadRecords(selectedProviderId, selectedZone); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function toggleProviderEnabled(p: DnsProviderSummary) { + try { + await api.dns.providers.update(p.id, { enabled: !p.enabled }); + loadProviders(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function removeProvider(p: DnsProviderSummary) { + if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return; + try { + await api.dns.providers.remove(p.id); + if (selectedProviderId === p.id) setSelectedProviderId(null); + loadProviders(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + const selectedProvider = providers?.find((p) => p.id === selectedProviderId) ?? null; + + return ( + <> +
+

DNS

+ {isAdmin && ( + + )} +
+ {error &&
{error}
} + + {managingProviders ? ( + <> + { + loadProviders(); + }} + onCancel={() => setManagingProviders(false)} + /> +
+
+ + + + + + + + + + + {providers?.map((p) => ( + + + + + + + ))} + {providers?.length === 0 && ( + + + + )} + +
NameProviderStatusActions
{p.name}{PROVIDER_LABELS[p.providerType]} + + + +
+ No DNS providers configured yet. +
+
+
+ + ) : ( + <> + {providers?.length === 0 ? ( +
+
+ No DNS providers configured yet. + {isAdmin && ( + <> + {" "} + + . + + )} +
+
+ ) : ( + <> +
+ +
+ +
+
+
+
+

Zones

+
+ {zonesError &&
{zonesError}
} +
+ + + + + + + + + + {zones?.map((z) => ( + selectZone(z)} + > + + + + + ))} + {zones && zones.length === 0 && ( + + + + )} + +
ZoneRecordsSynced
{z.name}{z.recordCount} + {z.syncedAt ? new Date(z.syncedAt).toLocaleString() : "never"} +
+ No zones found for this provider. +
+
+
+
+ +
+ {selectedZone ? ( +
+
+

{selectedZone.name}

+
+ {canEdit && ( + + )} + {canEdit && !recordForm && ( + + )} +
+
+ + {recordForm && ( +
+
+
+ + setRecordForm({ ...recordForm, type: e.target.value.toUpperCase() })} + /> + + {["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR"].map((t) => ( + +
+
+ + setRecordForm({ ...recordForm, name: e.target.value })} + /> +
+
+ + setRecordForm({ ...recordForm, content: e.target.value })} + /> +
+
+ + setRecordForm({ ...recordForm, ttl: Number(e.target.value) || undefined })} + /> +
+
+ + + setRecordForm({ ...recordForm, priority: e.target.value === "" ? undefined : Number(e.target.value) }) + } + /> +
+ {selectedProvider?.providerType === "cloudflare" && ( +
+ +
+ )} +
+
+ + +
+
+ )} + +
+ + + + + + + + {canEdit && } + + + + {records?.map((r) => ( + + + + + + {canEdit && ( + + )} + + ))} + {records?.length === 0 && ( + + + + )} + +
TypeNameContentTTLActions
+ {r.type} + {r.name}{r.content}{r.ttl ?? "—"} +
+ + +
+
+ No cached records.{" "} + {syncedAt === null ? "Sync this zone to fetch them." : ""} +
+
+
+ ) : ( +
+
Select a zone to view its records.
+
+ )} +
+
+ + )} + + )} + + ); +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo deleted file mode 100644 index 0324c22..0000000 --- a/web/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/layout/appshell.tsx","./src/pages/auditlog.tsx","./src/pages/comingsoon.tsx","./src/pages/dashboard.tsx","./src/pages/ipam.tsx","./src/pages/login.tsx","./src/pages/secrets.tsx","./src/pages/users.tsx","./src/utils/csv.ts"],"version":"5.9.3"} \ No newline at end of file