diff --git a/AGENTS.md b/AGENTS.md index eb87843..3eac2dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index b504b1b..4693eb5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) @@ -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 @@ -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 +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 diff --git a/docker-compose.yml b/docker-compose.yml index 09665bd..fa55ee2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/src/auto-discovery.ts b/src/auto-discovery.ts index a2f5d55..9f1d6ec 100644 --- a/src/auto-discovery.ts +++ b/src/auto-discovery.ts @@ -1,5 +1,5 @@ import { Hookified } from "hookified"; -import { MemcacheNode } from "./node.js"; +import { MemcacheNode, type MemcacheTlsOption } from "./node.js"; import type { ClusterConfig, DiscoveredNode, @@ -14,6 +14,7 @@ export interface AutoDiscoveryOptions { keepAlive: boolean; keepAliveDelay: number; sasl?: SASLCredentials; + tls?: MemcacheTlsOption; } /** @@ -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; @@ -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. */ @@ -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. @@ -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; + } this._configNode = new MemcacheNode(host, port, { timeout: this._timeout, keepAlive: this._keepAlive, keepAliveDelay: this._keepAliveDelay, sasl: this._sasl, + tls, }); await this._configNode.connect(); @@ -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("]"); diff --git a/src/index.ts b/src/index.ts index bea098e..ee8183b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { type CommandOptions, createNode, MemcacheNode } from "./node.js"; import { type AutoDiscoverOptions, type ClusterConfig, + type DiscoveredNode, type ExecuteOptions, type HashProvider, MemcacheEvents, @@ -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("]"); @@ -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 */ @@ -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); } catch (error) { this.emit(MemcacheEvents.ERROR, id, error); } @@ -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 { + 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 { diff --git a/src/node.ts b/src/node.ts index 1b0e493..f1658c0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -221,6 +221,21 @@ export class MemcacheNode extends Hookified { return this._authenticated; } + /** + * TLS option this node was constructed with (`true`, `false`, a + * `tls.ConnectionOptions` object, or `undefined` for plain TCP). + */ + public get tls(): MemcacheTlsOption | undefined { + return this._tls; + } + + /** + * Whether TLS is enabled for this node's connection. + */ + public get tlsEnabled(): boolean { + return Boolean(this._tls); + } + /** * Connect to the memcache server */ @@ -232,13 +247,9 @@ export class MemcacheNode extends Hookified { } if (this._tls) { - this._socket = createTlsConnection({ - host: this._host, - port: this._port, - keepAlive: this._keepAlive, - keepAliveInitialDelay: this._keepAliveDelay, - ...(this._tls === true ? {} : this._tls), - }); + this._socket = createTlsConnection( + this.buildTlsConnectOptions(this._tls), + ); } else { this._socket = createConnection({ host: this._host, @@ -344,6 +355,29 @@ export class MemcacheNode extends Hookified { await this.connect(); } + /** + * Build `tls.connect()` options. User-supplied ConnectionOptions (CA, cert, + * SNI, …) are passed through, but the node's host/port/path and keep-alive + * settings always win so a `tls: { host, port }` object cannot retarget + * the socket. + */ + private buildTlsConnectOptions(tls: MemcacheTlsOption): TlsConnectionOptions { + const options: TlsConnectionOptions = tls === true ? {} : { ...tls }; + // Node identity always wins over any host/port/path in user options. + options.host = undefined; + options.port = undefined; + options.path = undefined; + if (this._port === 0) { + options.path = this._host; + } else { + options.host = this._host; + options.port = this._port; + } + options.keepAlive = this._keepAlive; + options.keepAliveInitialDelay = this._keepAliveDelay; + return options; + } + /** * Perform SASL PLAIN authentication using the binary protocol */ diff --git a/src/types.ts b/src/types.ts index 0c8b585..7d9a67f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,7 +170,8 @@ export interface MemcacheOptions { maxExpiration?: number; /** - * Enable TLS for all node connections. + * Enable TLS for all node connections, including Auto Discovery's + * configuration-endpoint connection. * - `true`: connect using TLS with Node's default trust store. This is the * typical setting for servers with publicly-trusted certificates * (e.g. AWS ElastiCache Serverless, which requires TLS). diff --git a/test/auto-discovery.test.ts b/test/auto-discovery.test.ts index ddf4e3e..79e8047 100644 --- a/test/auto-discovery.test.ts +++ b/test/auto-discovery.test.ts @@ -817,6 +817,207 @@ describe("Memcache AutoDiscovery Integration", () => { expect(client.nodeIds).toContain("[2001:db8::1]:11211"); expect(client.nodeIds).toContain("10.0.0.1:11211"); }); + + it("should apply TLS and SNI hostname to discovered nodes", async () => { + const client = new Memcache({ + nodes: ["10.0.0.1:11211"], + lazyConnect: true, + tls: true, + }); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { hostname: "host1", ip: "10.0.0.1", port: 11211 }, + { + hostname: "host2.cache.amazonaws.com", + ip: "10.0.0.2", + port: 11211, + }, + ], + }); + + const existing = client.getNode("10.0.0.1:11211"); + expect(existing?.tlsEnabled).toBe(true); + expect(existing?.tls).toBe(true); + + const discovered = client.getNode("10.0.0.2:11211"); + expect(discovered?.tlsEnabled).toBe(true); + expect(discovered?.tls).toEqual({ + servername: "host2.cache.amazonaws.com", + }); + expect(discovered?.uri).toBe("memcaches://10.0.0.2:11211"); + }); + + it("should keep caller-supplied servername on discovered TLS nodes", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: { servername: "custom.example.com" }, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { + hostname: "host1.cache.amazonaws.com", + ip: "10.0.0.5", + port: 11211, + }, + ], + }); + + expect(client.getNode("10.0.0.5:11211")?.tls).toEqual({ + servername: "custom.example.com", + }); + }); + + it("should not set SNI when discovered hostname is the connecting IP", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [{ hostname: "10.0.0.8", ip: "10.0.0.8", port: 11211 }], + }); + + expect(client.getNode("10.0.0.8:11211")?.tls).toBe(true); + }); + + it("should apply TLS to IPv6 discovered nodes and set SNI", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { + hostname: "host1.example.com", + ip: "2001:db8::1", + port: 11211, + }, + ], + }); + + const discovered = client.getNode("[2001:db8::1]:11211"); + expect(discovered?.tlsEnabled).toBe(true); + expect(discovered?.tls).toEqual({ servername: "host1.example.com" }); + }); + + it("should not set SNI when discovered hostname is an IP", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [{ hostname: "10.0.0.1", ip: "10.0.0.2", port: 11211 }], + }); + + expect(client.getNode("10.0.0.2:11211")?.tls).toBe(true); + }); + + it("should not set SNI when discovered hostname is empty", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [{ hostname: "", ip: "10.0.0.4", port: 11211 }], + }); + + expect(client.getNode("10.0.0.4:11211")?.tls).toBe(true); + }); + + it("should keep hostname as TLS target when discovered IP is empty", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { + hostname: "myhost.cache.amazonaws.com", + ip: "", + port: 11211, + }, + ], + }); + + const discovered = client.getNode("myhost.cache.amazonaws.com:11211"); + expect(discovered?.tlsEnabled).toBe(true); + expect(discovered?.tls).toBe(true); + }); + + it("should not set SNI when discovered hostname is IPv6", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: true, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [{ hostname: "2001:db8::1", ip: "2001:db8::2", port: 11211 }], + }); + + expect(client.getNode("[2001:db8::2]:11211")?.tls).toBe(true); + }); + + it("should merge SNI into existing tls ConnectionOptions", async () => { + const client = new Memcache({ + nodes: [], + lazyConnect: true, + tls: { minVersion: "TLSv1.2" }, + }); + await client.removeNode("localhost:11211"); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { + hostname: "node.cache.amazonaws.com", + ip: "10.0.0.6", + port: 11211, + }, + ], + }); + + expect(client.getNode("10.0.0.6:11211")?.tls).toEqual({ + minVersion: "TLSv1.2", + servername: "node.cache.amazonaws.com", + }); + }); }); }); @@ -956,6 +1157,112 @@ describe("AutoDiscovery parseEndpoint", () => { const result = discovery.parseEndpoint("host:abc"); expect(result).toEqual({ host: "host", port: 11211 }); }); + + it("should parse memcaches:// as secure", () => { + const discovery = new AutoDiscovery({ + configEndpoint: "memcaches://myhost:11211", + pollingInterval: 60000, + useLegacyCommand: false, + timeout: 5000, + keepAlive: true, + keepAliveDelay: 1000, + }); + + // @ts-expect-error - accessing private method for testing + expect(discovery.parseEndpoint("memcaches://myhost:11211")).toEqual({ + host: "myhost", + port: 11211, + secure: true, + }); + // @ts-expect-error - accessing private method for testing + expect(discovery.parseEndpoint("memcache://myhost:11211")).toEqual({ + host: "myhost", + port: 11211, + }); + // @ts-expect-error - accessing private method for testing + expect(discovery.parseEndpoint("memcaches://[::1]:21211")).toEqual({ + host: "::1", + port: 21211, + secure: true, + }); + }); +}); + +describe("AutoDiscovery TLS", () => { + let server: FakeConfigServer; + + afterEach(async () => { + if (server) { + await server.stop(); + } + }); + + it("should pass tls options to the config node", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const discovery = new AutoDiscovery({ + configEndpoint: server.endpoint, + pollingInterval: 60000, + useLegacyCommand: false, + timeout: 500, + keepAlive: true, + keepAliveDelay: 1000, + tls: true, + }); + + await expect(discovery.start()).rejects.toThrow(); + expect(discovery.tls).toBe(true); + // @ts-expect-error - accessing private field for testing + expect(discovery._configNode?.tlsEnabled).toBe(true); + }); + + it("should enable TLS for memcaches:// config endpoints", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const discovery = new AutoDiscovery({ + configEndpoint: `memcaches://${server.endpoint}`, + pollingInterval: 60000, + useLegacyCommand: false, + timeout: 500, + keepAlive: true, + keepAliveDelay: 1000, + }); + + await expect(discovery.start()).rejects.toThrow(); + expect(discovery.tls).toBe(true); + // @ts-expect-error - accessing private field for testing + expect(discovery._configNode?.tlsEnabled).toBe(true); + }); + + it("should keep explicit tls options when the endpoint is memcaches://", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const tls = { servername: "cfg.example.com" }; + const discovery = new AutoDiscovery({ + configEndpoint: `memcaches://${server.endpoint}`, + pollingInterval: 60000, + useLegacyCommand: false, + timeout: 500, + keepAlive: true, + keepAliveDelay: 1000, + tls, + }); + + await expect(discovery.start()).rejects.toThrow(); + expect(discovery.tls).toEqual(tls); + }); }); describe("MemcacheNode CONFIG response parsing", () => { @@ -1218,6 +1525,7 @@ describe("Memcache startAutoDiscovery", () => { let server: FakeConfigServer; afterEach(async () => { + vi.restoreAllMocks(); if (server) { await server.stop(); } @@ -1296,6 +1604,136 @@ describe("Memcache startAutoDiscovery", () => { vi.restoreAllMocks(); }); + + it("should pass client-level tls to AutoDiscovery", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const client = new Memcache({ + nodes: ["10.0.0.1:11211"], + lazyConnect: true, + timeout: 500, + tls: true, + autoDiscover: { + enabled: true, + configEndpoint: server.endpoint, + }, + }); + client.on(MemcacheEvents.AUTO_DISCOVER_ERROR, () => {}); + client.on(MemcacheEvents.ERROR, () => {}); + // Config-endpoint node is created inside AutoDiscovery, so instance + // spies on client.nodes do not cover the TLS handshake to the fake + // plaintext server. Mock every MemcacheNode.connect instead. + vi.spyOn(MemcacheNode.prototype, "connect").mockResolvedValue(); + + await client.connect(); + // @ts-expect-error - accessing private field for testing + expect(client._autoDiscovery?.tls).toBe(true); + + await client.disconnect(); + }); + + it("should inherit tls from the first node when client tls is unset", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const tlsNode = new MemcacheNode("10.0.0.1", 11211, { tls: true }); + const client = new Memcache({ + nodes: [tlsNode], + lazyConnect: true, + timeout: 500, + autoDiscover: { + enabled: true, + configEndpoint: server.endpoint, + }, + }); + client.on(MemcacheEvents.AUTO_DISCOVER_ERROR, () => {}); + client.on(MemcacheEvents.ERROR, () => {}); + vi.spyOn(MemcacheNode.prototype, "connect").mockResolvedValue(); + + await client.connect(); + // @ts-expect-error - accessing private field for testing + expect(client._autoDiscovery?.tls).toBe(true); + + await client.disconnect(); + }); + + it("should pass tls: false through to AutoDiscovery", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: [], + }); + await server.start(); + server.nodes = [`host1|127.0.0.1|${server.port}`]; + + const client = new Memcache({ + nodes: [server.endpoint], + lazyConnect: true, + tls: false, + autoDiscover: { + enabled: true, + configEndpoint: server.endpoint, + }, + }); + client.on(MemcacheEvents.AUTO_DISCOVER_ERROR, () => {}); + client.on(MemcacheEvents.ERROR, () => {}); + + await client.connect(); + // @ts-expect-error - accessing private field for testing + expect(client._autoDiscovery?.tls).toBe(false); + + await client.disconnect(); + }); + + it("should apply inferred memcaches:// TLS to discovered nodes", async () => { + server = new FakeConfigServer({ + version: 1, + nodes: ["host1|10.0.0.1|11211"], + }); + await server.start(); + + const client = new Memcache({ + nodes: ["10.0.0.1:11211"], + lazyConnect: true, + timeout: 500, + autoDiscover: { + enabled: true, + configEndpoint: `memcaches://${server.endpoint}`, + }, + }); + client.on(MemcacheEvents.AUTO_DISCOVER_ERROR, () => {}); + client.on(MemcacheEvents.ERROR, () => {}); + vi.spyOn(MemcacheNode.prototype, "connect").mockResolvedValue(); + + await client.connect(); + // @ts-expect-error - accessing private field for testing + expect(client._autoDiscovery?.tls).toBe(true); + + // @ts-expect-error - accessing private method for testing + await client.applyClusterConfig({ + version: 1, + nodes: [ + { hostname: "host1", ip: "10.0.0.1", port: 11211 }, + { + hostname: "host2.example.com", + ip: "10.0.0.9", + port: 11211, + }, + ], + }); + + expect(client.getNode("10.0.0.9:11211")?.tls).toEqual({ + servername: "host2.example.com", + }); + + await client.disconnect(); + }); }); describe("AutoDiscovery additional coverage", () => { diff --git a/test/node.test.ts b/test/node.test.ts index d310c91..35c4bf8 100644 --- a/test/node.test.ts +++ b/test/node.test.ts @@ -132,6 +132,20 @@ describe("MemcacheNode", () => { expect(node.port).toBe(11212); expect(node.weight).toBe(1); // default weight }); + + it("should report tlsEnabled from TLS options", () => { + expect(createNode("localhost", 11211).tlsEnabled).toBe(false); + expect(createNode("localhost", 11211, { tls: false }).tlsEnabled).toBe( + false, + ); + expect(createNode("localhost", 11211, { tls: true }).tlsEnabled).toBe( + true, + ); + expect(createNode("localhost", 11211, { tls: {} }).tlsEnabled).toBe(true); + const withCa = createNode("localhost", 11211, { tls: { ca: "x" } }); + expect(withCa.tlsEnabled).toBe(true); + expect(withCa.tls).toEqual({ ca: "x" }); + }); }); describe("Constructor and Properties", () => { diff --git a/test/tls.test.ts b/test/tls.test.ts index 687d0da..484eb90 100644 --- a/test/tls.test.ts +++ b/test/tls.test.ts @@ -1,6 +1,9 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; -import Memcache from "../src/index"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer as createTlsServer } from "node:tls"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Memcache, { createNode, MemcacheNode } from "../src/index"; import { generateKey, generateValue } from "./test-utils.js"; /** @@ -304,5 +307,259 @@ describe("TLS", () => { await client.disconnect(); await source.disconnect(); }); + + it("should parse memcaches:// Unix socket URIs as secure", () => { + const client = new Memcache({ nodes: [PLAIN_URI] }); + expect(client.parseUri("memcaches:///var/run/memcached.sock")).toEqual({ + host: "/var/run/memcached.sock", + port: 0, + secure: true, + }); + expect(client.parseUri("memcache:///var/run/memcached.sock")).toEqual({ + host: "/var/run/memcached.sock", + port: 0, + }); + }); + }); + + describe("MemcacheNode with TLS", () => { + let node: MemcacheNode; + + afterEach(async () => { + if (node?.isConnected()) { + await node.disconnect(); + } + }); + + it("should connect with valid CA options", async () => { + node = new MemcacheNode(TLS_HOST, TLS_PORT, { tls: { ca } }); + await node.connect(); + expect(node.isConnected()).toBe(true); + expect(node.tlsEnabled).toBe(true); + const version = await node.command("version"); + expect(version).toContain("VERSION"); + }); + + it("should fail handshake with the system trust store", async () => { + node = new MemcacheNode(TLS_HOST, TLS_PORT, { + tls: true, + timeout: 2000, + }); + await expect(node.connect()).rejects.toThrow(); + expect(node.isConnected()).toBe(false); + }); + + it("should emit connect after the TLS handshake completes", async () => { + node = new MemcacheNode(TLS_HOST, TLS_PORT, { tls: { ca } }); + let connected = false; + node.on("connect", () => { + connected = true; + }); + await node.connect(); + expect(connected).toBe(true); + }); + + it("should reconnect over TLS", async () => { + node = new MemcacheNode(TLS_HOST, TLS_PORT, { tls: { ca } }); + await node.connect(); + await node.reconnect(); + expect(node.isConnected()).toBe(true); + const version = await node.command("version"); + expect(version).toContain("VERSION"); + }); + + it("should ignore host/port overrides in tls options", async () => { + node = new MemcacheNode(TLS_HOST, TLS_PORT, { + tls: { ca, host: "example.com", port: 443 }, + }); + await node.connect(); + expect(node.isConnected()).toBe(true); + }); + }); + + describe("createNode factory with TLS", () => { + it("should create a node with TLS options", () => { + const node = createNode(TLS_HOST, TLS_PORT, { tls: { ca } }); + expect(node.tlsEnabled).toBe(true); + expect(node.tls).toEqual({ ca }); + }); + + it("should create a node without TLS options", () => { + const node = createNode(TLS_HOST, TLS_PORT); + expect(node.tlsEnabled).toBe(false); + expect(node.tls).toBeUndefined(); + }); + }); + + describe("client operations over TLS", () => { + it("should perform add/replace over TLS", async () => { + const client = createTlsClient(); + const key = generateKey("tls-add-replace"); + const value1 = generateValue(); + const value2 = generateValue(); + + expect(await client.add(key, value1, 60)).toBe(true); + expect(await client.add(key, value2, 60)).toBe(false); + expect(await client.replace(key, value2, 60)).toBe(true); + expect(await client.get(key)).toBe(value2); + + await client.disconnect(); + }); + + it("should perform append/prepend over TLS", async () => { + const client = createTlsClient(); + const key = generateKey("tls-append-prepend"); + + expect(await client.set(key, "middle", 60)).toBe(true); + expect(await client.append(key, "-suffix")).toBe(true); + expect(await client.prepend(key, "prefix-")).toBe(true); + expect(await client.get(key)).toBe("prefix-middle-suffix"); + + await client.disconnect(); + }); + + it("should perform incr/decr over TLS", async () => { + const client = createTlsClient(); + const key = generateKey("tls-counter"); + + expect(await client.set(key, "10", 60)).toBe(true); + expect(await client.incr(key, 5)).toBe(15); + expect(await client.decr(key, 3)).toBe(12); + + await client.disconnect(); + }); + + it("should perform touch over TLS", async () => { + const client = createTlsClient(); + const key = generateKey("tls-touch"); + + expect(await client.set(key, "touchme", 60)).toBe(true); + expect(await client.touch(key, 3600)).toBe(true); + expect(await client.get(key)).toBe("touchme"); + + await client.disconnect(); + }); + + it("should get version over TLS", async () => { + const client = createTlsClient(); + const versions = await client.version(); + expect(versions.size).toBeGreaterThan(0); + for (const version of versions.values()) { + expect(version.length).toBeGreaterThan(0); + } + await client.disconnect(); + }); + + it("should get stats over TLS", async () => { + const client = createTlsClient(); + const stats = await client.stats(); + expect(stats.size).toBeGreaterThan(0); + for (const nodeStats of stats.values()) { + expect(nodeStats.pid).toBeDefined(); + } + await client.disconnect(); + }); + }); + + describe("Unix socket TLS", () => { + it("should complete a TLS handshake on a Unix socket path", async () => { + const dir = mkdtempSync(join(tmpdir(), "memcache-tls-")); + const socketPath = join(dir, "memcached.sock"); + const key = readFileSync( + new URL("./certs/server_key.pem", import.meta.url), + ); + const cert = readFileSync( + new URL("./certs/server_crt.pem", import.meta.url), + ); + + const server = createTlsServer({ key, cert }, (socket) => { + socket.end(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + + const node = new MemcacheNode(socketPath, 0, { + tls: { ca, checkServerIdentity: () => undefined }, + timeout: 2000, + }); + try { + await node.connect(); + expect(node.isConnected()).toBe(true); + expect(node.uri).toBe(`memcaches://${socketPath}`); + } finally { + await node.disconnect(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("TLS + SASL", () => { + const TLS_SASL_HOST = process.env.MEMCACHE_TLS_SASL_HOST ?? "localhost"; + const TLS_SASL_PORT = Number(process.env.MEMCACHE_TLS_SASL_PORT ?? "21215"); + const TEST_USER = "testuser@localhost"; + const TEST_PASS = "testpass"; + + let node: MemcacheNode; + + afterEach(async () => { + if (node?.isConnected()) { + await node.disconnect(); + } + }); + + it("should authenticate over TLS with valid credentials", async () => { + node = new MemcacheNode(TLS_SASL_HOST, TLS_SASL_PORT, { + tls: { ca }, + sasl: { username: TEST_USER, password: TEST_PASS }, + }); + await node.connect(); + expect(node.isConnected()).toBe(true); + expect(node.isAuthenticated).toBe(true); + expect(node.tlsEnabled).toBe(true); + }); + + it("should fail SASL over TLS with invalid credentials", async () => { + node = new MemcacheNode(TLS_SASL_HOST, TLS_SASL_PORT, { + tls: { ca }, + sasl: { username: "wrong", password: "wrong" }, + timeout: 2000, + }); + await expect(node.connect()).rejects.toThrow( + "SASL authentication failed", + ); + expect(node.isConnected()).toBe(false); + expect(node.isAuthenticated).toBe(false); + }); + + it("should execute binary commands after TLS+SASL authentication", async () => { + node = new MemcacheNode(TLS_SASL_HOST, TLS_SASL_PORT, { + tls: { ca }, + sasl: { username: TEST_USER, password: TEST_PASS }, + }); + await node.connect(); + + const key = generateKey("tls-sasl"); + const value = generateValue(); + expect(await node.binarySet(key, value)).toBe(true); + expect(await node.binaryGet(key)).toBe(value); + await node.binaryDelete(key); + }); + + it("should authenticate over TLS via the client", async () => { + const client = new Memcache({ + nodes: [`${TLS_SASL_HOST}:${TLS_SASL_PORT}`], + tls: { ca }, + sasl: { username: TEST_USER, password: TEST_PASS }, + lazyConnect: true, + }); + await client.connect(); + expect(client.isConnected()).toBe(true); + expect(client.nodes[0].isAuthenticated).toBe(true); + expect(client.nodes[0].tlsEnabled).toBe(true); + await client.disconnect(); + }); }); });