feat: 实现NapCat WebUI Gateway代理

This commit is contained in:
sunlei 2026-06-24 15:04:00 +08:00
parent 52d96294ed
commit d0fbc13763
9 changed files with 892 additions and 34 deletions

View File

@ -5,6 +5,7 @@ const DEFAULT_GATEWAY_PORT = 48086;
const DEFAULT_REDIS_HOST = '127.0.0.1';
const DEFAULT_REDIS_PORT = 6379;
const DEFAULT_SESSION_TTL_MS = 60_000;
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5000;
const MAX_TICKET_TTL_MS = 60_000;
@Injectable()
@ -59,6 +60,17 @@ export class NapcatWebuiGatewayConfigService {
);
}
/**
* Reads the bounded timeout for NapCat WebUI upstream HTTP calls.
* @returns Positive timeout in milliseconds, defaulting to 5 seconds.
*/
upstreamTimeoutMs() {
return this.getPositiveNumber(
'NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS',
DEFAULT_UPSTREAM_TIMEOUT_MS,
);
}
/**
* Reads the shared internal secret required for mutating API-to-Gateway calls.
* @returns Trimmed secret or an empty string when missing so callers fail closed.

View File

@ -0,0 +1,101 @@
import { createHash } from 'node:crypto';
import axios from 'axios';
import { BadGatewayException, Injectable } from '@nestjs/common';
import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
import type { NapcatWebuiGatewaySession } from '../domain/napcat-webui-gateway.types';
type NapcatCredentialBody = {
Credential?: string;
};
type NapcatCredentialResponse =
| NapcatCredentialBody
| {
data?: NapcatCredentialBody;
};
@Injectable()
export class NapcatWebuiCredentialClient {
private readonly credentials = new Map<
string,
{ credential: string; expiresAt: number }
>();
/**
* Creates the standalone Gateway credential client.
* @param config - Gateway config used for bounded upstream requests and time.
*/
constructor(private readonly config: NapcatWebuiGatewayConfigService) {}
/**
* Returns a cached or freshly exchanged NapCat WebUI credential for one Gateway session.
* @param session - Server-only Gateway session metadata containing token and upstream URL.
* @returns NapCat WebUI Credential string for upstream Authorization.
*/
async getCredential(session: NapcatWebuiGatewaySession) {
const cached = this.credentials.get(session.sessionId);
const now = this.config.now();
if (
cached &&
now < cached.expiresAt &&
cached.expiresAt <= session.expiresAt
) {
return cached.credential;
}
const credential = await this.exchangeCredential(session);
this.credentials.set(session.sessionId, {
credential,
expiresAt: session.expiresAt,
});
return credential;
}
/**
* Clears a cached credential when a Gateway session is revoked.
* @param sessionId - Gateway session id whose cached credential should be removed.
*/
clear(sessionId: string) {
this.credentials.delete(sessionId);
}
/**
* Exchanges the server-only WebUI token for a NapCat Credential.
* @param session - Gateway session containing upstream base URL and WebUI token.
* @returns Credential returned by NapCat WebUI.
*/
private async exchangeCredential(session: NapcatWebuiGatewaySession) {
const hash = createHash('sha256')
.update(`${session.webuiToken}.napcat`)
.digest('hex');
try {
const response = await axios.post<NapcatCredentialResponse>(
new URL('/api/auth/login', session.upstreamBaseUrl).toString(),
{ hash },
{
timeout: this.config.upstreamTimeoutMs(),
},
);
const credential = this.extractCredential(response.data);
if (!credential) {
throw new Error('Missing Credential');
}
return credential;
} catch {
throw new BadGatewayException('NapCat WebUI credential exchange failed');
}
}
/**
* Reads Credential from either the raw NapCat payload or its data wrapper.
* @param body - Axios response body from `/api/auth/login`.
* @returns Credential when present.
*/
private extractCredential(body: NapcatCredentialResponse) {
if ('data' in body) {
return body.data?.Credential;
}
return (body as NapcatCredentialBody).Credential;
}
}

