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
31 changes: 31 additions & 0 deletions config.loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ globalConfig = globalConfig && globalConfig.default;
// IFRAMELY_REDIS_PASSWORD password (optional)
// IFRAMELY_REDIS_TLS true | false (enables TLS socket)
// IFRAMELY_REDIS_MODE standard | cluster (default: standard)
//
// AWS ElastiCache auth via Secrets Manager (used when CACHE_ENGINE=redis).
// Handled in lib/cache-engines/redis.js, not here — listed for reference:
// ELASTICACHE_SECRET_ARN Secrets Manager ARN with Redis creds (enables it)
// AWS_REGION region for Secrets Manager (default: us-east-1)
// AWS_SECRET_CACHE_TTL secret cache + refresh interval, seconds (default: 300)
// REDIS_AUTH_TOKEN_KEY JSON key for auth token (default: REDIS_AUTH_TOKEN)
// REDIS_HOST_KEY JSON key for host (default: REDIS_HOST)
// REDIS_PORT_KEY JSON key for port (default: REDIS_PORT)
//
// Cluster worker tuning
// IFRAMELY_WORKER_MAX_MEMORY_MB per-worker memory before restart, MB (default: 120)
// IFRAMELY_WORKER_RESTART_PERIOD_SEC periodic worker restart interval, seconds (default: 28800)
// ---------------------------------------------------------------------------

