diff --git a/backend/src/adapters/technitium.js b/backend/src/adapters/technitium.js new file mode 100644 index 0000000..095a3cb --- /dev/null +++ b/backend/src/adapters/technitium.js @@ -0,0 +1,182 @@ +/** + * Technitium DNS Server adapter + * + * Requires env: + * TECHNITIUM_URL Base URL of the Technitium instance, e.g. http://192.168.1.1:5380 + * TECHNITIUM_TOKEN API token created in Technitium → Administration → Sessions → Create Token + * + * API docs: https://github.com/TechnitiumSoftware/DnsServer/blob/master/APIDOCS.md + * + * Record ID format: "type||name||content||priority" + * The || separator is safe for DNS content (IPs, FQDNs, TXT values). + */ + +const diag = require('../diag'); + +function base() { + return (process.env.TECHNITIUM_URL || '').replace(/\/$/, ''); +} + +function authHeader() { + return `Bearer ${process.env.TECHNITIUM_TOKEN}`; +} + +// ─── HTTP helpers ───────────────────────────────────────────────────────────── + +async function apiGet(path, params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = `${base()}${path}${qs ? '?' + qs : ''}`; + const start = Date.now(); + let status = null; + try { + const res = await fetch(url, { headers: { Authorization: authHeader(), Accept: 'application/json' } }); + status = res.status; + const data = await res.json(); + diag.logRequest('technitium', path, 'GET', url, status, Date.now() - start); + return handleResponse(data); + } catch (err) { + diag.logRequest('technitium', path, 'GET', url, status, Date.now() - start, err.message); + throw err; + } +} + +async function apiPost(path, params = {}) { + const url = `${base()}${path}`; + const body = new URLSearchParams(params).toString(); + const start = Date.now(); + let status = null; + try { + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: authHeader(), 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body, + }); + status = res.status; + const data = await res.json(); + diag.logRequest('technitium', path, 'POST', url, status, Date.now() - start); + return handleResponse(data); + } catch (err) { + diag.logRequest('technitium', path, 'POST', url, status, Date.now() - start, err.message); + throw err; + } +} + +function handleResponse(data) { + 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; +} + +// ─── Record helpers ─────────────────────────────────────────────────────────── + +function extractContent(r) { + 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, content, priority) { + switch (type) { + case 'A': + case 'AAAA': return { ipAddress: content }; + case 'CNAME': return { cname: content }; + case 'MX': return { exchange: content, preference: 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, name, content, priority) { + return `${type}||${name}||${content}||${priority ?? ''}`; +} + +function parseId(recordId) { + const [type, name, content, priorityStr] = recordId.split('||'); + return { type, name, content, priority: priorityStr ? parseInt(priorityStr, 10) : null }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +async function listZones() { + const resp = await apiGet('/api/zones/list'); + const zones = resp?.zones ?? []; + // Skip internal system zones (localhost, *.arpa, etc.) + return zones + .filter(z => !z.internal) + .map(z => ({ id: z.name, name: z.name })); +} + +async function listRecords(zone) { + const resp = await apiGet('/api/zones/records/get', { zone, listZone: 'true' }); + const records = resp?.records ?? []; + + // Skip DNSSEC, SOA and proprietary Technitium record types + const SKIP = new Set(['SOA', 'DNSKEY', 'RRSIG', 'NSEC', 'NSEC3', 'NSEC3PARAM', 'DS', 'ANAME', 'FWD', 'APP']); + + return records + .filter(r => r.name && !SKIP.has(r.type)) + .map(r => { + 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); +} + +async function addRecord(zone, record) { + const typeParams = buildTypeParams(record.type, record.content, record.priority); + await apiPost('/api/zones/records/add', { + domain: record.name, + zone, + type: record.type, + ttl: 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, recordId, record) { + // Delete the old record then add the updated one (safest cross-version approach) + await deleteRecord(zone, recordId); + return addRecord(zone, record); +} + +async function deleteRecord(zone, recordId) { + const { type, name, content, priority } = parseId(recordId); + const typeParams = buildTypeParams(type, content, priority); + await apiPost('/api/zones/records/delete', { + domain: name, + zone, + type, + ...typeParams, + }); +} + +module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord }; diff --git a/backend/src/index.js b/backend/src/index.js index c632092..aaa1a9c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -85,6 +85,7 @@ app.get('/api/providers', requireAuth, (req, res) => { if (!disabled.has('pihole') && process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) providers.push({ id: 'pihole', name: 'Pi-hole', url: process.env.PIHOLE_URL }); if (!disabled.has('azure') && process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) providers.push({ id: 'azure', name: 'Azure DNS' }); if (!disabled.has('cpanel') && process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) providers.push({ id: 'cpanel', name: 'cPanel', url: process.env.CPANEL_URL }); + if (!disabled.has('technitium') && process.env.TECHNITIUM_URL && process.env.TECHNITIUM_TOKEN) providers.push({ id: 'technitium', name: 'Technitium', url: process.env.TECHNITIUM_URL }); res.json(providers); }); diff --git a/backend/src/routes/health.js b/backend/src/routes/health.js index f5721b4..ea78ac1 100644 --- a/backend/src/routes/health.js +++ b/backend/src/routes/health.js @@ -115,6 +115,18 @@ async function checkCpanel() { return Date.now() - start; } +async function checkTechnitium() { + const start = Date.now(); + const res = await fetch(`${process.env.TECHNITIUM_URL.replace(/\/$/, '')}/api/zones/list`, { + headers: { Authorization: `Bearer ${process.env.TECHNITIUM_TOKEN}`, Accept: 'application/json' }, + }); + const data = await res.json(); + if (data.status === 'invalid-token') throw new Error('Invalid or expired API token'); + if (data.status === 'error') throw new Error(data.errorMessage ?? 'API error'); + if (data.status !== 'ok') throw new Error(`Unexpected status: ${data.status}`); + return Date.now() - start; +} + const CHECKS = { cloudflare: { name: 'Cloudflare', fn: checkCloudflare, configured: () => !!process.env.CLOUDFLARE_API_TOKEN }, @@ -124,8 +136,10 @@ const CHECKS = { configured: () => !!(process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) }, azure: { name: 'Azure DNS', fn: checkAzure, configured: () => !!(process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) }, - cpanel: { name: 'cPanel', fn: checkCpanel, + cpanel: { name: 'cPanel', fn: checkCpanel, configured: () => !!(process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) }, + technitium: { name: 'Technitium', fn: checkTechnitium, + configured: () => !!(process.env.TECHNITIUM_URL && process.env.TECHNITIUM_TOKEN) }, }; // GET /api/health/providers diff --git a/backend/src/routes/records.js b/backend/src/routes/records.js index 5b72713..6ea60a7 100644 --- a/backend/src/routes/records.js +++ b/backend/src/routes/records.js @@ -4,12 +4,13 @@ const cloudflare = require('../adapters/cloudflare'); const loopia = require('../adapters/loopia'); const pihole = require('../adapters/pihole'); const azure = require('../adapters/azure'); -const cpanel = require('../adapters/cpanel'); -const db = require('../db'); -const notify = require('../notify'); -const audit = require('../audit'); +const cpanel = require('../adapters/cpanel'); +const technitium = require('../adapters/technitium'); +const db = require('../db'); +const notify = require('../notify'); +const audit = require('../audit'); -const adapters = { cloudflare, loopia, pihole, azure, cpanel }; +const adapters = { cloudflare, loopia, pihole, azure, cpanel, technitium }; // GET /api/records/:provider/:zone // Returns cached records from the local DB. If the zone has never been diff --git a/backend/src/routes/zones.js b/backend/src/routes/zones.js index af23e26..546b364 100644 --- a/backend/src/routes/zones.js +++ b/backend/src/routes/zones.js @@ -3,10 +3,11 @@ const router = express.Router(); const cloudflare = require('../adapters/cloudflare'); const loopia = require('../adapters/loopia'); const pihole = require('../adapters/pihole'); -const azure = require('../adapters/azure'); -const cpanel = require('../adapters/cpanel'); +const azure = require('../adapters/azure'); +const cpanel = require('../adapters/cpanel'); +const technitium = require('../adapters/technitium'); -const adapters = { cloudflare, loopia, pihole, azure, cpanel }; +const adapters = { cloudflare, loopia, pihole, azure, cpanel, technitium }; // GET /api/zones/:provider router.get('/:provider', async (req, res) => { diff --git a/backend/src/settings.js b/backend/src/settings.js index 1e11e79..66761fe 100644 --- a/backend/src/settings.js +++ b/backend/src/settings.js @@ -41,11 +41,12 @@ const DEFAULTS = { secret: '', }, providerColors: { - cloudflare: '#f6821f', - loopia: '#2ecc71', - pihole: '#96060c', - azure: '#0078d4', - cpanel: '#ff6c2c', + cloudflare: '#f6821f', + loopia: '#2ecc71', + pihole: '#96060c', + azure: '#0078d4', + cpanel: '#ff6c2c', + technitium: '#26a69a', }, }; diff --git a/frontend/src/components/SettingsPage.js b/frontend/src/components/SettingsPage.js index 8621f9d..66ae8ab 100644 --- a/frontend/src/components/SettingsPage.js +++ b/frontend/src/components/SettingsPage.js @@ -489,11 +489,12 @@ function CacheTab() { // ─── Providers tab ─────────────────────────────────────────────────────────── const PROVIDER_NAMES = { - cloudflare: 'Cloudflare', - loopia: 'Loopia', - pihole: 'Pi-hole', - azure: 'Azure DNS', - cpanel: 'cPanel', + cloudflare: 'Cloudflare', + loopia: 'Loopia', + pihole: 'Pi-hole', + azure: 'Azure DNS', + cpanel: 'cPanel', + technitium: 'Technitium', }; function ProvidersTab() {