fix: harden backend production deployment #2

Merged
xiangwang25 merged 3 commits from fix/pr-1-production-hardening into main 2026-08-01 02:32:06 +08:00
11 changed files with 243 additions and 14 deletions

View File

@@ -66,7 +66,8 @@ ZULIP_MESSAGE_MAX_LENGTH=10000
ZULIP_CONTENT_FILTER_ENABLED=true ZULIP_CONTENT_FILTER_ENABLED=true
# Realtime server # Realtime server
WEBSOCKET_PORT=3000 # The native WebSocket server listens separately from the REST API.
WEBSOCKET_PORT=3001
WEBSOCKET_NAMESPACE=/game WEBSOCKET_NAMESPACE=/game
WEBSOCKET_PING_INTERVAL=25000 WEBSOCKET_PING_INTERVAL=25000
WEBSOCKET_PING_TIMEOUT=5000 WEBSOCKET_PING_TIMEOUT=5000

View File

@@ -46,7 +46,8 @@ ZULIP_DEGRADED_MODE_ENABLED=true
ZULIP_AUTO_RECONNECT_ENABLED=true ZULIP_AUTO_RECONNECT_ENABLED=true
# Realtime and generated assets # Realtime and generated assets
WEBSOCKET_PORT=3000 # The native WebSocket server listens separately from the REST API.
WEBSOCKET_PORT=3001
WEBSOCKET_NAMESPACE=/game WEBSOCKET_NAMESPACE=/game
ACCOUNT_ASSET_DIR=generated/account-assets ACCOUNT_ASSET_DIR=generated/account-assets
SKIN_GENERATION_OUTPUT_DIR=generated/skins SKIN_GENERATION_OUTPUT_DIR=generated/skins

113
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,113 @@
# WhaleTown End V2 部署
本文档覆盖 NestJS API、原生 WebSocket 服务和 React 管理端的单机部署。示例域名和目录与 `deploy/nginx` 中的模板一致,可按实际环境替换。
## 1. 环境要求
- Node.js 20 或更高版本
- pnpm 9
- MySQL 8 和 Redis 7
- PM2
- Nginx
- Python 3仅皮肤生成功能需要
生产目录默认为 `/var/www/whale-town-end-v2`。所有命令均在该目录执行。
## 2. 安装与配置
```bash
pnpm install --frozen-lockfile
cp .env.production.example .env
chmod 600 .env
```
编辑 `.env` 并至少完成以下配置:
-`JWT_SECRET``ADMIN_TOKEN_SECRET` 设置独立的随机值。
- 完整设置 `DB_HOST``DB_PORT``DB_USERNAME``DB_PASSWORD``DB_NAME`,避免服务回退到内存存储。
- 完整设置 Redis 连接信息。
- 保持 REST API 使用 `PORT=3000`,聊天 WebSocket 使用 `WEBSOCKET_PORT=3001`
- 使用 Zulip 时设置机器人凭据和至少 32 字节的 `ZULIP_API_KEY_ENCRYPTION_KEY`,并将 `ZULIP_DEGRADED_MODE_ENABLED` 设为 `false`
- 不使用 Zulip 时可将 `ZULIP_DEGRADED_MODE_ENABLED` 设为 `true` 并留空 Zulip 凭据;此时 Zulip 集成和 API Key 加密存取功能不可用。
可分别生成随机密钥:
```bash
openssl rand -hex 32
```
不要把 `.env`、生成的密钥或数据库备份提交到 Git。
## 3. 构建
构建后端:
```bash
pnpm run build
```
配置并构建管理端:
```bash
cp client/.env.example client/.env.local
pnpm --filter whale-town-admin run build
```
确认 `client/.env.local` 中的 `VITE_API_BASE_URL` 指向实际后端 HTTPS 地址。该值在构建时写入管理端产物,修改后需要重新构建。
## 4. 启动服务
```bash
pm2 start ecosystem.config.js
pm2 save
```
服务使用仓库根目录作为工作目录,并从根目录的 `.env` 加载运行配置。查看状态和日志:
```bash
pm2 status
pm2 logs whale-town-end-v2
```
## 5. 配置 Nginx
安装后端和管理端模板:
```bash
sudo cp deploy/nginx/whaletownend-v2.conf.example /etc/nginx/conf.d/whaletownend-v2.conf
sudo cp deploy/nginx/whaletown-admin-v2.conf.example /etc/nginx/conf.d/whaletown-admin-v2.conf
sudo nginx -t
sudo systemctl reload nginx
```
后端模板将 REST API 转发到 `3000`,将 `/game` 转发到独立的聊天 WebSocket 端口 `3001`,并为 `/location-broadcast``/ws/notice` 保留 REST 端口上的 WebSocket Upgrade。上线前还需在 Nginx 或上游代理配置 TLS。
## 6. 验收
```bash
curl --fail https://whaletownend.xinghangee.icu/
curl --fail https://whaletownend.xinghangee.icu/health
curl --fail https://whaletownend.xinghangee.icu/api-docs
```
根接口应返回 `version: 2.0.0`,健康接口应返回 `status: ok`。还应分别验证以下 WebSocket 地址能够完成 `101 Switching Protocols`
- `wss://whaletownend.xinghangee.icu/game`
- `wss://whaletownend.xinghangee.icu/location-broadcast`
- `wss://whaletownend.xinghangee.icu/ws/notice`
最后使用管理端和游戏客户端完成登录、刷新令牌、世界聊天、位置同步和通知的冒烟测试。
## 7. 更新与回滚
更新前备份 `.env` 和数据库,然后执行:
```bash
git pull --ff-only
pnpm install --frozen-lockfile
pnpm run build
pnpm --filter whale-town-admin run build
pm2 reload whale-town-end-v2
```
出现问题时切回上一已验证提交,重新安装锁定依赖并构建,然后执行 `pm2 reload whale-town-end-v2`。数据库结构变更必须使用对应版本的迁移或备份恢复方案,不能只回滚应用代码。

