-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathhost-iptables-rules.ts
More file actions
418 lines (372 loc) · 15.4 KB
/
host-iptables-rules.ts
File metadata and controls
418 lines (372 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
import execa from 'execa';
import { logger } from './logger';
import { API_PROXY_PORTS } from './types';
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
import {
AWF_NETWORK_GATEWAY,
CHAIN_NAME,
CHAIN_NAME_V6,
NETWORK_NAME,
addDnsRules,
disableIpv6ViaSysctl,
getDockerBridgeGateway,
getNetworkBridgeName,
isIp6tablesAvailable,
} from './host-iptables-shared';
/**
* Configuration for host access rules in the FW_WRAPPER chain.
* When enabled, allows container traffic to reach the Docker host gateway
* (needed for Playwright localhost testing, MCP servers, etc.).
*/
export interface HostAccessConfig {
enabled: boolean;
allowHostPorts?: string;
allowHostServicePorts?: string;
}
/**
* Configuration for the CLI proxy's connection to an external DIFC proxy on the host.
*/
export interface CliProxyHostConfig {
/** CLI proxy container IP on awf-net (e.g., 172.30.0.50) */
ip: string;
/** DIFC proxy port on the host (e.g., 18443) */
difcProxyPort: number;
}
/**
* Validates a port specification string.
* Accepts a single port (1-65535) or a port range ("N-M" where both are valid ports and N <= M).
*/
export function isValidPortSpec(spec: string): boolean {
const rangeMatch = spec.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (String(start) !== rangeMatch[1] || String(end) !== rangeMatch[2]) return false;
return start >= 1 && start <= 65535 && end >= 1 && end <= 65535 && start <= end;
}
const port = parseInt(spec, 10);
return !isNaN(port) && String(port) === spec && port >= 1 && port <= 65535;
}
/**
* Sets up host-level iptables rules using DOCKER-USER chain
* This ensures ALL containers on the firewall network are subject to egress filtering.
*
* Simplified security model: only localhost, Squid proxy, and DNS forwarding are allowed.
* Containers use Docker's embedded DNS (127.0.0.11) as their only nameserver.
* Docker's DNS proxy forwards queries to upstream servers configured via docker-compose dns: field.
* These forwarded queries traverse the Docker bridge and must be allowed in DOCKER-USER.
* Squid resolves DNS internally for all HTTP/HTTPS traffic.
*
* @param squidIp - IP address of the Squid proxy
* @param squidPort - Port number of the Squid proxy
* @param apiProxyIp - Optional IP address of the API proxy sidecar
* @param dnsServers - Upstream DNS servers that Docker embedded DNS forwards to
* @param hostAccess - Optional host access configuration for localhost/Playwright support
* @param cliProxyConfig - Optional CLI proxy config for DIFC proxy host access
*/
export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig, cliProxyConfig?: CliProxyHostConfig): Promise<void> {
logger.info('Setting up host-level iptables rules...');
// Get the bridge interface name
const bridgeName = await getNetworkBridgeName();
if (!bridgeName) {
throw new Error(`Failed to get bridge name for network '${NETWORK_NAME}'`);
}
logger.debug(`Bridge interface: ${bridgeName}`);
// Check if we have permission to run iptables commands
try {
await execa('iptables', ['-t', 'filter', '-L', 'DOCKER-USER', '-n'], { timeout: 5000 });
} catch (error: any) {
if (error.stderr && error.stderr.includes('Permission denied')) {
throw new Error(
'Permission denied: iptables commands require root privileges. ' +
'Please run this command with sudo.'
);
}
// DOCKER-USER chain doesn't exist (shouldn't happen, but handle it)
logger.warn('DOCKER-USER chain does not exist, which is unexpected. Attempting to create it...');
try {
await execa('iptables', ['-t', 'filter', '-N', 'DOCKER-USER']);
} catch {
throw new Error(
'Failed to create DOCKER-USER chain. This may indicate a permission or Docker installation issue.'
);
}
}
// Create dedicated chains for our rules to make cleanup easier
// Use CHAIN_NAME for IPv4 and CHAIN_NAME_V6 for IPv6
logger.debug(`Creating dedicated chain '${CHAIN_NAME}'...`);
// Remove chain if it exists (cleanup from previous runs)
try {
// Check if chain exists first
const { exitCode } = await execa('iptables', ['-t', 'filter', '-L', CHAIN_NAME, '-n'], { reject: false });
if (exitCode === 0) {
logger.debug(`Chain '${CHAIN_NAME}' already exists, cleaning up...`);
// First, remove any references from DOCKER-USER
const { stdout } = await execa('iptables', [
'-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers',
], { reject: false });
const lines = stdout.split('\n');
const lineNumbers: number[] = [];
for (const line of lines) {
if (line.includes(CHAIN_NAME)) {
const match = line.match(/^(\d+)/);
if (match) {
lineNumbers.push(parseInt(match[1], 10));
}
}
}
// Delete rules in reverse order
for (const lineNum of lineNumbers.reverse()) {
logger.debug(`Removing reference to ${CHAIN_NAME} from DOCKER-USER line ${lineNum}`);
await execa('iptables', [
'-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(),
], { reject: false });
}
// Then flush and delete the chain
await execa('iptables', ['-t', 'filter', '-F', CHAIN_NAME], { reject: false });
await execa('iptables', ['-t', 'filter', '-X', CHAIN_NAME], { reject: false });
}
} catch (error) {
// Ignore errors
logger.debug('Error during chain cleanup:', error);
}
// Create the chain
await execa('iptables', ['-t', 'filter', '-N', CHAIN_NAME]);
// Build rules in our dedicated chain
// 1. Allow all traffic FROM the Squid proxy (it needs unrestricted outbound access)
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-s', squidIp,
'-j', 'ACCEPT',
]);
// 1b. Allow HTTPS traffic FROM the DoH proxy (it needs to reach the DoH resolver directly)
if (dohProxyIp) {
logger.debug(`Allowing HTTPS traffic from DoH proxy at ${dohProxyIp}`);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-s', dohProxyIp, '-p', 'tcp', '--dport', '443',
'-j', 'ACCEPT',
]);
}
// Note: API proxy sidecar (when enabled) does NOT get a firewall exemption.
// It routes through Squid via HTTP_PROXY/HTTPS_PROXY environment variables,
// ensuring domain whitelisting is enforced by Squid ACLs.
// 2. Allow established and related connections (return traffic)
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED',
'-j', 'ACCEPT',
]);
// 3. Allow localhost traffic
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-o', 'lo',
'-j', 'ACCEPT',
]);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-d', '127.0.0.0/8',
'-j', 'ACCEPT',
]);
// 4. 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();
}
// 4b. Allow DNS forwarding to upstream servers
// 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 : 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)
const hasIpv6Dns = upstreamDns.some(s => s.includes(':'));
if (hasIpv6Dns && ip6tablesAvailable) {
logger.debug(`Creating dedicated IPv6 chain '${CHAIN_NAME_V6}' for IPv6 DNS rules...`);
try {
const { exitCode: v6ChainExists } = await execa('ip6tables', ['-t', 'filter', '-L', CHAIN_NAME_V6, '-n'], { reject: false });
if (v6ChainExists === 0) {
logger.debug(`Chain '${CHAIN_NAME_V6}' already exists, cleaning up...`);
await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false });
await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false });
}
} catch (error) {
logger.debug('Error during IPv6 chain cleanup:', error);
}
await execa('ip6tables', ['-t', 'filter', '-N', CHAIN_NAME_V6]);
}
for (const dnsServer of upstreamDns) {
// IPv6 DNS servers must use ip6tables, IPv4 uses iptables
const isV6 = dnsServer.includes(':');
if (isV6) {
if (ip6tablesAvailable) {
await addDnsRules('ip6tables', CHAIN_NAME_V6, dnsServer);
}
} else {
await addDnsRules('iptables', CHAIN_NAME, dnsServer);
}
}
// 5. Allow traffic to Squid proxy
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'tcp', '-d', squidIp, '--dport', squidPort.toString(),
'-j', 'ACCEPT',
]);
// 5a. Allow DNS traffic to DoH proxy sidecar (when enabled)
if (dohProxyIp) {
logger.debug(`Allowing DNS traffic to DoH proxy sidecar at ${dohProxyIp}:53`);
await addDnsRules('iptables', CHAIN_NAME, dohProxyIp);
}
// 5b. Allow traffic to API proxy sidecar (when enabled)
// Allow all API proxy ports (OpenAI, Anthropic, GitHub Copilot, OpenCode).
// The sidecar itself routes through Squid, so domain whitelisting is still enforced.
if (apiProxyIp) {
const allPorts = Object.values(API_PROXY_PORTS);
const minPort = Math.min(...allPorts);
const maxPort = Math.max(...allPorts);
logger.debug(`Allowing traffic to API proxy sidecar at ${apiProxyIp}:${minPort}-${maxPort}`);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'tcp', '-d', apiProxyIp, '--dport', `${minPort}:${maxPort}`,
'-j', 'ACCEPT',
]);
}
// 5b2. Allow CLI proxy container to reach host DIFC proxy (when enabled)
// The cli-proxy container needs to TCP-tunnel to the external DIFC proxy on the host.
// Only the cli-proxy IP is allowed to reach the host gateway on the DIFC port.
if (cliProxyConfig) {
const { ip: cliProxyIp, difcProxyPort } = cliProxyConfig;
const gatewayIp = await getDockerBridgeGateway();
const gatewayIps = [AWF_NETWORK_GATEWAY];
if (gatewayIp) {
gatewayIps.push(gatewayIp);
}
for (const gwIp of gatewayIps) {
logger.debug(`Allowing CLI proxy (${cliProxyIp}) → host gateway (${gwIp}):${difcProxyPort}`);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'tcp', '-s', cliProxyIp, '-d', gwIp, '--dport', difcProxyPort.toString(),
'-j', 'ACCEPT',
]);
}
logger.info(`CLI proxy host access enabled: ${cliProxyIp} → host gateway:${difcProxyPort}`);
}
// 5c. Allow traffic to host gateway when host access is enabled
// This is needed for Playwright localhost testing, MCP servers, etc.
if (hostAccess?.enabled) {
const gatewayIp = await getDockerBridgeGateway();
const gatewayIps = [AWF_NETWORK_GATEWAY];
if (gatewayIp) {
gatewayIps.push(gatewayIp);
}
// Default: allow HTTP (80) and HTTPS (443)
const defaultPorts = ['80', '443'];
// Parse additional custom ports
const customPorts: string[] = [];
if (hostAccess.allowHostPorts) {
for (const entry of hostAccess.allowHostPorts.split(',')) {
const trimmed = entry.trim();
if (trimmed) {
if (!isValidPortSpec(trimmed)) {
logger.warn(`Skipping invalid port spec: ${trimmed}`);
continue;
}
customPorts.push(trimmed);
}
}
}
// Also include host service ports (--allow-host-service-ports)
// These intentionally bypass dangerous port restrictions since traffic is host-gateway-only
if (hostAccess.allowHostServicePorts) {
for (const entry of hostAccess.allowHostServicePorts.split(',')) {
const trimmed = entry.trim();
if (trimmed) {
if (!isValidPortSpec(trimmed)) {
logger.warn(`Skipping invalid host service port spec: ${trimmed}`);
continue;
}
customPorts.push(trimmed);
}
}
}
const allPorts = [...new Set([...defaultPorts, ...customPorts])];
for (const gwIp of gatewayIps) {
for (const port of allPorts) {
// Port ranges (e.g., "3000-3010") use --dport with range syntax
logger.debug(`Allowing host gateway traffic: ${gwIp}:${port}`);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'tcp', '-d', gwIp, '--dport', port,
'-j', 'ACCEPT',
]);
}
}
logger.info(`Host access enabled: allowing traffic to gateway IPs ${gatewayIps.join(', ')} on ports ${allPorts.join(', ')}`);
}
// 6. Block multicast and link-local traffic
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-m', 'addrtype', '--dst-type', 'MULTICAST',
'-j', 'REJECT', '--reject-with', 'icmp-port-unreachable',
]);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-d', '169.254.0.0/16',
'-j', 'REJECT', '--reject-with', 'icmp-port-unreachable',
]);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-d', '224.0.0.0/4',
'-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
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'udp',
'-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP] ', '--log-level', '4',
]);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-p', 'udp',
'-j', 'REJECT', '--reject-with', 'icmp-port-unreachable',
]);
// 8. Default deny all other traffic
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER] ', '--log-level', '4',
]);
await execa('iptables', [
'-t', 'filter', '-A', CHAIN_NAME,
'-j', 'REJECT', '--reject-with', 'icmp-port-unreachable',
]);
// Now insert a rule in DOCKER-USER that jumps to our chain for traffic FROM the firewall bridge
// Note: We use -i (input interface) to match egress traffic FROM containers on the bridge
// Check if rule already exists
const { stdout: existingRules } = await execa('iptables', [
'-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers',
]);
if (!existingRules.includes(`-i ${bridgeName}`)) {
logger.debug(`Inserting rule in DOCKER-USER to jump to ${CHAIN_NAME} for bridge ${bridgeName}...`);
await execa('iptables', [
'-t', 'filter', '-I', 'DOCKER-USER', '1',
'-i', bridgeName,
'-j', CHAIN_NAME,
]);
} else {
logger.debug(`Rule for bridge ${bridgeName} already exists in DOCKER-USER`);
}
logger.success('Host-level iptables rules configured successfully');
// Show the rules for debugging
logger.debug('DOCKER-USER chain:');
const { stdout: dockerUserRules } = await execa('iptables', [
'-t', 'filter', '-L', 'DOCKER-USER', '-n', '-v',
]);
logger.debug(dockerUserRules);
logger.debug(`${CHAIN_NAME} chain:`);
const { stdout: fwWrapperRules } = await execa('iptables', [
'-t', 'filter', '-L', CHAIN_NAME, '-n', '-v',
]);
logger.debug(fwWrapperRules);
}