From f4044840925b4fcc6e58080d777fdd6e089dcd3b Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 11 Mar 2026 19:14:09 +0000 Subject: [PATCH 1/4] fix(security): simplify security model by removing direct DNS exceptions Remove explicit DNS server exception rules from both container-level iptables (setup-iptables.sh) and host-level iptables (host-iptables.ts). Docker's embedded DNS (127.0.0.11) handles all DNS resolution, forwarding to upstream servers configured via Docker's dns: field. This is already covered by the localhost rules (127.0.0.0/8 RETURN), so no separate DNS exceptions are needed. Benefits: - Simpler iptables rules with fewer exceptions to audit - Prevents DNS-based data exfiltration (no direct external DNS) - Single security boundary: Squid domain allowlist + localhost - Container can only talk to: localhost, Squid proxy, API proxy Fixes #11 Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/agent/entrypoint.sh | 29 ++-- containers/agent/setup-iptables.sh | 83 +---------- src/cli-workflow.ts | 5 +- src/docker-manager.test.ts | 11 +- src/docker-manager.ts | 8 +- src/host-iptables.test.ts | 166 ++++----------------- src/host-iptables.ts | 229 +++++++++-------------------- 7 files changed, 132 insertions(+), 399 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 29eb17a8d..273a56fab 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -65,37 +65,26 @@ if [ "$CURRENT_UID" != "$HOST_UID" ] || [ "$CURRENT_GID" != "$HOST_GID" ]; then echo "[entrypoint] UID/GID adjustment complete" fi -# Fix DNS configuration - ensure external DNS works alongside Docker's embedded DNS -# Docker's embedded DNS (127.0.0.11) is used for service name resolution (e.g., squid-proxy) -# Trusted external DNS servers are used for internet domain resolution +# DNS configuration - use ONLY Docker's embedded DNS (127.0.0.11) +# Docker's embedded DNS handles both container name resolution (squid-proxy, etc.) +# and external domain resolution (forwarding to upstream DNS servers configured +# via Docker's dns: field in docker-compose.yml). No direct external DNS access +# is needed, which prevents DNS-based data exfiltration. echo "[entrypoint] Configuring DNS..." if [ -f /etc/resolv.conf ]; then # Backup original resolv.conf cp /etc/resolv.conf /etc/resolv.conf.orig - # Get DNS servers from environment (default to Google DNS) - DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" - - # Create new resolv.conf with Docker embedded DNS first, then trusted external DNS servers + # Use Docker embedded DNS only - upstream forwarding configured via docker-compose dns: { echo "# Generated by awf entrypoint" - echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)" + echo "# Docker embedded DNS handles all resolution (service names + external domains)" + echo "# Upstream DNS servers configured via Docker's dns: field in docker-compose.yml" echo "nameserver 127.0.0.11" - echo "# Trusted external DNS servers for internet domain resolution" - - # Add each trusted DNS server - IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS" - for dns_server in "${DNS_ARRAY[@]}"; do - dns_server=$(echo "$dns_server" | tr -d ' ') - if [ -n "$dns_server" ]; then - echo "nameserver $dns_server" - fi - done - echo "options ndots:0" } > /etc/resolv.conf - echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and trusted servers: $DNS_SERVERS" + echo "[entrypoint] DNS configured with Docker embedded DNS only (127.0.0.11)" fi # Update CA certificates if SSL Bump is enabled diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index d54d02507..4428fc2cd 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -4,13 +4,6 @@ set -e echo "[iptables] Setting up NAT redirection to Squid proxy..." echo "[iptables] NOTE: Host-level DOCKER-USER chain handles egress filtering for all containers on this network" -# Function to check if an IP address is IPv6 -is_ipv6() { - local ip="$1" - # Check if it contains a colon (IPv6 addresses always contain colons) - [[ "$ip" == *:* ]] -} - # Function to validate an IPv4 address format (e.g., 172.17.0.1) is_valid_ipv4() { local ip="$1" @@ -79,63 +72,13 @@ if [ -n "$AGENT_IP" ] && is_valid_ipv4 "$AGENT_IP"; then iptables -A OUTPUT -p tcp -d "$AGENT_IP" -j ACCEPT fi -# Get DNS servers from environment (default to Google DNS) -DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" -echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS" - -# Separate IPv4 and IPv6 DNS servers -IPV4_DNS_SERVERS=() -IPV6_DNS_SERVERS=() -IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS" -for dns_server in "${DNS_ARRAY[@]}"; do - dns_server=$(echo "$dns_server" | tr -d ' ') - if [ -n "$dns_server" ]; then - if is_ipv6 "$dns_server"; then - IPV6_DNS_SERVERS+=("$dns_server") - else - IPV4_DNS_SERVERS+=("$dns_server") - fi - fi -done - -echo "[iptables] IPv4 DNS servers: ${IPV4_DNS_SERVERS[*]:-none}" -echo "[iptables] IPv6 DNS servers: ${IPV6_DNS_SERVERS[*]:-none}" - -# Allow DNS queries ONLY to trusted IPv4 DNS servers (prevents DNS exfiltration) -for dns_server in "${IPV4_DNS_SERVERS[@]}"; do - echo "[iptables] Allow DNS to trusted IPv4 server: $dns_server" - iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN - iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN -done - -# Allow DNS queries ONLY to trusted IPv6 DNS servers -if [ "$IP6TABLES_AVAILABLE" = true ]; then - for dns_server in "${IPV6_DNS_SERVERS[@]}"; do - echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server" - ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN - ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN - done -elif [ ${#IPV6_DNS_SERVERS[@]} -gt 0 ]; then - echo "[iptables] WARNING: IPv6 DNS servers configured but ip6tables not available" -fi - -# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution -echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..." -iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN -iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN - -# Allow return traffic to trusted IPv4 DNS servers -echo "[iptables] Allow traffic to trusted DNS servers..." -for dns_server in "${IPV4_DNS_SERVERS[@]}"; do - iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN -done - -# Allow return traffic to trusted IPv6 DNS servers -if [ "$IP6TABLES_AVAILABLE" = true ]; then - for dns_server in "${IPV6_DNS_SERVERS[@]}"; do - ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN - done -fi +# DNS: All DNS queries go through Docker's embedded DNS server (127.0.0.11), +# which is already covered by the localhost rules above (127.0.0.0/8 RETURN). +# Docker's embedded DNS forwards to the upstream DNS servers configured via +# the 'dns:' field in docker-compose.yml. This simplifies the security model +# by eliminating direct DNS exceptions - no container can talk to external +# DNS servers directly, preventing DNS-based data exfiltration. +echo "[iptables] DNS handled by Docker embedded DNS (127.0.0.11, covered by localhost rules)" # Allow traffic to Squid proxy itself (prevent redirect loop) echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." @@ -268,19 +211,9 @@ fi # These rules apply AFTER NAT translation echo "[iptables] Configuring OUTPUT filter chain rules..." -# Allow localhost traffic +# Allow localhost traffic (includes Docker embedded DNS at 127.0.0.11) iptables -A OUTPUT -o lo -j ACCEPT -# Allow DNS queries to trusted servers -for dns_server in "${IPV4_DNS_SERVERS[@]}"; do - iptables -A OUTPUT -p udp -d "$dns_server" --dport 53 -j ACCEPT - iptables -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j ACCEPT -done - -# Allow DNS to Docker's embedded DNS server -iptables -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j ACCEPT -iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT - # Allow traffic to Squid proxy (after NAT redirection) iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index e61bc6230..eda1f259e 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -2,7 +2,7 @@ import { WrapperConfig } from './types'; export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; - setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string) => Promise; + setupHostIptables: (squidIp: string, port: number, apiProxyIp?: string) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( @@ -42,11 +42,10 @@ export async function runMainWorkflow( // Step 0: Setup host-level network and iptables logger.info('Setting up host-level firewall network and iptables rules...'); const networkConfig = await dependencies.ensureFirewallNetwork(); - const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; // 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 apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp); + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, apiProxyIp); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 8ae4f50c7..946762dca 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1417,7 +1417,7 @@ describe('docker-manager', () => { }); describe('dnsServers option', () => { - it('should use custom DNS servers when specified', () => { + it('should use custom DNS servers for Docker upstream DNS when specified', () => { const config: WrapperConfig = { ...mockConfig, dnsServers: ['1.1.1.1', '1.0.0.1'], @@ -1426,17 +1426,20 @@ describe('docker-manager', () => { const agent = result.services.agent; const env = agent.environment as Record; + // DNS servers configure Docker's embedded DNS upstream (127.0.0.11) expect(agent.dns).toEqual(['1.1.1.1', '1.0.0.1']); - expect(env.AWF_DNS_SERVERS).toBe('1.1.1.1,1.0.0.1'); + // AWF_DNS_SERVERS is no longer passed - container uses Docker embedded DNS only + expect(env.AWF_DNS_SERVERS).toBeUndefined(); }); - it('should use default DNS servers when not specified', () => { + it('should use default DNS servers for Docker upstream DNS when not specified', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; expect(agent.dns).toEqual(['8.8.8.8', '8.8.4.4']); - expect(env.AWF_DNS_SERVERS).toBe('8.8.8.8,8.8.4.4'); + // AWF_DNS_SERVERS is no longer passed - container uses Docker embedded DNS only + expect(env.AWF_DNS_SERVERS).toBeUndefined(); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index ee39296fc..383ab4175 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -473,9 +473,9 @@ export function generateDockerCompose( } } - // Pass DNS servers to container for setup-iptables.sh and entrypoint.sh - const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; - environment.AWF_DNS_SERVERS = dnsServers.join(','); + // DNS servers are configured via Docker's dns: field in docker-compose.yml (line ~824). + // Docker's embedded DNS (127.0.0.11) forwards to these upstream servers. + // No AWF_DNS_SERVERS env var needed - the container only uses Docker embedded DNS. // Pass allowed ports to container for setup-iptables.sh (if specified) if (config.allowHostPorts) { @@ -847,7 +847,7 @@ export function generateDockerCompose( ipv4_address: networkConfig.agentIp, }, }, - dns: dnsServers, // Use configured DNS servers (prevents DNS exfiltration) + dns: config.dnsServers || ['8.8.8.8', '8.8.4.4'], // Upstream DNS for Docker's embedded DNS (127.0.0.11) dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback volumes: agentVolumes, environment, diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 7162b0987..01092c2ae 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -93,12 +93,12 @@ describe('host-iptables', () => { // Mock iptables -L DOCKER-USER (permission check) .mockRejectedValueOnce(permissionError); - await expect(setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'])).rejects.toThrow( + await expect(setupHostIptables('172.30.0.10', 3128)).rejects.toThrow( 'Permission denied: iptables commands require root privileges' ); }); - it('should create FW_WRAPPER chain and add rules', async () => { + it('should create FW_WRAPPER chain and add rules without DNS exceptions', async () => { mockedExeca // Mock getNetworkBridgeName .mockResolvedValueOnce({ @@ -124,7 +124,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Verify chain was created expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-N', 'FW_WRAPPER']); @@ -143,63 +143,10 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); - // Verify DNS query logging rules (LOG before ACCEPT for audit trail) - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - // Verify DNS rules for trusted servers only - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'ACCEPT', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'ACCEPT', - ]); - - // Verify DNS query logging rules for second DNS server - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'ACCEPT', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'ACCEPT', - ]); - - // Verify Docker embedded DNS is allowed - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '127.0.0.11', '--dport', '53', - '-j', 'ACCEPT', - ]); + // Verify NO DNS-specific rules (simplified security model) + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '--dport', '53', + ])); // Verify traffic to Squid rule expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -259,7 +206,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Should delete reference from DOCKER-USER expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -307,7 +254,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Verify localhost rules expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -348,7 +295,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Verify multicast block expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -372,7 +319,7 @@ describe('host-iptables', () => { ]); }); - it('should log and block all UDP traffic (DNS to non-whitelisted servers gets blocked)', async () => { + it('should log and block all UDP traffic', async () => { mockedExeca // Mock getNetworkBridgeName .mockResolvedValueOnce({ @@ -397,9 +344,9 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); - // Verify UDP logging (all UDP, DNS to whitelisted servers is allowed earlier in chain) + // Verify UDP logging expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', '-p', 'udp', @@ -414,7 +361,7 @@ describe('host-iptables', () => { ]); }); - it('should use ip6tables for IPv6 DNS servers', async () => { + it('should set up IPv6 default deny chain when ip6tables is available', async () => { mockedExeca // Mock getNetworkBridgeName .mockResolvedValueOnce({ @@ -439,42 +386,9 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '2001:4860:4860::8888']); - - // Verify IPv4 DNS rule uses iptables - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'ACCEPT', - ]); - - // Verify IPv6 DNS query logging rules (LOG before ACCEPT) - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'udp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'tcp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - // Verify IPv6 DNS rule uses ip6tables - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'udp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'ACCEPT', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'tcp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'ACCEPT', - ]); + await setupHostIptables('172.30.0.10', 3128); - // Verify IPv6 chain was created + // Verify IPv6 chain was created (always created when ip6tables is available) expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-N', 'FW_WRAPPER_V6']); // Verify IPv6 UDP block rules @@ -489,6 +403,17 @@ describe('host-iptables', () => { '-p', 'udp', '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', ]); + + // Verify IPv6 default deny + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // Verify NO DNS-specific IPv6 rules + expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', expect.arrayContaining([ + '--dport', '53', + ])); }); it('should disable IPv6 via sysctl when ip6tables unavailable', async () => { @@ -508,7 +433,7 @@ describe('host-iptables', () => { return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); }) as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Verify sysctl was called to disable IPv6 expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=1']); @@ -526,43 +451,12 @@ describe('host-iptables', () => { mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + await setupHostIptables('172.30.0.10', 3128); // Verify sysctl was NOT called to disable IPv6 expect(mockedExeca).not.toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.all.disable_ipv6=1']); expect(mockedExeca).not.toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=1']); }); - - it('should not create IPv6 chain when no IPv6 DNS servers', async () => { - mockedExeca - // Mock getNetworkBridgeName - .mockResolvedValueOnce({ - stdout: 'fw-bridge', - stderr: '', - exitCode: 0, - } as any) - // Mock iptables -L DOCKER-USER (permission check) - .mockResolvedValueOnce({ - stdout: '', - stderr: '', - exitCode: 0, - } as any) - // Mock chain existence check - .mockResolvedValueOnce({ - exitCode: 1, - } as any); - - mockedExeca.mockResolvedValue({ - stdout: '', - stderr: '', - exitCode: 0, - } as any); - - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); - - // Verify IPv6 chain was NOT created - expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-N', 'FW_WRAPPER_V6']); - }); }); describe('cleanupHostIptables', () => { @@ -599,7 +493,7 @@ describe('host-iptables', () => { return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); }) as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8']); + await setupHostIptables('172.30.0.10', 3128); // Now run cleanup jest.clearAllMocks(); diff --git a/src/host-iptables.ts b/src/host-iptables.ts index c298d7f0e..271234144 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -1,6 +1,5 @@ import execa from 'execa'; import { logger } from './logger'; -import { isIPv6 } from 'net'; import { API_PROXY_PORTS } from './types'; const NETWORK_NAME = 'awf-net'; @@ -141,7 +140,7 @@ export async function ensureFirewallNetwork(): Promise<{ } /** - * Sets up the IPv6 iptables chain for handling IPv6 DNS servers + * Sets up the IPv6 iptables chain for blocking unfiltered IPv6 traffic * @param bridgeName - Bridge interface name to filter traffic on */ async function setupIpv6Chain(bridgeName: string): Promise { @@ -199,12 +198,16 @@ async function setupIpv6Chain(bridgeName: string): Promise { /** * Sets up host-level iptables rules using DOCKER-USER chain - * This ensures ALL containers on the firewall network are subject to egress filtering + * This ensures ALL containers on the firewall network are subject to egress filtering. + * + * Simplified security model: only localhost, Squid proxy, and API proxy traffic is allowed. + * DNS is handled by Docker's embedded DNS (127.0.0.11), which forwards to the upstream + * DNS servers configured via Docker's dns: field. No direct DNS exceptions needed. + * * @param squidIp - IP address of the Squid proxy * @param squidPort - Port number of the Squid proxy - * @param dnsServers - Array of trusted DNS server IP addresses (DNS traffic is ONLY allowed to these servers) */ -export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, apiProxyIp?: string): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -315,172 +318,85 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'ACCEPT', ]); - // 4. Allow DNS ONLY to specified trusted DNS servers (prevents DNS exfiltration) - // Separate IPv4 and IPv6 DNS servers - const ipv4DnsServers = dnsServers.filter(s => !isIPv6(s)); - const ipv6DnsServers = dnsServers.filter(s => isIPv6(s)); + // 4. DNS: No explicit DNS exceptions needed. + // Docker's embedded DNS (127.0.0.11) is covered by the localhost rules above. + // It forwards to upstream DNS servers configured via Docker's dns: field. + // This simplifies the security model by eliminating direct DNS exceptions, + // preventing DNS-based data exfiltration. - logger.debug(`Configuring DNS rules for trusted servers: ${dnsServers.join(', ')}`); - logger.debug(` IPv4 DNS servers: ${ipv4DnsServers.join(', ') || '(none)'}`); - logger.debug(` IPv6 DNS servers: ${ipv6DnsServers.join(', ') || '(none)'}`); + // Check ip6tables availability and disable IPv6 if unavailable + const ip6tablesAvailable = await isIp6tablesAvailable(); + if (!ip6tablesAvailable) { + logger.warn('ip6tables is not available, disabling IPv6 via sysctl to prevent unfiltered bypass'); + await disableIpv6ViaSysctl(); + } else { + // Set up IPv6 chain to block all IPv6 egress (default deny) + await setupIpv6Chain(bridgeName); - // Add IPv4 DNS server rules using iptables - for (const dnsServer of ipv4DnsServers) { - // Log DNS queries first (LOG doesn't terminate processing) - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'udp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + // 1. Allow established and related connections (return traffic) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', + '-j', 'ACCEPT', ]); - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'udp', '-d', dnsServer, '--dport', '53', + // 2. Allow localhost/loopback traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-o', 'lo', '-j', 'ACCEPT', ]); - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', '::1/128', + '-j', 'ACCEPT', ]); - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', + // 3. Allow essential ICMPv6 (required for IPv6 functionality) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'ipv6-icmp', '-j', 'ACCEPT', ]); - } - // Check ip6tables availability and disable IPv6 if unavailable - const ip6tablesAvailable = await isIp6tablesAvailable(); - if (!ip6tablesAvailable) { - logger.warn('ip6tables is not available, disabling IPv6 via sysctl to prevent unfiltered bypass'); - await disableIpv6ViaSysctl(); - } + // 4. Block IPv6 multicast and link-local traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'ff00::/8', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); - // Add IPv6 DNS server rules using ip6tables - if (ipv6DnsServers.length > 0) { - if (!ip6tablesAvailable) { - logger.warn('IPv6 DNS servers configured but ip6tables not available; IPv6 has been disabled'); - } else { - // Set up IPv6 chain if we have IPv6 DNS servers - await setupIpv6Chain(bridgeName); - - // IPv6 chain needs to mirror IPv4 chain's comprehensive filtering - // This prevents IPv6 from becoming an unfiltered bypass path - - // Note: Squid proxy rule is omitted for IPv6 since Squid runs on IPv4 only - - // 1. Allow established and related connections (return traffic) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', - '-j', 'ACCEPT', - ]); - - // 2. Allow localhost/loopback traffic - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-o', 'lo', - '-j', 'ACCEPT', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', '::1/128', - '-j', 'ACCEPT', - ]); - - // 3. Allow essential ICMPv6 (required for IPv6 functionality) - // This includes: destination unreachable, packet too big, time exceeded, - // echo request/reply, and Neighbor Discovery Protocol (NDP) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'ipv6-icmp', - '-j', 'ACCEPT', - ]); - - // 4. Allow DNS ONLY to specified trusted IPv6 DNS servers - for (const dnsServer of ipv6DnsServers) { - // Log DNS queries first (LOG doesn't terminate processing) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', '-d', dnsServer, '--dport', '53', - '-j', 'ACCEPT', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', - '-j', 'ACCEPT', - ]); - } + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'fe80::/10', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); - // 5. Block IPv6 multicast and link-local traffic - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', 'ff00::/8', // IPv6 multicast range - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', 'fe80::/10', // IPv6 link-local range - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); - - // 6. Block all other IPv6 UDP traffic (DNS to whitelisted servers already allowed above) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', - '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); - - // 7. Default deny all other IPv6 traffic (including TCP) - // This prevents IPv6 from being an unfiltered bypass path - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); - } - } + // 5. Block all other IPv6 UDP traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', + ]); - // Also allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'udp', '-d', '127.0.0.11', '--dport', '53', - '-j', 'ACCEPT', - ]); + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'tcp', '-d', '127.0.0.11', '--dport', '53', - '-j', 'ACCEPT', - ]); + // 6. Default deny all other IPv6 traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + } // 5. Allow traffic to Squid proxy await execa('iptables', [ @@ -523,8 +439,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); - // 7. Block all other UDP traffic (DNS to whitelisted servers already allowed above) - // This catches DNS exfiltration attempts to unauthorized servers + // 7. Block all other UDP traffic (catches DNS exfiltration attempts) await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, '-p', 'udp', From e1e35bdee4cd6a253b16930a3457cc446605c1dd Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 11 Mar 2026 19:23:52 +0000 Subject: [PATCH 2/4] test: add coverage for IPv6 rules, API proxy, and cleanup paths Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-iptables.test.ts | 156 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 01092c2ae..a7b2f2a42 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -361,6 +361,120 @@ describe('host-iptables', () => { ]); }); + it('should allow API proxy traffic when apiProxyIp is provided', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); + + await setupHostIptables('172.30.0.10', 3128, '172.30.0.30'); + + // Verify API proxy rule was added with port range + expect(mockedExeca).toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.30', + ])); + }); + + it('should set up IPv6 ICMPv6, multicast, and link-local rules', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); + + await setupHostIptables('172.30.0.10', 3128); + + // Verify ICMPv6 allowed + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'ipv6-icmp', + '-j', 'ACCEPT', + ]); + + // Verify IPv6 multicast blocked + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-d', 'ff00::/8', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // Verify IPv6 link-local blocked + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-d', 'fe80::/10', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // Verify IPv6 loopback allowed + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-o', 'lo', + '-j', 'ACCEPT', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-d', '::1/128', + '-j', 'ACCEPT', + ]); + + // Verify IPv6 established/related + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', + '-j', 'ACCEPT', + ]); + }); + + it('should skip DOCKER-USER insert when bridge rule already exists', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + // Mock all calls to succeed, but return existing bridge rule in DOCKER-USER listing + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'iptables' && args.includes('-L') && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ + stdout: '1 FW_WRAPPER all -- -i fw-bridge * 0.0.0.0/0 0.0.0.0/0\n', + stderr: '', + exitCode: 0, + }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + await setupHostIptables('172.30.0.10', 3128); + + // Should NOT insert a new rule since bridge rule already exists + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-I', 'DOCKER-USER', '1', + '-i', 'fw-bridge', + '-j', 'FW_WRAPPER', + ]); + }); + + it('should handle DOCKER-USER chain not existing and create it', async () => { + const dockerUserError: any = new Error('Chain does not exist'); + dockerUserError.stderr = 'iptables: No chain/target/match by that name.'; + + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockRejectedValueOnce(dockerUserError); // DOCKER-USER check fails (no Permission denied) + + // Mock DOCKER-USER chain creation and subsequent calls + mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); + + await setupHostIptables('172.30.0.10', 3128); + + // Should attempt to create DOCKER-USER chain + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-N', 'DOCKER-USER']); + }); + it('should set up IPv6 default deny chain when ip6tables is available', async () => { mockedExeca // Mock getNetworkBridgeName @@ -511,6 +625,48 @@ describe('host-iptables', () => { expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=0']); }); + it('should remove DOCKER-USER references by line number during cleanup', async () => { + mockedExeca.mockImplementation(((cmd: string, args: string[], _opts?: any) => { + // getNetworkBridgeName + if (cmd === 'docker' && args.includes('inspect')) { + return Promise.resolve({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }); + } + // IPv4 DOCKER-USER list with existing references + if (cmd === 'iptables' && args.includes('-L') && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ + stdout: '1 FW_WRAPPER all -- -i fw-bridge -o * 0.0.0.0/0\n2 RETURN all -- * * 0.0.0.0/0\n', + stderr: '', + exitCode: 0, + }); + } + // IPv6 DOCKER-USER list with existing references + if (cmd === 'ip6tables' && args.includes('-L') && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ + stdout: '1 FW_WRAPPER_V6 all * * ::/0\n', + stderr: '', + exitCode: 0, + }); + } + // ip6tables availability check + if (cmd === 'ip6tables' && args.includes('-L') && args.length === 3) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + await cleanupHostIptables(); + + // Verify IPv4 rule deletion from DOCKER-USER + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-D', 'DOCKER-USER', '1', + ], { reject: false }); + + // Verify IPv6 rule deletion from DOCKER-USER + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-D', 'DOCKER-USER', '1', + ], { reject: false }); + }); + it('should not throw on errors (best-effort cleanup)', async () => { mockedExeca.mockRejectedValue(new Error('iptables error')); From 0bf57c8a1d6d9afdecf7a2f7850c50da1119cb6f Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 11 Mar 2026 19:29:02 +0000 Subject: [PATCH 3/4] test: add unmountSslTmpfs coverage to fix function regression Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ssl-bump.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ssl-bump.test.ts b/src/ssl-bump.test.ts index 8a87b88ae..2fba2adcc 100644 --- a/src/ssl-bump.test.ts +++ b/src/ssl-bump.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import execa from 'execa'; -import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial } from './ssl-bump'; +import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump'; // Pattern constant for the safer URL character class (matches the implementation) const URL_CHAR_PATTERN = '[^\\s]*'; @@ -414,4 +414,22 @@ describe('SSL Bump', () => { expect(() => cleanupSslKeyMaterial(tempDir)).not.toThrow(); }); }); + + describe('unmountSslTmpfs', () => { + it('should call umount on the ssl directory', async () => { + const mockedExeca = execa as unknown as jest.Mock; + mockedExeca.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + + await unmountSslTmpfs('/tmp/test-ssl'); + + expect(mockedExeca).toHaveBeenCalledWith('umount', ['/tmp/test-ssl']); + }); + + it('should not throw when umount fails', async () => { + const mockedExeca = execa as unknown as jest.Mock; + mockedExeca.mockRejectedValueOnce(new Error('not mounted')); + + await expect(unmountSslTmpfs('/tmp/test-ssl')).resolves.not.toThrow(); + }); + }); }); From a51001a1fa6d101e3a3a53d479f684e238bb35f9 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Thu, 12 Mar 2026 18:48:23 +0000 Subject: [PATCH 4/4] fix: preserve Docker embedded DNS rules when flushing NAT table The iptables -t nat -F OUTPUT flush removes Docker's DNAT rules that redirect 127.0.0.11:53 to its embedded DNS proxy. Save and restore these rules to fix DNS resolution when using Docker embedded DNS only. Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/agent/setup-iptables.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 4428fc2cd..74683dabf 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -45,12 +45,26 @@ if [ -z "$SQUID_IP" ]; then fi echo "[iptables] Squid IP resolved to: $SQUID_IP" +# Save Docker's embedded DNS DNAT rules before flushing. +# Docker sets up NAT rules to redirect 127.0.0.11:53 to its DNS proxy on a +# random high port. Flushing the OUTPUT chain removes these, breaking DNS. +DOCKER_DNS_RULES=$(iptables -t nat -S OUTPUT 2>/dev/null | grep "127.0.0.11" || true) + # Clear existing NAT rules (both IPv4 and IPv6) iptables -t nat -F OUTPUT 2>/dev/null || true if [ "$IP6TABLES_AVAILABLE" = true ]; then ip6tables -t nat -F OUTPUT 2>/dev/null || true fi +# Restore Docker's embedded DNS DNAT rules +if [ -n "$DOCKER_DNS_RULES" ]; then + echo "[iptables] Restoring Docker embedded DNS rules..." + while IFS= read -r rule; do + # Convert -A (from -S output) to actual iptables command + iptables -t nat $rule 2>/dev/null || true + done <<< "$DOCKER_DNS_RULES" +fi + # Allow localhost traffic (for stdio MCP servers and test frameworks) echo "[iptables] Allow localhost traffic..." iptables -t nat -A OUTPUT -o lo -j RETURN