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;
 | |
|   }
 | |
| } |