DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Technitium DNS Server Provider Adapter
|
||||
*
|
||||
* Wraps Technitium-specific DNS logic into the standard adapter interface.
|
||||
* Uses the Technitium HTTP API (default port 5380) for all operations.
|
||||
*/
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24-hour token lifetime
|
||||
|
||||
class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
this.providerId = 'technitium';
|
||||
this.displayName = 'Technitium DNS Server';
|
||||
|
||||
this.serverIp = config.serverIp;
|
||||
this.serverPort = config.serverPort || 5380;
|
||||
this.dnsId = config.dnsId || null;
|
||||
|
||||
// Token state
|
||||
this.token = null;
|
||||
this.tokenExpiry = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static CAPABILITIES = [
|
||||
'create-record',
|
||||
'delete-record',
|
||||
'resolve',
|
||||
'list-records',
|
||||
'logs',
|
||||
'restart',
|
||||
'update-check',
|
||||
'credentials',
|
||||
'zones'
|
||||
];
|
||||
|
||||
supportsCapability(cap) {
|
||||
return TechnitiumDNSProvider.CAPABILITIES.includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return [...TechnitiumDNSProvider.CAPABILITIES];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build the base URL for this server */
|
||||
_baseUrl() {
|
||||
return `http://${this.serverIp}:${this.serverPort}`;
|
||||
}
|
||||
|
||||
/** Build a full API URL with query-string params */
|
||||
_buildUrl(apiPath, params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return `${this._baseUrl()}${apiPath}${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
|
||||
/** Ensure we have a valid token; throws on failure */
|
||||
async _requireToken() {
|
||||
// Re-use existing token if still valid
|
||||
if (this.token && this.tokenExpiry && new Date() < new Date(this.tokenExpiry)) {
|
||||
return this.token;
|
||||
}
|
||||
const result = await this.authenticate();
|
||||
if (!result.success) {
|
||||
const err = new Error('No valid DNS token available. ' + (result.error || ''));
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Authenticate against the Technitium server.
|
||||
* Checks per-server credentials first (dns.{dnsId}.readonly.username),
|
||||
* then falls back to global credentials (dns.username).
|
||||
*
|
||||
* Stores token + expiry on success.
|
||||
*/
|
||||
async authenticate() {
|
||||
const { credentialManager, log } = this.ctx;
|
||||
|
||||
// Try per-server credentials first
|
||||
if (this.dnsId) {
|
||||
for (const role of ['readonly', 'admin']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.password`);
|
||||
if (username && password) {
|
||||
const result = await this._doLogin(username, password);
|
||||
if (result.success) return result;
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', `Per-server ${role} credential error`, {
|
||||
dnsId: this.dnsId,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global credentials
|
||||
try {
|
||||
const username = await credentialManager.retrieve('dns.username');
|
||||
const password = await credentialManager.retrieve('dns.password');
|
||||
if (username && password) {
|
||||
return await this._doLogin(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', 'Global credential error', { error: err.message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual login POST to Technitium.
|
||||
* Stores token on success.
|
||||
*/
|
||||
async _doLogin(username, password) {
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
user: username,
|
||||
pass: password,
|
||||
includeInfo: 'false'
|
||||
});
|
||||
|
||||
const url = `${this._baseUrl()}/api/user/login?${params.toString()}`;
|
||||
const response = await fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok' && result.token) {
|
||||
this.token = result.token;
|
||||
this.tokenExpiry = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
log.info('technitium', 'DNS token obtained', {
|
||||
server: this.serverIp,
|
||||
expires: this.tokenExpiry
|
||||
});
|
||||
return { success: true, token: this.token };
|
||||
}
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Login error', { error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create (or overwrite) a DNS record.
|
||||
* GET /api/zones/records/add?token=...&domain=...&zone=...&type=...&ipAddress=...&ttl=...&overwrite=...
|
||||
*/
|
||||
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
zone,
|
||||
type: type || 'A',
|
||||
ipAddress: value,
|
||||
ttl: String(ttl || 300),
|
||||
overwrite: String(overwrite !== false)
|
||||
};
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Creating DNS record', { domain, type, value });
|
||||
const url = this._buildUrl('/api/zones/records/add', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS record created', { domain, type, value });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// If token expired, re-authenticate and retry once
|
||||
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
|
||||
log.info('technitium', 'Token expired, re-authenticating');
|
||||
this.token = null;
|
||||
this.tokenExpiry = null;
|
||||
const retryToken = await this._requireToken();
|
||||
params.token = retryToken;
|
||||
const retryUrl = this._buildUrl('/api/zones/records/add', params);
|
||||
const retryResp = await fetchT(retryUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const retryResult = await retryResp.json();
|
||||
if (retryResult.status === 'ok') {
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(retryResult.errorMessage || 'Failed after token refresh');
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Unknown error');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS record.
|
||||
* GET /api/zones/records/delete?token=...&domain=...&type=... (+ ipAddress if value provided)
|
||||
*/
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
type: type || 'A'
|
||||
};
|
||||
if (value) {
|
||||
params.ipAddress = value;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Deleting DNS record', { domain, type, value });
|
||||
const url = this._buildUrl('/api/zones/records/delete', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS record deleted', { domain, type, value });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Unknown error');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete DNS record for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve/query records for a domain in a zone.
|
||||
* GET /api/zones/records/get?token=...&domain=...&zone=...&listZone=true
|
||||
* Filters returned records by type if provided.
|
||||
*/
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const params = {
|
||||
token,
|
||||
domain,
|
||||
zone,
|
||||
listZone: 'true'
|
||||
};
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Resolving records', { domain, zone, type });
|
||||
const url = this._buildUrl('/api/zones/records/get', params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
throw new Error(result.errorMessage || 'Failed to resolve records');
|
||||
}
|
||||
|
||||
let records = (result.response && result.response.records) || [];
|
||||
|
||||
// Filter by type if specified
|
||||
if (type) {
|
||||
records = records.filter(r => r.type === type);
|
||||
}
|
||||
|
||||
return { success: true, records };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to resolve records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all records in a zone.
|
||||
* Delegates to resolveRecords with a wildcard domain.
|
||||
*/
|
||||
async listRecords({ zone }) {
|
||||
return this.resolveRecords({ domain: zone, zone, type: null });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch and parse DNS query logs.
|
||||
* 1. GET /api/logs/list to discover the latest log file
|
||||
* 2. GET /api/logs/download?token=...&fileName=... to download it
|
||||
* 3. Parse text format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
|
||||
*/
|
||||
async getLogs({ limit, server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
const targetIp = server || this.serverIp;
|
||||
const targetPort = this.serverPort;
|
||||
const baseUrl = `http://${targetIp}:${targetPort}`;
|
||||
|
||||
try {
|
||||
// Step 1: Get log file list
|
||||
const listUrl = this._buildUrl('/api/logs/list', { token });
|
||||
const listResp = await fetchT(listUrl.replace(this._baseUrl(), baseUrl), {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const listResult = await listResp.json();
|
||||
|
||||
if (listResult.status !== 'ok' || !listResult.response || !listResult.response.length) {
|
||||
throw new Error(listResult.errorMessage || 'No log files found');
|
||||
}
|
||||
|
||||
// Pick the latest log file (last entry)
|
||||
const logFile = listResult.response[listResult.response.length - 1];
|
||||
const fileName = logFile.name || logFile.fileName || logFile;
|
||||
|
||||
// Step 2: Download the log file
|
||||
const downloadUrl = `${baseUrl}/api/logs/download?${new URLSearchParams({ token, fileName }).toString()}`;
|
||||
const downloadResp = await fetchT(downloadUrl, {
|
||||
method: 'GET'
|
||||
});
|
||||
const logText = await downloadResp.text();
|
||||
|
||||
// Step 3: Parse lines
|
||||
const parsed = this._parseLogText(logText, limit);
|
||||
return { success: true, logs: parsed };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
|
||||
throw new Error(`Failed to get DNS logs: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Technitium DNS log text format.
|
||||
* Line format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
|
||||
*/
|
||||
_parseLogText(text, limit) {
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
const parsed = [];
|
||||
|
||||
// Process newest first if we need to limit
|
||||
const iterable = limit ? lines.slice(-limit).reverse() : lines;
|
||||
|
||||
for (const line of iterable) {
|
||||
try {
|
||||
const entry = {};
|
||||
|
||||
// Extract timestamp: [2024-01-15 10:30:45]
|
||||
const tsMatch = line.match(/\[([^\]]+)\]/);
|
||||
if (tsMatch) entry.timestamp = tsMatch[1];
|
||||
|
||||
// Extract client:port: [192.168.1.100:12345]
|
||||
const clientMatch = line.match(/\[([^\]]+:\d+)\]/g);
|
||||
if (clientMatch && clientMatch.length >= 2) {
|
||||
entry.client = clientMatch[1].replace(/\[|\]/g, '');
|
||||
}
|
||||
|
||||
// Extract protocol: [UDP] or [TCP]
|
||||
const protoMatch = line.match(/\]\s*\[(UDP|TCP|DoH|DoT|DoH2)\]/i);
|
||||
if (protoMatch) entry.protocol = protoMatch[1];
|
||||
|
||||
// Extract key-value pairs: QNAME: value; QTYPE: value; etc.
|
||||
const kvPattern = /(\w+):\s*([^;]+)/g;
|
||||
let match;
|
||||
while ((match = kvPattern.exec(line)) !== null) {
|
||||
const key = match[1];
|
||||
const val = match[2].trim();
|
||||
if (['QNAME', 'QTYPE', 'QCLASS', 'RCODE'].includes(key)) {
|
||||
entry[key.toLowerCase()] = val;
|
||||
} else if (key === 'ANSWER') {
|
||||
entry.answer = val;
|
||||
}
|
||||
}
|
||||
|
||||
entry.raw = line;
|
||||
parsed.push(entry);
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Restart the DNS server.
|
||||
* POST /api/admin/restart?token=...
|
||||
* Requires admin credentials.
|
||||
*/
|
||||
async restartServer({ server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Restarting DNS server', { server: this.serverIp });
|
||||
const url = this._buildUrl('/api/admin/restart', { token });
|
||||
const response = await fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
log.info('technitium', 'DNS server restart initiated');
|
||||
return { success: true, message: 'Server restart initiated' };
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Restart failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'DNS restart error', { error: error.message });
|
||||
throw new Error(`Failed to restart DNS server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for DNS server updates.
|
||||
* GET /api/user/checkForUpdate?token=...
|
||||
*/
|
||||
async checkUpdate({ server } = {}) {
|
||||
const token = await this._requireToken();
|
||||
const { fetchT, log } = this.ctx;
|
||||
|
||||
try {
|
||||
log.info('technitium', 'Checking for DNS server update', { server: this.serverIp });
|
||||
const url = this._buildUrl('/api/user/checkForUpdate', { token });
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable: !!(result.response && result.response.updateAvailable),
|
||||
latestVersion: (result.response && result.response.latestVersion) || null,
|
||||
currentVersion: (result.response && result.response.currentVersion) || null,
|
||||
response: result.response
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(result.errorMessage || 'Update check failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Update check error', { error: error.message });
|
||||
throw new Error(`Failed to check for updates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
if (!this.serverIp) {
|
||||
errors.push('serverIp is required');
|
||||
}
|
||||
if (this.serverPort && (typeof this.serverPort !== 'number' || this.serverPort < 1 || this.serverPort > 65535)) {
|
||||
errors.push('serverPort must be a valid port number (1-65535)');
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TechnitiumDNSProvider;
|
||||
Reference in New Issue
Block a user