From c9cc0d5e3a9540e2733526dcaff5664d10d00dc3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 14:01:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7NapCat=20WebUI?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=BC=9A=E8=AF=9D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...apcat-webui-gateway-implementation-plan.md | 14 +- ...webui-gateway-implementation-plan.zh-CN.md | 6 +- .../napcat-webui-gateway-session.service.ts | 38 +++- .../napcat-webui-gateway-redis.store.ts | 89 +++++---- .../session-store.spec.ts | 176 +++++++++++++++++- 5 files changed, 261 insertions(+), 62 deletions(-) diff --git a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md index dd8c554..5123f5f 100644 --- a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md +++ b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.md @@ -756,7 +756,7 @@ export interface NapcatWebuiGatewaySessionStore { - [ ] **Step 6: Implement session service** -Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`. +Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, `requireBootstrapSession`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`. The `create()` implementation must: @@ -773,7 +773,7 @@ if (older) { } ``` -The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, and missing sessions. +The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions. - [ ] **Step 7: Implement Redis store and ticket service** @@ -1006,14 +1006,14 @@ Use `createProxyMiddleware` from `http-proxy-middleware` with: Before proxying: -- resolve session by `sessionId`; -- reject non-active/non-created sessions with 410; +- resolve an active session by `sessionId` through `sessionService.requireProxySession(sessionId)`; +- reject non-active, stale, terminal, expired, or missing sessions with 410; - redeem Credential through `NapcatWebuiCredentialClient`; - add `Authorization: Bearer ${credential}` upstream; - never forward API/Admin cookies upstream; - never allow the browser to override `target`. -On first successful upstream response, call `sessionService.markActive(sessionId)`. +Do not mark sessions active from the proxy path; bootstrap activates the session before redirecting into the proxied WebUI. For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`: @@ -1046,9 +1046,9 @@ GET /napcat-webui/session/:sessionId/bootstrap ALL /napcat-webui/session/:sessionId/webui/* ``` -Bootstrap must redeem `ticket`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`. +Bootstrap must redeem `ticket`, validate the redeemed session through `sessionService.requireBootstrapSession(sessionId)`, call `sessionService.markActive(sessionId)`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`. -Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling. +Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, active-only session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling. - [ ] **Step 7: Run tests and typecheck** diff --git a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md index 9effabc..3281003 100644 --- a/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md +++ b/docs/superpowers/plans/2026-06-24-qqbot-napcat-webui-gateway-implementation-plan.zh-CN.md @@ -302,7 +302,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/ - [ ] **Step 6:实现 session service** -创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。 +创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。 - [ ] **Step 7:实现 Redis store 和 ticket service** @@ -431,7 +431,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/ } ``` -代理前必须解析 session,拒绝非 active/created session,换取 Credential,注入 `Authorization: Bearer `,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。 +代理前必须通过 `sessionService.requireProxySession(sessionId)` 解析 active session,拒绝非 active、stale、终态、过期或缺失 session,换取 Credential,注入 `Authorization: Bearer `,删除浏览器传入的 API/Admin cookies,不允许浏览器改变 target。Proxy 路径不要再调用 `markActive()`;session 必须在 bootstrap redirect 前完成激活。 WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)`;`main.ts` 在 `app.listen()` 后调用: @@ -449,7 +449,7 @@ GET /napcat-webui/session/:sessionId/bootstrap ALL /napcat-webui/session/:sessionId/webui/* ``` -bootstrap 兑换一次性 ticket,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。 +bootstrap 兑换一次性 ticket,通过 `sessionService.requireBootstrapSession(sessionId)` 校验 bootstrap session,调用 `sessionService.markActive(sessionId)`,设置 HttpOnly session cookie,跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、active-only session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。 - [ ] **Step 7:运行测试和类型检查** diff --git a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts index c28467a..92b2272 100644 --- a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts +++ b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts @@ -74,7 +74,7 @@ export class NapcatWebuiGatewaySessionService { * @returns Updated active session. */ async markActive(sessionId: string) { - const session = await this.requireUsableSession(sessionId); + const session = await this.requireBootstrapSession(sessionId); const now = this.config.now(); return this.updateSession(sessionId, { @@ -97,7 +97,7 @@ export class NapcatWebuiGatewaySessionService { const now = this.config.now(); const expiresAt = now + this.config.ttlMs(); - await this.updateSession(input.sessionId, { + const updated = await this.updateSession(input.sessionId, { clientIp: this.toOptionalText(input.clientIp) || session.clientIp, expiresAt, lastSeenAt: now, @@ -106,7 +106,7 @@ export class NapcatWebuiGatewaySessionService { }); return { - expiresAt, + expiresAt: updated.expiresAt, sessionId: input.sessionId, status: 'active' as const, }; @@ -137,16 +137,30 @@ export class NapcatWebuiGatewaySessionService { } /** - * Loads a session only when it is usable for later proxy handling. - * @param sessionId - Gateway session id from a public Gateway route. - * @returns Stored server-only session metadata for proxy setup. + * Loads a session only when it is eligible for one-time bootstrap. + * @param sessionId - Gateway session id redeemed from a bootstrap ticket. + * @returns Indexed non-terminal session that may be marked active. */ - async requireProxySession(sessionId: string) { + async requireBootstrapSession(sessionId: string) { return this.requireUsableSession(sessionId); } /** - * Loads and validates a non-terminal, non-expired session. + * Loads a session only when it is active and usable for proxy handling. + * @param sessionId - Gateway session id from a public Gateway route. + * @returns Stored server-only session metadata for proxy setup. + */ + async requireProxySession(sessionId: string) { + const session = await this.requireUsableSession(sessionId); + if (session.status !== 'active') { + throw new GoneException('Gateway session is not active'); + } + + return session; + } + + /** + * Loads and validates a non-terminal, non-expired, currently indexed session. * @param sessionId - Gateway session id. * @returns Usable Gateway session. */ @@ -160,6 +174,14 @@ export class NapcatWebuiGatewaySessionService { throw new GoneException('Gateway session is not active'); } + const indexed = await this.store.findActiveByUserAndAccount( + session.adminUserId, + session.accountId, + ); + if (!indexed || indexed.sessionId !== session.sessionId) { + throw new GoneException('Gateway session is not active'); + } + return session; } diff --git a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts index 1270710..9b60601 100644 --- a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts +++ b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts @@ -17,29 +17,59 @@ if not currentJson then end local current = cjson.decode(currentJson) -local next = cjson.decode(ARGV[2]) +local patch = cjson.decode(ARGV[2]) local terminal = { expired = true, failed = true, revoked = true } -local indexValue = redis.call("GET", KEYS[2]) +local next = {} + +for key, value in pairs(current) do + next[key] = value +end +for key, value in pairs(patch) do + next[key] = value +end + +next["sessionId"] = ARGV[1] +next["adminUserId"] = current["adminUserId"] +next["accountId"] = current["accountId"] + +if current["expiresAt"] and patch["expiresAt"] and tonumber(current["expiresAt"]) > tonumber(patch["expiresAt"]) then + next["expiresAt"] = current["expiresAt"] +end +if current["lastSeenAt"] and patch["lastSeenAt"] and tonumber(current["lastSeenAt"]) > tonumber(patch["lastSeenAt"]) then + next["lastSeenAt"] = current["lastSeenAt"] +end +if current["activeAt"] then + next["activeAt"] = current["activeAt"] +end +if current["revokedAt"] then + next["revokedAt"] = current["revokedAt"] +end + +local indexKey = ARGV[3] .. current["adminUserId"] .. ":" .. current["accountId"] +local indexValue = redis.call("GET", indexKey) +local now = tonumber(ARGV[4]) +local ttl = math.max(1, tonumber(next["expiresAt"]) - now) +local nextJson = cjson.encode(next) 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]) + redis.call("PSETEX", KEYS[1], ttl, nextJson) if indexValue == ARGV[1] then - redis.call("DEL", KEYS[2]) + redis.call("DEL", indexKey) end - return {1, ARGV[2]} + return {1, nextJson} 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]} +redis.call("PSETEX", KEYS[1], ttl, nextJson) +redis.call("SET", indexKey, ARGV[1], "PX", ttl) +return {1, nextJson} `; @Injectable() @@ -107,21 +137,7 @@ export class NapcatWebuiGatewayRedisStore sessionId: string, patch: Partial, ) { - const current = await this.find(sessionId); - if (!current) throw new Error('Gateway session is not active'); - - const next = { - ...current, - ...patch, - sessionId, - }; - if (this.isTerminal(current) && !this.isTerminal(next)) { - throw new Error('Gateway terminal session cannot become active'); - } - - await this.writeSessionAndIndexAtomically(next); - - return next; + return this.mergeSessionPatchAtomically(sessionId, patch); } /** @@ -178,26 +194,29 @@ export class NapcatWebuiGatewayRedisStore } /** - * Atomically writes session JSON and maintains the user/account index. - * @param session - Already-merged Gateway session to commit. + * Atomically merges a patch into current Redis JSON and maintains the index. + * @param sessionId - Gateway session id to update. + * @param patch - Partial session fields to merge inside Redis. + * @returns Final merged session returned by the Lua script. */ - private async writeSessionAndIndexAtomically( - session: NapcatWebuiGatewaySession, + private async mergeSessionPatchAtomically( + sessionId: string, + patch: Partial, ) { - 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), + 1, + this.sessionKey(sessionId), + sessionId, + JSON.stringify(patch), + USER_ACCOUNT_KEY_PREFIX, + this.config.now(), )) as [number, string]; if (!Array.isArray(result) || Number(result[0]) !== 1) { throw new Error(String(result?.[1] || 'Gateway session is not active')); } + + return JSON.parse(result[1]) as NapcatWebuiGatewaySession; } /** diff --git a/test/apps/napcat-webui-gateway/session-store.spec.ts b/test/apps/napcat-webui-gateway/session-store.spec.ts index 7d8bad7..2f8bcd7 100644 --- a/test/apps/napcat-webui-gateway/session-store.spec.ts +++ b/test/apps/napcat-webui-gateway/session-store.spec.ts @@ -156,27 +156,52 @@ class FakeRedis { if (!script.includes('redis.call')) { throw new Error('Unexpected Redis script'); } - if (keyCount !== 2) { + if (keyCount !== 1) { throw new Error('Unexpected Redis key count'); } const [ sessionKey, - indexKey, sessionId, - nextSessionJson, - sessionTtlMs, - indexTtlMs, + patchJson, + userAccountKeyPrefix, + now, ] = 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 patch = JSON.parse( + patchJson, + ) as Partial; + const next = { + ...current, + ...patch, + accountId: current.accountId, + adminUserId: current.adminUserId, + sessionId, + } as NapcatWebuiGatewaySession; + next.expiresAt = Math.max(current.expiresAt, patch.expiresAt || 0); + if (current.lastSeenAt || patch.lastSeenAt) { + next.lastSeenAt = Math.max( + current.lastSeenAt || 0, + patch.lastSeenAt || 0, + ); + } + if (current.activeAt) { + next.activeAt = current.activeAt; + } + if (current.revokedAt) { + next.revokedAt = current.revokedAt; + } + const terminalStatuses = ['expired', 'failed', 'revoked']; const currentTerminal = terminalStatuses.includes(current.status); const nextTerminal = terminalStatuses.includes(next.status); + const indexKey = `${userAccountKeyPrefix}${current.adminUserId}:${current.accountId}`; const indexValue = this.values.get(indexKey); + const ttlMs = Math.max(1, next.expiresAt - Number(now)); + const nextSessionJson = JSON.stringify(next); if (currentTerminal && !nextTerminal) { return [0, 'Gateway session is not active']; @@ -184,7 +209,7 @@ class FakeRedis { if (nextTerminal) { this.values.set(sessionKey, nextSessionJson); - this.ttl.set(sessionKey, Number(sessionTtlMs)); + this.ttl.set(sessionKey, ttlMs); if (indexValue === sessionId) { this.values.delete(indexKey); this.ttl.delete(indexKey); @@ -197,9 +222,9 @@ class FakeRedis { } this.values.set(sessionKey, nextSessionJson); - this.ttl.set(sessionKey, Number(sessionTtlMs)); + this.ttl.set(sessionKey, ttlMs); this.values.set(indexKey, sessionId); - this.ttl.set(indexKey, Number(indexTtlMs)); + this.ttl.set(indexKey, ttlMs); return [1, nextSessionJson]; } } @@ -370,6 +395,37 @@ describe('NapcatWebuiGatewaySessionService', () => { ).rejects.toThrow('Gateway session owner mismatch'); }); + it('keeps created sessions out of proxy access until bootstrap marks them active', async () => { + const store = new MemorySessionStore(); + const currentTime = { value: 1000 }; + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig(currentTime) as never, + ); + const session = await service.create(createSessionInput()); + + await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( + 'Gateway session is not active', + ); + await expect( + service.requireBootstrapSession(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + status: 'created', + }); + + currentTime.value = 5000; + await service.markActive(session.sessionId); + + await expect( + service.requireProxySession(session.sessionId), + ).resolves.toMatchObject({ + activeAt: 5000, + sessionId: session.sessionId, + status: 'active', + }); + }); + it('rejects expired proxy sessions and marks them expired', async () => { const store = new MemorySessionStore(); const currentTime = { value: 1000 }; @@ -388,6 +444,24 @@ describe('NapcatWebuiGatewaySessionService', () => { status: 'expired', }); }); + + it('rejects revoked sessions for proxy access', async () => { + const store = new MemorySessionStore(); + const service = new NapcatWebuiGatewaySessionService( + store, + createConfig({ value: 1000 }) as never, + ); + const session = await service.create(createSessionInput()); + + await service.revoke({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }); + + await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( + 'Gateway session is not active', + ); + }); }); describe('NapcatWebuiGatewayRedisStore', () => { @@ -521,6 +595,90 @@ describe('NapcatWebuiGatewayRedisStore', () => { sessionId: second.sessionId, }); }); + + it('rejects stale replaced sessions from bootstrap and proxy loaders', 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( + service.requireBootstrapSession(first.sessionId), + ).rejects.toThrow('Gateway session is not active'); + + redis.values.set( + firstSessionKey, + JSON.stringify({ + ...first, + activeAt: 2000, + lastSeenAt: 2000, + status: 'active', + }), + ); + + await expect(service.requireProxySession(first.sessionId)).rejects.toThrow( + 'Gateway session is not active', + ); + }); + + it('merges delayed lifecycle patches without reducing monotonic timestamps', 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 session = await service.create(createSessionInput()); + + currentTime.value = 5000; + await service.markActive(session.sessionId); + + currentTime.value = 2000; + const heartbeat = await service.heartbeat({ + adminUserId: 'admin-1', + sessionId: session.sessionId, + }); + const markActive = await service.markActive(session.sessionId); + const stored = await store.find(session.sessionId); + + expect(heartbeat.expiresAt).toBe(65_000); + expect(markActive).toMatchObject({ + activeAt: 5000, + expiresAt: 65_000, + lastSeenAt: 5000, + status: 'active', + }); + expect(stored).toMatchObject({ + activeAt: 5000, + expiresAt: 65_000, + lastSeenAt: 5000, + status: 'active', + }); + }); }); describe('NapcatWebuiGatewayTicketService', () => {