From 39b1d1fe2e73d85bc388da327ac5a39f0fd552d1 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Mon, 14 Sep 2026 23:00:30 +0200 Subject: [PATCH] Add DNS module ported from Sloth Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports zone/record management across Cloudflare, Loopia, Pi-hole, Azure DNS, cPanel, and Technitium onto the new stack. Unlike the original (one instance per provider configured via env vars), providers are now configured through the UI and support multiple named instances per type, with API credentials encrypted at rest via the integration_credentials table. - server/src/dns/adapters/*: each provider ported to a config-based factory (no more process.env reads), preserving each provider's original quirks (Pi-hole session auth, Azure record-set merging, Loopia XML-RPC, cPanel UAPI/API2 fallbacks, Technitium composite record IDs). - server/src/routes/dns.ts: provider CRUD (admin), a "test connection" endpoint, and zone/record browsing+sync+CRUD (operator+), all audit-logged. - dns_zones_cache/dns_records_cache tables replace Sloth Manager's dns-cache.json file, keeping the same "cache is the source of truth for display, sync fetches fresh from the provider" behavior. - web: a DNS page with provider management, zone browsing, and a record editor, plus a reusable dynamic provider-config form. Verified: full build (tsc + vite) passes; a scripted HTTP-layer test against a running server exercises auth, role gating (403 for viewer), validation (400 on missing config fields), provider CRUD, credential non-leakage in list responses, and adapter error propagation (502 against an unreachable host) — all passing. Real provider connectivity still needs to be checked against the user's actual DNS accounts. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + package-lock.json | 43 ++ ...turne.sql => 0000_colossal_violations.sql} | 21 +- server/drizzle/meta/0000_snapshot.json | 106 +++- server/drizzle/meta/_journal.json | 4 +- server/package.json | 2 + server/src/db/schema.ts | 29 +- server/src/dns/adapters/azure.ts | 330 ++++++++++++ server/src/dns/adapters/cloudflare.ts | 98 ++++ server/src/dns/adapters/cpanel.ts | 277 ++++++++++ server/src/dns/adapters/loopia.ts | 183 +++++++ server/src/dns/adapters/pihole.ts | 142 ++++++ server/src/dns/adapters/technitium.ts | 152 ++++++ server/src/dns/loadProvider.ts | 31 ++ server/src/dns/providerSchemas.ts | 64 +++ server/src/dns/registry.ts | 25 + server/src/dns/types.ts | 34 ++ server/src/index.ts | 2 + server/src/routes/dns.ts | 474 +++++++++++++++++ web/src/App.tsx | 3 +- web/src/api/client.ts | 96 ++++ web/src/components/DnsProviderForm.tsx | 162 ++++++ web/src/pages/Dns.tsx | 479 ++++++++++++++++++ web/tsconfig.tsbuildinfo | 1 - 24 files changed, 2726 insertions(+), 33 deletions(-) rename server/drizzle/{0000_tricky_nocturne.sql => 0000_colossal_violations.sql} (87%) create mode 100644 server/src/dns/adapters/azure.ts create mode 100644 server/src/dns/adapters/cloudflare.ts create mode 100644 server/src/dns/adapters/cpanel.ts create mode 100644 server/src/dns/adapters/loopia.ts create mode 100644 server/src/dns/adapters/pihole.ts create mode 100644 server/src/dns/adapters/technitium.ts create mode 100644 server/src/dns/loadProvider.ts create mode 100644 server/src/dns/providerSchemas.ts create mode 100644 server/src/dns/registry.ts create mode 100644 server/src/dns/types.ts create mode 100644 server/src/routes/dns.ts create mode 100644 web/src/components/DnsProviderForm.tsx create mode 100644 web/src/pages/Dns.tsx delete mode 100644 web/tsconfig.tsbuildinfo 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