diff --git a/containers/cli-proxy/tcp-tunnel.js b/containers/cli-proxy/tcp-tunnel.js index cf911af8..5b14bb24 100644 --- a/containers/cli-proxy/tcp-tunnel.js +++ b/containers/cli-proxy/tcp-tunnel.js @@ -38,7 +38,11 @@ if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) { process.exit(1); } -const server = net.createServer(client => { +const bindHosts = ['127.0.0.1', '::1']; +let startedServers = 0; +let readyLogged = false; + +function handleConnection(client) { const clientAddr = `${client.remoteAddress}:${client.remotePort}`; console.error(`[tcp-tunnel] Connection from ${sanitizeForLog(clientAddr)}`); const upstream = net.connect(remotePort, remoteHost); @@ -47,13 +51,25 @@ const server = net.createServer(client => { client.on('error', (err) => { console.error(`[tcp-tunnel] Client error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); upstream.destroy(); }); upstream.on('error', (err) => { console.error(`[tcp-tunnel] Upstream error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); client.destroy(); }); client.on('close', () => { console.error(`[tcp-tunnel] Connection closed: ${sanitizeForLog(clientAddr)}`); }); -}); +} -server.on('error', (err) => { - console.error('[tcp-tunnel] Server error:', sanitizeForLog(err.message)); - process.exit(1); -}); +for (const bindHost of bindHosts) { + const server = net.createServer(handleConnection); + server.on('error', (err) => { + const errCode = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + if ((errCode === 'EADDRNOTAVAIL' || errCode === 'EAFNOSUPPORT') && bindHost === '::1') { + console.error(`[tcp-tunnel] IPv6 loopback unavailable, skipping ::1 bind (${sanitizeForLog(err.message)})`); + return; + } + console.error(`[tcp-tunnel] Server error (${bindHost}):`, sanitizeForLog(err.message)); + process.exit(1); + }); -server.listen(localPort, '127.0.0.1', () => { - console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`); -}); + server.listen(localPort, bindHost, () => { + startedServers += 1; + if (!readyLogged && (startedServers === bindHosts.length || bindHost === '127.0.0.1')) { + readyLogged = true; + console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`); + } + }); +} diff --git a/scripts/ci/tcp-tunnel.test.ts b/scripts/ci/tcp-tunnel.test.ts new file mode 100644 index 00000000..621b3b6f --- /dev/null +++ b/scripts/ci/tcp-tunnel.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from '@jest/globals'; +import net from 'net'; +import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; + +function supportsIpv6Loopback(): Promise { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.listen(0, '::1', () => { + probe.close(() => resolve(true)); + }); + }); +} + +function createTcpServer(host: string): Promise<{ server: net.Server; port: number }> { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => { + socket.on('error', () => {}); + socket.end('ok'); + }); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to acquire port')); + return; + } + resolve({ server, port: address.port }); + }); + }); +} + +function getFreePort(host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to acquire free port')); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +function waitForTunnelReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const onExit = (code: number | null) => reject(new Error(`Tunnel exited before ready (code=${code})`)); + const onStdout = (chunk: Buffer) => { + if (chunk.toString().includes('Forwarding localhost:')) { + child.stdout?.off('data', onStdout); + child.off('exit', onExit); + resolve(); + } + }; + child.on('exit', onExit); + child.stdout?.on('data', onStdout); + }); +} + +function connect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const socket = net.connect({ host, port }); + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + reject(new Error(`Timed out connecting to ${host}:${port}`)); + }, 1000); + + socket.on('connect', () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve(); + }); + + socket.on('error', (error: NodeJS.ErrnoException) => { + if (settled) { + return; + } + if (error.code === 'ECONNRESET') { + settled = true; + clearTimeout(timeout); + resolve(); + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }); + }); +} + +async function connectWithRetry(host: string, port: number, attempts = 5): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + await connect(host, port); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw lastError; +} + +describe('cli-proxy tcp tunnel', () => { + it('binds localhost tunnel on both IPv4 and IPv6 loopback when IPv6 is available', async () => { + if (!await supportsIpv6Loopback()) { + return; + } + + const upstream = await createTcpServer('127.0.0.1'); + const tunnelPort = await getFreePort('127.0.0.1'); + const tunnelScript = path.join(process.cwd(), 'containers/cli-proxy/tcp-tunnel.js'); + const tunnel = spawn(process.execPath, [tunnelScript, String(tunnelPort), '127.0.0.1', String(upstream.port)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + await waitForTunnelReady(tunnel); + await connect('127.0.0.1', tunnelPort); + await connectWithRetry('::1', tunnelPort); + } finally { + tunnel.kill('SIGTERM'); + await Promise.all([ + new Promise((resolve) => tunnel.once('exit', resolve)), + new Promise((resolve) => upstream.server.close(() => resolve(undefined))), + ]); + } + }, 10000); +});