431 lines
28 KiB
JavaScript
431 lines
28 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import * as paths from '../../paths.js';
|
||
|
|
import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
||
|
|
/**
|
||
|
|
* Manager for DNS-related operations, including record lookups, verification, and generation
|
||
|
|
*/
|
||
|
|
export class DNSManager {
|
||
|
|
dkimCreator;
|
||
|
|
cache = new Map();
|
||
|
|
defaultOptions = {
|
||
|
|
cacheTtl: 300000, // 5 minutes
|
||
|
|
timeout: 5000 // 5 seconds
|
||
|
|
};
|
||
|
|
constructor(dkimCreatorArg, options) {
|
||
|
|
this.dkimCreator = dkimCreatorArg;
|
||
|
|
if (options) {
|
||
|
|
this.defaultOptions = {
|
||
|
|
...this.defaultOptions,
|
||
|
|
...options
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// Ensure the DNS records directory exists
|
||
|
|
plugins.fs.mkdirSync(paths.dnsRecordsDir, { recursive: true });
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Lookup MX records for a domain
|
||
|
|
* @param domain Domain to look up
|
||
|
|
* @param options Lookup options
|
||
|
|
* @returns Array of MX records sorted by priority
|
||
|
|
*/
|
||
|
|
async lookupMx(domain, options) {
|
||
|
|
const lookupOptions = { ...this.defaultOptions, ...options };
|
||
|
|
const cacheKey = `mx:${domain}`;
|
||
|
|
// Check cache first
|
||
|
|
const cached = this.getFromCache(cacheKey);
|
||
|
|
if (cached) {
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
||
|
|
// Sort by priority
|
||
|
|
records.sort((a, b) => a.priority - b.priority);
|
||
|
|
// Cache the result
|
||
|
|
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||
|
|
return records;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.error(`Error looking up MX records for ${domain}:`, error);
|
||
|
|
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Lookup TXT records for a domain
|
||
|
|
* @param domain Domain to look up
|
||
|
|
* @param options Lookup options
|
||
|
|
* @returns Array of TXT records
|
||
|
|
*/
|
||
|
|
async lookupTxt(domain, options) {
|
||
|
|
const lookupOptions = { ...this.defaultOptions, ...options };
|
||
|
|
const cacheKey = `txt:${domain}`;
|
||
|
|
// Check cache first
|
||
|
|
const cached = this.getFromCache(cacheKey);
|
||
|
|
if (cached) {
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
||
|
|
// Cache the result
|
||
|
|
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||
|
|
return records;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.error(`Error looking up TXT records for ${domain}:`, error);
|
||
|
|
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Find specific TXT record by subdomain and prefix
|
||
|
|
* @param domain Base domain
|
||
|
|
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
||
|
|
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
||
|
|
* @param options Lookup options
|
||
|
|
* @returns Matching TXT record or null if not found
|
||
|
|
*/
|
||
|
|
async findTxtRecord(domain, subdomain = '', prefix = '', options) {
|
||
|
|
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
||
|
|
try {
|
||
|
|
const records = await this.lookupTxt(fullDomain, options);
|
||
|
|
for (const recordArray of records) {
|
||
|
|
// TXT records can be split into chunks, join them
|
||
|
|
const record = recordArray.join('');
|
||
|
|
if (!prefix || record.startsWith(prefix)) {
|
||
|
|
return record;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Domain might not exist or no TXT records
|
||
|
|
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Verify if a domain has a valid SPF record
|
||
|
|
* @param domain Domain to verify
|
||
|
|
* @returns Verification result
|
||
|
|
*/
|
||
|
|
async verifySpfRecord(domain) {
|
||
|
|
const result = {
|
||
|
|
record: 'SPF',
|
||
|
|
found: false,
|
||
|
|
valid: false
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
||
|
|
if (spfRecord) {
|
||
|
|
result.found = true;
|
||
|
|
result.value = spfRecord;
|
||
|
|
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
||
|
|
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
||
|
|
result.valid = isValid;
|
||
|
|
if (!isValid) {
|
||
|
|
result.error = 'SPF record format is invalid';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
result.error = 'No SPF record found';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
result.error = `Error verifying SPF: ${error.message}`;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Verify if a domain has a valid DKIM record
|
||
|
|
* @param domain Domain to verify
|
||
|
|
* @param selector DKIM selector (usually "mta" in our case)
|
||
|
|
* @returns Verification result
|
||
|
|
*/
|
||
|
|
async verifyDkimRecord(domain, selector = 'mta') {
|
||
|
|
const result = {
|
||
|
|
record: 'DKIM',
|
||
|
|
found: false,
|
||
|
|
valid: false
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
const dkimSelector = `${selector}._domainkey`;
|
||
|
|
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
||
|
|
if (dkimRecord) {
|
||
|
|
result.found = true;
|
||
|
|
result.value = dkimRecord;
|
||
|
|
// Basic validation - check for required fields
|
||
|
|
const hasP = dkimRecord.includes('p=');
|
||
|
|
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
||
|
|
if (!result.valid) {
|
||
|
|
result.error = 'DKIM record is missing required fields';
|
||
|
|
}
|
||
|
|
else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
||
|
|
result.valid = false;
|
||
|
|
result.error = 'DKIM record has invalid public key format';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
result.error = `No DKIM record found for selector ${selector}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
result.error = `Error verifying DKIM: ${error.message}`;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Verify if a domain has a valid DMARC record
|
||
|
|
* @param domain Domain to verify
|
||
|
|
* @returns Verification result
|
||
|
|
*/
|
||
|
|
async verifyDmarcRecord(domain) {
|
||
|
|
const result = {
|
||
|
|
record: 'DMARC',
|
||
|
|
found: false,
|
||
|
|
valid: false
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
const dmarcDomain = `_dmarc.${domain}`;
|
||
|
|
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
||
|
|
if (dmarcRecord) {
|
||
|
|
result.found = true;
|
||
|
|
result.value = dmarcRecord;
|
||
|
|
// Basic validation - check for required fields
|
||
|
|
const hasPolicy = dmarcRecord.includes('p=');
|
||
|
|
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
||
|
|
if (!result.valid) {
|
||
|
|
result.error = 'DMARC record is missing required fields';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
result.error = 'No DMARC record found';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
result.error = `Error verifying DMARC: ${error.message}`;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
||
|
|
* @param domain Domain to check
|
||
|
|
* @param dkimSelector DKIM selector
|
||
|
|
* @returns Object with verification results for each record type
|
||
|
|
*/
|
||
|
|
async verifyEmailAuthRecords(domain, dkimSelector = 'mta') {
|
||
|
|
const [spf, dkim, dmarc] = await Promise.all([
|
||
|
|
this.verifySpfRecord(domain),
|
||
|
|
this.verifyDkimRecord(domain, dkimSelector),
|
||
|
|
this.verifyDmarcRecord(domain)
|
||
|
|
]);
|
||
|
|
return { spf, dkim, dmarc };
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Generate a recommended SPF record for a domain
|
||
|
|
* @param domain Domain name
|
||
|
|
* @param options Configuration options for the SPF record
|
||
|
|
* @returns Generated SPF record
|
||
|
|
*/
|
||
|
|
generateSpfRecord(domain, options = {}) {
|
||
|
|
const { includeMx = true, includeA = true, includeIps = [], includeSpf = [], policy = 'softfail' } = options;
|
||
|
|
let value = 'v=spf1';
|
||
|
|
if (includeMx) {
|
||
|
|
value += ' mx';
|
||
|
|
}
|
||
|
|
if (includeA) {
|
||
|
|
value += ' a';
|
||
|
|
}
|
||
|
|
// Add IP addresses
|
||
|
|
for (const ip of includeIps) {
|
||
|
|
if (ip.includes(':')) {
|
||
|
|
value += ` ip6:${ip}`;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
value += ` ip4:${ip}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Add includes
|
||
|
|
for (const include of includeSpf) {
|
||
|
|
value += ` include:${include}`;
|
||
|
|
}
|
||
|
|
// Add policy
|
||
|
|
const policyMap = {
|
||
|
|
'none': '?all',
|
||
|
|
'neutral': '~all',
|
||
|
|
'softfail': '~all',
|
||
|
|
'fail': '-all',
|
||
|
|
'reject': '-all'
|
||
|
|
};
|
||
|
|
value += ` ${policyMap[policy]}`;
|
||
|
|
return {
|
||
|
|
name: domain,
|
||
|
|
type: 'TXT',
|
||
|
|
value: value
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Generate a recommended DMARC record for a domain
|
||
|
|
* @param domain Domain name
|
||
|
|
* @param options Configuration options for the DMARC record
|
||
|
|
* @returns Generated DMARC record
|
||
|
|
*/
|
||
|
|
generateDmarcRecord(domain, options = {}) {
|
||
|
|
const { policy = 'none', subdomainPolicy, pct = 100, rua, ruf, daysInterval = 1 } = options;
|
||
|
|
let value = 'v=DMARC1; p=' + policy;
|
||
|
|
if (subdomainPolicy) {
|
||
|
|
value += `; sp=${subdomainPolicy}`;
|
||
|
|
}
|
||
|
|
if (pct !== 100) {
|
||
|
|
value += `; pct=${pct}`;
|
||
|
|
}
|
||
|
|
if (rua) {
|
||
|
|
value += `; rua=mailto:${rua}`;
|
||
|
|
}
|
||
|
|
if (ruf) {
|
||
|
|
value += `; ruf=mailto:${ruf}`;
|
||
|
|
}
|
||
|
|
if (daysInterval !== 1) {
|
||
|
|
value += `; ri=${daysInterval * 86400}`;
|
||
|
|
}
|
||
|
|
// Add reporting format and ADKIM/ASPF alignment
|
||
|
|
value += '; fo=1; adkim=r; aspf=r';
|
||
|
|
return {
|
||
|
|
name: `_dmarc.${domain}`,
|
||
|
|
type: 'TXT',
|
||
|
|
value: value
|
||
|
|
};
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Save DNS record recommendations to a file
|
||
|
|
* @param domain Domain name
|
||
|
|
* @param records DNS records to save
|
||
|
|
*/
|
||
|
|
async saveDnsRecommendations(domain, records) {
|
||
|
|
try {
|
||
|
|
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
|
||
|
|
await plugins.smartfs.file(filePath).write(JSON.stringify(records, null, 2));
|
||
|
|
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get cache key value
|
||
|
|
* @param key Cache key
|
||
|
|
* @returns Cached value or undefined if not found or expired
|
||
|
|
*/
|
||
|
|
getFromCache(key) {
|
||
|
|
const cached = this.cache.get(key);
|
||
|
|
if (cached && cached.expires > Date.now()) {
|
||
|
|
return cached.data;
|
||
|
|
}
|
||
|
|
// Remove expired entry
|
||
|
|
if (cached) {
|
||
|
|
this.cache.delete(key);
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set cache key value
|
||
|
|
* @param key Cache key
|
||
|
|
* @param data Data to cache
|
||
|
|
* @param ttl TTL in milliseconds
|
||
|
|
*/
|
||
|
|
setInCache(key, data, ttl = this.defaultOptions.cacheTtl) {
|
||
|
|
if (ttl <= 0)
|
||
|
|
return; // Don't cache if TTL is disabled
|
||
|
|
this.cache.set(key, {
|
||
|
|
data,
|
||
|
|
expires: Date.now() + ttl
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clear the DNS cache
|
||
|
|
* @param key Optional specific key to clear, or all cache if not provided
|
||
|
|
*/
|
||
|
|
clearCache(key) {
|
||
|
|
if (key) {
|
||
|
|
this.cache.delete(key);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
this.cache.clear();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Promise-based wrapper for dns.resolveMx
|
||
|
|
* @param domain Domain to resolve
|
||
|
|
* @param timeout Timeout in milliseconds
|
||
|
|
* @returns Promise resolving to MX records
|
||
|
|
*/
|
||
|
|
dnsResolveMx(domain, timeout = 5000) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timeoutId = setTimeout(() => {
|
||
|
|
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
||
|
|
}, timeout);
|
||
|
|
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||
|
|
clearTimeout(timeoutId);
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
resolve(addresses);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Promise-based wrapper for dns.resolveTxt
|
||
|
|
* @param domain Domain to resolve
|
||
|
|
* @param timeout Timeout in milliseconds
|
||
|
|
* @returns Promise resolving to TXT records
|
||
|
|
*/
|
||
|
|
dnsResolveTxt(domain, timeout = 5000) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timeoutId = setTimeout(() => {
|
||
|
|
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
||
|
|
}, timeout);
|
||
|
|
plugins.dns.resolveTxt(domain, (err, records) => {
|
||
|
|
clearTimeout(timeoutId);
|
||
|
|
if (err) {
|
||
|
|
reject(err);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
resolve(records);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Generate all recommended DNS records for proper email authentication
|
||
|
|
* @param domain Domain to generate records for
|
||
|
|
* @returns Array of recommended DNS records
|
||
|
|
*/
|
||
|
|
async generateAllRecommendedRecords(domain) {
|
||
|
|
const records = [];
|
||
|
|
// Get DKIM record (already created by DKIMCreator)
|
||
|
|
try {
|
||
|
|
// Call the DKIM creator directly
|
||
|
|
const dkimRecord = await this.dkimCreator.getDNSRecordForDomain(domain);
|
||
|
|
records.push(dkimRecord);
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
console.error(`Error getting DKIM record for ${domain}:`, error);
|
||
|
|
}
|
||
|
|
// Generate SPF record
|
||
|
|
const spfRecord = this.generateSpfRecord(domain, {
|
||
|
|
includeMx: true,
|
||
|
|
includeA: true,
|
||
|
|
policy: 'softfail'
|
||
|
|
});
|
||
|
|
records.push(spfRecord);
|
||
|
|
// Generate DMARC record
|
||
|
|
const dmarcRecord = this.generateDmarcRecord(domain, {
|
||
|
|
policy: 'none', // Start with monitoring mode
|
||
|
|
rua: `dmarc@${domain}` // Replace with appropriate report address
|
||
|
|
});
|
||
|
|
records.push(dmarcRecord);
|
||
|
|
// Save recommendations
|
||
|
|
await this.saveDnsRecommendations(domain, records);
|
||
|
|
return records;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5kbnNtYW5hZ2VyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vdHMvbWFpbC9yb3V0aW5nL2NsYXNzZXMuZG5zbWFuYWdlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGtCQUFrQixDQUFDO0FBQzVDLE9BQU8sS0FBSyxLQUFLLE1BQU0sZ0JBQWdCLENBQUM7QUFDeEMsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLG9DQUFvQyxDQUFDO0FBbUNqRTs7R0FFRztBQUNILE1BQU0sT0FBTyxVQUFVO0lBQ2QsV0FBVyxDQUFjO0lBQ3hCLEtBQUssR0FBZ0QsSUFBSSxHQUFHLEVBQUUsQ0FBQztJQUMvRCxjQUFjLEdBQXNCO1FBQzFDLFFBQVEsRUFBRSxNQUFNLEVBQUUsWUFBWTtRQUM5QixPQUFPLEVBQUUsSUFBSSxDQUFDLFlBQVk7S0FDM0IsQ0FBQztJQUVGLFlBQVksY0FBMkIsRUFBRSxPQUEyQjtRQUNsRSxJQUFJLENBQUMsV0FBVyxHQUFHLGNBQWMsQ0FBQztRQUVsQyxJQUFJLE9BQU8sRUFBRSxDQUFDO1lBQ1osSUFBSSxDQUFDLGNBQWMsR0FBRztnQkFDcEIsR0FBRyxJQUFJLENBQUMsY0FBYztnQkFDdEIsR0FBRyxPQUFPO2FBQ1gsQ0FBQztRQUNKLENBQUM7UUFFRCwwQ0FBMEM7UUFDMUMsT0FBTyxDQUFDLEVBQUUsQ0FBQyxTQUFTLENBQUMsS0FBSyxDQUFDLGFBQWEsRUFBRSxFQUFFLFNBQVMsRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pFLENBQUM7SUFFRDs7Ozs7T0FLRztJQUNJLEtBQUssQ0FBQyxRQUFRLENBQUMsTUFBYyxFQUFFLE9BQTJCO1FBQy9ELE1BQU0sYUFBYSxHQUFHLEVBQUUsR0FBRyxJQUFJLENBQUMsY0FBYyxFQUFFLEdBQUcsT0FBTyxFQUFFLENBQUM7UUFDN0QsTUFBTSxRQUFRLEdBQUcsTUFBTSxNQUFNLEVBQUUsQ0FBQztRQUVo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
|