diff --git a/src/cliproxy/https-tunnel-proxy.ts b/src/cliproxy/https-tunnel-proxy.ts index f365dd90..81e17867 100644 --- a/src/cliproxy/https-tunnel-proxy.ts +++ b/src/cliproxy/https-tunnel-proxy.ts @@ -226,7 +226,10 @@ export class HttpsTunnelProxy { }); upstreamReq.on('timeout', () => { - upstreamReq.destroy(new Error('Upstream request timeout')); + const timeoutError = new Error('Upstream request timeout'); + this.log(`Timeout: ${timeoutError.message}`); + upstreamReq.destroy(); + reject(timeoutError); }); upstreamReq.on('error', (err) => { diff --git a/src/cliproxy/remote-token-uploader.ts b/src/cliproxy/remote-token-uploader.ts index f5e829db..dd50324a 100644 --- a/src/cliproxy/remote-token-uploader.ts +++ b/src/cliproxy/remote-token-uploader.ts @@ -63,7 +63,7 @@ export async function uploadTokenToRemote( const fileName = path.basename(tokenFilePath); const url = buildProxyUrl(target, '/v0/management/auth-files'); - // Use X-Management-Key header (CLIProxyAPI requirement) + // Use Authorization: Bearer header for authentication const authKey = target.managementKey ?? target.authToken; if (verbose) { diff --git a/tests/unit/cliproxy/https-tunnel-proxy.test.ts b/tests/unit/cliproxy/https-tunnel-proxy.test.ts index f4e40422..c6731261 100644 --- a/tests/unit/cliproxy/https-tunnel-proxy.test.ts +++ b/tests/unit/cliproxy/https-tunnel-proxy.test.ts @@ -4,34 +4,73 @@ * Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI. * Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL. */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, afterEach } from 'bun:test'; import * as http from 'http'; import * as https from 'https'; -import * as fs from 'fs'; -import * as path from 'path'; import type { AddressInfo } from 'net'; // Import the class under test import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy'; +/** + * Self-signed certificate for testing HTTPS servers. + * Generated with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost" + */ +const TEST_CERT = { + key: `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1Ji8aq3YnxiRC +i5syvghdG+08f9Gc1C55UiNZc5zxY6Ij73pg72fWO614WH5MT3GeeDHdA4jF/xxZ +tgLecJ14L5CyqpbcFYnayjRS5WjWfG40VOsVAf5OiJem6YL4Yu9EExO1MxhzIQmW +fqijCSBVPiYkJ9CoT1EuUMPBudWkxs5NQHUYJu2Hq/mG89W+yMuT+Yp9YKau1snb +x0I4aqf+OBJKrlWEZ+tgcTbyWW0bvQv8Ou9cFZjsXQF8jXBHJ97/LPW850jKt76J +6PjIZeaTScZo9Py/fSIf8+4XYFHr3TVmUpoa1f1jmp+kE0H51+KDnqbcYCB6EwFm +UFRaJ6ZtAgMBAAECggEAB5j3DMqi2qmIHR+3KKT+ZiP6X+PfKhFeyZ5/oQwk9Egr +erpb4EjqNVACHIle8rBk9t0vqjIFwILMm5lIptpY9bt4+XqyImo81+eh04A6VL9E +l/6fxXJ0n11B5Fw9g/wSRkFOkvZLpjh9Kx9befWzXMqN1aJd2/vyTwZQJNs4cgAF +BUppw0PG1ujLXl48pNoGqMVLALWA1XwlexBxh6EgU9rsar9desqqhF1pZIAimB4x +pvQSPqENFjCOMY93RRvZHITE37Y61BiofdHHqIDLAqYZLHlq+rUupYe9auUHMpaD +KFV99x9gfVS9l0aqzxXw+nqar9o1+h5lWO2hCw3HgQKBgQD1n19aJ3z1TC1bRLGV +H1UTgx/TAfFsXB7S3RO+Tkfhn+g169ByOWRELIGptOSBYauo/N29AYNHjNJPZDYM +sraJfyLStrcCTzXcum1Xfx6rjyq5LU5D93F6ZXGBlVrLmk/+YEep65g0X82VzbPN +9aIKW2kBLKuH2O9MwaqWko+ZEQKBgQC8zXlgsq2fkKeyRJfIAN8+Wx2Kd1n5KPbl +nvbj9k59oYBz1yggj9v6vjfo9MrgZxp5LmR5UgGsSFpqXpkA7SDYCvKZUwQj/eIx +LhV6NG+SnbFKtinIuh9GHiEKGNxJsRL7ZljVjW6f6C4f4MeyEW2IpE5AMAaCfpUS +JxI3afJXnQKBgBcKsGNAuRQ55Tdeploa6lw+PMoKsJ89tRaK7sM3jL65xYrpaFCO +2b0bf75v3c/VXckoj5Sfg7U+nKwd9oQSb9VOO/IQefKZg7AFPSSsJDBr6dIdUe5G +VDrrMU66uB3JiB+Q4KgsFcc0BZE8DtYPaPgXwy39Bspjq29D68DcVuRBAoGAZbA9 +qalS/lhJGij7nwtpMgqdNJDn8tzvbelajJmC2QN9Tecag783+istrdj61DZz+cTU +9MsIf6RQnm3o9qjBQdtTouUlm8UIaPirNLC9TziD3vuSMbydT4S2wtt0+nPXB3Su +cAbHCHVjMmQ86lmcpzXnt4amWu6Wl7pXg2Ua07kCgYEA8XMfXql48oUVIr+cN5AN +nnql5ojMi2Vp9SeTDM/LKCJ9HORCKi4DyHqm06OjDFi2Al57DVsLHpxENWVSgUmp +OpcE1P2kqmDqQguFg9GUPX38zspijdVBd1rtpPfHsAu+ZvRWT8ozFLKZ3Xjg1fIx +TL6BeOBii9TlZ66SlZT5HRM= +-----END PRIVATE KEY-----`, + cert: `-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUfoHoOgjjiqPNOxpbp7jHBFSfTeEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExNDE1MzAyMFoXDTI3MDEx +NDE1MzAyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtSYvGqt2J8YkQoubMr4IXRvtPH/RnNQueVIjWXOc8WOi +I+96YO9n1juteFh+TE9xnngx3QOIxf8cWbYC3nCdeC+QsqqW3BWJ2so0UuVo1nxu +NFTrFQH+ToiXpumC+GLvRBMTtTMYcyEJln6oowkgVT4mJCfQqE9RLlDDwbnVpMbO +TUB1GCbth6v5hvPVvsjLk/mKfWCmrtbJ28dCOGqn/jgSSq5VhGfrYHE28lltG70L +/DrvXBWY7F0BfI1wRyfe/yz1vOdIyre+iej4yGXmk0nGaPT8v30iH/PuF2BR6901 +ZlKaGtX9Y5qfpBNB+dfig56m3GAgehMBZlBUWiembQIDAQABo1MwUTAdBgNVHQ4E +FgQUHvZklBlcvTOIuN/xSO7BP7rgSqswHwYDVR0jBBgwFoAUHvZklBlcvTOIuN/x +SO7BP7rgSqswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWRj4 +bB1eObtMOal4VPmL5iX07XzL4hp6Dwu2LSrw9KMArqTTeyJEGNSsykuHPZwPIflD +JkzfrFfDv8q7YDpSLy9vJN2E+SPn/Oq2BehUmD+uURghaoSsXeyY9Kv6vGZri95l +rgg+6wLJDVrnw5tKxEHx5hUyVR3Ms4LwU/hwAcCGCxx5exhvLpfjjxGBR814kCEc +IKGISNjqDo1Pz1Xm8QBLzG4CtlzE/QEbkJKImmwskv6vvoRbWg+B529WzFFCReYK +/sULgpvG29Uc3MwZK242dKyTUFdI6tQuZ8xierXwP0kIFlP2phtkgE9kjQJqrtRZ +fago/IeVI/sKlApDxA== +-----END CERTIFICATE-----`, +}; + describe('HttpsTunnelProxy', () => { let tunnel: HttpsTunnelProxy | null = null; let mockServer: https.Server | null = null; let mockServerPort: number = 0; - // Self-signed certificate for testing (generated inline) - const selfSignedCert = { - key: `-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7MJnE6J3cELkN -k4Jn0HmkF1K9VvWHzQp3F5EqOPvO5d7X5qQvQFv1M8UY8LZI9u5X5T0FJ3XKQK9F -1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 ------END PRIVATE KEY-----`, - cert: `-----BEGIN CERTIFICATE----- -MIIC+TCCAeGgAwIBAgIJAKHBfpegQr2EMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV -BAMMCGxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMx ------END CERTIFICATE-----`, - }; - afterEach(async () => { // Clean up tunnel if (tunnel) { @@ -295,62 +334,83 @@ BAMMCGxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMx }); describe('error handling', () => { - it('should handle upstream timeout', async () => { - // Create a server that never responds to trigger timeout - const hangingServer = http.createServer(() => { - // Never respond - let it hang - }); + it( + 'should handle upstream timeout', + async () => { + // Create an HTTPS server that accepts connections but delays response + // beyond the tunnel's timeout. This tests the socket-level timeout. + const slowServer = https.createServer( + { key: TEST_CERT.key, cert: TEST_CERT.cert }, + (req, res) => { + // Delay response beyond tunnel timeout (500ms) + // The tunnel should timeout before this completes + setTimeout(() => { + res.writeHead(200); + res.end('too late'); + }, 2000); + } + ); - await new Promise((resolve) => { - hangingServer.listen(0, '127.0.0.1', () => resolve()); - }); - - const hangingPort = (hangingServer.address() as AddressInfo).port; - - try { - tunnel = new HttpsTunnelProxy({ - remoteHost: '127.0.0.1', - remotePort: hangingPort, - timeoutMs: 100, // Very short timeout - allowSelfSigned: true, + await new Promise((resolve) => { + slowServer.listen(0, '127.0.0.1', () => resolve()); }); - const port = await tunnel.start(); + const slowPort = (slowServer.address() as AddressInfo).port; - // Make request - should timeout - const response = await new Promise((resolve, reject) => { - const req = http.request( - { - hostname: '127.0.0.1', - port, - path: '/v1/messages', - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }, - resolve - ); - req.on('error', reject); - req.setTimeout(5000); - req.write('{}'); - req.end(); - }).catch((err) => err); + try { + tunnel = new HttpsTunnelProxy({ + remoteHost: '127.0.0.1', + remotePort: slowPort, + timeoutMs: 500, // Short timeout - server responds after 2000ms + allowSelfSigned: true, + }); - // Either 502 response or error is acceptable - expect(response).toBeDefined(); - } finally { - hangingServer.close(); - } - }); + const port = await tunnel.start(); + + // Make request - should timeout because server delays 2s but tunnel times out at 500ms + const response = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/v1/messages', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + resolve + ); + req.on('error', (err) => resolve(err)); // Resolve with error instead of reject + req.setTimeout(3000); // Client timeout longer than tunnel timeout + req.write('{}'); + req.end(); + }); + + // Either 502 response or connection error is acceptable + // The tunnel should have timed out and returned 502 or closed the connection + expect(response).toBeDefined(); + if (response instanceof http.IncomingMessage) { + expect(response.statusCode).toBe(502); + } + } finally { + slowServer.close(); + } + }, + { timeout: 10000 } + ); it('should handle client disconnect (premature close)', async () => { - // Create a slow server that holds connection - const slowServer = http.createServer((req, res) => { - // Wait before responding - setTimeout(() => { - res.writeHead(200); - res.end('ok'); - }, 2000); - }); + // Create a slow HTTPS server that holds connection + // HttpsTunnelProxy uses https.request(), so we need an HTTPS server + const slowServer = https.createServer( + { key: TEST_CERT.key, cert: TEST_CERT.cert }, + (req, res) => { + // Wait before responding + setTimeout(() => { + res.writeHead(200); + res.end('ok'); + }, 2000); + } + ); await new Promise((resolve) => { slowServer.listen(0, '127.0.0.1', () => resolve()); diff --git a/tests/unit/cliproxy/remote-token-uploader.test.ts b/tests/unit/cliproxy/remote-token-uploader.test.ts index c09d31af..4cd8c171 100644 --- a/tests/unit/cliproxy/remote-token-uploader.test.ts +++ b/tests/unit/cliproxy/remote-token-uploader.test.ts @@ -4,7 +4,7 @@ * Tests for uploadTokenToRemote and related functions. * Uses a local HTTP server to mock the remote CLIProxyAPI. */ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path';