feat: 实现NapCat WebUI Gateway代理
This commit is contained in:
parent
52d96294ed
commit
d0fbc13763
@ -5,6 +5,7 @@ const DEFAULT_GATEWAY_PORT = 48086;
|
|||||||
const DEFAULT_REDIS_HOST = '127.0.0.1';
|
const DEFAULT_REDIS_HOST = '127.0.0.1';
|
||||||
const DEFAULT_REDIS_PORT = 6379;
|
const DEFAULT_REDIS_PORT = 6379;
|
||||||
const DEFAULT_SESSION_TTL_MS = 60_000;
|
const DEFAULT_SESSION_TTL_MS = 60_000;
|
||||||
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 5000;
|
||||||
const MAX_TICKET_TTL_MS = 60_000;
|
const MAX_TICKET_TTL_MS = 60_000;
|
||||||
|
|
||||||
@Injectable()
|
@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.
|
* 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.
|
* @returns Trimmed secret or an empty string when missing so callers fail closed.
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { json, urlencoded } from 'express';
|
import { json, urlencoded } from 'express';
|
||||||
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
|
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';
|
import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,6 +16,7 @@ async function bootstrap() {
|
|||||||
app.use(json({ limit: '64kb' }));
|
app.use(json({ limit: '64kb' }));
|
||||||
app.use(urlencoded({ extended: true, limit: '64kb' }));
|
app.use(urlencoded({ extended: true, limit: '64kb' }));
|
||||||
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
||||||
|
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@ -6,9 +6,12 @@ import { createPinoLoggerParams } from '@/common';
|
|||||||
import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service';
|
import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service';
|
||||||
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
|
import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service';
|
||||||
import { NAPCAT_WEBUI_GATEWAY_SESSION_STORE } from './domain/napcat-webui-gateway.types';
|
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 { NapcatWebuiGatewayRedisStore } from './infrastructure/session/napcat-webui-gateway-redis.store';
|
||||||
import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service';
|
import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||||
import { InternalSessionController } from './presentation/internal-session.controller';
|
import { InternalSessionController } from './presentation/internal-session.controller';
|
||||||
|
import { PublicWebuiController } from './presentation/public-webui.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -44,9 +47,11 @@ import { InternalSessionController } from './presentation/internal-session.contr
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [InternalSessionController],
|
controllers: [InternalSessionController, PublicWebuiController],
|
||||||
providers: [
|
providers: [
|
||||||
NapcatWebuiGatewayConfigService,
|
NapcatWebuiGatewayConfigService,
|
||||||
|
NapcatWebuiCredentialClient,
|
||||||
|
NapcatWebuiProxyService,
|
||||||
NapcatWebuiGatewaySessionService,
|
NapcatWebuiGatewaySessionService,
|
||||||
NapcatWebuiGatewayRedisStore,
|
NapcatWebuiGatewayRedisStore,
|
||||||
NapcatWebuiGatewayTicketService,
|
NapcatWebuiGatewayTicketService,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
NapcatWebuiGatewayCreateSessionInput,
|
NapcatWebuiGatewayCreateSessionInput,
|
||||||
NapcatWebuiGatewayLifecycleInput,
|
NapcatWebuiGatewayLifecycleInput,
|
||||||
} from '../domain/napcat-webui-gateway.types';
|
} from '../domain/napcat-webui-gateway.types';
|
||||||
|
import { NapcatWebuiCredentialClient } from '../infrastructure/napcat-webui-credential.client';
|
||||||
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
|
import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service';
|
||||||
|
|
||||||
type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput;
|
type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput;
|
||||||
@ -24,11 +25,13 @@ export class InternalSessionController {
|
|||||||
* Creates the internal API-to-Gateway session controller.
|
* Creates the internal API-to-Gateway session controller.
|
||||||
* @param sessionService - Gateway session lifecycle application service.
|
* @param sessionService - Gateway session lifecycle application service.
|
||||||
* @param ticketService - One-time bootstrap ticket 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.
|
* @param config - Gateway config used for secret validation and public URL prefix.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
private readonly sessionService: NapcatWebuiGatewaySessionService,
|
||||||
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
private readonly ticketService: NapcatWebuiGatewayTicketService,
|
||||||
|
private readonly credentialClient: NapcatWebuiCredentialClient,
|
||||||
private readonly config: NapcatWebuiGatewayConfigService,
|
private readonly config: NapcatWebuiGatewayConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -91,16 +94,18 @@ export class InternalSessionController {
|
|||||||
* @returns Browser-safe revoke lifecycle result.
|
* @returns Browser-safe revoke lifecycle result.
|
||||||
*/
|
*/
|
||||||
@Post('sessions/:sessionId/revoke')
|
@Post('sessions/:sessionId/revoke')
|
||||||
revoke(
|
async revoke(
|
||||||
@Param('sessionId') sessionId: string,
|
@Param('sessionId') sessionId: string,
|
||||||
@Headers('x-kt-gateway-secret') secret: string,
|
@Headers('x-kt-gateway-secret') secret: string,
|
||||||
@Body() body: LifecycleBody,
|
@Body() body: LifecycleBody,
|
||||||
) {
|
) {
|
||||||
this.requireInternalSecret(secret);
|
this.requireInternalSecret(secret);
|
||||||
return this.sessionService.revoke({
|
const result = await this.sessionService.revoke({
|
||||||
...body,
|
...body,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
this.credentialClient.clear(sessionId);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
302
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal file
302
test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type NapcatWebuiGatewaySession,
|
type NapcatWebuiGatewaySession,
|
||||||
type NapcatWebuiGatewaySessionStore,
|
type NapcatWebuiGatewaySessionStore,
|
||||||
} from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types';
|
} 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 { 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 { 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';
|
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.
|
* @param patch - Fields to merge into the stored session.
|
||||||
* @returns Updated session.
|
* @returns Updated session.
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(sessionId: string, patch: Partial<NapcatWebuiGatewaySession>) {
|
||||||
sessionId: string,
|
|
||||||
patch: Partial<NapcatWebuiGatewaySession>,
|
|
||||||
) {
|
|
||||||
const current = this.sessions.get(sessionId);
|
const current = this.sessions.get(sessionId);
|
||||||
if (!current) throw new Error(`Missing session ${sessionId}`);
|
if (!current) throw new Error(`Missing session ${sessionId}`);
|
||||||
const next = { ...current, ...patch };
|
const next = { ...current, ...patch };
|
||||||
@ -160,20 +158,12 @@ class FakeRedis {
|
|||||||
throw new Error('Unexpected Redis key count');
|
throw new Error('Unexpected Redis key count');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const [sessionKey, sessionId, patchJson, userAccountKeyPrefix, now] = args;
|
||||||
sessionKey,
|
|
||||||
sessionId,
|
|
||||||
patchJson,
|
|
||||||
userAccountKeyPrefix,
|
|
||||||
now,
|
|
||||||
] = args;
|
|
||||||
const currentJson = this.values.get(sessionKey);
|
const currentJson = this.values.get(sessionKey);
|
||||||
if (!currentJson) return [0, 'Gateway session is not active'];
|
if (!currentJson) return [0, 'Gateway session is not active'];
|
||||||
|
|
||||||
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
|
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
|
||||||
const patch = JSON.parse(
|
const patch = JSON.parse(patchJson) as Partial<NapcatWebuiGatewaySession>;
|
||||||
patchJson,
|
|
||||||
) as Partial<NapcatWebuiGatewaySession>;
|
|
||||||
const next = {
|
const next = {
|
||||||
...current,
|
...current,
|
||||||
...patch,
|
...patch,
|
||||||
@ -235,7 +225,9 @@ class FakeRedis {
|
|||||||
* @returns Session creation payload.
|
* @returns Session creation payload.
|
||||||
*/
|
*/
|
||||||
function createSessionInput(
|
function createSessionInput(
|
||||||
override: Partial<Parameters<NapcatWebuiGatewaySessionService['create']>[0]> = {},
|
override: Partial<
|
||||||
|
Parameters<NapcatWebuiGatewaySessionService['create']>[0]
|
||||||
|
> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
accountId: 'account-1',
|
accountId: 'account-1',
|
||||||
@ -368,7 +360,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
|||||||
service.create(createSessionInput({ webuiToken: ' ' })),
|
service.create(createSessionInput({ webuiToken: ' ' })),
|
||||||
).rejects.toThrow('Gateway session field webuiToken is required');
|
).rejects.toThrow('Gateway session field webuiToken is required');
|
||||||
await expect(
|
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');
|
).rejects.toThrow('Gateway session upstream URL is invalid');
|
||||||
expect(store.sessions.size).toBe(0);
|
expect(store.sessions.size).toBe(0);
|
||||||
});
|
});
|
||||||
@ -425,9 +419,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
|||||||
);
|
);
|
||||||
const session = await service.create(createSessionInput());
|
const session = await service.create(createSessionInput());
|
||||||
|
|
||||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
await expect(
|
||||||
'Gateway session is not active',
|
service.requireProxySession(session.sessionId),
|
||||||
);
|
).rejects.toThrow('Gateway session is not active');
|
||||||
await expect(
|
await expect(
|
||||||
service.requireBootstrapSession(session.sessionId),
|
service.requireBootstrapSession(session.sessionId),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@ -458,9 +452,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
|||||||
|
|
||||||
currentTime.value = 70_000;
|
currentTime.value = 70_000;
|
||||||
|
|
||||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
await expect(
|
||||||
'Gateway session is not active',
|
service.requireProxySession(session.sessionId),
|
||||||
);
|
).rejects.toThrow('Gateway session is not active');
|
||||||
expect(await store.find(session.sessionId)).toMatchObject({
|
expect(await store.find(session.sessionId)).toMatchObject({
|
||||||
status: 'expired',
|
status: 'expired',
|
||||||
});
|
});
|
||||||
@ -479,9 +473,9 @@ describe('NapcatWebuiGatewaySessionService', () => {
|
|||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
|
await expect(
|
||||||
'Gateway session is not active',
|
service.requireProxySession(session.sessionId),
|
||||||
);
|
).rejects.toThrow('Gateway session is not active');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -540,9 +534,9 @@ describe('NapcatWebuiGatewayRedisStore', () => {
|
|||||||
sessionId: second.sessionId,
|
sessionId: second.sessionId,
|
||||||
status: 'created',
|
status: 'created',
|
||||||
});
|
});
|
||||||
expect(redis.values.get('napcat:webui:user-account:admin-1:account-1')).toBe(
|
expect(
|
||||||
second.sessionId,
|
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 () => {
|
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');
|
).rejects.toThrow('Gateway session is not active');
|
||||||
expect(redis.values.get(indexKey)).toBe(second.sessionId);
|
expect(redis.values.get(indexKey)).toBe(second.sessionId);
|
||||||
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject({
|
expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject(
|
||||||
|
{
|
||||||
status: 'created',
|
status: 'created',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-terminal updates when the user-account index is missing', async () => {
|
it('rejects non-terminal updates when the user-account index is missing', async () => {
|
||||||
@ -766,6 +762,9 @@ describe('NapcatWebuiGatewayTicketService', () => {
|
|||||||
|
|
||||||
describe('InternalSessionController', () => {
|
describe('InternalSessionController', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
const credentialClient = {
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
const store = new MemorySessionStore();
|
const store = new MemorySessionStore();
|
||||||
const currentTime = { value: 1000 };
|
const currentTime = { value: 1000 };
|
||||||
const config = createConfig(currentTime);
|
const config = createConfig(currentTime);
|
||||||
@ -780,6 +779,10 @@ describe('InternalSessionController', () => {
|
|||||||
provide: NapcatWebuiGatewayConfigService,
|
provide: NapcatWebuiGatewayConfigService,
|
||||||
useValue: config,
|
useValue: config,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: NapcatWebuiCredentialClient,
|
||||||
|
useValue: credentialClient,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
||||||
useValue: store,
|
useValue: store,
|
||||||
@ -796,6 +799,7 @@ describe('InternalSessionController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
credentialClient.clear.mockReset();
|
||||||
store.sessions.clear();
|
store.sessions.clear();
|
||||||
currentTime.value = 1000;
|
currentTime.value = 1000;
|
||||||
});
|
});
|
||||||
@ -892,6 +896,7 @@ describe('InternalSessionController', () => {
|
|||||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||||
.send({ adminUserId: 'admin-1' })
|
.send({ adminUserId: 'admin-1' })
|
||||||
.expect(HttpStatus.CREATED);
|
.expect(HttpStatus.CREATED);
|
||||||
|
expect(credentialClient.clear).toHaveBeenCalledWith(sessionId);
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
.post(`/internal/sessions/${sessionId}/heartbeat`)
|
||||||
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
.set('x-kt-gateway-secret', INTERNAL_SECRET)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user