fix: 防止NapCat WebUI旧会话抢占索引

This commit is contained in:
sunlei 2026-06-24 13:46:02 +08:00
parent d174d9c9c2
commit 43b2c4c32e
5 changed files with 300 additions and 70 deletions

View File

@ -51,7 +51,7 @@
"cron-parser": "4.9.0", "cron-parser": "4.9.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"express": "5.2.1", "express": "5.2.1",
"http-proxy-middleware": "^4.1.1", "http-proxy-middleware": "^3.0.7",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",

View File

@ -57,8 +57,8 @@ importers:
specifier: 5.2.1 specifier: 5.2.1
version: 5.2.1 version: 5.2.1
http-proxy-middleware: http-proxy-middleware:
specifier: ^4.1.1 specifier: ^3.0.7
version: 4.1.1 version: 3.0.7
ioredis: ioredis:
specifier: ^5.11.1 specifier: ^5.11.1
version: 5.11.1 version: 5.11.1
@ -1304,6 +1304,9 @@ packages:
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/http-proxy@1.17.17':
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
'@types/istanbul-lib-coverage@2.0.6': '@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@ -2342,6 +2345,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@ -2692,17 +2698,18 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
http-proxy-middleware@4.1.1: http-proxy-middleware@3.0.7:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==} resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0} engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
http-proxy@1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
human-signals@2.1.0: human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'} engines: {node: '>=10.17.0'}
@ -2828,6 +2835,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0: is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@ -3898,6 +3909,9 @@ packages:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'} engines: {node: '>=0.10.5'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resolve-cwd@3.0.0: resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -6086,6 +6100,10 @@ snapshots:
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 22.19.19
'@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3': '@types/istanbul-lib-report@3.0.3':
@ -6485,7 +6503,7 @@ snapshots:
axios@1.17.0: axios@1.17.0:
dependencies: dependencies:
follow-redirects: 1.16.0 follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.5 form-data: 4.0.5
https-proxy-agent: 5.0.1 https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0 proxy-from-env: 2.1.0
@ -6713,7 +6731,7 @@ snapshots:
centra@2.7.0: centra@2.7.0:
dependencies: dependencies:
follow-redirects: 1.16.0 follow-redirects: 1.16.0(debug@4.4.3)
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@ -7147,6 +7165,8 @@ snapshots:
event-target-shim@5.0.1: {} event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
events@3.3.0: {} events@3.3.0: {}
execa@5.1.1: execa@5.1.1:
@ -7297,7 +7317,9 @@ snapshots:
flatted@3.4.2: {} flatted@3.4.2: {}
follow-redirects@1.16.0: {} follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
@ -7655,16 +7677,25 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-proxy-middleware@4.1.1: http-proxy-middleware@3.0.7:
dependencies: dependencies:
'@types/http-proxy': 1.17.17
debug: 4.4.3 debug: 4.4.3
httpxy: 0.5.3 http-proxy: 1.18.1(debug@4.4.3)
is-glob: 4.0.3 is-glob: 4.0.3
is-plain-obj: 4.1.0 is-plain-object: 5.0.0
micromatch: 4.0.8 micromatch: 4.0.8
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
http-proxy@1.18.1(debug@4.4.3):
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.0(debug@4.4.3)
requires-port: 1.0.0
transitivePeerDependencies:
- debug
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
@ -7672,8 +7703,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
httpxy@0.5.3: {}
human-signals@2.1.0: {} human-signals@2.1.0: {}
husky@9.1.7: {} husky@9.1.7: {}
@ -7786,6 +7815,8 @@ snapshots:
is-plain-obj@4.1.0: {} is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-promise@4.0.0: {} is-promise@4.0.0: {}
is-property@1.0.2: {} is-property@1.0.2: {}
@ -9259,6 +9290,8 @@ snapshots:
requireindex@1.2.0: {} requireindex@1.2.0: {}
requires-port@1.0.0: {}
resolve-cwd@3.0.0: resolve-cwd@3.0.0:
dependencies: dependencies:
resolve-from: 5.0.0 resolve-from: 5.0.0

View File

