diff --git a/packages/sdk/server-node/TESTING.md b/packages/sdk/server-node/TESTING.md new file mode 100644 index 0000000000..dc51b6b196 --- /dev/null +++ b/packages/sdk/server-node/TESTING.md @@ -0,0 +1,123 @@ +# Using this build of `@launchdarkly/node-server-sdk` in your app + +This guide explains how to consume this local fork/build of the Node server SDK in a +separate test application — for example, to try out the SOCKS proxy support on the +`socks-proxy-support` branch before it is published to npm. + +The package is `@launchdarkly/node-server-sdk` (currently version `9.11.2`). It lives in +a monorepo, so you must build it (and its workspace dependencies) before consuming it. + +## 1. Build the SDK + +From the **root** of the monorepo, build this package and everything it depends on. +Running `yarn build` inside the package directory alone will **not** rebuild its +dependencies. + +```bash +yarn # install workspace deps (first time only) +yarn workspaces foreach -pR --topological-dev \ + --from '@launchdarkly/node-server-sdk' run build +``` + +This produces the compiled output in `packages/sdk/server-node/dist`. + +## 2a. Install via tarball (recommended) + +The tarball method most closely mirrors a real `npm install` from the registry. It +bundles only the files that would actually be published, and it avoids the symlink +pitfalls of a monorepo (see the note at the end). + +From `packages/sdk/server-node`: + +```bash +yarn pack # creates package.tgz in this directory +``` + +Then, in your **test app**, install the tarball by absolute or relative path: + +```bash +npm install /path/to/js-core/packages/sdk/server-node/package.tgz +# or with yarn: +yarn add /path/to/js-core/packages/sdk/server-node/package.tgz +``` + +Re-run `yarn pack` and re-install after every change you want to test. + +> Tip: `yarn pack --filename ld-node-sdk.tgz` lets you give the archive a stable name +> so your test app's `package.json` reference doesn't change between builds. + +## 2b. Install via `file:` reference (fast iteration) + +For rapid local iteration, point your test app's `package.json` directly at the built +package directory: + +```jsonc +{ + "dependencies": { + "@launchdarkly/node-server-sdk": "file:/path/to/js-core/packages/sdk/server-node" + } +} +``` + +Then `npm install` / `yarn install` in the test app. With this approach you only need to +re-run the **build** (step 1) after a change — no re-pack, no re-install. + +> Caveat: because this is a monorepo, the package's own dependencies +> (`@launchdarkly/js-server-sdk-common`, etc.) are symlinked under the workspace root, +> not inside `packages/sdk/server-node/node_modules`. A `file:` install copies/links the +> package but may not resolve those workspace deps the way the registry would. If you hit +> "module not found" errors for `@launchdarkly/*` packages, use the tarball method in 2a +> instead, which inlines the correct dependency versions. + +## 3. Use the SDK in your app + +Standard usage is unchanged from the published SDK: + +```js +const { init } = require('@launchdarkly/node-server-sdk'); + +const client = init('your-sdk-key'); + +await client.waitForInitialization({ timeout: 10 }); +const value = await client.variation('your-flag-key', { key: 'user-123' }, false); +console.log(value); +``` + +## 4. Testing the SOCKS proxy support + +This branch adds SOCKS proxy support via the existing `proxyOptions` config. Set +`scheme` to a SOCKS scheme; `host` and `port` identify the SOCKS proxy, and `auth` +(if needed) carries the `username:password` credentials. + +Supported schemes: `socks`, `socks4`, `socks4a`, `socks5`, `socks5h` +(plus the existing `http` / `https` HTTP-proxy schemes). + +```js +const { init } = require('@launchdarkly/node-server-sdk'); + +const client = init('your-sdk-key', { + proxyOptions: { + scheme: 'socks5', + host: '127.0.0.1', + port: 1080, + // Optional. The password may contain ':' — only the first ':' splits user/password. + auth: 'proxyuser:proxypassword', + }, +}); + +await client.waitForInitialization({ timeout: 10 }); +console.log(await client.variation('your-flag-key', { key: 'user-123' }, false)); +``` + +A single SOCKS agent handles both the streaming (HTTPS) and event-delivery connections, +so no separate configuration is required for each. + +If you just want to confirm the proxy path works without a real SDK key, the repo's own +test harness spins up an in-process SOCKS server — see +[`__tests__/socksProxyServer.ts`](__tests__/socksProxyServer.ts) and +[`__tests__/LDClientNode.socksProxy.test.ts`](__tests__/LDClientNode.socksProxy.test.ts). +Run them with: + +```bash +yarn workspace @launchdarkly/node-server-sdk test +``` diff --git a/packages/sdk/server-node/__tests__/LDClientNode.socksProxy.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.socksProxy.test.ts new file mode 100644 index 0000000000..6b03fd3c06 --- /dev/null +++ b/packages/sdk/server-node/__tests__/LDClientNode.socksProxy.test.ts @@ -0,0 +1,117 @@ +import { AsyncQueue, SSEItem, TestHttpHandlers, TestHttpServer } from 'launchdarkly-js-test-helpers'; + +import { basicLogger, LDLogger } from '../src'; +import LDClientNode from '../src/LDClientNode'; +import { SocksProxyServer, startSocksProxyServer } from './socksProxyServer'; + +const sdkKey = 'sdkKey'; +const flagKey = 'flagKey'; +const expectedFlagValue = 'yes'; +const flag = { + key: flagKey, + version: 1, + on: false, + offVariation: 0, + variations: [expectedFlagValue, 'no'], +}; +const allData = { flags: { flagKey: flag }, segments: {} }; + +describe('When using a SOCKS proxy', () => { + let logger: LDLogger; + let closeable: { close: () => void }[]; + + beforeEach(() => { + closeable = []; + logger = basicLogger({ + destination: () => {}, + }); + }); + + afterEach(() => { + closeable.forEach((item) => item.close()); + }); + + it('can use a SOCKS proxy in polling mode', async () => { + const proxy: SocksProxyServer = await startSocksProxyServer(); + const server = await TestHttpServer.start(); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData)); + + const client = new LDClientNode(sdkKey, { + baseUri: server.url, + proxyOptions: { + host: proxy.hostname, + port: proxy.port, + scheme: 'socks5', + }, + stream: false, + sendEvents: false, + logger, + }); + + closeable.push(proxy, server, client); + + await client.waitForInitialization({ timeout: 10 }); + expect(client.initialized()).toBe(true); + + // If the SOCKS proxy did not see a connection then the SDK did not actually use it. + expect(proxy.requestCount()).toBeGreaterThanOrEqual(1); + }); + + it('can use a SOCKS proxy in streaming mode', async () => { + const proxy: SocksProxyServer = await startSocksProxyServer(); + const server = await TestHttpServer.start(); + const events = new AsyncQueue(); + events.add({ type: 'put', data: JSON.stringify({ data: allData }) }); + server.forMethodAndPath('get', '/all', TestHttpHandlers.sseStream(events)); + + const client = new LDClientNode(sdkKey, { + streamUri: server.url, + proxyOptions: { + host: proxy.hostname, + port: proxy.port, + scheme: 'socks5', + }, + sendEvents: false, + logger, + }); + + closeable.push(proxy, server, events, client); + + await client.waitForInitialization({ timeout: 10 }); + expect(client.initialized()).toBe(true); + + expect(proxy.requestCount()).toBeGreaterThanOrEqual(1); + }); + + it('can use a SOCKS proxy with username/password authentication', async () => { + // The password contains a colon to verify that everything after the first colon in `auth` is + // treated as the password. + const proxy: SocksProxyServer = await startSocksProxyServer({ + username: 'user', + password: 'p@ss:word', + }); + const server = await TestHttpServer.start(); + server.forMethodAndPath('get', '/sdk/latest-all', TestHttpHandlers.respondJson(allData)); + + const client = new LDClientNode(sdkKey, { + baseUri: server.url, + proxyOptions: { + host: proxy.hostname, + port: proxy.port, + scheme: 'socks5', + auth: 'user:p@ss:word', + }, + stream: false, + sendEvents: false, + logger, + }); + + closeable.push(proxy, server, client); + + await client.waitForInitialization({ timeout: 10 }); + expect(client.initialized()).toBe(true); + + expect(proxy.requestCount()).toBeGreaterThanOrEqual(1); + expect(proxy.authFailures()).toEqual([]); + }); +}); diff --git a/packages/sdk/server-node/__tests__/socksProxyServer.ts b/packages/sdk/server-node/__tests__/socksProxyServer.ts new file mode 100644 index 0000000000..fd682b7a4c --- /dev/null +++ b/packages/sdk/server-node/__tests__/socksProxyServer.ts @@ -0,0 +1,119 @@ +import * as net from 'net'; + +export interface SocksProxyServer { + hostname: string; + port: number; + requestCount: () => number; + authFailures: () => { uname: string; passwd: string }[]; + close: () => void; +} + +export interface SocksProxyServerOptions { + username?: string; + password?: string; +} + +// A minimal SOCKS5 proxy server for tests. It implements just enough of RFC 1928 (and the +// username/password auth method from RFC 1929) to let the SDK's SOCKS support be exercised +// end-to-end: it negotiates a method, optionally checks credentials, handles a CONNECT command, +// and then pipes bytes between the client and the real target server. +export function startSocksProxyServer( + options: SocksProxyServerOptions = {}, +): Promise { + const expectedUser = options.username; + const expectedPassword = options.password; + const requireAuth = !!expectedUser; + + let connectionCount = 0; + const authFailures: { uname: string; passwd: string }[] = []; + + const server = net.createServer((clientSocket) => { + function handleConnect(request: Buffer) { + // VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2) + const atyp = request[3]; + let host: string; + let portOffset: number; + if (atyp === 0x01) { + host = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`; + portOffset = 8; + } else if (atyp === 0x03) { + const len = request[4]; + host = request.slice(5, 5 + len).toString(); + portOffset = 5 + len; + } else if (atyp === 0x04) { + // IPv6: 16 bytes formatted as eight colon-separated hextets. The SOCKS client resolves + // hostnames such as "localhost" itself, and on many systems that yields ::1, so we have + // to handle this address type as well as IPv4. + const groups: string[] = []; + for (let i = 0; i < 8; i += 1) { + groups.push(request.readUInt16BE(4 + i * 2).toString(16)); + } + host = groups.join(':'); + portOffset = 4 + 16; + } else { + // Unsupported address type + clientSocket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + clientSocket.end(); + return; + } + const port = request.readUInt16BE(portOffset); + + const targetSocket = net.connect(port, host, () => { + connectionCount += 1; + // Success reply with a dummy bound address/port (0.0.0.0:0) + clientSocket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + clientSocket.pipe(targetSocket); + targetSocket.pipe(clientSocket); + }); + targetSocket.on('error', () => { + clientSocket.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + clientSocket.end(); + }); + } + + clientSocket.once('data', () => { + // greeting: VER(1) NMETHODS(1) METHODS(NMETHODS) + const chosenMethod = requireAuth ? 0x02 : 0x00; + clientSocket.write(Buffer.from([0x05, chosenMethod])); + + const afterAuth = () => clientSocket.once('data', handleConnect); + + if (requireAuth) { + clientSocket.once('data', (authData) => { + // VER(1) ULEN(1) UNAME PLEN(1) PASSWD + const ulen = authData[1]; + const uname = authData.slice(2, 2 + ulen).toString(); + const plen = authData[2 + ulen]; + const passwd = authData.slice(3 + ulen, 3 + ulen + plen).toString(); + const ok = uname === expectedUser && passwd === expectedPassword; + if (!ok) { + authFailures.push({ uname, passwd }); + } + clientSocket.write(Buffer.from([0x01, ok ? 0x00 : 0x01])); + if (ok) { + afterAuth(); + } else { + clientSocket.end(); + } + }); + } else { + afterAuth(); + } + }); + + clientSocket.on('error', () => {}); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() as net.AddressInfo; + resolve({ + hostname: '127.0.0.1', + port: address.port, + requestCount: () => connectionCount, + authFailures: () => authFailures, + close: () => server.close(), + }); + }); + }); +} diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 86f307818c..6b4bf180a6 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -47,7 +47,8 @@ "dependencies": { "@launchdarkly/js-server-sdk-common": "2.19.1", "https-proxy-agent": "^7.0.6", - "launchdarkly-eventsource": "2.2.0" + "launchdarkly-eventsource": "2.2.0", + "socks-proxy-agent": "^8.0.5" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index 4a8962acab..1c9c749644 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -4,6 +4,7 @@ import { HttpsProxyAgent, HttpsProxyAgentOptions } from 'https-proxy-agent'; // No types for the event source. // @ts-ignore import { EventSource as LDEventSource } from 'launchdarkly-eventsource'; +import { SocksProxyAgent } from 'socks-proxy-agent'; import { format as formatUrl } from 'url'; import { promisify } from 'util'; import * as zlib from 'zlib'; @@ -48,10 +49,36 @@ function processTlsOptions(tlsOptions: LDTLSOptions): https.AgentOptions { return options; } +const socksSchemes = ['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']; + +function isSocksScheme(scheme?: string): boolean { + return scheme !== undefined && socksSchemes.includes(scheme); +} + +function processSocksProxyOptions( + proxyOptions: LDProxyOptions, + additional: https.AgentOptions = {}, +): https.Agent | http.Agent { + // A single SOCKS agent works for both http and https targets. Build the proxy address as a URL + // so its username/password setters percent-encode the credentials; socks-proxy-agent decodes + // them again, which means an `auth` password may safely contain characters such as ':'. + const proxyUrl = new URL(`${proxyOptions.scheme}://${proxyOptions.host}:${proxyOptions.port}`); + if (proxyOptions.auth) { + const [userId, ...passwordParts] = proxyOptions.auth.split(':'); + proxyUrl.username = userId; + proxyUrl.password = passwordParts.join(':'); + } + return new SocksProxyAgent(proxyUrl, additional); +} + function processProxyOptions( proxyOptions: LDProxyOptions, additional: https.AgentOptions = {}, ): https.Agent | http.Agent { + if (isSocksScheme(proxyOptions.scheme)) { + return processSocksProxyOptions(proxyOptions, additional); + } + const proxyUrl = formatUrl({ protocol: proxyOptions.scheme?.startsWith('https') ? 'https:' : 'http:', slashes: true, diff --git a/packages/shared/sdk-server/src/api/options/LDProxyOptions.ts b/packages/shared/sdk-server/src/api/options/LDProxyOptions.ts index 1760167ecf..1c3155e74f 100644 --- a/packages/shared/sdk-server/src/api/options/LDProxyOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDProxyOptions.ts @@ -12,12 +12,17 @@ export interface LDProxyOptions { port?: number; /** - * When using an HTTP proxy, specifies whether it is accessed via `http` or `https`. + * Specifies the scheme used to access the proxy. + * + * For an HTTP proxy, use `http` (the default) or `https`. To use a SOCKS proxy instead, set + * this to one of `socks`, `socks4`, `socks4a`, `socks5`, or `socks5h`; in that case `host` and + * `port` identify the SOCKS proxy, and `auth` (if set) provides the `username:password` + * credentials. */ scheme?: string; /** - * Allows you to specify basic authentication parameters for an optional HTTP proxy. + * Allows you to specify basic authentication parameters for an optional proxy. * Usually of the form `username:password`. */ auth?: string;