559 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			559 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import * as plugins from '../../plugins.ts'; | ||
|  | import * as paths from '../../paths.ts'; | ||
|  | import { DKIMCreator } from '../security/classes.dkimcreator.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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 dkimCreator: DKIMCreator; | ||
|  |   private cache: Map<string, { data: any; expires: number }> = new Map(); | ||
|  |   private defaultOptions: IDnsLookupOptions = { | ||
|  |     cacheTtl: 300000, // 5 minutes
 | ||
|  |     timeout: 5000 // 5 seconds
 | ||
|  |   }; | ||
|  | 
 | ||
|  |   constructor(dkimCreatorArg: DKIMCreator, options?: IDnsLookupOptions) { | ||
|  |     this.dkimCreator = dkimCreatorArg; | ||
|  |      | ||
|  |     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<plugins.dns.MxRecord[]> { | ||
|  |     const lookupOptions = { ...this.defaultOptions, ...options }; | ||
|  |     const cacheKey = `mx:${domain}`; | ||
|  |      | ||
|  |     // Check cache first
 | ||
|  |     const cached = this.getFromCache<plugins.dns.MxRecord[]>(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<string[][]> { | ||
|  |     const lookupOptions = { ...this.defaultOptions, ...options }; | ||
|  |     const cacheKey = `txt:${domain}`; | ||
|  |      | ||
|  |     // Check cache first
 | ||
|  |     const cached = this.getFromCache<string[][]>(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<string | null> { | ||
|  |     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<IDnsVerificationResult> { | ||
|  |     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<IDnsVerificationResult> { | ||
|  |     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<IDnsVerificationResult> { | ||
|  |     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<void> { | ||
|  |     try { | ||
|  |       const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.tson`); | ||
|  |       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<T>(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<T>(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<plugins.dns.MxRecord[]> { | ||
|  |     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<string[][]> { | ||
|  |     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<IDnsRecord[]> { | ||
|  |     const records: IDnsRecord[] = []; | ||
|  |      | ||
|  |     // 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; | ||
|  |   } | ||
|  | } |