Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli-workflow.ts
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 14 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1304,8 +1306,7 @@ program
// -- Network & Security --
.option(
'--dns-servers <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]',
Expand Down Expand Up @@ -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);
}
Comment on lines +1605 to 1616
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if (options.dnsServers) check treats an explicitly provided empty string (e.g. --dns-servers "") as “not provided” and silently falls back to host auto-detection instead of erroring. Prefer checking options.dnsServers !== undefined (or reusing getEffectiveDnsServers() so the behavior is centralized and tested).

Copilot uses AI. Check for mistakes.

// Parse and validate --dns-over-https
Expand Down
137 changes: 137 additions & 0 deletions src/dns-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs>;

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);
});
});
90 changes: 90 additions & 0 deletions src/dns-resolver.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(',');
Expand Down
3 changes: 2 additions & 1 deletion src/host-iptables.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions src/squid-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading