diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index cce4b7a8..cca04105 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -1,5 +1,6 @@ import { WrapperConfig } from './types'; import { HostAccessConfig } from './host-iptables'; +import { DEFAULT_DNS_SERVERS } from './dns-resolver'; export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; @@ -46,7 +47,7 @@ export async function runMainWorkflow( const networkConfig = await dependencies.ensureFirewallNetwork(); // When API proxy is enabled, allow agent→sidecar traffic at the host level. // The sidecar itself routes through Squid, so domain whitelisting is still enforced. - const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; + const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS; const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; // When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver const dohProxyIp = config.dnsOverHttps ? '172.30.0.40' : undefined; diff --git a/src/cli.ts b/src/cli.ts index ae1ea9a4..eac89a81 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,7 @@ import { runMainWorkflow } from './cli-workflow'; import { redactSecrets } from './redact-secrets'; import { validateDomainOrPattern } from './domain-patterns'; import { loadAndMergeDomains } from './rules'; +import { detectHostDnsServers } from './dns-resolver'; import { OutputFormat } from './types'; import { version } from '../package.json'; @@ -81,8 +82,9 @@ export function parseDomainsFile(filePath: string): string[] { /** * Default DNS servers (Google Public DNS) + * @deprecated Import from dns-resolver.ts instead */ -export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4']; +export { DEFAULT_DNS_SERVERS } from './dns-resolver'; /** * Validates that a string is a valid IPv4 address @@ -1304,8 +1306,7 @@ program // -- Network & Security -- .option( '--dns-servers ', - 'Comma-separated trusted DNS servers', - '8.8.8.8,8.8.4.4' + 'Comma-separated trusted DNS servers (auto-detected from host if omitted)' ) .option( '--dns-over-https [resolver-url]', @@ -1601,13 +1602,17 @@ program logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`); } - // Parse and validate DNS servers + // Parse and validate DNS servers (auto-detect if not explicitly provided) let dnsServers: string[]; - try { - dnsServers = parseDnsServers(options.dnsServers); - } catch (error) { - logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`); - process.exit(1); + if (options.dnsServers) { + try { + dnsServers = parseDnsServers(options.dnsServers); + } catch (error) { + logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + } else { + dnsServers = detectHostDnsServers(logger); } // Parse and validate --dns-over-https diff --git a/src/dns-resolver.test.ts b/src/dns-resolver.test.ts new file mode 100644 index 00000000..a99a9b74 --- /dev/null +++ b/src/dns-resolver.test.ts @@ -0,0 +1,137 @@ +import { parseResolvConf, detectHostDnsServers, getEffectiveDnsServers, DEFAULT_DNS_SERVERS } from './dns-resolver'; +import * as fs from 'fs'; + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn(), + setLevel: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('parseResolvConf', () => { + it('extracts nameservers from standard content', () => { + const content = `# Generated by systemd-resolved +nameserver 1.1.1.1 +nameserver 9.9.9.9 +search example.com +`; + expect(parseResolvConf(content)).toEqual(['1.1.1.1', '9.9.9.9']); + }); + + it('ignores comments and empty lines', () => { + const content = ` +# This is a comment +; Another comment style + +nameserver 1.1.1.1 + +# nameserver 2.2.2.2 +nameserver 8.8.8.8 +`; + expect(parseResolvConf(content)).toEqual(['1.1.1.1', '8.8.8.8']); + }); + + it('skips invalid IPs', () => { + const content = `nameserver 1.1.1.1 +nameserver not-an-ip +nameserver 8.8.8.8 +`; + expect(parseResolvConf(content)).toEqual(['1.1.1.1', '8.8.8.8']); + }); + + it('handles IPv6 nameservers', () => { + const content = `nameserver 2001:4860:4860::8888 +nameserver 1.1.1.1 +nameserver ::1 +`; + expect(parseResolvConf(content)).toEqual(['2001:4860:4860::8888', '1.1.1.1', '::1']); + }); +}); + +describe('detectHostDnsServers', () => { + it('filters out 127.0.0.11 (Docker embedded DNS)', () => { + mockedFs.readFileSync.mockReturnValue( + 'nameserver 127.0.0.11\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n' + ); + const result = detectHostDnsServers(mockLogger as any); + expect(result).toEqual(['1.1.1.1', '8.8.8.8']); + }); + + it('filters out 127.0.0.53 and tries secondary file', () => { + mockedFs.readFileSync.mockImplementation((filePath: any) => { + if (filePath === '/run/systemd/resolve/resolv.conf') { + throw new Error('ENOENT'); + } + if (filePath === '/etc/resolv.conf') { + return 'nameserver 127.0.0.53\n'; + } + throw new Error('ENOENT'); + }); + const result = detectHostDnsServers(mockLogger as any); + expect(result).toEqual(DEFAULT_DNS_SERVERS); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('returns DEFAULT_DNS_SERVERS when no files are readable', () => { + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const result = detectHostDnsServers(mockLogger as any); + expect(result).toEqual(DEFAULT_DNS_SERVERS); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to') + ); + }); + + it('uses first readable file with usable servers', () => { + mockedFs.readFileSync.mockImplementation((filePath: any) => { + if (filePath === '/run/systemd/resolve/resolv.conf') { + return 'nameserver 9.9.9.9\nnameserver 1.1.1.1\n'; + } + return 'nameserver 8.8.8.8\n'; + }); + const result = detectHostDnsServers(mockLogger as any); + expect(result).toEqual(['9.9.9.9', '1.1.1.1']); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('/run/systemd/resolve/resolv.conf') + ); + }); + + it('filters out ::1 IPv6 loopback', () => { + mockedFs.readFileSync.mockReturnValue( + 'nameserver ::1\nnameserver 2001:4860:4860::8888\n' + ); + const result = detectHostDnsServers(mockLogger as any); + expect(result).toEqual(['2001:4860:4860::8888']); + }); +}); + +describe('getEffectiveDnsServers', () => { + it('returns explicit servers when provided', () => { + const result = getEffectiveDnsServers(['1.1.1.1', '9.9.9.9'], mockLogger as any); + expect(result).toEqual(['1.1.1.1', '9.9.9.9']); + }); + + it('calls auto-detect when explicit is undefined', () => { + mockedFs.readFileSync.mockReturnValue('nameserver 9.9.9.9\n'); + const result = getEffectiveDnsServers(undefined, mockLogger as any); + expect(result).toEqual(['9.9.9.9']); + }); + + it('calls auto-detect when explicit is empty array', () => { + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const result = getEffectiveDnsServers([], mockLogger as any); + expect(result).toEqual(DEFAULT_DNS_SERVERS); + }); +}); diff --git a/src/dns-resolver.ts b/src/dns-resolver.ts new file mode 100644 index 00000000..390d7fe2 --- /dev/null +++ b/src/dns-resolver.ts @@ -0,0 +1,90 @@ +import * as fs from 'fs'; +import { isIP } from 'net'; +import { logger as defaultLogger } from './logger'; + +type Logger = typeof defaultLogger; + +/** Fallback when no usable resolvers are detected on the host */ +export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4']; + +/** + * Paths to try for resolv.conf, in priority order. + * systemd-resolved's upstream config first (has real upstream servers), + * then the standard resolv.conf (may contain 127.0.0.53 stub). + */ +const RESOLV_CONF_PATHS = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf']; + +function isValidIp(ip: string): boolean { + return isIP(ip) !== 0; +} + +function isLoopback(ip: string): boolean { + // 127.0.0.0/8 for IPv4 + if (ip.startsWith('127.')) return true; + // ::1 for IPv6 + if (ip === '::1') return true; + return false; +} + +/** + * Parse nameserver entries from resolv.conf content. + * Pure function — no I/O. + */ +export function parseResolvConf(content: string): string[] { + const servers: string[] = []; + for (const line of content.split('\n')) { + const match = line.match(/^\s*nameserver\s+(\S+)/); + if (match) { + const ip = match[1]; + if (isValidIp(ip)) { + servers.push(ip); + } + } + } + return servers; +} + +/** + * Detect usable DNS servers from the host's resolv.conf files. + * Filters out loopback addresses (127.0.0.0/8, ::1) since those point to + * local stub resolvers that won't be reachable from inside a container. + * Falls back to DEFAULT_DNS_SERVERS if no usable servers are found. + */ +export function detectHostDnsServers(logger?: Logger): string[] { + const log = logger ?? defaultLogger; + + for (const filePath of RESOLV_CONF_PATHS) { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + log.debug(`DNS auto-detect: could not read ${filePath}, trying next`); + continue; + } + + const allServers = parseResolvConf(content); + const usable = allServers.filter(ip => !isLoopback(ip)); + + if (usable.length > 0) { + log.info(`Auto-detected DNS servers from ${filePath}: ${usable.join(', ')}`); + return usable; + } + + log.debug(`DNS auto-detect: ${filePath} had no usable servers after filtering loopback addresses`); + } + + log.warn(`Could not detect host DNS servers; falling back to ${DEFAULT_DNS_SERVERS.join(', ')}`); + return DEFAULT_DNS_SERVERS; +} + +/** + * Return the effective DNS server list. + * If the user explicitly passed --dns-servers, use those. + * Otherwise, auto-detect from the host. + */ +export function getEffectiveDnsServers(explicit: string[] | undefined, logger?: Logger): string[] { + if (explicit && explicit.length > 0) { + return explicit; + } + return detectHostDnsServers(logger); +} diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 286a781e..06997287 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -7,6 +7,7 @@ import { DockerComposeConfig, WrapperConfig, BlockedTarget, API_PROXY_PORTS, API import { logger } from './logger'; import { generateSquidConfig, generatePolicyManifest } from './squid-config'; import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump'; +import { DEFAULT_DNS_SERVERS } from './dns-resolver'; const SQUID_PORT = 3128; @@ -687,7 +688,7 @@ export function generateDockerCompose( } // DNS servers for Docker embedded DNS forwarding (used in docker-compose dns: field) - const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; + const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS; // Pass DNS servers to container so setup-iptables.sh can allow Docker DNS forwarding // to these upstream servers while blocking direct DNS to all other servers. environment.AWF_DNS_SERVERS = dnsServers.join(','); diff --git a/src/host-iptables.ts b/src/host-iptables.ts index bb8e9824..96b041b5 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -1,6 +1,7 @@ import execa from 'execa'; import { logger } from './logger'; import { API_PROXY_PORTS } from './types'; +import { DEFAULT_DNS_SERVERS } from './dns-resolver'; const NETWORK_NAME = 'awf-net'; const CHAIN_NAME = 'FW_WRAPPER'; @@ -340,7 +341,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS // Docker's embedded DNS (127.0.0.11) proxies queries to upstream servers configured // via docker-compose dns: field. These forwarded queries traverse the Docker bridge // and need to be allowed here. Only the configured upstream servers are permitted. - const upstreamDns = dnsServers && dnsServers.length > 0 ? dnsServers : ['8.8.8.8', '8.8.4.4']; + const upstreamDns = dnsServers && dnsServers.length > 0 ? dnsServers : DEFAULT_DNS_SERVERS; logger.debug(`Allowing DNS forwarding to upstream servers: ${upstreamDns.join(', ')}`); // Create IPv6 chain if needed (only when IPv6 DNS servers are configured) diff --git a/src/squid-config.ts b/src/squid-config.ts index 73deae6a..4986e41b 100644 --- a/src/squid-config.ts +++ b/src/squid-config.ts @@ -6,6 +6,7 @@ import { DomainPattern, } from './domain-patterns'; import { generateDlpSquidConfig } from './dlp'; +import { DEFAULT_DNS_SERVERS } from './dns-resolver'; /** * Ports that should never be allowed, even with --allow-host-ports @@ -582,7 +583,7 @@ http_access deny all cache deny all # DNS settings - Squid resolves all domains for HTTP/HTTPS traffic -dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : '8.8.8.8 8.8.4.4'} +dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : DEFAULT_DNS_SERVERS.join(' ')} # Forwarded headers forwarded_for delete @@ -820,7 +821,7 @@ export function generatePolicyManifest(config: SquidConfig): PolicyManifest { generatedAt: new Date().toISOString(), rules, dangerousPorts: DANGEROUS_PORTS, - dnsServers: dnsServers || ['8.8.8.8', '8.8.4.4'], + dnsServers: dnsServers || DEFAULT_DNS_SERVERS, sslBumpEnabled: sslBump ?? false, dlpEnabled: enableDlp ?? false, hostAccessEnabled: enableHostAccess ?? false,