Skip to content

Commit 221cc8f

Browse files
Mossakaclaude
andauthored
feat(cli): add DNS-over-HTTPS support via --dns-over-https flag (#1280)
* feat(dns): add DNS-over-HTTPS support via --dns-over-https flag Deploy a cloudflare/cloudflared sidecar container as a DoH proxy when --dns-over-https is used. Agent DNS queries are routed through the DoH proxy, which encrypts them over HTTPS to prevent DNS spoofing and interception. Legacy UDP DNS to external servers is blocked. - Add --dns-over-https [resolver-url] CLI flag (default: dns.google) - Add doh-proxy sidecar service with security hardening - Update host-iptables to allow DoH proxy HTTPS access - Update setup-iptables.sh to route DNS through DoH proxy - Update entrypoint.sh to configure resolv.conf for DoH - Add 14 unit tests for DoH configuration Fixes #307 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add parseDnsOverHttps tests to restore CLI coverage Extract DNS-over-HTTPS validation into testable parseDnsOverHttps() function and add 5 unit tests covering: undefined input, flag without argument (default resolver), custom URL, non-https URL error, and plain string error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: trigger CI --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f76ee0f commit 221cc8f

File tree

10 files changed

+433
-80
lines changed

10 files changed

+433
-80
lines changed

containers/agent/entrypoint.sh

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -73,29 +73,43 @@ if [ -f /etc/resolv.conf ]; then
7373
# Backup original resolv.conf
7474
cp /etc/resolv.conf /etc/resolv.conf.orig
7575

76-
# Get DNS servers from environment (default to Google DNS)
77-
DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}"
78-
79-
# Create new resolv.conf with Docker embedded DNS first, then trusted external DNS servers
80-
{
81-
echo "# Generated by awf entrypoint"
82-
echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)"
83-
echo "nameserver 127.0.0.11"
84-
echo "# Trusted external DNS servers for internet domain resolution"
85-
86-
# Add each trusted DNS server
87-
IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS"
88-
for dns_server in "${DNS_ARRAY[@]}"; do
89-
dns_server=$(echo "$dns_server" | tr -d ' ')
90-
if [ -n "$dns_server" ]; then
91-
echo "nameserver $dns_server"
92-
fi
93-
done
94-
95-
echo "options ndots:0"
96-
} > /etc/resolv.conf
97-
98-
echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and trusted servers: $DNS_SERVERS"
76+
if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then
77+
# DNS-over-HTTPS mode: use DoH proxy as the DNS resolver
78+
{
79+
echo "# Generated by awf entrypoint (DNS-over-HTTPS mode)"
80+
echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)"
81+
echo "nameserver 127.0.0.11"
82+
echo "# DNS-over-HTTPS proxy for encrypted internet domain resolution"
83+
echo "nameserver $AWF_DOH_PROXY_IP"
84+
echo "options ndots:0"
85+
} > /etc/resolv.conf
86+
echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and DoH proxy ($AWF_DOH_PROXY_IP)"
87+
else
88+
# Traditional DNS mode: use configured DNS servers
89+
# Get DNS servers from environment (default to Google DNS)
90+
DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}"
91+
92+
# Create new resolv.conf with Docker embedded DNS first, then trusted external DNS servers
93+
{
94+
echo "# Generated by awf entrypoint"
95+
echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)"
96+
echo "nameserver 127.0.0.11"
97+
echo "# Trusted external DNS servers for internet domain resolution"
98+
99+
# Add each trusted DNS server
100+
IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS"
101+
for dns_server in "${DNS_ARRAY[@]}"; do
102+
dns_server=$(echo "$dns_server" | tr -d ' ')
103+
if [ -n "$dns_server" ]; then
104+
echo "nameserver $dns_server"
105+
fi
106+
done
107+
108+
echo "options ndots:0"
109+
} > /etc/resolv.conf
110+
111+
echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and trusted servers: $DNS_SERVERS"
112+
fi
99113
fi
100114

101115
# Update CA certificates if SSL Bump is enabled

containers/agent/setup-iptables.sh

Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -81,60 +81,82 @@ fi
8181

8282
# Get DNS servers from environment (default to Google DNS)
8383
DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}"
84-
echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS"
85-
86-
# Separate IPv4 and IPv6 DNS servers
87-
IPV4_DNS_SERVERS=()
88-
IPV6_DNS_SERVERS=()
89-
IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS"
90-
for dns_server in "${DNS_ARRAY[@]}"; do
91-
dns_server=$(echo "$dns_server" | tr -d ' ')
92-
if [ -n "$dns_server" ]; then
93-
if is_ipv6 "$dns_server"; then
94-
IPV6_DNS_SERVERS+=("$dns_server")
95-
else
96-
IPV4_DNS_SERVERS+=("$dns_server")
97-
fi
98-
fi
99-
done
10084

101-
echo "[iptables] IPv4 DNS servers: ${IPV4_DNS_SERVERS[*]:-none}"
102-
echo "[iptables] IPv6 DNS servers: ${IPV6_DNS_SERVERS[*]:-none}"
85+
# Check if DNS-over-HTTPS mode is enabled
86+
if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then
87+
echo "[iptables] DNS-over-HTTPS mode: routing DNS through DoH proxy at $AWF_DOH_PROXY_IP"
10388

