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