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
25 changes: 25 additions & 0 deletions .github/workflows/server-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)" } }'
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand All @@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/sdk/server-node/examples/README.md
Original file line number Diff line number Diff line change
@@ -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`. |
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { spawnSync } from 'node:child_process';

// This example prints "The <flagKey> feature flag evaluates to <flagValue>." 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.');
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
Original file line number Diff line number Diff line change
@@ -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"]
}
1 change: 1 addition & 0 deletions packages/sdk/server-node/src/LDClientNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions packages/sdk/server-node/src/api/LDOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion packages/sdk/server-node/src/platform/NodeRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading