Add DNS module ported from Sloth Manager
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
89919fdbff
commit
39b1d1fe2e
24 files changed
+2726
-33
No files matched your search
@@ -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<string, unknown>)[field.key];
|
||||
}
|
||||
configJson = JSON.stringify(mergedNonSecret);
|
||||
|
||||
if (Object.keys(secretFields).length > 0) {
|
||||
const existingSecrets: Record<string, string | boolean> = {};
|
||||
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<string, number>();
|
||||
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) });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user