View File

@@ -27,7 +27,7 @@ pnpm run build
pnpm run start:prod pnpm run start:prod
``` ```
启动前至少需要在 `.env` 中设置随机的 `JWT_SECRET``ADMIN_TOKEN_SECRET` `ZULIP_API_KEY_ENCRYPTION_KEY`。生产环境请从 `.env.production.example` 开始配置,不要直接使用示例值。 启动前至少需要在 `.env` 中设置随机的 `JWT_SECRET``ADMIN_TOKEN_SECRET`。启用 Zulip 时还必须设置 `ZULIP_API_KEY_ENCRYPTION_KEY`;若 `ZULIP_DEGRADED_MODE_ENABLED=true`,可以不配置 Zulip 凭据和加密密钥,但 Zulip 集成及 API Key 加密存取功能将不可用。生产环境请从 `.env.production.example` 开始配置,不要直接使用示例值。
API 默认监听 `3000` 端口Swagger 地址为 `/api-docs` API 默认监听 `3000` 端口Swagger 地址为 `/api-docs`
@@ -39,6 +39,10 @@ pnpm --filter whale-town-admin run build
管理端的 API 地址通过 `client/.env.local` 中的 `VITE_API_BASE_URL` 配置。 管理端的 API 地址通过 `client/.env.local` 中的 `VITE_API_BASE_URL` 配置。
## 部署
生产部署、Nginx、PM2、验收和回滚步骤见 [DEPLOYMENT.md](DEPLOYMENT.md)。
## 安全 ## 安全
仓库不包含 `.env`、访问令牌、SSH 私钥、数据库文件、Redis 数据、日志或生成资产。敏感配置必须通过部署环境注入。 仓库不包含 `.env`、访问令牌、SSH 私钥、数据库文件、Redis 数据、日志或生成资产。敏感配置必须通过部署环境注入。

View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name whaletownadmin.xinghangee.icu;
root /var/www/whale-town-end-v2/client/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
try_files $uri =404;
}
}

View File