@ -42,7 +42,7 @@ export class NapcatWebuiGatewaySessionService {
normalizedInput.accountId, normalizedInput.accountId,
); );
if (existing) { if (existing) {
await this.store.update(existing.sessionId, { await this.updateSession(existing.sessionId, {
revokedAt: this.config.now(), revokedAt: this.config.now(),
status: 'revoked', status: 'revoked',
}); });
@ -77,7 +77,7 @@ export class NapcatWebuiGatewaySessionService {
const session = await this.requireUsableSession(sessionId); const session = await this.requireUsableSession(sessionId);
const now = this.config.now(); const now = this.config.now();
return this.store.update(sessionId, { return this.updateSession(sessionId, {
activeAt: session.activeAt || now, activeAt: session.activeAt || now,
expiresAt: now + this.config.ttlMs(), expiresAt: now + this.config.ttlMs(),
lastSeenAt: now, lastSeenAt: now,
@ -91,12 +91,13 @@ export class NapcatWebuiGatewaySessionService {
* @returns Browser-safe lifecycle result. * @returns Browser-safe lifecycle result.
*/ */
async heartbeat(input: NapcatWebuiGatewayLifecycleInput) { async heartbeat(input: NapcatWebuiGatewayLifecycleInput) {
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
const session = await this.requireUsableSession(input.sessionId); const session = await this.requireUsableSession(input.sessionId);
this.assertOwner(session, input.adminUserId); this.assertOwner(session, adminUserId);
const now = this.config.now(); const now = this.config.now();
const expiresAt = now + this.config.ttlMs(); const expiresAt = now + this.config.ttlMs();
await this.store.update(input.sessionId, { await this.updateSession(input.sessionId, {
clientIp: this.toOptionalText(input.clientIp) || session.clientIp, clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
expiresAt, expiresAt,
lastSeenAt: now, lastSeenAt: now,
@ -117,10 +118,11 @@ export class NapcatWebuiGatewaySessionService {
* @returns Browser-safe lifecycle result. * @returns Browser-safe lifecycle result.
*/ */
async revoke(input: NapcatWebuiGatewayLifecycleInput) { async revoke(input: NapcatWebuiGatewayLifecycleInput) {
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
const session = await this.requireUsableSession(input.sessionId); const session = await this.requireUsableSession(input.sessionId);
this.assertOwner(session, input.adminUserId); this.assertOwner(session, adminUserId);
const updated = await this.store.update(input.sessionId, { const updated = await this.updateSession(input.sessionId, {
clientIp: this.toOptionalText(input.clientIp) || session.clientIp, clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
revokedAt: this.config.now(), revokedAt: this.config.now(),
status: 'revoked', status: 'revoked',
@ -154,7 +156,7 @@ export class NapcatWebuiGatewaySessionService {
throw new GoneException('Gateway session is not active'); throw new GoneException('Gateway session is not active');
} }
if (session.expiresAt <= this.config.now()) { if (session.expiresAt <= this.config.now()) {
await this.store.update(sessionId, { status: 'expired' }); await this.updateSession(sessionId, { status: 'expired' });
throw new GoneException('Gateway session is not active'); throw new GoneException('Gateway session is not active');
} }
@ -175,6 +177,48 @@ export class NapcatWebuiGatewaySessionService {
} }
} }
/**
* Applies a store update and maps expected stale lifecycle rejections to 410.
* @param sessionId - Gateway session id to update.
* @param patch - Session fields to merge in the store.
* @returns Updated session from the backing store.
*/
private async updateSession(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
) {
try {
return await this.store.update(sessionId, patch);
} catch (error) {
if (this.isInactiveStoreError(error)) {
throw new GoneException('Gateway session is not active');
}
throw error;
}
}
/**
* Detects stale or inactive store errors that should not become HTTP 500.
* @param error - Error thrown by the session store.
* @returns Whether the error represents an expected inactive session race.
*/
private isInactiveStoreError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('Gateway session is not active') ||
message.includes('Gateway terminal session cannot become active')
);
}
/**
* Requires a lifecycle Admin actor before owner comparison.
* @param adminUserId - Candidate Admin actor id from heartbeat or revoke body.
* @returns Trimmed Admin actor id.
*/
private requireLifecycleAdminUserId(adminUserId: string) {
return this.requireText(adminUserId, 'adminUserId');
}
/** /**
* Validates and normalizes the internal create-session payload before persistence. * Validates and normalizes the internal create-session payload before persistence.
* @param input - Internal API payload supplied by the main API process. * @param input - Internal API payload supplied by the main API process.

View File

@ -10,11 +10,36 @@ import type {
const SESSION_KEY_PREFIX = 'napcat:webui:session:'; const SESSION_KEY_PREFIX = 'napcat:webui:session:';
const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:'; const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:';
const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked']; const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked'];
const COMPARE_DELETE_SCRIPT = ` const UPDATE_SESSION_SCRIPT = `
if redis.call("GET", KEYS[1]) == ARGV[1] then local currentJson = redis.call("GET", KEYS[1])
return redis.call("DEL", KEYS[1]) if not currentJson then
return {0, "Gateway session is not active"}
end end
return 0
local current = cjson.decode(currentJson)
local next = cjson.decode(ARGV[2])
local terminal = { expired = true, failed = true, revoked = true }
local indexValue = redis.call("GET", KEYS[2])
if terminal[current["status"]] and not terminal[next["status"]] then
return {0, "Gateway session is not active"}
end
if terminal[next["status"]] then
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2])
if indexValue == ARGV[1] then
redis.call("DEL", KEYS[2])
end
return {1, ARGV[2]}
end
if indexValue and indexValue ~= ARGV[1] then
return {0, "Gateway session is not active"}
end
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2])
redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[4])
return {1, ARGV[2]}
`; `;
@Injectable() @Injectable()
@ -94,12 +119,7 @@ export class NapcatWebuiGatewayRedisStore
throw new Error('Gateway terminal session cannot become active'); throw new Error('Gateway terminal session cannot become active');
} }
await this.writeSession(next); await this.writeSessionAndIndexAtomically(next);
if (this.isTerminal(next)) {
await this.deleteUserAccountIndexIfCurrent(next);
} else {
await this.writeUserAccountIndex(next);
}
return next; return next;
} }
@ -129,21 +149,6 @@ export class NapcatWebuiGatewayRedisStore
); );
} }
/**
* Deletes the user/account index only when it still points at the terminal session.
* @param session - Terminal Gateway session whose index may need cleanup.
*/
private async deleteUserAccountIndexIfCurrent(
session: NapcatWebuiGatewaySession,
) {
await this.redis.eval(
COMPARE_DELETE_SCRIPT,
1,
this.userAccountKey(session.adminUserId, session.accountId),
session.sessionId,
);
}
/** /**
* Builds the Redis session key. * Builds the Redis session key.
* @param sessionId - Gateway session id. * @param sessionId - Gateway session id.
@ -172,6 +177,29 @@ export class NapcatWebuiGatewayRedisStore
return Math.max(1, session.expiresAt - this.config.now()); return Math.max(1, session.expiresAt - this.config.now());
} }
/**
* Atomically writes session JSON and maintains the user/account index.
* @param session - Already-merged Gateway session to commit.
*/
private async writeSessionAndIndexAtomically(
session: NapcatWebuiGatewaySession,
) {
const sessionJson = JSON.stringify(session);
const result = (await this.redis.eval(
UPDATE_SESSION_SCRIPT,
2,
this.sessionKey(session.sessionId),
this.userAccountKey(session.adminUserId, session.accountId),
session.sessionId,
sessionJson,
this.remainingTtlMs(session),
this.remainingTtlMs(session),
)) as [number, string];
if (!Array.isArray(result) || Number(result[0]) !== 1) {
throw new Error(String(result?.[1] || 'Gateway session is not active'));
}
}
/** /**
* Checks whether the session has reached a terminal lifecycle status. * Checks whether the session has reached a terminal lifecycle status.
* @param session - Gateway session to inspect. * @param session - Gateway session to inspect.

View File

@ -145,27 +145,62 @@ class FakeRedis {
} }
/** /**
* Simulates the compare-and-delete Lua script used by Redis index cleanup. * Simulates Gateway Redis Lua scripts used by ticket and session store tests.
* @param script - Lua script text. * @param script - Lua script text.
* @param keyCount - Number of Redis keys in the script call. * @param keyCount - Number of Redis keys in the script call.
* @param key - Redis key to conditionally delete. * @param args - Redis keys and script arguments after `keyCount`.
* @param expectedValue - Value that must match before deletion. * @returns Script-shaped response used by the store.
* @returns 1 when the index was deleted, otherwise 0.
*/ */
async eval( async eval(script: string, keyCount: number, ...args: string[]) {
script: string, this.calls.push(`eval:${keyCount}:${args.join(':')}`);
keyCount: number, if (!script.includes('redis.call')) {
key: string,
expectedValue: string,
) {
this.calls.push(`eval:${keyCount}:${key}:${expectedValue}`);
if (!script.includes('redis.call') || keyCount !== 1) {
throw new Error('Unexpected Redis script'); throw new Error('Unexpected Redis script');
} }
if (this.values.get(key) !== expectedValue) return 0; if (keyCount !== 2) {
this.values.delete(key); throw new Error('Unexpected Redis key count');
this.ttl.delete(key); }
return 1;
const [
sessionKey,
indexKey,
sessionId,
nextSessionJson,
sessionTtlMs,
indexTtlMs,
] = args;
const currentJson = this.values.get(sessionKey);
if (!currentJson) return [0, 'Gateway session is not active'];
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
const next = JSON.parse(nextSessionJson) as NapcatWebuiGatewaySession;
const terminalStatuses = ['expired', 'failed', 'revoked'];
const currentTerminal = terminalStatuses.includes(current.status);
const nextTerminal = terminalStatuses.includes(next.status);
const indexValue = this.values.get(indexKey);
if (currentTerminal && !nextTerminal) {
return [0, 'Gateway session is not active'];
}
if (nextTerminal) {
this.values.set(sessionKey, nextSessionJson);
this.ttl.set(sessionKey, Number(sessionTtlMs));
if (indexValue === sessionId) {
this.values.delete(indexKey);
this.ttl.delete(indexKey);
}
return [1, nextSessionJson];
}
if (indexValue && indexValue !== sessionId) {
return [0, 'Gateway session is not active'];
}
this.values.set(sessionKey, nextSessionJson);
this.ttl.set(sessionKey, Number(sessionTtlMs));
this.values.set(indexKey, sessionId);
this.ttl.set(indexKey, Number(indexTtlMs));
return [1, nextSessionJson];
} }
} }
@ -410,11 +445,81 @@ describe('NapcatWebuiGatewayRedisStore', () => {
sessionId: second.sessionId, sessionId: second.sessionId,
status: 'created', status: 'created',
}); });
expect( expect(redis.values.get('napcat:webui:user-account:admin-1:account-1')).toBe(
redis.calls.some((call) => second.sessionId,
call.startsWith('eval:1:napcat:webui:user-account:admin-1:account-1:'), );
), });
).toBe(true);
it('rejects stale non-terminal updates when the index points at a newer session', async () => {
const redis = new FakeRedis();
const config = createConfig({ value: 1000 });
const store = new NapcatWebuiGatewayRedisStore(
redis as never,
config as never,
);
const service = new NapcatWebuiGatewaySessionService(
store,
config as never,
);
const first = await service.create(createSessionInput());
const second = await service.create(createSessionInput());
const firstSessionKey = `napcat:webui:session:${first.sessionId}`;
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
redis.values.set(
firstSessionKey,
JSON.stringify({
...first,
status: 'created',
}),
);
redis.values.set(indexKey, second.sessionId);
await expect(
store.update(first.sessionId, {
activeAt: 2000,
expiresAt: 62_000,
lastSeenAt: 2000,
status: 'active',
}),
).rejects.toThrow('Gateway session is not active');
expect(redis.values.get(indexKey)).toBe(second.sessionId);
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject({
status: 'created',
});
});
it('keeps delayed old heartbeat and activation from reviving a replaced session', async () => {
const redis = new FakeRedis();
const currentTime = { value: 1000 };
const config = createConfig(currentTime);
const store = new NapcatWebuiGatewayRedisStore(
redis as never,
config as never,
);
const service = new NapcatWebuiGatewaySessionService(
store,
config as never,
);
const first = await service.create(createSessionInput());
const second = await service.create(createSessionInput());
currentTime.value = 2000;
await expect(service.markActive(first.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.heartbeat({
adminUserId: 'admin-1',
sessionId: first.sessionId,
}),
).rejects.toThrow('Gateway session is not active');
await expect(
store.findActiveByUserAndAccount('admin-1', 'account-1'),
).resolves.toMatchObject({
sessionId: second.sessionId,
});
}); });
}); });
@ -568,6 +673,26 @@ describe('InternalSessionController', () => {
.expect(HttpStatus.GONE); .expect(HttpStatus.GONE);
}); });
it('rejects blank lifecycle admin user ids with bad request status', async () => {
const createResponse = await request(app.getHttpServer())
.post('/internal/sessions')
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send(createSessionInput())
.expect(HttpStatus.CREATED);
const sessionId = createResponse.body.sessionId;
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/heartbeat`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({})
.expect(HttpStatus.BAD_REQUEST);
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/revoke`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: ' ' })
.expect(HttpStatus.BAD_REQUEST);
});
it('rejects invalid create-session payloads with bad request status', async () => { it('rejects invalid create-session payloads with bad request status', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/internal/sessions') .post('/internal/sessions')