var envOverrides = {};
Expand Down Expand Up @@ -82,4 +95,22 @@ if (effectiveEngine === 'redis' && (
envOverrides.REDIS_OPTIONS = redisOptions;
}

// --- Cluster worker tuning ---
// The default per-worker memory cap (config.js CLUSTER_WORKER_RESTART_ON_MEMORY_USED)
// is 120 MB, which a freshly-booted worker can exceed just loading domains+plugins,
// causing a restart loop. Allow raising it (and the periodic restart) via env.
if (process.env.IFRAMELY_WORKER_MAX_MEMORY_MB) {
var maxMemMb = parseInt(process.env.IFRAMELY_WORKER_MAX_MEMORY_MB, 10);
if (!isNaN(maxMemMb)) {
envOverrides.CLUSTER_WORKER_RESTART_ON_MEMORY_USED = maxMemMb * 1024 * 1024;
}
}

if (process.env.IFRAMELY_WORKER_RESTART_PERIOD_SEC) {
var restartSec = parseInt(process.env.IFRAMELY_WORKER_RESTART_PERIOD_SEC, 10);
if (!isNaN(restartSec)) {
envOverrides.CLUSTER_WORKER_RESTART_ON_PERIOD = restartSec * 1000;
}
}

export default {...iframelyConfig, ...globalConfig, ...envOverrides};
22 changes: 22 additions & 0 deletions config.local.js.SAMPLE
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ export default {
},
*/

/*
// AWS ElastiCache auth via Secrets Manager (Plane fork extension).
//
// When CACHE_ENGINE is 'redis' and the ELASTICACHE_SECRET_ARN env var is set,
// the Redis auth token (and optionally host/port) is fetched from AWS Secrets
// Manager and injected into the connection options above. A background timer
// re-fetches the secret and rebuilds the client when the token rotates.
//
// Configure via environment variables (no config-file changes needed):
//
// ELASTICACHE_SECRET_ARN AWS Secrets Manager ARN with the Redis creds (enables this feature)
// AWS_REGION region for Secrets Manager (default: us-east-1)
// AWS_SECRET_CACHE_TTL secret cache + refresh interval, sec (default: 300)
// REDIS_AUTH_TOKEN_KEY JSON key for the auth token (default: REDIS_AUTH_TOKEN)
// REDIS_HOST_KEY JSON key for the host (default: REDIS_HOST)
// REDIS_PORT_KEY JSON key for the port (default: REDIS_PORT)
//
// host/port from IFRAMELY_REDIS_HOST/PORT or REDIS_OPTIONS take precedence over
// the secret; TLS is controlled by IFRAMELY_REDIS_TLS (not forced on).
// AWS credentials use the SDK default chain (IRSA / Pod Identity / keys / IMDS).
*/

/*
// Memcached options. See https://github.com/3rd-Eden/node-memcached#server-locations
MEMCACHED_OPTIONS: {
Expand Down
4 changes: 4 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ In your local config file, define caching parameters:

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.

### AWS ElastiCache auth (Secrets Manager)

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.


## Run Server

Expand Down
40 changes: 40 additions & 0 deletions lib/aws-secrets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import log from '../logging.js';

// ---------------------------------------------------------------------------
// AWS Secrets Manager helper (Plane fork extension).
//
// Port of plane-ee/apps/silo/src/lib/aws-secrets.ts to plain ESM. Fetches a
// secret by ARN and TTL-caches the parsed JSON so repeated lookups (e.g. the
// periodic credential refresh in the redis cache engine) don't hammer the API.
//
// The AWS SDK is lazy-imported so deployments that never set a *_SECRET_ARN
// do not need `@aws-sdk/client-secrets-manager` installed/loaded.
// ---------------------------------------------------------------------------

const secretCache = new Map(); // key: `${arn}:${region}` -> { value, fetchedAt }

export async function getSecret(secretArn, region, forceRefresh = false) {

const cacheTtl = parseInt(process.env.AWS_SECRET_CACHE_TTL || '300', 10) * 1000;
const key = secretArn + ':' + region;
const now = Date.now();

if (!forceRefresh && secretCache.has(key)) {
const entry = secretCache.get(key);
if (now - entry.fetchedAt < cacheTtl) {
return { ...entry.value };
}
}

const { SecretsManagerClient, GetSecretValueCommand } =
await import('@aws-sdk/client-secrets-manager');

const client = new SecretsManagerClient({ region });
const response = await client.send(new GetSecretValueCommand({ SecretId: secretArn }));
const value = JSON.parse(response.SecretString || '{}');

secretCache.set(key, { value, fetchedAt: now });
log(' -- Secrets Manager: refreshed secret ' + secretArn);

return { ...value };
};
161 changes: 153 additions & 8 deletions lib/cache-engines/redis.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,161 @@
import log from '../../logging.js';
import CONFIG from '../../config.loader.js';

// ---------------------------------------------------------------------------
// Redis cache engine.
//
// Standard mode uses the `redis` client; cluster mode uses `redis-clustr`.
//
// AWS ElastiCache auth (Plane fork extension):
// When ELASTICACHE_SECRET_ARN is set, 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.
// Mirrors plane-ee/apps/silo/src/env.ts resolveRedisUrl()/resolveSecrets().
// ---------------------------------------------------------------------------

var client;
var currentToken;

// True only when the pod has IRSA or EKS Pod Identity credentials available.
// Mirrors silo's resolveSecrets() gate (env.ts) so that non-EKS deployments
// never attempt to reach AWS Secrets Manager even if ELASTICACHE_SECRET_ARN
// happens to be set in the environment. Static AWS_ACCESS_KEY_ID creds are
// intentionally NOT accepted here.
function hasAwsManagedCredentials() {
return Boolean(
(process.env.AWS_ROLE_ARN || '').trim() || // IRSA
process.env.AWS_WEB_IDENTITY_TOKEN_FILE || // IRSA (token file)
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || // EKS Pod Identity
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE // EKS Pod Identity (token file)
);
}

// True when secret resolution should run at all: ARN configured AND the pod
// has IRSA / Pod Identity credentials to read it with.
function secretResolutionEnabled() {
return Boolean(process.env.ELASTICACHE_SECRET_ARN) && hasAwsManagedCredentials();
}

// Resolve the auth token (and host/port) from AWS Secrets Manager.
// Returns null when secret resolution is not enabled (no ARN, or no
// IRSA/Pod Identity credentials present).
async function resolveSecretValues() {
if (!secretResolutionEnabled()) {
return null;
}

var region = process.env.AWS_REGION || 'us-east-1';
var { getSecret } = await import('../aws-secrets.js');
var secret = await getSecret(process.env.ELASTICACHE_SECRET_ARN, region, true);

var tokenKey = process.env.REDIS_AUTH_TOKEN_KEY || 'REDIS_AUTH_TOKEN';
var hostKey = process.env.REDIS_HOST_KEY || 'REDIS_HOST';
var portKey = process.env.REDIS_PORT_KEY || 'REDIS_PORT';

return {
token: typeof secret[tokenKey] === 'string' ? secret[tokenKey] : undefined,
host: typeof secret[hostKey] === 'string' ? secret[hostKey] : undefined,
port: secret[portKey] !== undefined ? Number(secret[portKey]) : undefined,
};
}

// Merge resolved secret values into the configured connection options.
// Existing config / IFRAMELY_REDIS_* values take precedence for host/port/TLS;
// only the auth token is always taken from the secret.
function buildOptions(secretVals) {

if (CONFIG.REDIS_MODE === 'cluster') {
var clusterOptions = { ...(CONFIG.REDIS_CLUSTER_OPTIONS || {}) };
if (secretVals && secretVals.token) {
// redis-clustr forwards `redisOptions` to node_redis v2's
// createClient (see redis-clustr@1.7.0 -> redis ^2.6.0). node_redis
// v2 uses `auth_pass`; `password` is a newer alias, so set both.
clusterOptions.redisOptions = {
...(clusterOptions.redisOptions || {}),
auth_pass: secretVals.token,
password: secretVals.token,
};
}
return clusterOptions;
}

var options = { ...(CONFIG.REDIS_OPTIONS || {}) };
var socket = { ...(options.socket || {}) };

if (CONFIG.REDIS_MODE === 'cluster') {
const pkg = await import('redis-clustr');
const RedisClustr = pkg.default;
client = new RedisClustr(CONFIG.REDIS_CLUSTER_OPTIONS);
} else {
var pkg = await import('redis');
client = pkg.createClient(CONFIG.REDIS_OPTIONS);
await client.connect();
if (secretVals) {
if (secretVals.token) {
options.password = secretVals.token;
}
// Fill host/port from the secret only when not already provided via
// config or IFRAMELY_REDIS_HOST/PORT (do not force TLS here).
if (socket.host === undefined && secretVals.host !== undefined) {
socket.host = secretVals.host;
}
if (socket.port === undefined && secretVals.port !== undefined) {
socket.port = secretVals.port;
}
}

options.socket = socket;
return options;
}

async function createAndConnect() {
var secretVals = await resolveSecretValues();
var options = buildOptions(secretVals);

var newClient;
if (CONFIG.REDIS_MODE === 'cluster') {
const pkg = await import('redis-clustr');
const RedisClustr = pkg.default;
newClient = new RedisClustr(options);
} else {
var pkg = await import('redis');
newClient = pkg.createClient(options);
await newClient.connect();
}

currentToken = secretVals && secretVals.token;
return newClient;
}

// Surface a misconfiguration: ARN set but no managed credentials to use it.
if (process.env.ELASTICACHE_SECRET_ARN && !hasAwsManagedCredentials()) {
log(' -- Redis: ELASTICACHE_SECRET_ARN is set but no IRSA / EKS Pod Identity credentials detected — skipping AWS Secrets Manager.');
}

// Initial connection.
client = await createAndConnect();

// Background credential refresh: rebuild the client when the token rotates.
if (secretResolutionEnabled()) {
var ttlMs = parseInt(process.env.AWS_SECRET_CACHE_TTL || '300', 10) * 1000;
if (ttlMs > 0) {
var refreshTimer = setInterval(async function () {
try {
var secretVals = await resolveSecretValues();
var newToken = secretVals && secretVals.token;
if (newToken && newToken !== currentToken) {
var oldClient = client;
client = await createAndConnect();
log(' -- Redis: rebuilt client after credential rotation');
try {
if (oldClient && oldClient.quit) {
await oldClient.quit();
}
} catch (quitErr) {
log(' -- Redis: error closing old client ' + quitErr);
}
}
} catch (err) {
log(' -- Redis: failed to refresh credentials ' + err);
}
}, ttlMs);
// Allow the process to exit even if the timer is still running.
refreshTimer.unref();
}
}

export async function set(key, data, options) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"license": "MIT",
"dependencies": {
"@adobe/fetch": "^4.1.11",
"@aws-sdk/client-secrets-manager": "^3.700.0",
"async": "^3.2.4",
"cheerio": "^1.1.2",
"cookie": "^1.0.2",
Expand Down
Loading