diff --git a/.github/workflows/server-node.yml b/.github/workflows/server-node.yml index 01f2c26554..d244cdf09b 100644 --- a/.github/workflows/server-node.yml +++ b/.github/workflows/server-node.yml @@ -55,3 +55,28 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} version: v3.0.0-alpha.6 extra_params: '--skip-from=./packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt' + + run-custom-socks-proxy-example: + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./actions/setup-yarn + with: + node-version: 20 + # Installs and starts Dante directly on the runner rather than in a container - see + # proxy/setup.sh. + - name: Install and start Dante (SOCKS proxy) + run: sudo packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/setup.sh + - name: Verify Dante actually started + run: test -f /var/run/danted.pid + - uses: ./actions/run-example + with: + workspace_name: '@launchdarkly/node-server-sdk-example-custom-socks-proxy' + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + env_vars: | + SOCKS_PROXY_URL=socks5h://127.0.0.1:1080 + - name: Verify traffic flowed through the SOCKS proxy + run: | + sudo cat /var/log/danted.log | grep -c "tcp/connect \[" | awk '{ if ($1 < 2) { print "FAIL: expected at least 2 SOCKS connections, got " $1; exit 1 } else { print "OK: proxy logged " $1 " connection(s)" } }' diff --git a/package.json b/package.json index 38230b2e29..a469d4a5d7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "packages/shared/openfeature-server-common", "packages/sdk/server-node", "packages/sdk/server-node/contract-tests", + "packages/sdk/server-node/examples/features/custom-socks-proxy", "packages/sdk/cloudflare", "packages/sdk/cloudflare/example", "packages/sdk/electron", diff --git a/packages/sdk/server-node/__tests__/LDClientNode.proxyAgent.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.proxyAgent.test.ts index 2d831873c0..ead9dadad2 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.proxyAgent.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.proxyAgent.test.ts @@ -47,6 +47,7 @@ describe('When using a custom proxyAgent', () => { // addRequest exists at runtime but is not part of the public http.Agent type. // @ts-ignore const addRequestSpy = jest.spyOn(agent, 'addRequest'); + const warnSpy = jest.spyOn(logger, 'warn'); const client = new LDClientNode(sdkKey, { baseUri: server.url, @@ -61,6 +62,9 @@ describe('When using a custom proxyAgent', () => { await client.waitForInitialization({ timeout: 10 }); expect(client.initialized()).toBe(true); expect(addRequestSpy).toHaveBeenCalled(); + // proxyAgent is a real, known LDOptions member; the shared Configuration validator should + // not flag it as unrecognized just because it isn't part of the common LDOptions. + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('unknown config option')); }); it('uses the supplied proxyAgent for streaming requests', async () => { diff --git a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts index 249161a4bb..41bd72b2b2 100644 --- a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts +++ b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts @@ -258,6 +258,9 @@ describe('given an instance of NodeRequests with a proxyAgent and proxyOptions b expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('proxyAgent')); expect(addRequestSpy).toHaveBeenCalled(); + // Same best-effort reasoning as usingProxy(): the SDK can't verify whether the supplied agent + // carries its own auth, but reporting true is the better default here. + expect(requests.usingProxyAuth()).toBe(true); }); }); @@ -276,5 +279,8 @@ describe('given an instance of NodeRequests with only a proxyAgent supplied', () // be for mTLS), but reporting true is the better default for this option's motivating case // (a SOCKS proxy), so usingProxy() treats any supplied proxyAgent as a best-effort signal. expect(requests.usingProxy()).toBe(true); + // Same reasoning applies to auth: the agent could carry its own credentials (e.g. a SOCKS + // URL's embedded username/password) that the SDK has no way to inspect. + expect(requests.usingProxyAuth()).toBe(true); }); }); diff --git a/packages/sdk/server-node/examples/README.md b/packages/sdk/server-node/examples/README.md new file mode 100644 index 0000000000..187618f8b8 --- /dev/null +++ b/packages/sdk/server-node/examples/README.md @@ -0,0 +1,7 @@ +# LaunchDarkly Node.js Server SDK - Examples + +This directory contains example applications demonstrating the LaunchDarkly Node.js server-side SDK. + +| Example | Description | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [features/custom-socks-proxy](./features/custom-socks-proxy/) | Routes SDK traffic through a SOCKS proxy using the `proxyAgent` option on `LDOptions`. | diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/README.md b/packages/sdk/server-node/examples/features/custom-socks-proxy/README.md new file mode 100644 index 0000000000..1042fec940 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/README.md @@ -0,0 +1,72 @@ +# LaunchDarkly Node.js server-side SDK: custom SOCKS proxy example + +This example demonstrates the `proxyAgent` option on `LDOptions`, a Node-specific escape hatch +that lets an application route all of the SDK's outbound traffic (polling and streaming) through +an `http.Agent`/`https.Agent` of its own choosing. The SDK does not build SOCKS support itself; +instead, the application constructs a [`SocksProxyAgent`](https://www.npmjs.com/package/socks-proxy-agent) +and hands it to the SDK via `proxyAgent`. + +```ts +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { init } from '@launchdarkly/node-server-sdk'; + +const client = init(sdkKey, { + proxyAgent: new SocksProxyAgent('socks5h://127.0.0.1:1080'), +}); +``` + +This demo requires Node.js 20 or higher, and a running SOCKS proxy. + +## Run instructions + +This example uses the workspace build of `@launchdarkly/node-server-sdk`. From the repository +root, build the SDK and its dependencies first: + +```bash +yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/node-server-sdk' run build +``` + +You need a SOCKS5 proxy to route traffic through. `SOCKS_PROXY_URL` is **required** - the example +exits with an error if it is unset. This directory's `proxy/` folder builds and runs +[Dante](https://www.inet.no/dante/), a real SOCKS5 server, in Docker: + +```bash +docker build -t ld-example-socks-proxy packages/sdk/server-node/examples/features/custom-socks-proxy/proxy +docker run --rm -d --name ld-example-socks-proxy -p 1080:1080 ld-example-socks-proxy +export SOCKS_PROXY_URL="socks5h://127.0.0.1:1080" +``` + +If you already have an SSH server reachable (for example your own machine with Remote Login / +sshd enabled), a dynamic port forward works just as well and needs no Docker: + +```bash +ssh -D 1080 -N user@your-server & +export SOCKS_PROXY_URL="socks5h://127.0.0.1:1080" +``` + +`SOCKS_PROXY_URL` accepts any URL `SocksProxyAgent` understands, including embedded credentials +(`socks5://user:password@host:port`) if your proxy requires authentication. + +Then set your LaunchDarkly server-side SDK key and start the example: + +```bash +export LAUNCHDARKLY_SDK_KEY="my-sdk-key" +export LAUNCHDARKLY_FLAG_KEY="my-boolean-flag" # optional; defaults to "sample-feature" + +yarn start +``` + +When you're done, stop the proxy container (only needed if you used the Docker option above): + +```bash +docker stop ld-example-socks-proxy +``` + +## What it demonstrates + +- Constructing a `SocksProxyAgent` and passing it as `proxyAgent` in `LDOptions`. +- The SDK using that agent for both polling and streaming connections, with no additional + proxy-specific configuration. + +The application evaluates one flag and exits. Toggle `LAUNCHDARKLY_FLAG_KEY` in your LaunchDarkly +project to see a different value. diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/e2e/verify.mjs b/packages/sdk/server-node/examples/features/custom-socks-proxy/e2e/verify.mjs new file mode 100644 index 0000000000..5aa2bf3403 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/e2e/verify.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from 'node:child_process'; + +// This example prints "The feature flag evaluates to ." on success. A +// successful run must contain that line. Proof that the SDK actually used the proxy (rather than +// connecting directly) is verified externally in CI by grepping the SOCKS proxy container's own +// logs; see .github/workflows/server-node.yml. +const EXPECTED_EVALUATION = 'feature flag evaluates to true'; + +const result = spawnSync('node', ['./dist/index.js'], { + encoding: 'utf8', + timeout: 60_000, + env: { ...process.env }, +}); + +process.stdout.write(result.stdout ?? ''); +process.stderr.write(result.stderr ?? ''); + +if (result.status !== 0) { + console.error(`\n*** e2e failed: app exited with status ${result.status}.`); + process.exit(1); +} + +const output = result.stdout ?? ''; +if (!output.includes(EXPECTED_EVALUATION)) { + console.error(`\n*** e2e failed: expected output to contain "${EXPECTED_EVALUATION}".`); + process.exit(1); +} + +console.log('\n*** e2e passed: SDK evaluated the flag through the SOCKS proxy.'); diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/package.json b/packages/sdk/server-node/examples/features/custom-socks-proxy/package.json new file mode 100644 index 0000000000..44fc13c4ef --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/package.json @@ -0,0 +1,41 @@ +{ + "name": "@launchdarkly/node-server-sdk-example-custom-socks-proxy", + "version": "0.1.0", + "description": "Example: route the Node.js server-side SDK's traffic through a SOCKS proxy via the proxyAgent option", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "start": "yarn build && node ./dist/index.js", + "test": "node ./e2e/verify.mjs", + "lint": "npx eslint .", + "lint:fix": "yarn run lint --fix", + "check": "yarn lint && yarn build" + }, + "keywords": [ + "launchdarkly", + "socks", + "proxy", + "feature-flags" + ], + "author": "LaunchDarkly", + "license": "Apache-2.0", + "dependencies": { + "@launchdarkly/node-server-sdk": "9.11.3", + "socks-proxy-agent": "^10.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@tsconfig/node20": "20.1.4", + "@types/node": "^25.9.1", + "eslint": "^9.0.0", + "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import-x": "^4.0.0", + "globals": "^16.0.0", + "prettier": "^3.0.0", + "typescript": "5.1.6", + "typescript-eslint": "^8.0.0" + } +} diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/Dockerfile b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/Dockerfile new file mode 100644 index 0000000000..1182748b51 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/Dockerfile @@ -0,0 +1,11 @@ +# A real SOCKS5 server (Dante: https://www.inet.no/dante/) for local development to route +# traffic through. +FROM ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + +COPY setup.sh danted.conf /proxy/ + +EXPOSE 1080 + +# setup.sh daemonizes rather than staying in the foreground, so this keeps the container alive +# afterward. +CMD ["/bin/sh", "-c", "/proxy/setup.sh && tail -f /dev/null"] diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/danted.conf b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/danted.conf new file mode 100644 index 0000000000..0e93256651 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/danted.conf @@ -0,0 +1,22 @@ +# Using a file instead of stdout for easier verification in CI run. +logoutput: /var/log/danted.log + +user.privileged: root +user.unprivileged: nobody + +internal: 0.0.0.0 port=1080 +external: eth0 + +clientmethod: none +socksmethod: none + +client pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + log: connect disconnect error +} + +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + protocol: tcp udp + log: connect disconnect error +} diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/setup.sh b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/setup.sh new file mode 100755 index 0000000000..50f467f3db --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/proxy/setup.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Installs and starts Dante (https://www.inet.no/dante/), a real SOCKS5 server, using the +# danted.conf next to this script. Shared by proxy/Dockerfile (for local `docker build` use) and +# the CI workflow. + +set -euo pipefail + +apt-get update +apt-get install -y --no-install-recommends dante-server +rm -rf /var/lib/apt/lists/* + +danted -D -f "$(dirname "$0")/danted.conf" diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/src/index.ts b/packages/sdk/server-node/examples/features/custom-socks-proxy/src/index.ts new file mode 100644 index 0000000000..c5b39555c3 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/src/index.ts @@ -0,0 +1,69 @@ +import { SocksProxyAgent } from 'socks-proxy-agent'; + +import { basicLogger, init, type LDLogger } from '@launchdarkly/node-server-sdk'; + +const sdkKey = process.env.LAUNCHDARKLY_SDK_KEY; + +// Override with LAUNCHDARKLY_FLAG_KEY to test against a flag other than the sample one. +const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; + +// Point this at a SOCKS proxy (for example one started with `ssh -D 1080 -N user@host`, or the +// container described in README.md). This is required: the example routes all SDK traffic +// through it. +const socksProxyUrl = process.env.SOCKS_PROXY_URL; + +if (!sdkKey) { + console.error( + '*** LaunchDarkly SDK key is required: set the LAUNCHDARKLY_SDK_KEY environment variable and try again.', + ); + process.exit(1); +} + +if (!socksProxyUrl) { + console.error( + '*** A SOCKS proxy is required: set the SOCKS_PROXY_URL environment variable (for example socks5h://user:password@127.0.0.1:1080) and try again.', + ); + process.exit(1); +} + +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Sandy', +}; + +async function main(): Promise { + console.log(`*** Routing SDK traffic through the SOCKS proxy at ${socksProxyUrl}\n`); + const proxyAgent = new SocksProxyAgent(socksProxyUrl!); + + const logger: LDLogger = basicLogger({ level: 'warn' }); + + // The `proxyAgent` option is a Node-specific escape hatch: the SDK hands every request to + // this caller-supplied agent instead of building its own connection, so any proxy scheme the + // agent supports (SOCKS included) works without the SDK needing to know about it. + const client = init(sdkKey!, { + proxyAgent, + logger, + }); + + try { + await client.waitForInitialization({ timeout: 10 }); + console.log('*** SDK successfully initialized through the SOCKS proxy!\n'); + } catch (error) { + console.error( + `*** SDK failed to initialize through the SOCKS proxy. Please check your SDK credential ` + + `and proxy configuration.\n${error}`, + ); + process.exit(1); + } + + const flagValue = await client.variation(flagKey, context, false); + console.log(`*** The '${flagKey}' feature flag evaluates to ${flagValue}.\n`); + + client.close(); +} + +main().catch((error) => { + console.error(`*** Unhandled error: ${error}`); + process.exit(1); +}); diff --git a/packages/sdk/server-node/examples/features/custom-socks-proxy/tsconfig.json b/packages/sdk/server-node/examples/features/custom-socks-proxy/tsconfig.json new file mode 100644 index 0000000000..737b952241 --- /dev/null +++ b/packages/sdk/server-node/examples/features/custom-socks-proxy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/sdk/server-node/src/LDClientNode.ts b/packages/sdk/server-node/src/LDClientNode.ts index 2f8a0511fe..71aaf26d9c 100644 --- a/packages/sdk/server-node/src/LDClientNode.ts +++ b/packages/sdk/server-node/src/LDClientNode.ts @@ -46,6 +46,7 @@ class LDClientNode extends LDClientImpl implements LDClient { const baseOptions = { ...options, logger }; delete baseOptions.plugins; + delete baseOptions.proxyAgent; const platform = new NodePlatform({ ...options, logger }); // Per SCMP-server-connection-minutes-polling, generate one v4 GUID per SDK diff --git a/packages/sdk/server-node/src/api/LDOptions.ts b/packages/sdk/server-node/src/api/LDOptions.ts index 471ab5dbe8..703a10edab 100644 --- a/packages/sdk/server-node/src/api/LDOptions.ts +++ b/packages/sdk/server-node/src/api/LDOptions.ts @@ -33,10 +33,9 @@ export interface LDOptions extends LDOptionsCommon { * * @remarks * The SDK cannot inspect a caller-supplied agent, so diagnostic reporting treats any agent - * supplied here as a best-effort signal that a proxy is in use. This may be inaccurate if the - * agent is used for something other than proxying, such as custom certificates. Credentials - * carried by the agent itself (for example a SOCKS URL's embedded username/password) are not - * reflected in proxy-authentication diagnostics, since the SDK has no way to inspect them. + * supplied here as a best-effort signal that a proxy - and proxy authentication - is in use. + * This may be inaccurate if the agent is used for something other than an authenticated proxy, + * such as custom certificates. */ proxyAgent?: https.Agent | http.Agent; } diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index d17b70f3fc..fddce38559 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -144,7 +144,11 @@ export default class NodeRequests implements platform.Requests { // proxying is this option's primary motivation, while other connection concerns (such as // custom TLS) are handled by tlsParams. this._hasProxy = !!proxyOptions || !!proxyAgent; - this._hasProxyAuth = !!proxyOptions?.auth; + // Same best-effort reasoning as _hasProxy above: the SDK can't inspect a caller-supplied + // proxyAgent to know whether it carries its own auth (e.g. credentials embedded in a SOCKS + // URL), so treat any proxyAgent as a best-effort signal for auth too, rather than reporting + // false just because it doesn't come from proxyOptions.auth. + this._hasProxyAuth = !!proxyOptions?.auth || !!proxyAgent; this._enableBodyCompression = !!enableEventCompression; } diff --git a/packages/sdk/server-node/tsconfig.json b/packages/sdk/server-node/tsconfig.json index ac97ece8c9..d42c08648d 100644 --- a/packages/sdk/server-node/tsconfig.json +++ b/packages/sdk/server-node/tsconfig.json @@ -17,5 +17,5 @@ "stripInternal": true, "moduleResolution": "node" }, - "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "contract-tests"] + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "contract-tests", "examples"] } diff --git a/release-please-config.json b/release-please-config.json index c9f695ecc3..de2e328417 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -232,6 +232,11 @@ "type": "json", "path": "/packages/sdk/openfeature-node-server/examples/getting-started/package.json", "jsonpath": "$.dependencies['@launchdarkly/node-server-sdk']" + }, + { + "type": "json", + "path": "/packages/sdk/server-node/examples/features/custom-socks-proxy/package.json", + "jsonpath": "$.dependencies['@launchdarkly/node-server-sdk']" } ] },