Files
dashcaddy/dashcaddy-api/src/dns/dns-propagation.js
T
Krystie e99413150e
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
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.
2026-08-16 04:18:07 -07:00

270 lines
7.9 KiB
JavaScript

/**
* DNS Propagation Checker
* Verifies DNS record propagation by querying multiple resolvers.
* Runs as background jobs with configurable timeout and interval.
*
* @module dns-propagation
*/
const dns = require('dns').promises;
const EventEmitter = require('events');
/** Default verification options */
const DEFAULT_OPTIONS = {
timeout: 300000, // 5 minutes
interval: 10000, // 10 seconds
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
};
/** Maximum age for stored verification results (1 hour) */
const MAX_RESULT_AGE_MS = 3600000;
class DNSPropagationChecker extends EventEmitter {
/**
* Create a DNSPropagationChecker instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} domain → verification status */
this.verifications = new Map();
}
/**
* Verify that a DNS record has propagated by querying multiple resolvers.
* Retries every `interval` ms until `timeout` is reached.
*
* @param {string} domain - The domain to check (e.g., 'test.sami')
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
* @param {number} [options.interval=10000] - Time between retries (ms)
* @param {string[]} [options.resolvers] - DNS resolvers to query
* @returns {Promise<Object>} Verification result
*/
async verifyRecord(domain, expectedIp, options = {}) {
const startTime = Date.now();
const {
timeout = DEFAULT_OPTIONS.timeout,
interval = DEFAULT_OPTIONS.interval,
resolvers = DEFAULT_OPTIONS.resolvers
} = options;
const allResults = [];
let propagated = false;
while (Date.now() - startTime < timeout) {
const roundResults = [];
for (const resolver of resolvers) {
const checkStart = Date.now();
try {
// Use dns.resolve4 with a custom resolver
const resolverInstance = new dns.Resolver();
resolverInstance.setServers([resolver]);
resolverInstance.setTimeout(5000);
const addresses = await resolverInstance.resolve4(domain);
const matched = addresses.includes(expectedIp);
const result = {
resolver,
ips: addresses,
matched,
checkedAt: new Date().toISOString(),
responseTime: Date.now() - checkStart
};
roundResults.push(result);
if (matched) {
propagated = true;
}
} catch (err) {
roundResults.push({
resolver,
ips: [],
matched: false,
checkedAt: new Date().toISOString(),
error: err.code || err.message,
responseTime: Date.now() - checkStart
});
}
}
allResults.push(...roundResults);
// Emit progress event
this.emit('propagation-check', {
domain,
expectedIp,
roundResults,
elapsed: Date.now() - startTime,
propagated
});
if (propagated) {
break;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, interval));
}
const totalTime = Date.now() - startTime;
return {
domain,
expectedIp,
propagated,
results: allResults,
totalTime,
checkedAt: new Date().toISOString()
};
}
/**
* Start a background DNS propagation verification.
* Does not block — returns immediately with the job reference.
*
* @param {string} domain - The domain to verify
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @returns {Object} Job status object
*/
startVerification(domain, expectedIp, options = {}) {
// If there's already a running verification for this domain, return it
const existing = this.verifications.get(domain);
if (existing && existing.status === 'running') {
return existing;
}
const job = {
domain,
expectedIp,
status: 'running',
startedAt: new Date().toISOString(),
progress: [],
result: null
};
this.verifications.set(domain, job);
// Run verification in background (non-blocking)
this.verifyRecord(domain, expectedIp, options)
.then(result => {
job.status = 'completed';
job.result = result;
job.completedAt = new Date().toISOString();
if (result.propagated) {
this.emit('propagation-complete', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'success').catch(err => {
this.log.error('dns-propagation', err, null, { note: 'Failed to send propagation notification' });
});
}
} else {
this.emit('propagation-timeout', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'warning').catch(err => {
this.log.error('dns-propagation', err, null, { note: 'Failed to send timeout notification' });
});
}
}
})
.catch(err => {
job.status = 'error';
job.error = err.message;
job.completedAt = new Date().toISOString();
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
error: err.message
});
});
return job;
}
/**
* Get the current verification status for a domain.
*
* @param {string} domain - The domain to look up
* @returns {Object|null} Verification status or null if not found
*/
getVerificationStatus(domain) {
const job = this.verifications.get(domain);
if (!job) return null;
return {
domain: job.domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
result: job.result || null,
error: job.error || null
};
}
/**
* Get all recent verifications.
*
* @returns {Object[]} Array of verification statuses
*/
getAllVerifications() {
const results = [];
for (const [domain, job] of this.verifications.entries()) {
results.push({
domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
propagated: job.result?.propagated || null,
totalTime: job.result?.totalTime || null,
error: job.error || null
});
}
return results;
}
/**
* Remove verifications older than 1 hour.
*/
cleanup() {
const now = Date.now();
for (const [domain, job] of this.verifications.entries()) {
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
const startedAt = new Date(job.startedAt).getTime();
// Clean up completed/error jobs older than 1 hour
// Also clean up stale running jobs that started over 2 hours ago
const age = completedAt ? (now - completedAt) : (now - startedAt);
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
if (age > maxAge) {
this.verifications.delete(domain);
}
}
}
}
module.exports = DNSPropagationChecker;