UNPKG

dnsweeper

Version:

Advanced CLI tool for DNS record risk analysis and cleanup. Features CSV import for Cloudflare/Route53, automated risk assessment, and parallel DNS validation.

534 lines (464 loc) 15 kB
import { promises as dns } from 'node:dns'; import { traceCnameChain, validateCnameChain, type CnameChainResult, } from '../utils/cname-chain.js'; import { mapConcurrent, ProgressTracker } from '../utils/concurrent.js'; import { normalizeIPv6, isValidIPv6 } from '../utils/ipv6.js'; import { withTimeout, withRetry } from '../utils/retry.js'; import { DnsCache, type DnsCacheOptions } from './dns-cache.js'; import { MemoryOptimizer } from './performance/memory-optimizer.js'; import type { DNSRecordType, IDNSQuery } from '../types/index.js'; interface INodeDNSError extends Error { code?: string; } export interface IDNSRecord { type: DNSRecordType; value: string; ttl?: number; priority?: number; weight?: number; port?: number; exchange?: string; // For MX records target?: string; // For SRV records } export interface IDNSResponse { query: IDNSQuery; records: IDNSRecord[]; responseTime: number; status: 'success' | 'error' | 'timeout'; error?: string; cnameChain?: CnameChainResult; } export class DNSResolver { private servers: string[]; private timeout: number; private cache?: DnsCache; constructor( options: { timeout?: number; servers?: string[]; enableCache?: boolean; cacheOptions?: DnsCacheOptions; batchSize?: number; concurrency?: number; } = {}, ) { this.servers = options.servers ?? ['8.8.8.8', '1.1.1.1']; this.timeout = options.timeout ?? 5000; // キャッシュを有効化 if (options.enableCache !== false) { this.cache = new DnsCache(options.cacheOptions); } // Set custom DNS servers if provided if (this.servers.length > 0) { dns.setServers(this.servers); } } async resolve(domain: string, type: DNSRecordType): Promise<IDNSResponse> { const startTime = Date.now(); const query: IDNSQuery = { domain, type }; // キャッシュから確認 if (this.cache) { const cached = this.cache.get(query); if (cached) { return { ...cached, responseTime: Date.now() - startTime, // 新しいレスポンス時間を設定 }; } } try { const records = await this.withTimeout(this.resolveByType(domain, type), this.timeout); const responseTime = Date.now() - startTime; const response: IDNSResponse = { query, records, responseTime, status: 'success', }; // キャッシュに保存 if (this.cache) { this.cache.set(query, response); } return response; } catch (error) { const responseTime = Date.now() - startTime; // Enhanced error handling let errorMessage: string; let status: 'error' | 'timeout' = 'error'; if (error instanceof Error) { const dnsError = error as INodeDNSError; if (error.message.includes('timeout')) { status = 'timeout'; errorMessage = `DNS query timeout after ${this.timeout}ms`; } else if (dnsError.code === 'ENOTFOUND') { errorMessage = `Domain not found: ${domain}`; } else if (dnsError.code === 'ENODATA') { errorMessage = `No ${type} records found for ${domain}`; } else if (dnsError.code === 'ESERVFAIL') { errorMessage = `DNS server failure for ${domain}`; } else if (dnsError.code === 'ETIMEOUT') { status = 'timeout'; errorMessage = `DNS query timeout for ${domain}`; } else { errorMessage = error.message; } } else { errorMessage = 'Unknown DNS resolution error'; } return { query, records: [], responseTime, status, error: errorMessage, }; } } private async withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { const timeoutPromise = new Promise<never>((_, reject) => { setTimeout(() => { reject(new Error(`Operation timeout after ${timeoutMs}ms`)); }, timeoutMs); }); return Promise.race([promise, timeoutPromise]); } private async resolveByType(domain: string, type: DNSRecordType): Promise<IDNSRecord[]> { switch (type) { case 'A': return this.resolveA(domain); case 'AAAA': return this.resolveAAAA(domain); case 'CNAME': return this.resolveCNAME(domain); case 'MX': return this.resolveMX(domain); case 'TXT': return this.resolveTXT(domain); case 'NS': return this.resolveNS(domain); case 'SOA': return this.resolveSOA(domain); case 'SRV': return this.resolveSRV(domain); case 'PTR': return this.resolvePTR(domain); case 'CAA': return this.resolveCAA(domain); default: throw new Error(`Unsupported DNS record type: ${String(type)}`); } } private async resolveA(domain: string): Promise<IDNSRecord[]> { const addresses = await dns.resolve4(domain); return addresses.map((value) => ({ type: 'A' as const, value, })); } private async resolveAAAA(domain: string): Promise<IDNSRecord[]> { const addresses = await dns.resolve6(domain); return addresses.map((value) => { // IPv6アドレスを正規化 const normalizedValue = isValidIPv6(value) ? normalizeIPv6(value) : value; return { type: 'AAAA' as const, value: normalizedValue, }; }); } private async resolveCNAME(domain: string): Promise<IDNSRecord[]> { const cnames = await dns.resolveCname(domain); return cnames.map((value) => ({ type: 'CNAME' as const, value, })); } /** * CNAMEチェーンを追跡してレコードを解決 */ async resolveCNAMEWithChain(domain: string): Promise<IDNSResponse> { const startTime = Date.now(); try { // CNAMEチェーンを追跡 const cnameChain = await traceCnameChain(domain, { maxDepth: 10, timeout: this.timeout, followToEnd: true, }); // チェーンを検証 const validation = validateCnameChain(cnameChain); // CNAMEレコードを作成 const records: IDNSRecord[] = cnameChain.chain.slice(1).map((target) => ({ type: 'CNAME' as const, value: target, })); const responseTime = Date.now() - startTime; return { query: { domain, type: 'CNAME' }, records, responseTime, status: validation.isValid ? 'success' : 'error', error: validation.issues.length > 0 ? validation.issues.join('; ') : undefined, cnameChain, }; } catch (error) { const responseTime = Date.now() - startTime; return { query: { domain, type: 'CNAME' }, records: [], responseTime, status: 'error', error: error instanceof Error ? error.message : 'Unknown error', }; } } private async resolveMX(domain: string): Promise<IDNSRecord[]> { const mxRecords = await dns.resolveMx(domain); return mxRecords.map((record) => ({ type: 'MX' as const, value: record.exchange, priority: record.priority, exchange: record.exchange, })); } private async resolveTXT(domain: string): Promise<IDNSRecord[]> { const txtRecords = await dns.resolveTxt(domain); return txtRecords.map((record) => ({ type: 'TXT' as const, value: Array.isArray(record) ? record.join('') : record, })); } private async resolveNS(domain: string): Promise<IDNSRecord[]> { const nsRecords = await dns.resolveNs(domain); return nsRecords.map((value) => ({ type: 'NS' as const, value, })); } private async resolveSOA(domain: string): Promise<IDNSRecord[]> { const soaRecord = await dns.resolveSoa(domain); return [ { type: 'SOA' as const, value: `${soaRecord.nsname} ${soaRecord.hostmaster} ${soaRecord.serial} ${soaRecord.refresh} ${soaRecord.retry} ${soaRecord.expire} ${soaRecord.minttl}`, }, ]; } private async resolveSRV(domain: string): Promise<IDNSRecord[]> { const srvRecords = await dns.resolveSrv(domain); return srvRecords.map((record) => ({ type: 'SRV' as const, value: record.name, priority: record.priority, weight: record.weight, port: record.port, target: record.name, })); } private async resolvePTR(domain: string): Promise<IDNSRecord[]> { const ptrRecords = await dns.resolvePtr(domain); return ptrRecords.map((value) => ({ type: 'PTR' as const, value, })); } private resolveCAA(domain: string): IDNSRecord[] { // node:dns doesn't support CAA records natively // For now, return empty array - could be implemented with dns2 if needed console.warn(`CAA records not supported with node:dns for domain: ${domain}`); return []; } // Utility methods async lookupMultiple(domains: string[], type: DNSRecordType): Promise<IDNSResponse[]> { const promises = domains.map((domain) => this.resolve(domain, type)); return Promise.all(promises); } async reverseLookup(ip: string): Promise<IDNSResponse> { return this.resolve(ip, 'PTR'); } // Performance method for batch operations async batchResolve(queries: IDNSQuery[]): Promise<IDNSResponse[]> { console.log(`Starting batch DNS resolution for ${queries.length} queries`); MemoryOptimizer.logMemoryUsage('Before batch resolve'); try { // バッチプロセッサーを使用して効率的に処理 const results: IDNSResponse[] = []; const errors: Array<{ item: IDNSQuery; error: Error }> = []; const startTime = Date.now(); for (const query of queries) { try { const response = await this.resolve(query.domain, query.type); results.push(response); } catch (error) { errors.push({ item: query, error: error as Error }); } } const result = { successful: results, failed: errors, totalProcessed: queries.length, duration: Date.now() - startTime, }; MemoryOptimizer.logMemoryUsage('After batch resolve'); console.log( `Batch resolution completed: ${result.successful.length} successful, ${result.failed.length} failed, ${result.duration}ms`, ); // 成功した結果と失敗した結果を統合 const allResults: IDNSResponse[] = [ ...result.successful, ...result.failed.map((failure) => ({ query: failure.item, records: [], responseTime: 0, status: 'error' as const, error: failure.error.message, })), ]; return allResults; } catch (error) { console.error('Batch resolve failed:', error); // フォールバックとして従来の方法を使用 const promises = queries.map((query) => this.resolve(query.domain, query.type)); return Promise.allSettled(promises).then((results) => results.map((result) => result.status === 'fulfilled' ? result.value : { query: { domain: '', type: 'A' as const }, records: [], responseTime: 0, status: 'error' as const, error: 'Promise rejected', }, ), ); } } // 並列解決の最適化版 async batchResolveOptimized( queries: IDNSQuery[], options: { concurrency?: number; retryOnError?: boolean; onProgress?: (completed: number, total: number) => void; } = {}, ): Promise<IDNSResponse[]> { const { concurrency = 10, retryOnError = true, onProgress } = options; // 進捗トラッカーの設定 const tracker = onProgress ? new ProgressTracker(queries.length, (info) => { onProgress(info.completed, info.total); }) : null; // 各クエリを実行する関数を作成 const executeQuery = async (query: IDNSQuery): Promise<IDNSResponse> => { try { const resolver = retryOnError ? () => withTimeout( () => this.resolve(query.domain, query.type), this.timeout, `DNS resolution timeout for ${query.domain}`, ) : () => this.resolve(query.domain, query.type); const result = retryOnError ? await withRetry(resolver, { maxAttempts: 3, delay: 500, backoff: 'exponential', onRetry: (attempt, error) => { console.warn(`Retry attempt ${attempt} for ${query.domain}:`, error); }, }) : await resolver(); if (tracker) { tracker.increment(); } return result; } catch (error) { if (tracker) { tracker.increment(); } return { query, records: [], responseTime: 0, status: 'error' as const, error: error instanceof Error ? error.message : 'Unknown error', }; } }; // 並列実行 return mapConcurrent(queries, executeQuery, { concurrency }); } // 複数ドメインの全レコードタイプを解決 async resolveAllRecordsForDomains( domains: string[], options: { recordTypes?: DNSRecordType[]; concurrency?: number; onProgress?: (completed: number, total: number) => void; } = {}, ): Promise<Map<string, IDNSResponse[]>> { const { recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS'], concurrency = 10, onProgress, } = options; // クエリを生成 const queries: IDNSQuery[] = []; for (const domain of domains) { for (const type of recordTypes) { queries.push({ domain, type }); } } // 並列解決 const responses = await this.batchResolveOptimized(queries, { concurrency, onProgress, }); // ドメインごとにグループ化 const resultMap = new Map<string, IDNSResponse[]>(); for (const response of responses) { const domain = response.query.domain; if (!resultMap.has(domain)) { resultMap.set(domain, []); } resultMap.get(domain)!.push(response); } return resultMap; } /** * キャッシュ統計を取得 */ getCacheStats() { return this.cache?.getStats() || null; } /** * キャッシュをクリア */ clearCache(): void { this.cache?.clear(); } /** * 特定のドメイン/タイプのキャッシュを無効化 */ invalidateCache(domain?: string, type?: string): number { return this.cache?.invalidate(domain, type) || 0; } /** * キャッシュクリーンアップを実行 */ cleanupCache(): number { return this.cache?.cleanup() || 0; } /** * リソースを破棄 */ destroy(): void { this.cache?.destroy(); } }