From c96c5e8ae8f556f541be24bb7755f93b2897428a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 24 Mar 2026 18:12:51 -0700 Subject: [PATCH] Cache token for thundering hurd --- server/lib/tokenCache.ts | 22 ++++++ server/private/lib/cache.ts | 13 ++++ server/private/lib/tokenCache.ts | 77 +++++++++++++++++++ .../remoteExitNode/getRemoteExitNodeToken.ts | 25 ++++-- server/routers/newt/getNewtToken.ts | 18 ++++- server/routers/olm/getOlmToken.ts | 19 ++++- 6 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 server/lib/tokenCache.ts create mode 100644 server/private/lib/tokenCache.ts diff --git a/server/lib/tokenCache.ts b/server/lib/tokenCache.ts new file mode 100644 index 000000000..022f46c15 --- /dev/null +++ b/server/lib/tokenCache.ts @@ -0,0 +1,22 @@ +/** + * Returns a cached plaintext token from Redis if one exists and decrypts + * cleanly, otherwise calls `createSession` to mint a fresh token, stores the + * encrypted value in Redis with the given TTL, and returns it. + * + * Failures at the Redis layer are non-fatal – the function always falls + * through to session creation so the caller is never blocked by a Redis outage. + * + * @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"` + * @param secret Server secret used for AES encryption/decryption + * @param ttlSeconds Cache TTL in seconds (should match session expiry) + * @param createSession Factory that mints a new session and returns its raw token + */ +export async function getOrCreateCachedToken( + cacheKey: string, + secret: string, + ttlSeconds: number, + createSession: () => Promise +): Promise { + const token = await createSession(); + return token; +} diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts index e8c03ba3d..1a2006d46 100644 --- a/server/private/lib/cache.ts +++ b/server/private/lib/cache.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import NodeCache from "node-cache"; import logger from "@server/logger"; import { redisManager } from "@server/private/lib/redis"; diff --git a/server/private/lib/tokenCache.ts b/server/private/lib/tokenCache.ts new file mode 100644 index 000000000..bb6645688 --- /dev/null +++ b/server/private/lib/tokenCache.ts @@ -0,0 +1,77 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import redisManager from "#dynamic/lib/redis"; +import { encrypt, decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; + +/** + * Returns a cached plaintext token from Redis if one exists and decrypts + * cleanly, otherwise calls `createSession` to mint a fresh token, stores the + * encrypted value in Redis with the given TTL, and returns it. + * + * Failures at the Redis layer are non-fatal – the function always falls + * through to session creation so the caller is never blocked by a Redis outage. + * + * @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"` + * @param secret Server secret used for AES encryption/decryption + * @param ttlSeconds Cache TTL in seconds (should match session expiry) + * @param createSession Factory that mints a new session and returns its raw token + */ +export async function getOrCreateCachedToken( + cacheKey: string, + secret: string, + ttlSeconds: number, + createSession: () => Promise +): Promise { + if (redisManager.isRedisEnabled()) { + try { + const cached = await redisManager.get(cacheKey); + if (cached) { + const token = decrypt(cached, secret); + if (token) { + logger.debug(`Token cache hit for key: ${cacheKey}`); + return token; + } + // Decryption produced an empty string – treat as a miss + logger.warn( + `Token cache decryption returned empty string for key: ${cacheKey}, treating as miss` + ); + } + } catch (e) { + logger.warn( + `Token cache read/decrypt failed for key ${cacheKey}, falling through to session creation:`, + e + ); + } + } + + const token = await createSession(); + + if (redisManager.isRedisEnabled()) { + try { + const encrypted = encrypt(token, secret); + await redisManager.set(cacheKey, encrypted, ttlSeconds); + logger.debug( + `Token cached in Redis for key: ${cacheKey} (TTL ${ttlSeconds}s)` + ); + } catch (e) { + logger.warn( + `Token cache write failed for key ${cacheKey} (session was still created):`, + e + ); + } + } + + return token; +} diff --git a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts index 24f0de159..025e2d34e 100644 --- a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts +++ b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts @@ -23,8 +23,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createRemoteExitNodeSession, - validateRemoteExitNodeSessionToken + validateRemoteExitNodeSessionToken, + EXPIRES } from "#private/auth/sessions/remoteExitNode"; +import { getOrCreateCachedToken } from "@server/private/lib/tokenCache"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -103,14 +105,23 @@ export async function getRemoteExitNodeToken( ); } - const resToken = generateSessionToken(); - await createRemoteExitNodeSession( - resToken, - existingRemoteExitNode.remoteExitNodeId + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `remote_exit_node:token_cache:${existingRemoteExitNode.remoteExitNodeId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createRemoteExitNodeSession( + token, + existingRemoteExitNode.remoteExitNodeId + ); + return token; + } ); - // logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`); - return response<{ token: string }>(res, { data: { token: resToken diff --git a/server/routers/newt/getNewtToken.ts b/server/routers/newt/getNewtToken.ts index 9d3da7e97..c5abb9968 100644 --- a/server/routers/newt/getNewtToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -1,6 +1,8 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db, newtSessions } from "@server/db"; import { newts } from "@server/db"; +import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; +import { EXPIRES } from "@server/auth/sessions/newt"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -92,9 +94,19 @@ export async function getNewtToken( ); } - // otherwise generate a new one - const resToken = generateSessionToken(); - await createNewtSession(resToken, existingNewt.newtId); + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `newt:token_cache:${existingNewt.newtId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createNewtSession(token, existingNewt.newtId); + return token; + } + ); return response<{ token: string; serverVersion: string }>(res, { data: { diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index 741b29f0a..5b8411eb7 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -20,8 +20,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createOlmSession, - validateOlmSessionToken + validateOlmSessionToken, + EXPIRES } from "@server/auth/sessions/olm"; +import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -132,8 +134,19 @@ export async function getOlmToken( logger.debug("Creating new olm session token"); - const resToken = generateSessionToken(); - await createOlmSession(resToken, existingOlm.olmId); + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `olm:token_cache:${existingOlm.olmId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createOlmSession(token, existingOlm.olmId); + return token; + } + ); let clientIdToUse; if (orgId) {