View File

@ -0,0 +1,325 @@
import type { IncomingMessage, Server } from 'node:http';
import type { Socket } from 'node:net';
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import {
createProxyMiddleware,
type RequestHandler,
} from 'http-proxy-middleware';
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gateway.types';
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
const STRIPPED_UPSTREAM_HEADERS = [
'authorization',
'cookie',
'x-admin-token',
'x-api-token',
'x-access-token',
'x-kt-access-token',
'x-kt-gateway-secret',
'x-wordpress-cookie',
] as const;
type ProxyPathInput = string | string[] | undefined;
type RewriteLocationInput = {
location: string;
sessionId: string;
upstreamBaseUrl: string;
};
type CookiePathRewriteInput = {
sessionId: string;
};
/**
* Normalizes a Gateway route tail into a safe upstream pathname.
* @param input - Route parameter from Nest/path-to-regexp.
* @returns Absolute upstream pathname beginning with `/`.
*/
export function sanitizeGatewayProxyPath(input: ProxyPathInput) {
const raw = Array.isArray(input) ? input.join('/') : String(input || '');
const trimmed = raw.trim();
const decoded = decodeProxyPath(trimmed);
if (
!decoded ||
decoded.includes('\\') ||
decoded.startsWith('//') ||
/^[a-z][a-z0-9+.-]*:/i.test(decoded)
) {
throw new BadRequestException('Gateway proxy path is invalid');
}
const path = decoded.startsWith('/') ? decoded : `/${decoded}`;
const segments = path.split('/').filter(Boolean);
if (segments.some((segment) => segment === '..')) {
throw new BadRequestException('Gateway proxy path is invalid');
}
return path;
}
/**
* Rewrites NapCat redirects so browsers stay under the Gateway session route.
* @param input - Upstream Location header plus Gateway session context.
* @returns Rewritten safe Location header.
*/
export function rewriteNapcatLocationHeader(input: RewriteLocationInput) {
const gatewayPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(
input.sessionId,
)}/webui`;
const location = input.location.trim();
if (!location) return location;
if (location.startsWith('//')) {
try {
const upstream = new URL(input.upstreamBaseUrl);
const target = new URL(`${upstream.protocol}${location}`);
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`;
} catch {
return `${gatewayPrefix}/webui`;
}
}
if (location.startsWith('/')) {
return `${gatewayPrefix}${location}`;
}
if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) {
return `${gatewayPrefix}/${location.replace(/^\/+/, '')}`;
}
try {
const target = new URL(location);
if (target.protocol === 'http:' || target.protocol === 'https:') {
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`;
}
return `${gatewayPrefix}/webui`;
} catch {
return `${gatewayPrefix}/webui`;
}
}
/**
* Builds HPM cookie path rewrite config for Gateway-scoped upstream cookies.
* @param input - Gateway session id used in the public route prefix.
* @returns HPM cookiePathRewrite object.
*/
export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) {
return {
'*': `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(input.sessionId)}/webui`,
};
}
/**
* Decodes path text until stable so nested encoded traversal cannot pass through.
* @param value - Raw route path text.
* @returns Decoded path text.
*/
function decodeProxyPath(value: string) {
try {
let decoded = value;
for (let index = 0; index < 6; index += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) {
return next;
}
decoded = next;
}
} catch {
throw new BadRequestException('Gateway proxy path is invalid');
}
throw new BadRequestException('Gateway proxy path is invalid');
}
@Injectable()
export class NapcatWebuiProxyService {
/**
* Creates the Gateway proxy service.
* @param sessionService - Session lifecycle guard for bootstrap/proxy eligibility.
* @param credentialClient - NapCat WebUI credential exchange/cache client.
*/
constructor(
private readonly sessionService: NapcatWebuiGatewaySessionService,
private readonly credentialClient: NapcatWebuiCredentialClient,
) {}
/**
* Proxies one HTTP request to the active session's NapCat WebUI.
* @param sessionId - Gateway session id from the public route.
* @param proxyPath - Route tail mapped to the upstream pathname.
* @param req - Express request delegated from the public controller.
* @param res - Express response owned by HPM after delegation.
* @param next - Express next callback used by HPM.
*/
async handleHttpProxy(
sessionId: string,
proxyPath: ProxyPathInput,
req: Request,
res: Response,
next: NextFunction,
) {
const session = await this.sessionService.requireProxySession(sessionId);
const upstreamPath = this.buildUpstreamPath(proxyPath, req.originalUrl);
const credential = await this.credentialClient.getCredential(session);
this.stripBrowserHeaders(req);
req.url = upstreamPath;
const proxy = this.createProxy(session, credential);
return proxy(req, res, next);
}
/**
* Subscribes the Gateway HTTP server to NapCat WebUI WebSocket upgrades.
* @param server - HTTP server returned by the Nest application.
*/
bindWebSocketUpgrade(server: Server) {
server.on('upgrade', (req, socket, head) => {
void this.handleWebSocketUpgrade(req, socket as Socket, head);
});
}
/**
* Handles one matching WebSocket upgrade and ignores unrelated upgrade URLs.
* @param req - Raw Node upgrade request.
* @param socket - TCP socket for the upgrade.
* @param head - First packet of the upgraded stream.
*/
private async handleWebSocketUpgrade(
req: IncomingMessage,
socket: Socket,
head: Buffer,
) {
try {
const match = this.matchGatewayUpgrade(req.url || '');
if (!match) return;
const session = await this.sessionService.requireProxySession(
match.sessionId,
);
const credential = await this.credentialClient.getCredential(session);
this.stripBrowserHeaders(req);
req.url = `${match.proxyPath}${match.search}`;
const proxy = this.createProxy(session, credential);
proxy.upgrade(req, socket, head);
} catch {
this.rejectUpgrade(socket);
}
}
/**
* Creates one HPM proxy bound to a validated session and server-side credential.
* @param session - Active Gateway session metadata.
* @param credential - NapCat WebUI Credential for upstream Authorization.
* @returns HPM request handler with HTTP and WebSocket support.
*/
private createProxy(
session: NapcatWebuiGatewaySession,
credential: string,
): RequestHandler<Request, Response, NextFunction> {
return createProxyMiddleware<Request, Response, NextFunction>({
changeOrigin: true,
cookiePathRewrite: buildGatewayCookiePathRewrite({
sessionId: session.sessionId,
}),
on: {
error: (_error, _req, res) => {
this.writeProxyError(res);
},
proxyReq: (proxyReq) => {
proxyReq.removeHeader('cookie');
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
},
proxyReqWs: (proxyReq) => {
proxyReq.removeHeader('cookie');
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
},
proxyRes: (proxyRes) => {
const location = proxyRes.headers.location;
if (typeof location === 'string') {
proxyRes.headers.location = rewriteNapcatLocationHeader({
location,
sessionId: session.sessionId,
upstreamBaseUrl: session.upstreamBaseUrl,
});
}
},
},
secure: false,
target: session.upstreamBaseUrl,
ws: true,
});
}
/**
* Builds the upstream URL path while preserving the original query string.
* @param proxyPath - Gateway route tail to sanitize.
* @param originalUrl - Original Express URL containing the query string.
* @returns Upstream path plus query string.
*/
private buildUpstreamPath(proxyPath: ProxyPathInput, originalUrl?: string) {
const pathname = sanitizeGatewayProxyPath(proxyPath);
const queryIndex = String(originalUrl || '').indexOf('?');
const query = queryIndex >= 0 ? String(originalUrl).slice(queryIndex) : '';
return `${pathname}${query}`;
}
/**
* Parses a public WebSocket upgrade URL into Gateway session and upstream path.
* @param rawUrl - Raw URL from the Node upgrade request.
* @returns Parsed session id, sanitized upstream path, and query string when matched.
*/
private matchGatewayUpgrade(rawUrl: string) {
const url = new URL(rawUrl, 'http://gateway.local');
const match = url.pathname.match(
/^\/napcat-webui\/session\/([^/]+)\/webui(?:\/(.*))?$/,
);
if (!match) return undefined;
return {
proxyPath: sanitizeGatewayProxyPath(match[2] || ''),
search: url.search,
sessionId: decodeURIComponent(match[1]),
};
}
/**
* Removes browser-provided auth/session headers before HPM builds upstream requests.
* @param req - Express or Node request whose headers are being proxied.
*/
private stripBrowserHeaders(req: IncomingMessage) {
STRIPPED_UPSTREAM_HEADERS.forEach((header) => {
delete req.headers[header];
});
}
/**
* Writes a generic HTTP proxy failure without exposing upstream target data.
* @param res - HTTP response object passed by HPM.
*/
private writeProxyError(res: Response | Socket) {
if ('headersSent' in res) {
if (res.headersSent) return;
res.status(HttpStatus.BAD_GATEWAY).json({
message: 'NapCat WebUI proxy failed',
statusCode: HttpStatus.BAD_GATEWAY,
});
return;
}
this.rejectUpgrade(res);
}
/**
* Sends a compact HTTP error for failed WebSocket upgrade validation.
* @param socket - Upgrade socket to close after the error response.
*/
private rejectUpgrade(socket: Socket) {
if (socket.writable) {
socket.write(
'HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
);
}
socket.destroy();
}
}

View File

@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { json, urlencoded } from 'express';
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module';
/**
@ -15,6 +16,7 @@ async function bootstrap() {
app.use(json({ limit: '64kb' }));
app.use(urlencoded({ extended: true, limit: '64kb' }));
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer());
}
bootstrap();

View File

@ -6,9 +6,12 @@ import { createPinoLoggerParams } from '@/common';
import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service';
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
import { NAPCAT_WEBUI_GATEWAY_SESSION_STORE } from './domain/napcat-webui-gateway.types';
import { NapcatWebuiCredentialClient } from './infrastructure/napcat-webui-credential.client';
import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service';
import { NapcatWebuiGatewayRedisStore } from './infrastructure/session/napcat-webui-gateway-redis.store';
import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service';
import { InternalSessionController } from './presentation/internal-session.controller';
import { PublicWebuiController } from './presentation/public-webui.controller';
@Module({
imports: [
@ -44,9 +47,11 @@ import { InternalSessionController } from './presentation/internal-session.contr
},
}),
],
controllers: [InternalSessionController],
controllers: [InternalSessionController, PublicWebuiController],
providers: [
NapcatWebuiGatewayConfigService,
NapcatWebuiCredentialClient,
NapcatWebuiProxyService,
NapcatWebuiGatewaySessionService,
NapcatWebuiGatewayRedisStore,
NapcatWebuiGatewayTicketService,

View File

@ -13,6 +13,7 @@ import type {
NapcatWebuiGatewayCreateSessionInput,
NapcatWebuiGatewayLifecycleInput,
} from '../domain/napcat-webui-gateway.types';
import { NapcatWebuiCredentialClient } from '../infrastructure/napcat-webui-credential.client';
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput;
@ -24,11 +25,13 @@ export class InternalSessionController {
* Creates the internal API-to-Gateway session controller.
* @param sessionService - Gateway session lifecycle application service.
* @param ticketService - One-time bootstrap ticket service.
* @param credentialClient - Server-side credential cache cleared on revoke.
* @param config - Gateway config used for secret validation and public URL prefix.
*/
constructor(
private readonly sessionService: NapcatWebuiGatewaySessionService,
private readonly ticketService: NapcatWebuiGatewayTicketService,
private readonly credentialClient: NapcatWebuiCredentialClient,
private readonly config: NapcatWebuiGatewayConfigService,
) {}
@ -91,16 +94,18 @@ export class InternalSessionController {
* @returns Browser-safe revoke lifecycle result.
*/
@Post('sessions/:sessionId/revoke')
revoke(
async revoke(
@Param('sessionId') sessionId: string,
@Headers('x-kt-gateway-secret') secret: string,
@Body() body: LifecycleBody,
) {
this.requireInternalSecret(secret);
return this.sessionService.revoke({
const result = await this.sessionService.revoke({
...body,
sessionId,
});
this.credentialClient.clear(sessionId);
return result;
}
/**

View File

@ -0,0 +1,101 @@
import {
All,
Controller,
Get,
GoneException,
HttpStatus,
Next,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { NapcatWebuiGatewaySessionService } from '../application/napcat-webui-gateway-session.service';
import { NapcatWebuiProxyService } from '../infrastructure/proxy/napcat-webui-proxy.service';
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
@Controller('napcat-webui')
export class PublicWebuiController {
/**
* Creates the browser-facing NapCat WebUI controller.
* @param sessionService - Gateway session lifecycle service.
* @param ticketService - One-time bootstrap ticket service.
* @param proxyService - HTTP/WebSocket proxy delegation service.
*/
constructor(
private readonly sessionService: NapcatWebuiGatewaySessionService,
private readonly ticketService: NapcatWebuiGatewayTicketService,
private readonly proxyService: NapcatWebuiProxyService,
) {}
/**
* Redeems a one-time bootstrap ticket, activates the session, and enters WebUI.
* @param sessionId - Gateway session id from the public route.
* @param ticket - One-time ticket issued by the internal create-session endpoint.
* @param res - Express response used to set the scoped cookie and redirect.
*/
@Get('session/:sessionId/bootstrap')
async bootstrap(
@Param('sessionId') sessionId: string,
@Query('ticket') ticket: string,
@Res() res: Response,
) {
const redeemedSessionId = await this.ticketService.redeem(
this.requireTicket(ticket),
);
if (redeemedSessionId !== sessionId) {
throw new GoneException('Gateway bootstrap ticket is not active');
}
await this.sessionService.requireBootstrapSession(sessionId);
await this.sessionService.markActive(sessionId);
res.cookie('kt_napcat_webui_gateway', 'active', {
httpOnly: true,
path: `/napcat-webui/session/${encodeURIComponent(sessionId)}`,
sameSite: 'lax',
});
res.redirect(
HttpStatus.FOUND,
`/napcat-webui/session/${encodeURIComponent(sessionId)}/webui/webui`,
);
}
/**
* Delegates Gateway-owned WebUI HTTP routes to the proxy service.
* @param sessionId - Gateway session id from the public route.
* @param proxyPath - Path-to-regexp route tail for the upstream pathname.
* @param req - Express request passed through to HPM.
* @param res - Express response passed through to HPM.
* @param next - Express next callback passed through to HPM.
*/
@All('session/:sessionId/webui/*proxyPath')
proxy(
@Param('sessionId') sessionId: string,
@Param('proxyPath') proxyPath: string | string[] | undefined,
@Req() req: Request,
@Res() res: Response,
@Next() next: NextFunction,
) {
return this.proxyService.handleHttpProxy(
sessionId,
proxyPath,
req,
res,
next,
);
}
/**
* Requires a non-empty bootstrap ticket before touching the ticket store.
* @param ticket - Candidate query ticket.
* @returns Trimmed ticket value.
*/
private requireTicket(ticket: string) {
const value = String(ticket || '').trim();
if (!value) {
throw new GoneException('Gateway bootstrap ticket is not active');
}
return value;
}
}

View File

@ -0,0 +1,302 @@
import { createHash } from 'node:crypto';
import { HttpStatus, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import axios from 'axios';
import * as request from 'supertest';
import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service';
import type { NapcatWebuiGatewaySession } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service';
import {
buildGatewayCookiePathRewrite,
NapcatWebuiProxyService,
rewriteNapcatLocationHeader,
sanitizeGatewayProxyPath,
} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
import { PublicWebuiController } from '../../../src/apps/napcat-webui-gateway/presentation/public-webui.controller';
jest.mock('axios');
const SESSION_ID = 'session-1';
const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099';
const mockedAxiosPost = axios.post as jest.Mock;
/**
* Creates server-only Gateway session metadata for public bootstrap tests.
* @param override - Session fields that should replace the default fixture.
* @returns Gateway session fixture.
*/
function createGatewaySession(
override: Partial<NapcatWebuiGatewaySession> = {},
): NapcatWebuiGatewaySession {
return {
accountId: 'account-1',
adminUserId: 'admin-1',
containerId: 'container-1',
containerName: 'kt-qqbot-napcat-1914728559',
createdAt: 1000,
expiresAt: 61_000,
selfId: '1914728559',
sessionId: SESSION_ID,
status: 'created',
upstreamBaseUrl: UPSTREAM_BASE_URL,
webuiToken: ['webui', 'token', 'fixture'].join('-'),
...override,
};
}
/**
* Creates a Gateway config fixture for credential client tests.
* @param currentTime - Mutable timestamp source used to check cache expiry decisions.
* @returns Config facade shape required by NapcatWebuiCredentialClient.
*/
function createCredentialConfig(currentTime: { value: number }) {
return {
now: () => currentTime.value,
upstreamTimeoutMs: () => 5000,
};
}
describe('Napcat WebUI proxy rewrite helpers', () => {
it('rejects absolute URL proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow(
'Gateway proxy path is invalid',
);
});
it('rejects dot-segment traversal proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('../api/auth/login')).toThrow(
'Gateway proxy path is invalid',
);
});
it('rejects encoded dot-segment traversal proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('%2e%2e/api/auth/login')).toThrow(
'Gateway proxy path is invalid',
);
});
it('rejects double-encoded dot-segment traversal proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('%252e%252e/api/auth/login')).toThrow(
'Gateway proxy path is invalid',
);
});
it('normalizes string proxy paths to absolute upstream paths', () => {
expect(sanitizeGatewayProxyPath('api/QQLogin/CheckLoginStatus')).toBe(
'/api/QQLogin/CheckLoginStatus',
);
});
it('normalizes path-to-regexp array proxy paths to absolute upstream paths', () => {
expect(
sanitizeGatewayProxyPath(['api', 'QQLogin', 'CheckLoginStatus']),
).toBe('/api/QQLogin/CheckLoginStatus');
});
it('rewrites NapCat relative redirects under the Gateway session prefix', () => {
expect(
rewriteNapcatLocationHeader({
location: '/webui/login',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
});
it('rewrites absolute redirects under the Gateway session prefix without leaking origin', () => {
expect(
rewriteNapcatLocationHeader({
location: 'http://127.0.0.1:6099/webui/login?next=/',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
);
expect(
rewriteNapcatLocationHeader({
location: 'http://container.internal:6099/webui/login',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
});
it('rewrites protocol-relative redirects under the Gateway session prefix without leaking origin', () => {
expect(
rewriteNapcatLocationHeader({
location: '//container.internal:6099/webui/login?next=/',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
);
});
it('fails closed for malformed absolute redirects', () => {
expect(
rewriteNapcatLocationHeader({
location: 'http://%',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
expect(
rewriteNapcatLocationHeader({
location: '//%',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`);
});
it('scopes all upstream cookies to the Gateway WebUI path', () => {
expect(buildGatewayCookiePathRewrite({ sessionId: SESSION_ID })).toEqual({
'*': `/napcat-webui/session/${SESSION_ID}/webui`,
});
});
});
describe('NapcatWebuiCredentialClient', () => {
beforeEach(() => {
mockedAxiosPost.mockReset();
});
it('exchanges the WebUI token hash and caches the server-side Credential until session expiry', async () => {
const currentTime = { value: 1000 };
const client = new NapcatWebuiCredentialClient(
createCredentialConfig(currentTime) as never,
);
mockedAxiosPost.mockResolvedValue({ data: { Credential: 'credential-1' } });
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
'credential-1',
);
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
'credential-1',
);
expect(mockedAxiosPost).toHaveBeenCalledTimes(1);
expect(mockedAxiosPost).toHaveBeenCalledWith(
`${UPSTREAM_BASE_URL}/api/auth/login`,
{
hash: createHash('sha256')
.update('webui-token-fixture.napcat')
.digest('hex'),
},
{ timeout: 5000 },
);
});
it('clears cached Credential when the Gateway session is revoked', async () => {
const currentTime = { value: 1000 };
const client = new NapcatWebuiCredentialClient(
createCredentialConfig(currentTime) as never,
);
mockedAxiosPost
.mockResolvedValueOnce({ data: { Credential: 'credential-1' } })
.mockResolvedValueOnce({ data: { Credential: 'credential-2' } });
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
'credential-1',
);
client.clear(SESSION_ID);
await expect(client.getCredential(createGatewaySession())).resolves.toBe(
'credential-2',
);
expect(mockedAxiosPost).toHaveBeenCalledTimes(2);
});
});
describe('PublicWebuiController bootstrap', () => {
let app: INestApplication;
let ticketService: { redeem: jest.Mock };
let sessionService: {
markActive: jest.Mock;
requireBootstrapSession: jest.Mock;
};
beforeAll(async () => {
ticketService = {
redeem: jest.fn(),
};
sessionService = {
markActive: jest.fn(),
requireBootstrapSession: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
controllers: [PublicWebuiController],
providers: [
{
provide: NapcatWebuiGatewaySessionService,
useValue: sessionService,
},
{
provide: NapcatWebuiGatewayTicketService,
useValue: ticketService,
},
{
provide: NapcatWebuiProxyService,
useValue: {
handleHttpProxy: jest.fn(),
},
},
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
beforeEach(() => {
ticketService.redeem.mockReset();
sessionService.markActive.mockReset();
sessionService.requireBootstrapSession.mockReset();
});
afterAll(async () => {
await app?.close();
});
it('redeems a ticket, activates the session, sets an HttpOnly cookie, and redirects to WebUI', async () => {
ticketService.redeem.mockResolvedValue(SESSION_ID);
sessionService.requireBootstrapSession.mockResolvedValue(
createGatewaySession(),
);
sessionService.markActive.mockResolvedValue(
createGatewaySession({ status: 'active' }),
);
const response = await request(app.getHttpServer())
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=ticket-1`)
.expect(HttpStatus.FOUND);
expect(ticketService.redeem).toHaveBeenCalledWith('ticket-1');
expect(sessionService.requireBootstrapSession).toHaveBeenCalledWith(
SESSION_ID,
);
expect(sessionService.markActive).toHaveBeenCalledWith(SESSION_ID);
expect(response.headers.location).toBe(
`/napcat-webui/session/${SESSION_ID}/webui/webui`,
);
expect(response.headers['set-cookie']).toEqual([
expect.stringContaining(`Path=/napcat-webui/session/${SESSION_ID}`),
]);
expect(response.headers['set-cookie'][0]).toContain('HttpOnly');
});
it('rejects expired tickets without activating the session', async () => {
ticketService.redeem.mockResolvedValue(undefined);
await request(app.getHttpServer())
.get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=expired`)
.expect(HttpStatus.GONE);
expect(sessionService.requireBootstrapSession).not.toHaveBeenCalled();
expect(sessionService.markActive).not.toHaveBeenCalled();
});
});

View File

@ -8,6 +8,7 @@ import {
type NapcatWebuiGatewaySession,
type NapcatWebuiGatewaySessionStore,
} from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client';
import { NapcatWebuiGatewayRedisStore } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store';
import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service';
import { InternalSessionController } from '../../../src/apps/napcat-webui-gateway/presentation/internal-session.controller';
@ -60,10 +61,7 @@ class MemorySessionStore implements NapcatWebuiGatewaySessionStore {
* @param patch - Fields to merge into the stored session.
* @returns Updated session.
*/
async update(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
) {
async update(sessionId: string, patch: Partial<NapcatWebuiGatewaySession>) {
const current = this.sessions.get(sessionId);
if (!current) throw new Error(`Missing session ${sessionId}`);
const next = { ...current, ...patch };
@ -160,20 +158,12 @@ class FakeRedis {
throw new Error('Unexpected Redis key count');
}
const [
sessionKey,
sessionId,
patchJson,
userAccountKeyPrefix,
now,
] = args;
const [sessionKey, sessionId, 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 patch = JSON.parse(
patchJson,
) as Partial<NapcatWebuiGatewaySession>;
const patch = JSON.parse(patchJson) as Partial<NapcatWebuiGatewaySession>;
const next = {
...current,
...patch,
@ -235,7 +225,9 @@ class FakeRedis {
* @returns Session creation payload.
*/
function createSessionInput(
override: Partial<Parameters<NapcatWebuiGatewaySessionService['create']>[0]> = {},
override: Partial<
Parameters<NapcatWebuiGatewaySessionService['create']>[0]
> = {},
) {
return {
accountId: 'account-1',
@ -368,7 +360,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
service.create(createSessionInput({ webuiToken: ' ' })),
).rejects.toThrow('Gateway session field webuiToken is required');
await expect(
service.create(createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' })),
service.create(
createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' }),
),
).rejects.toThrow('Gateway session upstream URL is invalid');
expect(store.sessions.size).toBe(0);
});
@ -425,9 +419,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
);
const session = await service.create(createSessionInput());
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.requireProxySession(session.sessionId),
).rejects.toThrow('Gateway session is not active');
await expect(
service.requireBootstrapSession(session.sessionId),
).resolves.toMatchObject({
@ -458,9 +452,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
currentTime.value = 70_000;
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.requireProxySession(session.sessionId),
).rejects.toThrow('Gateway session is not active');
expect(await store.find(session.sessionId)).toMatchObject({
status: 'expired',
});
@ -479,9 +473,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
sessionId: session.sessionId,
});
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.requireProxySession(session.sessionId),
).rejects.toThrow('Gateway session is not active');
});
});
@ -540,9 +534,9 @@ describe('NapcatWebuiGatewayRedisStore', () => {
sessionId: second.sessionId,
status: 'created',
});
expect(redis.values.get('napcat:webui:user-account:admin-1:account-1')).toBe(
second.sessionId,
);
expect(
redis.values.get('napcat:webui:user-account:admin-1:account-1'),
).toBe(second.sessionId);
});
it('rejects stale non-terminal updates when the index points at a newer session', async () => {
@ -579,9 +573,11 @@ describe('NapcatWebuiGatewayRedisStore', () => {
}),
).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',
});
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject(
{
status: 'created',
},
);
});
it('rejects non-terminal updates when the user-account index is missing', async () => {
@ -766,6 +762,9 @@ describe('NapcatWebuiGatewayTicketService', () => {
describe('InternalSessionController', () => {
let app: INestApplication;
const credentialClient = {
clear: jest.fn(),
};
const store = new MemorySessionStore();
const currentTime = { value: 1000 };
const config = createConfig(currentTime);
@ -780,6 +779,10 @@ describe('InternalSessionController', () => {
provide: NapcatWebuiGatewayConfigService,
useValue: config,
},
{
provide: NapcatWebuiCredentialClient,
useValue: credentialClient,
},
{
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
useValue: store,
@ -796,6 +799,7 @@ describe('InternalSessionController', () => {
});
beforeEach(() => {
credentialClient.clear.mockReset();
store.sessions.clear();
currentTime.value = 1000;
});
@ -892,6 +896,7 @@ describe('InternalSessionController', () => {
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: 'admin-1' })
.expect(HttpStatus.CREATED);
expect(credentialClient.clear).toHaveBeenCalledWith(sessionId);
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/heartbeat`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)