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,383 @@
|
||||
/**
|
||||
* RFC 2136 Dynamic DNS Provider Adapter
|
||||
*
|
||||
* Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool.
|
||||
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
|
||||
*
|
||||
* Capabilities: create-record, delete-record, resolve, credentials
|
||||
* Not supported: logs, restart, update-check, list-records, zones
|
||||
*/
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const dns = require('dns');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const BaseDNSProvider = require('./base');
|
||||
|
||||
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials'];
|
||||
|
||||
const DEFAULT_PORT = 53;
|
||||
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
|
||||
const NSUPDATE_TIMEOUT_MS = 15000;
|
||||
|
||||
class RFC2136Provider extends BaseDNSProvider {
|
||||
static providerId = 'rfc2136';
|
||||
static displayName = 'RFC 2136 (Dynamic DNS)';
|
||||
|
||||
constructor(config, ctx) {
|
||||
super(config, ctx);
|
||||
|
||||
this.providerId = 'rfc2136';
|
||||
this.displayName = 'RFC 2136 (Dynamic DNS)';
|
||||
|
||||
// Core config
|
||||
this.server = config.server || null;
|
||||
this.port = config.port || DEFAULT_PORT;
|
||||
this.zone = config.zone || null;
|
||||
|
||||
// TSIG authentication
|
||||
this.tsigAlgorithm = config.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM;
|
||||
this.tsigKeyName = config.tsigKeyName || null;
|
||||
this.tsigSecret = config.tsigSecret || null;
|
||||
|
||||
// Resolve credentials from credential manager if available
|
||||
if (ctx && ctx.credentialManager) {
|
||||
if (!this.tsigKeyName && ctx.credentialManager.get) {
|
||||
this.tsigKeyName = ctx.credentialManager.get('rfc2136_tsigKeyName') || null;
|
||||
}
|
||||
if (!this.tsigSecret && ctx.credentialManager.get) {
|
||||
this.tsigSecret = ctx.credentialManager.get('rfc2136_tsigSecret') || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Logger shorthand
|
||||
this._log = ctx && ctx.log ? ctx.ctx : null;
|
||||
}
|
||||
|
||||
// ── Logging helper ────────────────────────────────────────────────────────
|
||||
|
||||
_log(level, message, meta) {
|
||||
if (this.ctx && this.ctx.log && typeof this.ctx.log[level] === 'function') {
|
||||
this.ctx.log[level](`[rfc2136] ${message}`, meta || {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────────────────
|
||||
|
||||
supportsCapability(cap) {
|
||||
return CAPABILITIES.includes(cap);
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return [...CAPABILITIES];
|
||||
}
|
||||
|
||||
// ── Config validation ─────────────────────────────────────────────────────
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
if (!this.server) errors.push('Missing required config: server');
|
||||
if (!this.zone) errors.push('Missing required config: zone');
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure a domain name ends with a trailing dot (FQDN for nsupdate).
|
||||
*/
|
||||
_ensureFqdn(domain) {
|
||||
if (!domain) return domain;
|
||||
return domain.endsWith('.') ? domain : `${domain}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the common nsupdate header lines (server, zone, key).
|
||||
*/
|
||||
_buildHeader() {
|
||||
const lines = [];
|
||||
lines.push(`server ${this.server} ${this.port}`);
|
||||
lines.push(`zone ${this.zone}`);
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
lines.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an nsupdate script and return { stdout, stderr }.
|
||||
* Writes commands to a temporary file and runs `nsupdate <file>`.
|
||||
*/
|
||||
async _runNsupdate(commands) {
|
||||
const script = commands.join('\n') + '\n';
|
||||
const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`);
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
|
||||
this._log('debug', `Executing nsupdate script`, { script: script.trim() });
|
||||
|
||||
const { stdout, stderr } = await execFileAsync('nsupdate', [tmpFile], {
|
||||
timeout: NSUPDATE_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
this._log('debug', 'nsupdate completed', { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
|
||||
|
||||
if (stderr && stderr.toLowerCase().includes('refused')) {
|
||||
throw new Error(`nsupdate refused: ${stderr.trim()}`);
|
||||
}
|
||||
if (stderr && stderr.toLowerCase().includes('failed')) {
|
||||
throw new Error(`nsupdate failed: ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
return { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw new Error('nsupdate command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
try { await fs.promises.unlink(tmpFile); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authenticate ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify nsupdate is available and optionally test connectivity.
|
||||
* Runs a minimal nsupdate with just "show" (no-op) to confirm the tool works.
|
||||
*/
|
||||
async authenticate() {
|
||||
const validation = this.validateConfig();
|
||||
if (!validation.valid) {
|
||||
throw new Error(`RFC 2136 config invalid: ${validation.errors.join('; ')}`);
|
||||
}
|
||||
|
||||
// Check nsupdate binary is available with a dry-run command set
|
||||
const commands = [
|
||||
...this._buildHeader(),
|
||||
'show',
|
||||
];
|
||||
|
||||
try {
|
||||
const { stdout } = await this._runNsupdate(commands);
|
||||
this._log('info', 'Authenticated to RFC 2136 server', { server: this.server, port: this.port });
|
||||
return { success: true, server: this.server, port: this.port };
|
||||
} catch (err) {
|
||||
this._log('error', 'Authentication test failed', { error: err.message });
|
||||
// If nsupdate is missing, rethrow immediately
|
||||
if (err.message.includes('not found')) throw err;
|
||||
// Otherwise, the server might be unreachable but the tool works — return partial
|
||||
return { success: false, error: err.message, server: this.server };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create Record ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create (add) a DNS record via RFC 2136 UPDATE.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Record name (e.g. "www.example.com")
|
||||
* @param {string} params.zone - Zone name (overrides constructor zone)
|
||||
* @param {string} params.type - Record type (A, AAAA, CNAME, TXT, etc.)
|
||||
* @param {string} params.value - Record value
|
||||
* @param {number} [params.ttl=300] - TTL in seconds
|
||||
*/
|
||||
async createRecord({ domain, zone, type, value, ttl }) {
|
||||
const effectiveZone = zone || this.zone;
|
||||
const effectiveTtl = ttl || 300;
|
||||
const fqdn = this._ensureFqdn(domain);
|
||||
|
||||
const commands = [
|
||||
`server ${this.server} ${this.port}`,
|
||||
`zone ${effectiveZone}`,
|
||||
];
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
commands.push(`update add ${fqdn} ${effectiveTtl} ${type} ${value}`);
|
||||
commands.push('show');
|
||||
commands.push('send');
|
||||
|
||||
this._log('info', 'Creating DNS record', { domain: fqdn, type, value, ttl: effectiveTtl });
|
||||
|
||||
const result = await this._runNsupdate(commands);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'create-record',
|
||||
domain: fqdn,
|
||||
type,
|
||||
value,
|
||||
ttl: effectiveTtl,
|
||||
zone: effectiveZone,
|
||||
raw: result.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Delete Record ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delete a DNS record via RFC 2136 UPDATE.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Record name
|
||||
* @param {string} params.type - Record type
|
||||
* @param {string} [params.value] - Optional specific value to match
|
||||
*/
|
||||
async deleteRecord({ domain, type, value }) {
|
||||
const effectiveZone = this.zone;
|
||||
const fqdn = this._ensureFqdn(domain);
|
||||
|
||||
const commands = [
|
||||
`server ${this.server} ${this.port}`,
|
||||
`zone ${effectiveZone}`,
|
||||
];
|
||||
|
||||
if (this.tsigKeyName && this.tsigSecret) {
|
||||
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
|
||||
}
|
||||
|
||||
// "update delete" with value removes that specific RR;
|
||||
// without value it removes all RRs of that type for the name.
|
||||
const deleteClause = value
|
||||
? `update delete ${fqdn} ${type} ${value}`
|
||||
: `update delete ${fqdn} ${type}`;
|
||||
|
||||
commands.push(deleteClause);
|
||||
commands.push('show');
|
||||
commands.push('send');
|
||||
|
||||
this._log('info', 'Deleting DNS record', { domain: fqdn, type, value: value || '(all)' });
|
||||
|
||||
const result = await this._runNsupdate(commands);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: 'delete-record',
|
||||
domain: fqdn,
|
||||
type,
|
||||
value: value || null,
|
||||
zone: effectiveZone,
|
||||
raw: result.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resolve Records ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve DNS records for a domain.
|
||||
* First attempts dig against the configured server, then falls back to Node dns module.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.domain - Domain to resolve
|
||||
* @param {string} [params.zone] - Zone (unused for resolution, kept for interface consistency)
|
||||
* @param {string} [params.type='A'] - Record type to query
|
||||
*/
|
||||
async resolveRecords({ domain, zone, type }) {
|
||||
const queryType = type || 'A';
|
||||
const fqdn = domain.endsWith('.') ? domain : domain;
|
||||
|
||||
// Strategy 1: Use dig against the configured RFC 2136 server
|
||||
try {
|
||||
const { stdout } = await execFileAsync('dig', [
|
||||
`@${this.server}`,
|
||||
'-p', String(this.port),
|
||||
fqdn,
|
||||
queryType,
|
||||
'+short',
|
||||
'+time=5',
|
||||
'+tries=1',
|
||||
], { timeout: 10000 });
|
||||
|
||||
const records = stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (records.length > 0) {
|
||||
this._log('debug', `Resolved ${fqdn} ${queryType} via dig`, { records });
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: records.map(r => ({ value: r, type: queryType })),
|
||||
source: 'dig',
|
||||
server: this.server,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this._log('warn', 'dig resolution failed, falling back to Node dns', { error: err.message });
|
||||
}
|
||||
|
||||
// Strategy 2: Fallback to Node.js built-in resolver
|
||||
try {
|
||||
const resolver = new dns.Resolver();
|
||||
resolver.setServers([this.server]);
|
||||
|
||||
const resolveMethod = this._getResolveMethod(queryType);
|
||||
const resolveAsync = promisify(resolver[resolveMethod]).bind(resolver);
|
||||
|
||||
const results = await resolveAsync(fqdn);
|
||||
const records = Array.isArray(results) ? results : [results];
|
||||
|
||||
this._log('debug', `Resolved ${fqdn} ${queryType} via Node dns`, { records });
|
||||
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: records.map(r => ({ value: String(r), type: queryType })),
|
||||
source: 'node-dns',
|
||||
server: this.server,
|
||||
};
|
||||
} catch (err) {
|
||||
this._log('warn', 'Node dns resolution also failed', { error: err.message });
|
||||
return {
|
||||
domain: fqdn,
|
||||
type: queryType,
|
||||
records: [],
|
||||
source: 'none',
|
||||
server: this.server,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map record type to the Node dns resolver method name.
|
||||
*/
|
||||
_getResolveMethod(type) {
|
||||
const map = {
|
||||
A: 'resolve4',
|
||||
AAAA: 'resolve6',
|
||||
CNAME: 'resolveCname',
|
||||
MX: 'resolveMx',
|
||||
TXT: 'resolveTxt',
|
||||
NS: 'resolveNs',
|
||||
SOA: 'resolveSoa',
|
||||
SRV: 'resolveSrv',
|
||||
PTR: 'reverse',
|
||||
};
|
||||
return map[(type || '').toUpperCase()] || 'resolve4';
|
||||
}
|
||||
|
||||
// ── Shutdown ──────────────────────────────────────────────────────────────
|
||||
|
||||
async shutdown() {
|
||||
this._log('info', 'RFC 2136 provider shutting down');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose providerId on the prototype so the registry's auto-discover can detect it
|
||||
RFC2136Provider.prototype.providerId = 'rfc2136';
|
||||
|
||||
module.exports = RFC2136Provider;
|
||||
Reference in New Issue
Block a user