Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):
P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.
P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).
P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.
Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).
Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.
Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
508 lines
16 KiB
JavaScript
508 lines
16 KiB
JavaScript
/**
|
|
* 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', err, null, { note: 'Global credential error' });
|
|
}
|
|
|
|
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', error, null, { note: 'Login error' });
|
|
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', error, null, { note: 'Failed to fetch DNS logs' });
|
|
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', error, null, { note: 'DNS restart error' });
|
|
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', error, null, { note: 'Update check error' });
|
|
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;
|