Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/host-iptables-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ describe('host-iptables branch coverage', () => {
setupHostIptablesTestSuite(iptablesSharedTestHelpers.resetIpv6State);

// -------------------------------------------------------------------------
// Branch 1: host-iptables-rules.ts line 85
// checkPermissionsAndSetupChain – the ternary `? error.stderr : ''` when the
// thrown error object does NOT have a string `stderr` property.
// Branch 1: host-iptables-rules.ts
// checkPermissionsAndSetupChain – the fallback when the thrown error object
// does NOT have a string `stderr` property.
// -------------------------------------------------------------------------
describe('checkPermissionsAndSetupChain – no-stderr error object', () => {
it('treats missing stderr as empty string and proceeds with chain creation', async () => {
// Throw a plain Error (no .stderr property) from the DOCKER-USER list check.
// This forces the ternary on line 85 to evaluate the `''` (else) branch.
// This forces the stderr lookup to use the empty-string fallback.
mockedExeca
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', exitCode: 0 })) // getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ exitCode: 0 })) // iptables --version
.mockRejectedValueOnce(new Error('iptables: table locked')) // DOCKER-USER check – no stderr
.mockResolvedValueOnce(execaResult({ exitCode: 0 })) // iptables -N DOCKER-USER
.mockResolvedValueOnce(execaResult({ exitCode: 1 })); // FW_WRAPPER existence check
Expand Down
3 changes: 3 additions & 0 deletions src/host-iptables-doh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ describe('host-iptables (doh)', () => {
mockedExeca
// Mock getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }))
// Mock iptables --version
.mockResolvedValueOnce(execaResult({ stdout: '', stderr: '', exitCode: 0 }))
// Mock iptables -L DOCKER-USER (permission check)
.mockResolvedValueOnce(execaResult({ stdout: '', stderr: '', exitCode: 0 }))
// Mock chain existence check (doesn't exist)
Expand Down Expand Up @@ -49,6 +51,7 @@ describe('host-iptables (doh)', () => {
mockedExeca
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ stdout: '', stderr: '', exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ stdout: '', stderr: '', exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ exitCode: 1 }));

mockedExeca.mockResolvedValue(execaResult({
Expand Down
31 changes: 28 additions & 3 deletions src/host-iptables-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ function isValidPortSpec(spec: string): boolean {
// ts-prune-ignore-next
export const iptablesRulesTestHelpers = { isValidPortSpec };

function getErrorStringProperty(error: unknown, property: string): string {
return typeof error === 'object'
&& error !== null
&& property in error
&& typeof (error as Record<string, unknown>)[property] === 'string'
? (error as Record<string, unknown>)[property] as string
: '';
}

function isMissingIptablesError(error: unknown): boolean {
const code = getErrorStringProperty(error, 'code');
const message = error instanceof Error ? error.message : '';
return code === 'ENOENT' || message.includes('ENOENT') || message.includes('not found');
}

function parseValidPortSpecs(input: string | undefined, label: string): string[] {
if (!input) {
return [];
Expand All @@ -78,13 +93,23 @@ function parseValidPortSpecs(input: string | undefined, label: string): string[]
}

async function checkPermissionsAndSetupChain(chain: string): Promise<void> {
try {
await execa('iptables', ['--version'], { timeout: 5000 });
} catch (error: unknown) {
if (isMissingIptablesError(error)) {
throw new Error('iptables is required but was not found. Please install iptables and try again.');
}
throw error;
}

// Check if we have permission to run iptables commands
try {
await execa('iptables', ['-t', 'filter', '-L', 'DOCKER-USER', '-n'], { timeout: 5000 });
} catch (error: unknown) {
const stderr = typeof error === 'object' && error !== null && 'stderr' in error && typeof error.stderr === 'string'
? error.stderr
: '';
if (isMissingIptablesError(error)) {
throw new Error('iptables is required but was not found. Please install iptables and try again.');
}
const stderr = getErrorStringProperty(error, 'stderr');
if (stderr.includes('Permission denied')) {
throw new Error(
'Permission denied: iptables commands require root privileges. ' +
Expand Down
24 changes: 24 additions & 0 deletions src/host-iptables-setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { API_PROXY_PORTS } from './types';
import {
execaError,
execaMissingCommandError,
execaResult,
mockedExeca,
setupDefaultIptablesMocks,
Expand Down Expand Up @@ -28,6 +29,8 @@ describe('host-iptables (setup)', () => {
stderr: '',
exitCode: 0,
}))
// Mock iptables --version
.mockResolvedValueOnce(execaResult())
// Mock iptables -L DOCKER-USER (permission check)
.mockRejectedValueOnce(permissionError);

Expand All @@ -36,6 +39,21 @@ describe('host-iptables (setup)', () => {
);
});

it('should throw a clear error if iptables is not installed', async () => {
mockedExeca
// Mock getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }))
// Mock iptables --version (missing binary)
.mockRejectedValueOnce(execaMissingCommandError());

await expect(setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'])).rejects.toThrow(
'iptables is required but was not found'
);

expect(mockedExeca).not.toHaveBeenCalledWith('iptables', ['-t', 'filter', '-L', 'DOCKER-USER', '-n'], { timeout: 5000 });
expect(mockedExeca).not.toHaveBeenCalledWith('iptables', ['-t', 'filter', '-N', 'DOCKER-USER']);
});

it('should create FW_WRAPPER chain and add rules', async () => {
setupDefaultIptablesMocks({ catchAllStdout: 'Chain DOCKER-USER\nChain FW_WRAPPER' });

Expand Down Expand Up @@ -218,6 +236,8 @@ describe('host-iptables (setup)', () => {
mockedExeca
// Mock getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }))
// Mock iptables --version
.mockResolvedValueOnce(execaResult())
// Mock iptables -L DOCKER-USER (chain doesn't exist)
.mockRejectedValueOnce(noChainError)
// Mock iptables -N DOCKER-USER (create chain)
Expand Down Expand Up @@ -475,6 +495,8 @@ describe('host-iptables (setup)', () => {
mockedExeca
// getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }))
// iptables --version
.mockResolvedValueOnce(execaResult())
// iptables -L DOCKER-USER (chain doesn't exist)
.mockRejectedValueOnce(noChainError)
// iptables -N DOCKER-USER (creation fails)
Expand Down Expand Up @@ -512,6 +534,8 @@ describe('host-iptables (setup)', () => {
mockedExeca
// getNetworkBridgeName
.mockResolvedValueOnce(execaResult({ stdout: 'fw-bridge', exitCode: 0 }))
// iptables --version
.mockResolvedValueOnce(execaResult({ exitCode: 0, stdout: '' }))
// iptables -L DOCKER-USER (permission check) — success
.mockResolvedValueOnce(execaResult({ exitCode: 0, stdout: '' }))
// iptables -L FW_WRAPPER (check if chain exists) — exists
Expand Down
6 changes: 6 additions & 0 deletions src/test-helpers/host-iptables-test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function execaError(message: string, stderr = message): ExecaMockError {
return Object.assign(new Error(message), { stderr });
}

// ts-prune-ignore-next
export function execaMissingCommandError(command = 'iptables'): ExecaMockError & { code: string } {
return Object.assign(new Error(`spawn ${command} ENOENT`), { code: 'ENOENT' });
}

// ts-prune-ignore-next
export function setupHostIptablesTestSuite(resetIpv6State: () => void): void {
beforeEach(() => {
Expand All @@ -78,6 +83,7 @@ export function setupDefaultIptablesMocks(
mockedExeca
.mockResolvedValueOnce(execaResult({ stdout: bridgeName, exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ stdout: '', exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ stdout: '', exitCode: 0 }))
.mockResolvedValueOnce(execaResult({ exitCode: chainExists ? 0 : 1 }));
mockedExeca.mockImplementation(((cmd: string, args: readonly string[]) => {
if (cmd === 'iptables' && Array.isArray(args) && args.includes('-C')) {
Expand Down
Loading