104-
# Allow DNS queries ONLY to trusted IPv4 DNS servers (prevents DNS exfiltration)
105-
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
106-
echo "[iptables] Allow DNS to trusted IPv4 server: $dns_server"
107-
iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
108-
iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
109-
done
89+
# Allow DNS to DoH proxy
90+
iptables -t nat -A OUTPUT -p udp -d "$AWF_DOH_PROXY_IP" --dport 53 -j RETURN
91+
iptables -t nat -A OUTPUT -p tcp -d "$AWF_DOH_PROXY_IP" --dport 53 -j RETURN
11092

111-
# Allow DNS queries ONLY to trusted IPv6 DNS servers
112-
if [ "$IP6TABLES_AVAILABLE" = true ]; then
113-
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
114-
echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server"
115-
ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
116-
ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
93+
# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution
94+
echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..."
95+
iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN
96+
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN
97+
98+
# Allow return traffic to DoH proxy
99+
iptables -t nat -A OUTPUT -d "$AWF_DOH_PROXY_IP" -j RETURN
100+
101+
# Set variables for OUTPUT filter chain (used later)
102+
IPV4_DNS_SERVERS=()
103+
IPV6_DNS_SERVERS=()
104+
else
105+
echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS"
106+
107+
# Separate IPv4 and IPv6 DNS servers
108+
IPV4_DNS_SERVERS=()
109+
IPV6_DNS_SERVERS=()
110+
IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS"
111+
for dns_server in "${DNS_ARRAY[@]}"; do
112+
dns_server=$(echo "$dns_server" | tr -d ' ')
113+
if [ -n "$dns_server" ]; then
114+
if is_ipv6 "$dns_server"; then
115+
IPV6_DNS_SERVERS+=("$dns_server")
116+
else
117+
IPV4_DNS_SERVERS+=("$dns_server")
118+
fi
119+
fi
117120
done
118-
elif [ ${#IPV6_DNS_SERVERS[@]} -gt 0 ]; then
119-
echo "[iptables] WARNING: IPv6 DNS servers configured but ip6tables not available"
120-
fi
121121

122-
# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution
123-
echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..."
124-
iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN
125-
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN
122+
echo "[iptables] IPv4 DNS servers: ${IPV4_DNS_SERVERS[*]:-none}"
123+
echo "[iptables] IPv6 DNS servers: ${IPV6_DNS_SERVERS[*]:-none}"
126124

127-
# Allow return traffic to trusted IPv4 DNS servers
128-
echo "[iptables] Allow traffic to trusted DNS servers..."
129-
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
130-
iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN
131-
done
125+
# Allow DNS queries ONLY to trusted IPv4 DNS servers (prevents DNS exfiltration)
126+
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
127+
echo "[iptables] Allow DNS to trusted IPv4 server: $dns_server"
128+
iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
129+
iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
130+
done
132131

133-
# Allow return traffic to trusted IPv6 DNS servers
134-
if [ "$IP6TABLES_AVAILABLE" = true ]; then
135-
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
136-
ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN
132+
# Allow DNS queries ONLY to trusted IPv6 DNS servers
133+
if [ "$IP6TABLES_AVAILABLE" = true ]; then
134+
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
135+
echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server"
136+
ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
137+
ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
138+
done
139+
elif [ ${#IPV6_DNS_SERVERS[@]} -gt 0 ]; then
140+
echo "[iptables] WARNING: IPv6 DNS servers configured but ip6tables not available"
141+
fi
142+
143+
# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution
144+
echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..."
145+
iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN
146+
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN
147+
148+
# Allow return traffic to trusted IPv4 DNS servers
149+
echo "[iptables] Allow traffic to trusted DNS servers..."
150+
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
151+
iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN
137152
done
153+
154+
# Allow return traffic to trusted IPv6 DNS servers
155+
if [ "$IP6TABLES_AVAILABLE" = true ]; then
156+
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
157+
ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN
158+
done
159+
fi
138160
fi
139161

140162
# Allow traffic to Squid proxy itself (prevent redirect loop)
@@ -271,11 +293,16 @@ echo "[iptables] Configuring OUTPUT filter chain rules..."
271293
# Allow localhost traffic
272294
iptables -A OUTPUT -o lo -j ACCEPT
273295

274-
# Allow DNS queries to trusted servers
275-
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
276-
iptables -A OUTPUT -p udp -d "$dns_server" --dport 53 -j ACCEPT
277-
iptables -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j ACCEPT
278-
done
296+
# Allow DNS queries to trusted servers (or DoH proxy)
297+
if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then
298+
iptables -A OUTPUT -p udp -d "$AWF_DOH_PROXY_IP" --dport 53 -j ACCEPT
299+
iptables -A OUTPUT -p tcp -d "$AWF_DOH_PROXY_IP" --dport 53 -j ACCEPT
300+
else
301+
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
302+
iptables -A OUTPUT -p udp -d "$dns_server" --dport 53 -j ACCEPT
303+
iptables -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j ACCEPT
304+
done
305+
fi
279306

280307
# Allow DNS to Docker's embedded DNS server
281308
iptables -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j ACCEPT

src/cli-workflow.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { WrapperConfig } from './types';
22

33
export interface WorkflowDependencies {
44
ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>;
5-
setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string) => Promise<void>;
5+
setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string) => Promise<void>;
66
writeConfigs: (config: WrapperConfig) => Promise<void>;
77
startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise<void>;
88
runAgentCommand: (
@@ -47,7 +47,9 @@ export async function runMainWorkflow(
4747
// When API proxy is enabled, allow agent→sidecar traffic at the host level.
4848
// The sidecar itself routes through Squid, so domain whitelisting is still enforced.
4949
const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined;
50-
await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp);
50+
// When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver
51+
const dohProxyIp = config.dnsOverHttps ? '172.30.0.40' : undefined;
52+
await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp);
5153
onHostIptablesSetup?.();
5254

5355
// Step 1: Write configuration files

src/cli.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -782,6 +782,32 @@ describe('cli', () => {
782782
});
783783
});
784784

785+
describe('parseDnsOverHttps', () => {
786+
it('should return undefined when value is undefined', () => {
787+
expect(parseDnsOverHttps(undefined)).toBeUndefined();
788+
});
789+
790+
it('should return default Google resolver when value is true (flag without argument)', () => {
791+
const result = parseDnsOverHttps(true);
792+
expect(result).toEqual({ url: 'https://dns.google/dns-query' });
793+
});
794+
795+
it('should return custom resolver URL when provided', () => {
796+
const result = parseDnsOverHttps('https://cloudflare-dns.com/dns-query');
797+
expect(result).toEqual({ url: 'https://cloudflare-dns.com/dns-query' });
798+
});
799+
800+
it('should return error for non-https URL', () => {
801+
const result = parseDnsOverHttps('http://dns.google/dns-query');
802+
expect(result).toEqual({ error: '--dns-over-https resolver URL must start with https://' });
803+
});
804+
805+
it('should return error for plain string without https prefix', () => {
806+
const result = parseDnsOverHttps('dns.google');
807+
expect(result).toEqual({ error: '--dns-over-https resolver URL must start with https://' });
808+
});
809+
});
810+
785811
describe('DEFAULT_DNS_SERVERS', () => {
786812
it('should have correct default DNS servers', async () => {
787813
// Dynamic import to get the constant

src/cli.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,26 @@ export function parseDnsServers(input: string): string[] {
587587
return servers;
588588
}
589589

590+
const DEFAULT_DOH_RESOLVER = 'https://dns.google/dns-query';
591+
592+
/**
593+
* Parses and validates the --dns-over-https option value.
594+
* Commander sets the value to `true` when the flag is used without an argument.
595+
* Returns the resolved URL, or an error string.
596+
*/
597+
export function parseDnsOverHttps(
598+
value: boolean | string | undefined
599+
): { url: string } | { error: string } | undefined {
600+
if (value === undefined) {
601+
return undefined;
602+
}
603+
const resolvedUrl: string = value === true ? DEFAULT_DOH_RESOLVER : String(value);
604+
if (!resolvedUrl.startsWith('https://')) {
605+
return { error: '--dns-over-https resolver URL must start with https://' };
606+
}
607+
return { url: resolvedUrl };
608+
}
609+
590610
/**
591611
* Result of processing the localhost keyword in allowed domains
592612
*/
@@ -1020,6 +1040,10 @@ program
10201040
'Comma-separated trusted DNS servers',
10211041
'8.8.8.8,8.8.4.4'
10221042
)
1043+
.option(
1044+
'--dns-over-https [resolver-url]',
1045+
'Enable DNS-over-HTTPS via sidecar proxy (default: https://dns.google/dns-query)'
1046+
)
10231047
.option(
10241048
'--enable-host-access',
10251049
'Enable access to host services via host.docker.internal',
@@ -1287,6 +1311,17 @@ program
12871311
process.exit(1);
12881312
}
12891313

1314+
// Parse and validate --dns-over-https
1315+
let dnsOverHttps: string | undefined;
1316+
const dohResult = parseDnsOverHttps(options.dnsOverHttps);
1317+
if (dohResult && 'error' in dohResult) {
1318+
logger.error(dohResult.error);
1319+
process.exit(1);
1320+
} else if (dohResult) {
1321+
dnsOverHttps = dohResult.url;
1322+
logger.info(`DNS-over-HTTPS enabled: ${dnsOverHttps}`);
1323+
}
1324+
12901325
// Parse --allow-urls for SSL Bump mode
12911326
let allowedUrls: string[] | undefined;
12921327
if (options.allowUrls) {
@@ -1381,6 +1416,7 @@ program
13811416
volumeMounts,
13821417
containerWorkDir: options.containerWorkdir,
13831418
dnsServers,
1419+
dnsOverHttps,
13841420
memoryLimit: memoryLimit.value,
13851421
proxyLogsDir: options.proxyLogsDir,
13861422
enableHostAccess: options.enableHostAccess,

0 commit comments

Comments
 (0)