import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import type { MtaService } from './mta.classes.mta.js'; /** * Interface for DNS record information */ export interface IDnsRecord { name: string; type: string; value: string; ttl?: number; dnsSecEnabled?: boolean; } /** * Interface for DNS lookup options */ export interface IDnsLookupOptions { /** Cache time to live in milliseconds, 0 to disable caching */ cacheTtl?: number; /** Timeout for DNS queries in milliseconds */ timeout?: number; } /** * Interface for DNS verification result */ export interface IDnsVerificationResult { record: string; found: boolean; valid: boolean; value?: string; expectedValue?: string; error?: string; } /** * Manager for DNS-related operations, including record lookups, verification, and generation */ export class DNSManager { public mtaRef: MtaService; private cache: Map = new Map(); private defaultOptions: IDnsLookupOptions = { cacheTtl: 300000, // 5 minutes timeout: 5000 // 5 seconds }; constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) { this.mtaRef = mtaRefArg; if (options) { this.defaultOptions = { ...this.defaultOptions, ...options }; } // Ensure the DNS records directory exists plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir); } /** * Lookup MX records for a domain * @param domain Domain to look up * @param options Lookup options * @returns Array of MX records sorted by priority */ public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise { 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 */ public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise { 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 */ public async findTxtRecord( domain: string, subdomain: string = '', prefix: string = '', options?: IDnsLookupOptions ): Promise { 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 */ public async verifySpfRecord(domain: string): Promise { const result: IDnsVerificationResult = { 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 */ public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise { const result: IDnsVerificationResult = { 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 */ public async verifyDmarcRecord(domain: string): Promise { const result: IDnsVerificationResult = { 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 */ public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{ spf: IDnsVerificationResult; dkim: IDnsVerificationResult; dmarc: IDnsVerificationResult; }> { 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 */ public generateSpfRecord(domain: string, options: { includeMx?: boolean; includeA?: boolean; includeIps?: string[]; includeSpf?: string[]; policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject'; } = {}): IDnsRecord { 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 */ public generateDmarcRecord(domain: string, options: { policy?: 'none' | 'quarantine' | 'reject'; subdomainPolicy?: 'none' | 'quarantine' | 'reject'; pct?: number; rua?: string; ruf?: string; daysInterval?: number; } = {}): IDnsRecord { 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 */ public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise { try { const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`); plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath); 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 */ private getFromCache(key: string): T | undefined { const cached = this.cache.get(key); if (cached && cached.expires > Date.now()) { return cached.data as T; } // 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 */ private setInCache(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void { 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 */ public clearCache(key?: string): void { 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 */ private dnsResolveMx(domain: string, timeout: number = 5000): Promise { 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 */ private dnsResolveTxt(domain: string, timeout: number = 5000): Promise { 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 */ public async generateAllRecommendedRecords(domain: string): Promise { const records: IDnsRecord[] = []; // Get DKIM record (already created by DKIMCreator) try { // Now using the public method const dkimRecord = await this.mtaRef.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; } }