@@ -0,0 +1,54 @@
server {
listen 80;
server_name whaletownend.xinghangee.icu;
client_max_body_size 24m;
location /game {
proxy_pass http://127.0.0.1:3001/game;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /location-broadcast {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /ws/notice {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -2,6 +2,7 @@ module.exports = {
apps: [ apps: [
{ {
name: 'whale-town-end-v2', name: 'whale-town-end-v2',
cwd: __dirname,
script: 'dist/main.js', script: 'dist/main.js',
instances: 1, instances: 1,
exec_mode: 'cluster', exec_mode: 'cluster',

View File

@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AppStatusResponseDto } from './business/shared'; import { AppStatusResponseDto } from './business/shared';
const packageJson: { version: string } = require('../package.json');
/** /**
* 应用服务类 * 应用服务类
* *
@@ -31,7 +33,7 @@ export class AppService {
return { return {
service: 'Pixel Game Server', service: 'Pixel Game Server',
version: '1.1.1', version: packageJson.version,
status: 'running', status: 'running',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000), uptime: Math.floor((Date.now() - this.startTime) / 1000),

View File

@@ -147,7 +147,7 @@ export class ApiKeySecurityService implements IApiKeySecurityService {
private readonly SECURITY_LOG_RETENTION = 30 * 24 * 3600; // 30天 private readonly SECURITY_LOG_RETENTION = 30 * 24 * 3600; // 30天
// 加密密钥(生产环境应从环境变量或密钥管理服务获取) // 加密密钥(生产环境应从环境变量或密钥管理服务获取)
private readonly encryptionKey: Buffer; private readonly encryptionKey: Buffer | null;
constructor( constructor(
@Inject('REDIS_SERVICE') @Inject('REDIS_SERVICE')
@@ -155,11 +155,21 @@ export class ApiKeySecurityService implements IApiKeySecurityService {
) { ) {
// 加密密钥必须由部署环境提供,不允许回退到共享的固定密钥。 // 加密密钥必须由部署环境提供,不允许回退到共享的固定密钥。
const keyFromEnv = process.env.ZULIP_API_KEY_ENCRYPTION_KEY; const keyFromEnv = process.env.ZULIP_API_KEY_ENCRYPTION_KEY;
const degradedModeEnabled = process.env.ZULIP_DEGRADED_MODE_ENABLED === 'true';
if (!keyFromEnv) { if (!keyFromEnv) {
this.encryptionKey = null;
if (!degradedModeEnabled) {
throw new Error('ZULIP_API_KEY_ENCRYPTION_KEY未配置'); throw new Error('ZULIP_API_KEY_ENCRYPTION_KEY未配置');
} }
this.logger.warn(
'Zulip降级模式已启用且未配置API Key加密密钥API Key加密存取功能不可用',
);
return;
}
// 如果环境变量是十六进制格式使用hex解析否则使用utf8。 // 如果环境变量是十六进制格式使用hex解析否则使用utf8。
if (/^[0-9a-fA-F]+$/.test(keyFromEnv) && keyFromEnv.length === 64) { if (/^[0-9a-fA-F]+$/.test(keyFromEnv) && keyFromEnv.length === 64) {
this.encryptionKey = Buffer.from(keyFromEnv, 'hex'); this.encryptionKey = Buffer.from(keyFromEnv, 'hex');
@@ -736,10 +746,11 @@ export class ApiKeySecurityService implements IApiKeySecurityService {
iv: string; iv: string;
authTag: string; authTag: string;
} { } {
const encryptionKey = this.requireEncryptionKey();
const iv = crypto.randomBytes(this.IV_LENGTH); const iv = crypto.randomBytes(this.IV_LENGTH);
const cipher = crypto.createCipheriv( const cipher = crypto.createCipheriv(
this.ENCRYPTION_ALGORITHM, this.ENCRYPTION_ALGORITHM,
this.encryptionKey, encryptionKey,
iv iv
); );
@@ -764,11 +775,12 @@ export class ApiKeySecurityService implements IApiKeySecurityService {
* @private * @private
*/ */
private decrypt(encryptedData: string, ivHex: string, authTagHex: string): string { private decrypt(encryptedData: string, ivHex: string, authTagHex: string): string {
const encryptionKey = this.requireEncryptionKey();
const iv = Buffer.from(ivHex, 'hex'); const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
this.ENCRYPTION_ALGORITHM, this.ENCRYPTION_ALGORITHM,
this.encryptionKey, encryptionKey,
iv iv
); );
decipher.setAuthTag(authTag); decipher.setAuthTag(authTag);
@@ -779,6 +791,22 @@ export class ApiKeySecurityService implements IApiKeySecurityService {
return decrypted; return decrypted;
} }
/**
* 获取已配置的加密密钥。
*
* 降级模式允许服务在没有密钥时启动但涉及Zulip API Key明文的操作仍必须失败
* 以避免使用临时密钥导致数据在重启后无法解密。
*/
private requireEncryptionKey(): Buffer {
if (!this.encryptionKey) {
throw new Error(
'ZULIP_API_KEY_ENCRYPTION_KEY未配置Zulip API Key加密存取功能不可用',
);
}
return this.encryptionKey;
}
/** /**
* 验证API Key格式 * 验证API Key格式
* *

View File

@@ -360,7 +360,7 @@ export class ConfigManagerService implements OnModuleDestroy {
zulipBotEmail: process.env.ZULIP_BOT_EMAIL || 'bot@example.com', zulipBotEmail: process.env.ZULIP_BOT_EMAIL || 'bot@example.com',
zulipBotApiKey: process.env.ZULIP_BOT_API_KEY || '', zulipBotApiKey: process.env.ZULIP_BOT_API_KEY || '',
websocketPort: parseInt(process.env.WEBSOCKET_PORT || '3000', 10), websocketPort: parseInt(process.env.WEBSOCKET_PORT || '3001', 10),
websocketNamespace: process.env.WEBSOCKET_NAMESPACE || '/game', websocketNamespace: process.env.WEBSOCKET_NAMESPACE || '/game',
messageRateLimit: parseInt(process.env.MESSAGE_RATE_LIMIT || '10', 10), messageRateLimit: parseInt(process.env.MESSAGE_RATE_LIMIT || '10', 10),
@@ -1189,7 +1189,7 @@ export class ConfigManagerService implements OnModuleDestroy {
zulipServerUrl: 'https://your-zulip-server.com', zulipServerUrl: 'https://your-zulip-server.com',
zulipBotEmail: 'bot@example.com', zulipBotEmail: 'bot@example.com',
zulipBotApiKey: '', zulipBotApiKey: '',
websocketPort: 3000, websocketPort: 3001,
websocketNamespace: '/game', websocketNamespace: '/game',
messageRateLimit: 10, messageRateLimit: 10,
messageMaxLength: 1000, messageMaxLength: 1000,

View File

@@ -169,7 +169,7 @@ export const DEFAULT_ZULIP_CONFIG: ZulipConfiguration = {
botApiKey: '', botApiKey: '',
}, },
websocket: { websocket: {
port: 3000, port: 3001,
namespace: '/game', namespace: '/game',
pingInterval: 25000, pingInterval: 25000,
pingTimeout: 5000, pingTimeout: 5000,
@@ -349,12 +349,16 @@ export function validateZulipConfig(
// 生产环境特殊验证 // 生产环境特殊验证
if (isProduction) { if (isProduction) {
if (!config.security.apiKeyEncryptionKey) { if (!config.security.apiKeyEncryptionKey) {
if (config.errorHandling.degradedModeEnabled) {
warnings.push('降级模式未配置API Key加密密钥Zulip API Key加密存取功能不可用');
} else {
errors.push('生产环境必须配置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)'); errors.push('生产环境必须配置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)');
}
} else if (config.security.apiKeyEncryptionKey.length < 32) { } else if (config.security.apiKeyEncryptionKey.length < 32) {
errors.push('API Key加密密钥长度必须至少32字符'); errors.push('API Key加密密钥长度必须至少32字符');
} }
if (!config.server.botApiKey) { if (!config.server.botApiKey && !config.errorHandling.degradedModeEnabled) {
errors.push('生产环境必须配置Zulip机器人API Key'); errors.push('生产环境必须配置Zulip机器人API Key');
} }
} }