Added Technitium

This commit is contained in:
2026-07-13 23:54:59 +02:00
parent 5304fa4e18
commit e8a3d44ec0
7 changed files with 220 additions and 19 deletions
+182
View File
@@ -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 };
+1
View File
@@ -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('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('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('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); res.json(providers);
}); });
+15 -1
View File
@@ -115,6 +115,18 @@ async function checkCpanel() {
return Date.now() - start; 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 = { const CHECKS = {
cloudflare: { name: 'Cloudflare', fn: checkCloudflare, cloudflare: { name: 'Cloudflare', fn: checkCloudflare,
configured: () => !!process.env.CLOUDFLARE_API_TOKEN }, configured: () => !!process.env.CLOUDFLARE_API_TOKEN },
@@ -124,8 +136,10 @@ const CHECKS = {
configured: () => !!(process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) }, configured: () => !!(process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) },
azure: { name: 'Azure DNS', fn: checkAzure, 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) }, 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) }, 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 // GET /api/health/providers
+6 -5
View File
@@ -4,12 +4,13 @@ const cloudflare = require('../adapters/cloudflare');
const loopia = require('../adapters/loopia'); const loopia = require('../adapters/loopia');
const pihole = require('../adapters/pihole'); const pihole = require('../adapters/pihole');
const azure = require('../adapters/azure'); const azure = require('../adapters/azure');
const cpanel = require('../adapters/cpanel'); const cpanel = require('../adapters/cpanel');
const db = require('../db'); const technitium = require('../adapters/technitium');
const notify = require('../notify'); const db = require('../db');
const audit = require('../audit'); 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 // GET /api/records/:provider/:zone
// Returns cached records from the local DB. If the zone has never been // Returns cached records from the local DB. If the zone has never been
+4 -3
View File
@@ -3,10 +3,11 @@ const router = express.Router();
const cloudflare = require('../adapters/cloudflare'); const cloudflare = require('../adapters/cloudflare');
const loopia = require('../adapters/loopia'); const loopia = require('../adapters/loopia');
const pihole = require('../adapters/pihole'); const pihole = require('../adapters/pihole');
const azure = require('../adapters/azure'); const azure = require('../adapters/azure');
const cpanel = require('../adapters/cpanel'); 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 // GET /api/zones/:provider
router.get('/:provider', async (req, res) => { router.get('/:provider', async (req, res) => {
+6 -5
View File
@@ -41,11 +41,12 @@ const DEFAULTS = {
secret: '', secret: '',
}, },
providerColors: { providerColors: {
cloudflare: '#f6821f', cloudflare: '#f6821f',
loopia: '#2ecc71', loopia: '#2ecc71',
pihole: '#96060c', pihole: '#96060c',
azure: '#0078d4', azure: '#0078d4',
cpanel: '#ff6c2c', cpanel: '#ff6c2c',
technitium: '#26a69a',
}, },
}; };
+6 -5
View File
@@ -489,11 +489,12 @@ function CacheTab() {
// ─── Providers tab ─────────────────────────────────────────────────────────── // ─── Providers tab ───────────────────────────────────────────────────────────
const PROVIDER_NAMES = { const PROVIDER_NAMES = {
cloudflare: 'Cloudflare', cloudflare: 'Cloudflare',
loopia: 'Loopia', loopia: 'Loopia',
pihole: 'Pi-hole', pihole: 'Pi-hole',
azure: 'Azure DNS', azure: 'Azure DNS',
cpanel: 'cPanel', cpanel: 'cPanel',
technitium: 'Technitium',
}; };
function ProvidersTab() { function ProvidersTab() {