Skip to content

Commit a70dac1

Browse files
feat: authenticate to Redis via AWS ElastiCache secret (Secrets Manager) (#3)
* feat: authenticate to Redis via AWS ElastiCache secret (Secrets Manager) Mirror the silo app's ELASTICACHE_SECRET_ARN pattern. When set (and CACHE_ENGINE=redis), the Redis auth token (and optionally host/port) is fetched from AWS Secrets Manager and injected into the connection options. A background timer re-fetches the secret every AWS_SECRET_CACHE_TTL seconds and rebuilds the client when the token rotates, so reconnections use fresh credentials without a restart. - New lib/aws-secrets.js: TTL-cached getSecret(), lazy-imports the AWS SDK - Rework lib/cache-engines/redis.js: secret resolution, build options (standard + cluster), refresh timer; set/get unchanged - Config/IFRAMELY_REDIS_HOST/PORT/TLS take precedence; TLS not forced on - Configurable secret keys (defaults match silo so the same secret works) - Add @aws-sdk/client-secrets-manager dependency (lazy-imported) - Document new env vars in config.loader.js, config.local.js.SAMPLE, SETUP.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update pnpm-lock.yaml for @aws-sdk/client-secrets-manager The Dockerfile installs with --frozen-lockfile, so the new dependency must be present in the lockfile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: make cluster worker memory/restart limits configurable via env The default per-worker memory cap (CLUSTER_WORKER_RESTART_ON_MEMORY_USED, 120 MB) is below the ~122 MB a worker uses just loading domains+plugins, causing an immediate restart loop. Expose it (and the periodic restart interval) via IFRAMELY_WORKER_MAX_MEMORY_MB / IFRAMELY_WORKER_RESTART_PERIOD_SEC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: only resolve ElastiCache secret when IRSA/Pod Identity is present Previously the AWS Secrets Manager fetch was gated solely on ELASTICACHE_SECRET_ARN, so any non-EKS deployment with that var set would still attempt to reach Secrets Manager (and fail/fall back). Mirror silo's resolveSecrets() gate: skip secret resolution unless IRSA or EKS Pod Identity credentials are detected via one of: - AWS_ROLE_ARN / AWS_WEB_IDENTITY_TOKEN_FILE (IRSA) - AWS_CONTAINER_CREDENTIALS_FULL_URI / AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE (EKS Pod Identity) Static AWS_ACCESS_KEY_ID creds are intentionally not accepted. Log a one-time warning when the ARN is set but no managed credentials are present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a991333 commit a70dac1

7 files changed

Lines changed: 663 additions & 8 deletions

File tree

config.loader.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ globalConfig = globalConfig && globalConfig.default;
2222
// IFRAMELY_REDIS_PASSWORD password (optional)
2323
// IFRAMELY_REDIS_TLS true | false (enables TLS socket)
2424
// IFRAMELY_REDIS_MODE standard | cluster (default: standard)
25+
//
26+
// AWS ElastiCache auth via Secrets Manager (used when CACHE_ENGINE=redis).
27+
// Handled in lib/cache-engines/redis.js, not here — listed for reference:
28+
// ELASTICACHE_SECRET_ARN Secrets Manager ARN with Redis creds (enables it)
29+
// AWS_REGION region for Secrets Manager (default: us-east-1)
30+
// AWS_SECRET_CACHE_TTL secret cache + refresh interval, seconds (default: 300)
31+
// REDIS_AUTH_TOKEN_KEY JSON key for auth token (default: REDIS_AUTH_TOKEN)
32+
// REDIS_HOST_KEY JSON key for host (default: REDIS_HOST)
33+
// REDIS_PORT_KEY JSON key for port (default: REDIS_PORT)
34+
//
35+
// Cluster worker tuning
36+
// IFRAMELY_WORKER_MAX_MEMORY_MB per-worker memory before restart, MB (default: 120)
37+
// IFRAMELY_WORKER_RESTART_PERIOD_SEC periodic worker restart interval, seconds (default: 28800)
2538
// ---------------------------------------------------------------------------
2639

2740
var envOverrides = {};
@@ -82,4 +95,22 @@ if (effectiveEngine === 'redis' && (
8295
envOverrides.REDIS_OPTIONS = redisOptions;
8396
}
8497

98+
// --- Cluster worker tuning ---
99+
// The default per-worker memory cap (config.js CLUSTER_WORKER_RESTART_ON_MEMORY_USED)
100+
// is 120 MB, which a freshly-booted worker can exceed just loading domains+plugins,
101+
// causing a restart loop. Allow raising it (and the periodic restart) via env.
102+
if (process.env.IFRAMELY_WORKER_MAX_MEMORY_MB) {
103+
var maxMemMb = parseInt(process.env.IFRAMELY_WORKER_MAX_MEMORY_MB, 10);
104+
if (!isNaN(maxMemMb)) {
105+
envOverrides.CLUSTER_WORKER_RESTART_ON_MEMORY_USED = maxMemMb * 1024 * 1024;
106+
}
107+
}
108+
109+
if (process.env.IFRAMELY_WORKER_RESTART_PERIOD_SEC) {
110+
var restartSec = parseInt(process.env.IFRAMELY_WORKER_RESTART_PERIOD_SEC, 10);
111+
if (!isNaN(restartSec)) {
112+
envOverrides.CLUSTER_WORKER_RESTART_ON_PERIOD = restartSec * 1000;
113+
}
114+
}
115+
85116
export default {...iframelyConfig, ...globalConfig, ...envOverrides};

config.local.js.SAMPLE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,28 @@ export default {
102102
},
103103
*/
104104

105+
/*
106+
// AWS ElastiCache auth via Secrets Manager (Plane fork extension).
107+
//
108+
// When CACHE_ENGINE is 'redis' and the ELASTICACHE_SECRET_ARN env var is set,
109+
// the Redis auth token (and optionally host/port) is fetched from AWS Secrets
110+
// Manager and injected into the connection options above. A background timer
111+
// re-fetches the secret and rebuilds the client when the token rotates.
112+
//
113+
// Configure via environment variables (no config-file changes needed):
114+
//
115+
// ELASTICACHE_SECRET_ARN AWS Secrets Manager ARN with the Redis creds (enables this feature)
116+
// AWS_REGION region for Secrets Manager (default: us-east-1)
117+
// AWS_SECRET_CACHE_TTL secret cache + refresh interval, sec (default: 300)
118+
// REDIS_AUTH_TOKEN_KEY JSON key for the auth token (default: REDIS_AUTH_TOKEN)
119+
// REDIS_HOST_KEY JSON key for the host (default: REDIS_HOST)
120+
// REDIS_PORT_KEY JSON key for the port (default: REDIS_PORT)
121+
//
122+
// host/port from IFRAMELY_REDIS_HOST/PORT or REDIS_OPTIONS take precedence over
123+
// the secret; TLS is controlled by IFRAMELY_REDIS_TLS (not forced on).
124+
// AWS credentials use the SDK default chain (IRSA / Pod Identity / keys / IMDS).
125+
*/
126+
105127
/*
106128
// Memcached options. See https://github.com/3rd-Eden/node-memcached#server-locations
107129
MEMCACHED_OPTIONS: {

docs/SETUP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ In your local config file, define caching parameters:
5757

5858
Valid cache engine values are `no-cache`, `node-cache` (default), `redis` and `memcached`. For Redis and Memcached, the connection options are also required. See sample config file.
5959

60+
### AWS ElastiCache auth (Secrets Manager)
61+
62+
For AWS ElastiCache with an auth token, set `CACHE_ENGINE: 'redis'` and the `ELASTICACHE_SECRET_ARN` environment variable. On startup the Redis auth token (and optionally host/port) is read from AWS Secrets Manager and injected into the Redis connection; a background timer re-fetches the secret every `AWS_SECRET_CACHE_TTL` seconds and rebuilds the client when the token rotates, so no restart is needed. Supported env vars: `ELASTICACHE_SECRET_ARN`, `AWS_REGION` (default `us-east-1`), `AWS_SECRET_CACHE_TTL` (default `300`), and the configurable secret-key names `REDIS_AUTH_TOKEN_KEY` / `REDIS_HOST_KEY` / `REDIS_PORT_KEY`. AWS credentials use the SDK default chain (IRSA / Pod Identity / static keys / IMDS). TLS is controlled by `IFRAMELY_REDIS_TLS` and is not forced on. See the sample config file for details.
63+
6064

6165
## Run Server
6266

lib/aws-secrets.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import log from '../logging.js';
2+
3+
// ---------------------------------------------------------------------------
4+
// AWS Secrets Manager helper (Plane fork extension).
5+
//
6+
// Port of plane-ee/apps/silo/src/lib/aws-secrets.ts to plain ESM. Fetches a
7+
// secret by ARN and TTL-caches the parsed JSON so repeated lookups (e.g. the
8+
// periodic credential refresh in the redis cache engine) don't hammer the API.
9+
//
10+
// The AWS SDK is lazy-imported so deployments that never set a *_SECRET_ARN
11+
// do not need `@aws-sdk/client-secrets-manager` installed/loaded.
12+
// ---------------------------------------------------------------------------
13+
14+
const secretCache = new Map(); // key: `${arn}:${region}` -> { value, fetchedAt }
15+
16+
export async function getSecret(secretArn, region, forceRefresh = false) {
17+
18+
const cacheTtl = parseInt(process.env.AWS_SECRET_CACHE_TTL || '300', 10) * 1000;
19+
const key = secretArn + ':' + region;
20+
const now = Date.now();
21+
22+
if (!forceRefresh && secretCache.has(key)) {
23+
const entry = secretCache.get(key);
24+
if (now - entry.fetchedAt < cacheTtl) {
25+
return { ...entry.value };
26+
}
27+
}
28+
29+
const { SecretsManagerClient, GetSecretValueCommand } =
30+
await import('@aws-sdk/client-secrets-manager');
31+
32+
const client = new SecretsManagerClient({ region });
33+
const response = await client.send(new GetSecretValueCommand({ SecretId: secretArn }));
34+
const value = JSON.parse(response.SecretString || '{}');
35+
36+
secretCache.set(key, { value, fetchedAt: now });
37+
log(' -- Secrets Manager: refreshed secret ' + secretArn);
38+
39+
return { ...value };
40+
};

lib/cache-engines/redis.js

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,161 @@
11
import log from '../../logging.js';
22
import CONFIG from '../../config.loader.js';
33

4+
// ---------------------------------------------------------------------------
5+
// Redis cache engine.
6+
//
7+
// Standard mode uses the `redis` client; cluster mode uses `redis-clustr`.
8+
//
9+
// AWS ElastiCache auth (Plane fork extension):
10+
// When ELASTICACHE_SECRET_ARN is set, the Redis auth token (and optionally
11+
// host/port) is fetched from AWS Secrets Manager and injected into the
12+
// connection options. A background timer re-fetches the secret every
13+
// AWS_SECRET_CACHE_TTL seconds and rebuilds the client when the token
14+
// rotates, so reconnections use fresh credentials without a restart.
15+
// Mirrors plane-ee/apps/silo/src/env.ts resolveRedisUrl()/resolveSecrets().
16+
// ---------------------------------------------------------------------------
17+
418
var client;
19+
var currentToken;
20+
21+
// True only when the pod has IRSA or EKS Pod Identity credentials available.
22+
// Mirrors silo's resolveSecrets() gate (env.ts) so that non-EKS deployments
23+
// never attempt to reach AWS Secrets Manager even if ELASTICACHE_SECRET_ARN
24+
// happens to be set in the environment. Static AWS_ACCESS_KEY_ID creds are
25+
// intentionally NOT accepted here.
26+
function hasAwsManagedCredentials() {
27+
return Boolean(
28+
(process.env.AWS_ROLE_ARN || '').trim() || // IRSA
29+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE || // IRSA (token file)
30+
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || // EKS Pod Identity
31+
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE // EKS Pod Identity (token file)
32+
);
33+
}
34+
35+
// True when secret resolution should run at all: ARN configured AND the pod
36+
// has IRSA / Pod Identity credentials to read it with.
37+
function secretResolutionEnabled() {
38+
return Boolean(process.env.ELASTICACHE_SECRET_ARN) && hasAwsManagedCredentials();
39+
}
40+
41+
// Resolve the auth token (and host/port) from AWS Secrets Manager.
42+
// Returns null when secret resolution is not enabled (no ARN, or no
43+
// IRSA/Pod Identity credentials present).
44+
async function resolveSecretValues() {
45+
if (!secretResolutionEnabled()) {
46+
return null;
47+
}
48+
49+
var region = process.env.AWS_REGION || 'us-east-1';
50+
var { getSecret } = await import('../aws-secrets.js');
51+
var secret = await getSecret(process.env.ELASTICACHE_SECRET_ARN, region, true);
52+
53+
var tokenKey = process.env.REDIS_AUTH_TOKEN_KEY || 'REDIS_AUTH_TOKEN';
54+
var hostKey = process.env.REDIS_HOST_KEY || 'REDIS_HOST';
55+
var portKey = process.env.REDIS_PORT_KEY || 'REDIS_PORT';
56+
57+
return {
58+
token: typeof secret[tokenKey] === 'string' ? secret[tokenKey] : undefined,
59+
host: typeof secret[hostKey] === 'string' ? secret[hostKey] : undefined,
60+
port: secret[portKey] !== undefined ? Number(secret[portKey]) : undefined,
61+
};
62+
}
63+
64+
// Merge resolved secret values into the configured connection options.
65+
// Existing config / IFRAMELY_REDIS_* values take precedence for host/port/TLS;
66+
// only the auth token is always taken from the secret.
67+
function buildOptions(secretVals) {
68+
69+
if (CONFIG.REDIS_MODE === 'cluster') {
70+
var clusterOptions = { ...(CONFIG.REDIS_CLUSTER_OPTIONS || {}) };
71+
if (secretVals && secretVals.token) {
72+
// redis-clustr forwards `redisOptions` to node_redis v2's
73+
// createClient (see redis-clustr@1.7.0 -> redis ^2.6.0). node_redis
74+
// v2 uses `auth_pass`; `password` is a newer alias, so set both.
75+
clusterOptions.redisOptions = {
76+
...(clusterOptions.redisOptions || {}),
77+
auth_pass: secretVals.token,
78+
password: secretVals.token,
79+
};
80+
}
81+
return clusterOptions;
82+
}
83+
84+
var options = { ...(CONFIG.REDIS_OPTIONS || {}) };
85+
var socket = { ...(options.socket || {}) };
586

6-
if (CONFIG.REDIS_MODE === 'cluster') {
7-
const pkg = await import('redis-clustr');
8-
const RedisClustr = pkg.default;
9-
client = new RedisClustr(CONFIG.REDIS_CLUSTER_OPTIONS);
10-
} else {
11-
var pkg = await import('redis');
12-
client = pkg.createClient(CONFIG.REDIS_OPTIONS);
13-
await client.connect();
87+
if (secretVals) {
88+
if (secretVals.token) {
89+
options.password = secretVals.token;
90+
}
91+
// Fill host/port from the secret only when not already provided via
92+
// config or IFRAMELY_REDIS_HOST/PORT (do not force TLS here).
93+
if (socket.host === undefined && secretVals.host !== undefined) {
94+
socket.host = secretVals.host;
95+
}
96+
if (socket.port === undefined && secretVals.port !== undefined) {
97+
socket.port = secretVals.port;
98+
}
99+
}
100+
101+
options.socket = socket;
102+
return options;
103+
}
104+
105+
async function createAndConnect() {
106+
var secretVals = await resolveSecretValues();
107+
var options = buildOptions(secretVals);
108+
109+
var newClient;
110+
if (CONFIG.REDIS_MODE === 'cluster') {
111+
const pkg = await import('redis-clustr');
112+
const RedisClustr = pkg.default;
113+
newClient = new RedisClustr(options);
114+
} else {
115+
var pkg = await import('redis');
116+
newClient = pkg.createClient(options);
117+
await newClient.connect();
118+
}
119+
120+
currentToken = secretVals && secretVals.token;
121+
return newClient;
122+
}
123+
124+
// Surface a misconfiguration: ARN set but no managed credentials to use it.
125+
if (process.env.ELASTICACHE_SECRET_ARN && !hasAwsManagedCredentials()) {
126+
log(' -- Redis: ELASTICACHE_SECRET_ARN is set but no IRSA / EKS Pod Identity credentials detected — skipping AWS Secrets Manager.');
127+
}
128+
129+
// Initial connection.
130+
client = await createAndConnect();
131+
132+
// Background credential refresh: rebuild the client when the token rotates.
133+
if (secretResolutionEnabled()) {
134+
var ttlMs = parseInt(process.env.AWS_SECRET_CACHE_TTL || '300', 10) * 1000;
135+
if (ttlMs > 0) {
136+
var refreshTimer = setInterval(async function () {
137+
try {
138+
var secretVals = await resolveSecretValues();
139+
var newToken = secretVals && secretVals.token;
140+
if (newToken && newToken !== currentToken) {
141+
var oldClient = client;
142+
client = await createAndConnect();
143+
log(' -- Redis: rebuilt client after credential rotation');
144+
try {
145+
if (oldClient && oldClient.quit) {
146+
await oldClient.quit();
147+
}
148+
} catch (quitErr) {
149+
log(' -- Redis: error closing old client ' + quitErr);
150+
}
151+
}
152+
} catch (err) {
153+
log(' -- Redis: failed to refresh credentials ' + err);
154+
}
155+
}, ttlMs);
156+
// Allow the process to exit even if the timer is still running.
157+
refreshTimer.unref();
158+
}
14159
}
15160

16161
export async function set(key, data, options) {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"license": "MIT",
2222
"dependencies": {
2323
"@adobe/fetch": "^4.1.11",
24+
"@aws-sdk/client-secrets-manager": "^3.700.0",
2425
"async": "^3.2.4",
2526
"cheerio": "^1.1.2",
2627
"cookie": "^1.0.2",

0 commit comments

Comments
 (0)