Skip to content

Commit d0d77f8

Browse files
Mossakaclaude
andauthored
feat: auto-detect host DNS resolvers instead of hardcoding Google DNS (#1513)
* feat: auto-detect host DNS resolvers instead of hardcoding Google DNS Fixes #1512 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback on dns-resolver - Remove unused DOCKER_EMBEDDED_DNS and LOCAL_STUB_RESOLVERS constants (and their eslint-disable comments) - Replace hand-rolled isValidIp with Node's net.isIP() for strict IPv4/IPv6 validation - Allow leading whitespace in resolv.conf nameserver lines - Convert dynamic import to static import in cli.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f7361c5 commit d0d77f8

File tree

7 files changed

+250
-14
lines changed

7 files changed

+250
-14
lines changed

src/cli-workflow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { WrapperConfig } from './types';
22
import { HostAccessConfig } from './host-iptables';
3+
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
34

45
export interface WorkflowDependencies {
56
ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>;
@@ -46,7 +47,7 @@ export async function runMainWorkflow(
4647
const networkConfig = await dependencies.ensureFirewallNetwork();
4748
// When API proxy is enabled, allow agent→sidecar traffic at the host level.
4849
// The sidecar itself routes through Squid, so domain whitelisting is still enforced.
49-
const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4'];
50+
const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS;
5051
const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined;
5152
// When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver
5253
const dohProxyIp = config.dnsOverHttps ? '172.30.0.40' : undefined;

src/cli.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { runMainWorkflow } from './cli-workflow';
2424
import { redactSecrets } from './redact-secrets';
2525
import { validateDomainOrPattern } from './domain-patterns';
2626
import { loadAndMergeDomains } from './rules';
27+
import { detectHostDnsServers } from './dns-resolver';
2728
import { OutputFormat } from './types';
2829
import { version } from '../package.json';
2930

@@ -81,8 +82,9 @@ export function parseDomainsFile(filePath: string): string[] {
8182

8283
/**
8384
* Default DNS servers (Google Public DNS)
85+
* @deprecated Import from dns-resolver.ts instead
8486
*/
85-
export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4'];
87+
export { DEFAULT_DNS_SERVERS } from './dns-resolver';
8688

8789
/**
8890
* Validates that a string is a valid IPv4 address
@@ -1304,8 +1306,7 @@ program
13041306
// -- Network & Security --
13051307
.option(
13061308
'--dns-servers <servers>',
1307-
'Comma-separated trusted DNS servers',
1308-
'8.8.8.8,8.8.4.4'
1309+
'Comma-separated trusted DNS servers (auto-detected from host if omitted)'
13091310
)
13101311
.option(
13111312
'--dns-over-https [resolver-url]',
@@ -1601,13 +1602,17 @@ program
16011602
logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`);
16021603
}
16031604

1604-
// Parse and validate DNS servers
1605+
// Parse and validate DNS servers (auto-detect if not explicitly provided)
16051606
let dnsServers: string[];
1606-
try {
1607-
dnsServers = parseDnsServers(options.dnsServers);
1608-
} catch (error) {
1609-
logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`);
1610-
process.exit(1);
1607+
if (options.dnsServers) {
1608+
try {
1609+
dnsServers = parseDnsServers(options.dnsServers);
1610+
} catch (error) {
1611+
logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`);
1612+
process.exit(1);
1613+
}
1614+
} else {
1615+
dnsServers = detectHostDnsServers(logger);
16111616
}
16121617

16131618
// Parse and validate --dns-over-https

src/dns-resolver.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { parseResolvConf, detectHostDnsServers, getEffectiveDnsServers, DEFAULT_DNS_SERVERS } from './dns-resolver';
2+
import * as fs from 'fs';
3+
4+
jest.mock('fs');
5+
const mockedFs = fs as jest.Mocked<typeof fs>;
6+
7+
const mockLogger = {
8+
debug: jest.fn(),
9+
info: jest.fn(),
10+
warn: jest.fn(),
11+
error: jest.fn(),
12+
success: jest.fn(),
13+
setLevel: jest.fn(),
14+
};
15+
16+
beforeEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
describe('parseResolvConf', () => {
21+
it('extracts nameservers from standard content', () => {
22+
const content = `# Generated by systemd-resolved
23+
nameserver 1.1.1.1
24+
nameserver 9.9.9.9
25+
search example.com
26+
`;
27+
expect(parseResolvConf(content)).toEqual(['1.1.1.1', '9.9.9.9']);
28+
});
29+
30+
it('ignores comments and empty lines', () => {
31+
const content = `
32+
# This is a comment
33+
; Another comment style
34+
35+
nameserver 1.1.1.1
36+
37+
# nameserver 2.2.2.2
38+
nameserver 8.8.8.8
39+
`;
40+
expect(parseResolvConf(content)).toEqual(['1.1.1.1', '8.8.8.8']);
41+
});
42+
43+
it('skips invalid IPs', () => {
44+
const content = `nameserver 1.1.1.1
45+
nameserver not-an-ip
46+
nameserver 8.8.8.8
47+
`;
48+
expect(parseResolvConf(content)).toEqual(['1.1.1.1', '8.8.8.8']);
49+
});
50+
51+
it('handles IPv6 nameservers', () => {
52+
const content = `nameserver 2001:4860:4860::8888
53+
nameserver 1.1.1.1
54+
nameserver ::1
55+
`;
56+
expect(parseResolvConf(content)).toEqual(['2001:4860:4860::8888', '1.1.1.1', '::1']);
57+
});
58+
});
59+
60+
describe('detectHostDnsServers', () => {
61+
it('filters out 127.0.0.11 (Docker embedded DNS)', () => {
62+
mockedFs.readFileSync.mockReturnValue(
63+
'nameserver 127.0.0.11\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n'
64+
);
65+
const result = detectHostDnsServers(mockLogger as any);
66+
expect(result).toEqual(['1.1.1.1', '8.8.8.8']);
67+
});
68+
69+
it('filters out 127.0.0.53 and tries secondary file', () => {
70+
mockedFs.readFileSync.mockImplementation((filePath: any) => {
71+
if (filePath === '/run/systemd/resolve/resolv.conf') {
72+
throw new Error('ENOENT');
73+
}
74+
if (filePath === '/etc/resolv.conf') {
75+
return 'nameserver 127.0.0.53\n';
76+
}
77+
throw new Error('ENOENT');
78+
});
79+
const result = detectHostDnsServers(mockLogger as any);
80+
expect(result).toEqual(DEFAULT_DNS_SERVERS);
81+
expect(mockLogger.warn).toHaveBeenCalled();
82+
});
83+
84+
it('returns DEFAULT_DNS_SERVERS when no files are readable', () => {
85+
mockedFs.readFileSync.mockImplementation(() => {
86+
throw new Error('ENOENT');
87+
});
88+
const result = detectHostDnsServers(mockLogger as any);
89+
expect(result).toEqual(DEFAULT_DNS_SERVERS);
90+
expect(mockLogger.warn).toHaveBeenCalledWith(
91+
expect.stringContaining('falling back to')
92+
);
93+
});
94+
95+
it('uses first readable file with usable servers', () => {
96+
mockedFs.readFileSync.mockImplementation((filePath: any) => {
97+
if (filePath === '/run/systemd/resolve/resolv.conf') {
98+
return 'nameserver 9.9.9.9\nnameserver 1.1.1.1\n';
99+
}
100+
return 'nameserver 8.8.8.8\n';
101+
});
102+
const result = detectHostDnsServers(mockLogger as any);
103+
expect(result).toEqual(['9.9.9.9', '1.1.1.1']);
104+
expect(mockLogger.info).toHaveBeenCalledWith(
105+
expect.stringContaining('/run/systemd/resolve/resolv.conf')
106+
);
107+
});
108+
109+
it('filters out ::1 IPv6 loopback', () => {
110+
mockedFs.readFileSync.mockReturnValue(
111+
'nameserver ::1\nnameserver 2001:4860:4860::8888\n'
112+
);
113+
const result = detectHostDnsServers(mockLogger as any);
114+
expect(result).toEqual(['2001:4860:4860::8888']);
115+
});
116+
});
117+
118+
describe('getEffectiveDnsServers', () => {
119+
it('returns explicit servers when provided', () => {
120+
const result = getEffectiveDnsServers(['1.1.1.1', '9.9.9.9'], mockLogger as any);
121+
expect(result).toEqual(['1.1.1.1', '9.9.9.9']);
122+
});
123+
124+
it('calls auto-detect when explicit is undefined', () => {
125+
mockedFs.readFileSync.mockReturnValue('nameserver 9.9.9.9\n');
126+
const result = getEffectiveDnsServers(undefined, mockLogger as any);
127+
expect(result).toEqual(['9.9.9.9']);
128+
});
129+
130+
it('calls auto-detect when explicit is empty array', () => {
131+
mockedFs.readFileSync.mockImplementation(() => {
132+
throw new Error('ENOENT');
133+
});
134+
const result = getEffectiveDnsServers([], mockLogger as any);
135+
expect(result).toEqual(DEFAULT_DNS_SERVERS);
136+
});
137+
});

src/dns-resolver.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as fs from 'fs';
2+
import { isIP } from 'net';
3+
import { logger as defaultLogger } from './logger';
4+
5+
type Logger = typeof defaultLogger;
6+
7+
/** Fallback when no usable resolvers are detected on the host */
8+
export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4'];
9+
10+
/**
11+
* Paths to try for resolv.conf, in priority order.
12+
* systemd-resolved's upstream config first (has real upstream servers),
13+
* then the standard resolv.conf (may contain 127.0.0.53 stub).
14+
*/
15+
const RESOLV_CONF_PATHS = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf'];
16+
17+
function isValidIp(ip: string): boolean {
18+
return isIP(ip) !== 0;
19+
}
20+
21+
function isLoopback(ip: string): boolean {
22+
// 127.0.0.0/8 for IPv4
23+
if (ip.startsWith('127.')) return true;
24+
// ::1 for IPv6
25+
if (ip === '::1') return true;
26+
return false;
27+
}
28+
29+
/**
30+
* Parse nameserver entries from resolv.conf content.
31+
* Pure function — no I/O.
32+
*/
33+
export function parseResolvConf(content: string): string[] {
34+
const servers: string[] = [];
35+
for (const line of content.split('\n')) {
36+
const match = line.match(/^\s*nameserver\s+(\S+)/);
37+
if (match) {
38+
const ip = match[1];
39+
if (isValidIp(ip)) {
40+
servers.push(ip);
41+
}
42+
}
43+
}
44+
return servers;
45+
}
46+
47+
/**
48+
* Detect usable DNS servers from the host's resolv.conf files.
49+
* Filters out loopback addresses (127.0.0.0/8, ::1) since those point to
50+
* local stub resolvers that won't be reachable from inside a container.
51+
* Falls back to DEFAULT_DNS_SERVERS if no usable servers are found.
52+
*/
53+
export function detectHostDnsServers(logger?: Logger): string[] {
54+
const log = logger ?? defaultLogger;
55+
56+
for (const filePath of RESOLV_CONF_PATHS) {
57+
let content: string;
58+
try {
59+
content = fs.readFileSync(filePath, 'utf-8');
60+
} catch {
61+
log.debug(`DNS auto-detect: could not read ${filePath}, trying next`);
62+
continue;
63+
}
64+
65+
const allServers = parseResolvConf(content);
66+
const usable = allServers.filter(ip => !isLoopback(ip));
67+
68+
if (usable.length > 0) {
69+
log.info(`Auto-detected DNS servers from ${filePath}: ${usable.join(', ')}`);
70+
return usable;
71+
}
72+
73+
log.debug(`DNS auto-detect: ${filePath} had no usable servers after filtering loopback addresses`);
74+
}
75+
76+
log.warn(`Could not detect host DNS servers; falling back to ${DEFAULT_DNS_SERVERS.join(', ')}`);
77+
return DEFAULT_DNS_SERVERS;
78+
}
79+
80+
/**
81+
* Return the effective DNS server list.
82+
* If the user explicitly passed --dns-servers, use those.
83+
* Otherwise, auto-detect from the host.
84+
*/
85+
export function getEffectiveDnsServers(explicit: string[] | undefined, logger?: Logger): string[] {
86+
if (explicit && explicit.length > 0) {
87+
return explicit;
88+
}
89+
return detectHostDnsServers(logger);
90+
}

src/docker-manager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DockerComposeConfig, WrapperConfig, BlockedTarget, API_PROXY_PORTS, API
77
import { logger } from './logger';
88
import { generateSquidConfig, generatePolicyManifest } from './squid-config';
99
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';
10+
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
1011

1112
const SQUID_PORT = 3128;
1213

@@ -688,7 +689,7 @@ export function generateDockerCompose(
688689
}
689690

690691
// DNS servers for Docker embedded DNS forwarding (used in docker-compose dns: field)
691-
const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4'];
692+
const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS;
692693
// Pass DNS servers to container so setup-iptables.sh can allow Docker DNS forwarding
693694
// to these upstream servers while blocking direct DNS to all other servers.
694695
environment.AWF_DNS_SERVERS = dnsServers.join(',');

src/host-iptables.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import execa from 'execa';
22
import { logger } from './logger';
33
import { API_PROXY_PORTS } from './types';
4+
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
45

56
const NETWORK_NAME = 'awf-net';
67
const CHAIN_NAME = 'FW_WRAPPER';
@@ -340,7 +341,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS
340341
// Docker's embedded DNS (127.0.0.11) proxies queries to upstream servers configured
341342
// via docker-compose dns: field. These forwarded queries traverse the Docker bridge
342343
// and need to be allowed here. Only the configured upstream servers are permitted.
343-
const upstreamDns = dnsServers && dnsServers.length > 0 ? dnsServers : ['8.8.8.8', '8.8.4.4'];
344+
const upstreamDns = dnsServers && dnsServers.length > 0 ? dnsServers : DEFAULT_DNS_SERVERS;
344345
logger.debug(`Allowing DNS forwarding to upstream servers: ${upstreamDns.join(', ')}`);
345346

346347
// Create IPv6 chain if needed (only when IPv6 DNS servers are configured)

src/squid-config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DomainPattern,
77
} from './domain-patterns';
88
import { generateDlpSquidConfig } from './dlp';
9+
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
910

1011
/**
1112
* Ports that should never be allowed, even with --allow-host-ports
@@ -590,7 +591,7 @@ http_access deny all
590591
cache deny all
591592
592593
# DNS settings - Squid resolves all domains for HTTP/HTTPS traffic
593-
dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : '8.8.8.8 8.8.4.4'}
594+
dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : DEFAULT_DNS_SERVERS.join(' ')}
594595
595596
# Forwarded headers
596597
forwarded_for delete
@@ -828,7 +829,7 @@ export function generatePolicyManifest(config: SquidConfig): PolicyManifest {
828829
generatedAt: new Date().toISOString(),
829830
rules,
830831
dangerousPorts: DANGEROUS_PORTS,
831-
dnsServers: dnsServers || ['8.8.8.8', '8.8.4.4'],
832+
dnsServers: dnsServers || DEFAULT_DNS_SERVERS,
832833
sslBumpEnabled: sslBump ?? false,
833834
dlpEnabled: enableDlp ?? false,
834835
hostAccessEnabled: enableHostAccess ?? false,

0 commit comments

Comments
 (0)