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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ This is a pure Node.js library (no long-running app server); "running" it means
- Docker is required for tests but the daemon does NOT auto-start. Before running integration tests, start it once per session and make the socket usable by the repo's non-sudo scripts:
- `sudo dockerd > /tmp/dockerd.log 2>&1 &`
- `sudo chmod 666 /var/run/docker.sock`
- Then `pnpm test:services:start` (docker compose) brings up memcached on ports `11211`, `11212`, `11213` and a SASL server on `11215`. `pnpm test` / `pnpm test:ci` need these running or most suites fail.
- Then `pnpm test:services:start` (docker compose) brings up memcached on ports `11211`, `11212`, `11213`, a SASL server on `11215`, a TLS-only server on `21211`, and a TLS+SASL server on `21215`. `pnpm test` / `pnpm test:ci` need these running or most suites fail.
- Docker note: the daemon is configured with the `fuse-overlayfs` storage driver and `containerd-snapshotter` disabled (required for Docker 29 in this VM). This is already set in `/etc/docker/daemon.json`.
- Known environment-only test failures: the two `should handle connection timeout` tests (`test/index.test.ts`, `test/node.test.ts`) fail here because outbound TCP to the reserved TEST-NET-1 address `192.0.2.0` connects instantly in this sandbox instead of timing out. This is a network-environment quirk, not a code bug; these pass on GitHub CI. All other tests (610) pass.

