Added Technitium
This commit is contained in:
@@ -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 };
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user