diff --git a/src/host-iptables-cleanup.ts b/src/host-iptables-cleanup.ts index e22efea1..ee37e145 100644 --- a/src/host-iptables-cleanup.ts +++ b/src/host-iptables-cleanup.ts @@ -1,8 +1,8 @@ -import execa from 'execa'; import { logger } from './logger'; import { CHAIN_NAME, CHAIN_NAME_V6, + cleanupChain, enableIpv6ViaSysctl, getNetworkBridgeName, isIp6tablesAvailable, @@ -18,70 +18,21 @@ export async function cleanupHostIptables(): Promise { // Get the bridge name const bridgeName = await getNetworkBridgeName(); - // Clean up IPv4 rules - if (bridgeName) { - // Find and remove the rule that jumps to our chain - const { stdout } = await execa('iptables', [ - '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', - ], { reject: false }); - - // Parse line numbers for rules that reference our bridge - const lines = stdout.split('\n'); - const lineNumbers: number[] = []; - for (const line of lines) { - if ((line.includes(`-i ${bridgeName}`) || line.includes(`-o ${bridgeName}`)) && line.includes(CHAIN_NAME)) { - const match = line.match(/^(\d+)/); - if (match) { - lineNumbers.push(parseInt(match[1], 10)); - } - } - } - - // Delete rules in reverse order (to maintain line numbers) - for (const lineNum of lineNumbers.reverse()) { - logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv4)`); - await execa('iptables', [ - '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), - ], { reject: false }); - } - } - - // Flush and delete our custom IPv4 chain - await execa('iptables', ['-t', 'filter', '-F', CHAIN_NAME], { reject: false }); - await execa('iptables', ['-t', 'filter', '-X', CHAIN_NAME], { reject: false }); + await cleanupChain('iptables', CHAIN_NAME, { + removeDockerUserReferences: Boolean(bridgeName), + matchPredicate: bridgeName + ? (line: string) => (line.includes(`-i ${bridgeName}`) || line.includes(`-o ${bridgeName}`)) && line.includes(CHAIN_NAME) + : undefined, + }); logger.debug('IPv4 iptables rules cleaned up'); // Clean up IPv6 rules (only if ip6tables is available) const ip6tablesAvailable = await isIp6tablesAvailable(); if (ip6tablesAvailable) { - if (bridgeName) { - const { stdout: stdout6 } = await execa('ip6tables', [ - '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', - ], { reject: false }); - - const lines6 = stdout6.split('\n'); - const lineNumbers6: number[] = []; - for (const line of lines6) { - if (line.includes(CHAIN_NAME_V6)) { - const match = line.match(/^(\d+)/); - if (match) { - lineNumbers6.push(parseInt(match[1], 10)); - } - } - } - - for (const lineNum of lineNumbers6.reverse()) { - logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv6)`); - await execa('ip6tables', [ - '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), - ], { reject: false }); - } - } - - // Flush and delete our custom IPv6 chain - await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false }); - await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false }); + await cleanupChain('ip6tables', CHAIN_NAME_V6, { + removeDockerUserReferences: Boolean(bridgeName), + }); logger.debug('IPv6 ip6tables rules cleaned up'); } else { diff --git a/src/host-iptables-rules.ts b/src/host-iptables-rules.ts index 35758706..2d33a71b 100644 --- a/src/host-iptables-rules.ts +++ b/src/host-iptables-rules.ts @@ -8,6 +8,7 @@ import { CHAIN_NAME_V6, NETWORK_NAME, addDnsRules, + cleanupChain, disableIpv6ViaSysctl, getDockerBridgeGateway, getNetworkBridgeName, @@ -110,34 +111,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS 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 }); + await cleanupChain('iptables', CHAIN_NAME); } } catch (error) { // Ignore errors diff --git a/src/host-iptables-shared.test.ts b/src/host-iptables-shared.test.ts new file mode 100644 index 00000000..8d5c881f --- /dev/null +++ b/src/host-iptables-shared.test.ts @@ -0,0 +1,40 @@ +import { execaResult, mockedExeca } from './test-helpers/host-iptables-test-setup'; +import { cleanupChain } from './host-iptables-shared'; + +describe('host-iptables-shared', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('cleanupChain', () => { + it('removes matching DOCKER-USER references in reverse order before deleting the chain', async () => { + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'iptables' && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve(execaResult({ + stdout: '1 FW_WRAPPER all -- 0.0.0.0/0 0.0.0.0/0\n3 FW_WRAPPER all -- 0.0.0.0/0 0.0.0.0/0\n', + })); + } + return Promise.resolve(execaResult()); + }) as any); + + await cleanupChain('iptables', 'FW_WRAPPER'); + + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-D', 'DOCKER-USER', '3'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-D', 'DOCKER-USER', '1'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-F', 'FW_WRAPPER'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-X', 'FW_WRAPPER'], { reject: false }); + }); + + it('skips DOCKER-USER reference removal when configured', async () => { + mockedExeca.mockResolvedValue(execaResult()); + + await cleanupChain('ip6tables', 'FW_WRAPPER_V6', { removeDockerUserReferences: false }); + + expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-F', 'FW_WRAPPER_V6'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-X', 'FW_WRAPPER_V6'], { reject: false }); + }); + }); +}); diff --git a/src/host-iptables-shared.ts b/src/host-iptables-shared.ts index 33809f6b..da3c1245 100644 --- a/src/host-iptables-shared.ts +++ b/src/host-iptables-shared.ts @@ -145,6 +145,44 @@ export async function addDnsRules( } } +/** + * Removes references to a chain from DOCKER-USER, then flushes and deletes the chain. + */ +export async function cleanupChain( + cmd: 'iptables' | 'ip6tables', + chainName: string, + options: { + removeDockerUserReferences?: boolean; + matchPredicate?: (line: string) => boolean; + } = {}, +): Promise { + const { removeDockerUserReferences = true, matchPredicate } = options; + + if (removeDockerUserReferences) { + const { stdout } = await execa(cmd, [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); + + const lineNumbers: number[] = []; + for (const line of stdout.split('\n')) { + const shouldDelete = matchPredicate ? matchPredicate(line) : line.includes(chainName); + if (shouldDelete) { + const match = line.match(/^(\d+)/); + if (match) { + lineNumbers.push(parseInt(match[1], 10)); + } + } + } + + for (const lineNum of lineNumbers.reverse()) { + await execa(cmd, ['-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString()], { reject: false }); + } + } + + await execa(cmd, ['-t', 'filter', '-F', chainName], { reject: false }); + await execa(cmd, ['-t', 'filter', '-X', chainName], { reject: false }); +} + /** * Re-enables IPv6 via sysctl if it was previously disabled. */