Expand Down
125 changes: 114 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ Nodejs Memcache Client
- [IPv6 Support](#ipv6-support)
- [TLS Support](#tls-support)
- [Connecting with TLS](#connecting-with-tls)
- [TLS Options](#tls-options)
- [Per-Node TLS Configuration](#per-node-tls-configuration)
- [TLS Node Properties](#tls-node-properties)
- [AWS ElastiCache Serverless](#aws-elasticache-serverless)
- [Custom certificate authorities](#custom-certificate-authorities)
- [Auto Discovery with TLS](#auto-discovery-with-tls)
- [TLS and SASL together](#tls-and-sasl-together)
- [TLS Server Configuration](#tls-server-configuration)
- [Benchmarks](#benchmarks)
- [Contributing](#contributing)
- [License and Copyright](#license-and-copyright)
Expand Down Expand Up @@ -209,6 +215,7 @@ const client = new Memcache({
- `retryDelay?: number` - Base delay in milliseconds between retries (default: 100)
- `retryBackoff?: RetryBackoffFunction` - Function to calculate backoff delay (default: fixed delay)
- `retryOnlyIdempotent?: boolean` - Only retry commands marked as idempotent (default: true)
- `sasl?: SASLCredentials` - SASL PLAIN credentials for all nodes (see [SASL Authentication](#sasl-authentication))
- `lazyConnect?: boolean` - When `true`, nodes will not connect until the first command is executed. When `false`, nodes connect eagerly during construction (default: true)
- `maxKeySize?: number` - Maximum allowed key size in characters (default: 250, memcache protocol max)
- `maxValueSize?: number` - Maximum allowed value size in bytes (default: 1048576, memcached default)
Expand Down Expand Up @@ -1143,6 +1150,47 @@ even when the client-level option is unset):
const client = new Memcache('memcaches://my-cache.example.com:11211');
```

## TLS Options

The `tls` option accepts:

- `true` / `{}` — connect using TLS with Node's default trust store
- a `tls.ConnectionOptions` object — passed through to `tls.connect()` (CA,
client certificates, `servername`, `minVersion`, …)
- `false` / `undefined` (default) — plain TCP

Certificate verification is always on (standard Node behavior). To connect to
a server with a self-signed cert, pass its CA — do not disable verification.

The node's host, port (or Unix socket path), and keep-alive settings always
win over any `host` / `port` / `path` fields in a `tls` options object.

## Per-Node TLS Configuration

You can also enable TLS when creating individual nodes:

```javascript
import { createNode } from 'memcache';
import { readFileSync } from 'node:fs';

const node = createNode('memcached-internal', 11211, {
tls: { ca: readFileSync('/etc/ssl/private-ca.pem') },
});

await node.connect();
await node.command('version');
```

## TLS Node Properties

- `node.tlsEnabled` — `true` when TLS is configured for the node
- `node.tls` — the TLS option the node was constructed with
- `node.uri` — `memcaches://host:port` when TLS is enabled, so passing it
back into `addNode()` or the constructor keeps TLS on even without a
client-level `tls` option

The client's `connect` event fires after the TLS handshake completes.

## AWS ElastiCache Serverless

ElastiCache Serverless (Memcached) **requires** TLS and only speaks the text
Expand Down Expand Up @@ -1173,17 +1221,72 @@ const client = new Memcache({
});
```

Notes:

- `tls: true` / `tls: {}` both enable TLS with default trust; certificate
verification is always on (standard Node behavior). To connect to a server
with a self-signed cert, pass its CA — do not disable verification.
- `node.uri` uses `memcaches://` when TLS is enabled, so passing it back into
`addNode()` or the constructor keeps TLS on even without a client-level
`tls` option.
- Auto-discovery's configuration-endpoint connection does not use TLS yet;
for ElastiCache node-based clusters with in-transit encryption, connect to
node endpoints directly.
## Auto Discovery with TLS

Auto Discovery uses the same client-level `tls` (and `sasl`) options as data
nodes, including the configuration-endpoint connection. Set `tls: true` (or a
Comment on lines +1226 to +1227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid promising SASL support for Auto Discovery

This claim is not supported for a SASL-protected configuration endpoint: AutoDiscovery.fetchConfig() sends the ASCII config get cluster/legacy command through MemcacheNode.command(), while a SASL node requires the binary protocol and its permanent data handler explicitly skips handleData() whenever _sasl is set. Consequently, configuring the advertised TLS+SASL Auto Discovery flow cannot obtain a topology and may leave the command pending until the connection closes; either implement the discovery exchange over the authenticated protocol or remove the SASL claim and avoid forwarding credentials to this connection.

Useful? React with 👍 / 👎.

CA options object) when the cluster requires in-transit encryption:

```javascript
const client = new Memcache({
nodes: [],
tls: true,
autoDiscover: {
enabled: true,
configEndpoint: 'my-cluster.cfg.use1.cache.amazonaws.com:11211',
},
});
```

A `memcaches://` configuration endpoint also enables TLS for discovery. When
discovery returns a DNS hostname plus an IP, the client connects to the IP
(stable node IDs) and sets TLS SNI/`servername` to the hostname so certificate
verification matches ElastiCache node certs.

## TLS and SASL together

TLS and SASL compose: the TCP handshake completes, then SASL PLAIN runs on
the encrypted socket. Use both for ElastiCache clusters that require
in-transit encryption **and** AUTH. SASL-enabled servers still require the
binary protocol after authentication (see [SASL Authentication](#sasl-authentication)).

```javascript
const client = new Memcache({
nodes: ['my-cluster.use1.cache.amazonaws.com:11211'],
tls: true,
sasl: { username: 'user', password: 'token' },
});

await client.connect();
const node = client.nodes[0];
await node.binarySet('mykey', 'hello');
```

## TLS Server Configuration

To run memcached with TLS:

1. **Build or use an image with TLS** — memcached 1.5.13+ (`--enable-tls`);
the official Docker image supports it since 1.5.21

2. **Provide a certificate chain and key**

3. **Start memcached with `-Z`**:
```bash
memcached -Z \
-o ssl_chain_cert=/path/to/server_crt.pem \
-o ssl_key=/path/to/server_key.pem \
-o ssl_session_cache
```

ElastiCache Serverless is ASCII-only; a local stand-in is:
```bash
memcached -Z -B ascii -U 0 \
-o ssl_chain_cert=/path/to/server_crt.pem \
-o ssl_key=/path/to/server_key.pem
```

For more details, see the [memcached TLS documentation](https://github.com/memcached/memcached/wiki/TLS).

# Benchmarks

Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,18 @@ services:
container_name: memcached-server-sasl
ports:
- "11215:11211"
restart: unless-stopped

# TLS + SASL — ElastiCache-style in-transit encryption plus AUTH.
# Reuses the SASL image (PLAIN) with the TLS test certs mounted.
memcached-tls-sasl:
build:
context: ./test/sasl
dockerfile: Dockerfile
container_name: memcached-server-tls-sasl
ports:
- "21215:11211"
command: memcached -m 64 -vv -S -Z -o ssl_chain_cert=/certs/server_crt.pem -o ssl_key=/certs/server_key.pem -o ssl_session_cache -U 0 -u root
volumes:
- ./test/certs:/certs:ro
restart: unless-stopped
42 changes: 39 additions & 3 deletions src/auto-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hookified } from "hookified";
import { MemcacheNode } from "./node.js";
import { MemcacheNode, type MemcacheTlsOption } from "./node.js";
import type {
ClusterConfig,
DiscoveredNode,
Expand All @@ -14,6 +14,7 @@ export interface AutoDiscoveryOptions {
keepAlive: boolean;
keepAliveDelay: number;
sasl?: SASLCredentials;
tls?: MemcacheTlsOption;
}

/**
Expand All @@ -32,6 +33,7 @@ export class AutoDiscovery extends Hookified {
private _keepAlive: boolean;
private _keepAliveDelay: number;
private _sasl: SASLCredentials | undefined;
private _tls: MemcacheTlsOption | undefined;
private _isRunning = false;
private _isPolling = false;

Expand All @@ -44,6 +46,7 @@ export class AutoDiscovery extends Hookified {
this._keepAlive = options.keepAlive;
this._keepAliveDelay = options.keepAliveDelay;
this._sasl = options.sasl;
this._tls = options.tls;
}

/** Current config version. -1 means no config has been fetched yet. */
Expand All @@ -61,6 +64,14 @@ export class AutoDiscovery extends Hookified {
return this._configEndpoint;
}

/**
* TLS option applied to the configuration-endpoint connection.
* `memcaches://` endpoints enable TLS even when this was not set.
*/
public get tls(): MemcacheTlsOption | undefined {
return this._tls;
}

/**
* Start the auto discovery process.
* Performs an initial discovery, then starts the polling timer.
Expand Down Expand Up @@ -207,13 +218,18 @@ export class AutoDiscovery extends Hookified {
return this._configNode;
}

const { host, port } = this.parseEndpoint(this._configEndpoint);
const { host, port, secure } = this.parseEndpoint(this._configEndpoint);
const tls = secure ? this._tls || true : this._tls;
if (this._tls === undefined && tls) {
this._tls = tls;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False TLS loses to memcaches scheme

Medium Severity

this._tls || true treats an explicit tls: false as unset when the configuration endpoint is memcaches://, so the config node still does a TLS handshake. effectiveTls uses ??, so that same false keeps discovered data nodes on plaintext. The cluster can end up with a TLS config connection and unencrypted node connections.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ee786d. Configure here.


this._configNode = new MemcacheNode(host, port, {
timeout: this._timeout,
keepAlive: this._keepAlive,
keepAliveDelay: this._keepAliveDelay,
sasl: this._sasl,
tls,
Comment on lines +221 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set SNI for DNS configuration endpoints

When the configuration endpoint is a DNS name and TLS is enabled with true or inferred from memcaches://, this passes a bare TLS option to MemcacheNode. Node's tls.connect() does not infer servername from host, so a configuration service that selects its certificate through SNI returns its default certificate and the discovery handshake fails. For non-IP endpoint hosts, merge servername: host unless the caller already supplied a server name, as is done for discovered data nodes.

Useful? React with 👍 / 👎.

});

await this._configNode.connect();
Expand Down Expand Up @@ -276,7 +292,27 @@ export class AutoDiscovery extends Hookified {
}
}

private parseEndpoint(endpoint: string): { host: string; port: number } {
private parseEndpoint(endpoint: string): {
host: string;
port: number;
secure?: boolean;
} {
let rest = endpoint;
let secure: true | undefined;
const schemeEnd = endpoint.indexOf("://");
if (schemeEnd !== -1) {
const protocol = endpoint.slice(0, schemeEnd);
rest = endpoint.slice(schemeEnd + 3);
if (protocol === "memcaches") {
secure = true;
}
}

const parsed = this.parseHostPort(rest);
return secure ? { ...parsed, secure } : parsed;
}

private parseHostPort(endpoint: string): { host: string; port: number } {
// Handle IPv6 with brackets
if (endpoint.startsWith("[")) {
const bracketEnd = endpoint.indexOf("]");
Expand Down
72 changes: 69 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type CommandOptions, createNode, MemcacheNode } from "./node.js";
import {
type AutoDiscoverOptions,
type ClusterConfig,
type DiscoveredNode,
type ExecuteOptions,
type HashProvider,
MemcacheEvents,
Expand Down Expand Up @@ -602,6 +603,13 @@ export class Memcache extends Hookified {
cleanUri = protocolParts[1];
}

// Unix path after a scheme, e.g. memcaches:///var/run/memcached.sock
if (cleanUri.startsWith("/")) {
return secure
? { host: cleanUri, port: 0, secure }
: { host: cleanUri, port: 0 };
}

// Handle IPv6 addresses with brackets [::1]:11211
if (cleanUri.startsWith("[")) {
const bracketEnd = cleanUri.indexOf("]");
Expand Down Expand Up @@ -1643,6 +1651,7 @@ export class Memcache extends Hookified {
keepAlive: this._keepAlive,
keepAliveDelay: this._keepAliveDelay,
sasl: this._sasl,
tls: this._tls !== undefined ? this._tls : this._nodes[0]?.tls,
});

/* v8 ignore next -- @preserve */
Expand Down Expand Up @@ -1696,9 +1705,7 @@ export class Memcache extends Hookified {
const id = AutoDiscovery.nodeId(node);
if (!currentNodeIds.has(id)) {
try {
const host = node.ip || node.hostname;
const wrappedHost = host.includes(":") ? `[${host}]` : host;
await this.addNode(`${wrappedHost}:${node.port}`);
await this.addDiscoveredNode(node);
Comment on lines 1706 to +1708

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh matching seed nodes with inferred TLS

When TLS is inferred solely from a memcaches:// configuration endpoint, any preconfigured node whose ID also appears in the discovered topology is skipped here and retains its original plaintext configuration. For example, a client seeded with 10.0.0.1:11211 will leave that node plaintext while newly discovered peers use TLS/SNI, so keys hashed to the seed fail against a TLS-only cluster. Existing matching nodes need to be replaced or upgraded when their effective TLS/SNI settings differ.

Useful? React with 👍 / 👎.

} catch (error) {
this.emit(MemcacheEvents.ERROR, id, error);
}
Expand All @@ -1716,6 +1723,65 @@ export class Memcache extends Hookified {
}
}
}

/**
* TLS applied to newly discovered nodes: client-level option, else the
* auto-discovery config-endpoint option (`memcaches://` infers `true`).
*/
private get effectiveTls(): MemcacheTlsOption | undefined {
return this._tls ?? this._autoDiscovery?.tls;
}

/**
* Merge SNI (`servername`) into TLS options when discovery returns a DNS
* hostname plus an IP. Connecting to the IP keeps node IDs stable; SNI
* and certificate verification still use the hostname (required for
* ElastiCache in-transit encryption).
*/
private tlsOptionsForDiscoveredNode(
node: DiscoveredNode,
): MemcacheTlsOption | undefined {
const tls = this.effectiveTls;
if (!tls) {
return tls;
}

const connectingHost = node.ip || node.hostname;
if (
!node.hostname ||
node.hostname === connectingHost ||
node.hostname.includes(":") ||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(node.hostname)
) {
return tls;
}

const base = tls === true ? {} : { ...tls };
if (base.servername) {
return tls;
}
return { ...base, servername: node.hostname };
}

private async addDiscoveredNode(node: DiscoveredNode): Promise<void> {
const host = node.ip || node.hostname;
const tls = this.tlsOptionsForDiscoveredNode(node);
if (tls) {
await this.addNode(
new MemcacheNode(host, node.port, {
timeout: this._timeout,
keepAlive: this._keepAlive,
keepAliveDelay: this._keepAliveDelay,
sasl: this._sasl,
tls,
}),
);
return;
}

const wrappedHost = host.includes(":") ? `[${host}]` : host;
await this.addNode(`${wrappedHost}:${node.port}`);
}
}

export {
Expand Down
Loading