From a72920d1e0660522b84c5ace9108504bfde1b5d3 Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:07:27 +0800 Subject: [PATCH 1/3] fix: harden backend production deployment --- .env.production.example | 3 ++- deploy/nginx/whaletown-admin-v2.conf.example | 21 +++++++++++++++ deploy/nginx/whaletownend-v2.conf.example | 28 ++++++++++++++++++++ ecosystem.config.js | 1 + src/app.service.ts | 4 ++- src/core/zulip_core/zulip.config.ts | 4 +-- 6 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 deploy/nginx/whaletown-admin-v2.conf.example create mode 100644 deploy/nginx/whaletownend-v2.conf.example diff --git a/.env.production.example b/.env.production.example index 2afa744..ac006f1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -46,7 +46,8 @@ ZULIP_DEGRADED_MODE_ENABLED=true ZULIP_AUTO_RECONNECT_ENABLED=true # Realtime and generated assets -WEBSOCKET_PORT=3000 +# The native WebSocket server listens separately from the REST API. +WEBSOCKET_PORT=3001 WEBSOCKET_NAMESPACE=/game ACCOUNT_ASSET_DIR=generated/account-assets SKIN_GENERATION_OUTPUT_DIR=generated/skins diff --git a/deploy/nginx/whaletown-admin-v2.conf.example b/deploy/nginx/whaletown-admin-v2.conf.example new file mode 100644 index 0000000..d17bff0 --- /dev/null +++ b/deploy/nginx/whaletown-admin-v2.conf.example @@ -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; + } +} diff --git a/deploy/nginx/whaletownend-v2.conf.example b/deploy/nginx/whaletownend-v2.conf.example new file mode 100644 index 0000000..5393e8b --- /dev/null +++ b/deploy/nginx/whaletownend-v2.conf.example @@ -0,0 +1,28 @@ +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 / { + 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; + } +} diff --git a/ecosystem.config.js b/ecosystem.config.js index 5c196ac..4618fef 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -2,6 +2,7 @@ module.exports = { apps: [ { name: 'whale-town-end-v2', + cwd: __dirname, script: 'dist/main.js', instances: 1, exec_mode: 'cluster', diff --git a/src/app.service.ts b/src/app.service.ts index 8ecd62c..c8277cd 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AppStatusResponseDto } from './business/shared'; +const packageJson: { version: string } = require('../package.json'); + /** * 应用服务类 * @@ -31,7 +33,7 @@ export class AppService { return { service: 'Pixel Game Server', - version: '1.1.1', + version: packageJson.version, status: 'running', timestamp: new Date().toISOString(), uptime: Math.floor((Date.now() - this.startTime) / 1000), diff --git a/src/core/zulip_core/zulip.config.ts b/src/core/zulip_core/zulip.config.ts index a8a30c0..8808196 100644 --- a/src/core/zulip_core/zulip.config.ts +++ b/src/core/zulip_core/zulip.config.ts @@ -354,7 +354,7 @@ export function validateZulipConfig( errors.push('API Key加密密钥长度必须至少32字符'); } - if (!config.server.botApiKey) { + if (!config.server.botApiKey && !config.errorHandling.degradedModeEnabled) { errors.push('生产环境必须配置Zulip机器人API Key'); } } @@ -405,4 +405,4 @@ function isValidEmail(email: string): boolean { */ export const zulipConfig = registerAs('zulip', () => { return loadZulipConfigFromEnv(); -}); \ No newline at end of file +}); From 77302354accd49edc4f4133249a996eb51c96c4a Mon Sep 17 00:00:00 2001 From: ANG-Server <96008766+ANGJustinl@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:43:23 +0800 Subject: [PATCH 2/3] fix: allow degraded startup without Zulip keys --- .env.example | 3 +- .../services/api_key_security.service.ts | 36 ++++++++++++++++--- src/core/zulip_core/zulip.config.ts | 6 +++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 14d844d..b7c6a4f 100644 --- a/.env.example +++ b/.env.example @@ -66,7 +66,8 @@ ZULIP_MESSAGE_MAX_LENGTH=10000 ZULIP_CONTENT_FILTER_ENABLED=true # Realtime server -WEBSOCKET_PORT=3000 +# The native WebSocket server listens separately from the REST API. +WEBSOCKET_PORT=3001 WEBSOCKET_NAMESPACE=/game WEBSOCKET_PING_INTERVAL=25000 WEBSOCKET_PING_TIMEOUT=5000 diff --git a/src/core/zulip_core/services/api_key_security.service.ts b/src/core/zulip_core/services/api_key_security.service.ts index a21cae2..24315d1 100644 --- a/src/core/zulip_core/services/api_key_security.service.ts +++ b/src/core/zulip_core/services/api_key_security.service.ts @@ -147,7 +147,7 @@ export class ApiKeySecurityService implements IApiKeySecurityService { private readonly SECURITY_LOG_RETENTION = 30 * 24 * 3600; // 30天 // 加密密钥(生产环境应从环境变量或密钥管理服务获取) - private readonly encryptionKey: Buffer; + private readonly encryptionKey: Buffer | null; constructor( @Inject('REDIS_SERVICE') @@ -155,9 +155,19 @@ export class ApiKeySecurityService implements IApiKeySecurityService { ) { // 加密密钥必须由部署环境提供,不允许回退到共享的固定密钥。 const keyFromEnv = process.env.ZULIP_API_KEY_ENCRYPTION_KEY; + const degradedModeEnabled = process.env.ZULIP_DEGRADED_MODE_ENABLED === 'true'; if (!keyFromEnv) { - throw new Error('ZULIP_API_KEY_ENCRYPTION_KEY未配置'); + this.encryptionKey = null; + + if (!degradedModeEnabled) { + throw new Error('ZULIP_API_KEY_ENCRYPTION_KEY未配置'); + } + + this.logger.warn( + 'Zulip降级模式已启用且未配置API Key加密密钥,API Key加密存取功能不可用', + ); + return; } // 如果环境变量是十六进制格式,使用hex解析;否则使用utf8。 @@ -736,10 +746,11 @@ export class ApiKeySecurityService implements IApiKeySecurityService { iv: string; authTag: string; } { + const encryptionKey = this.requireEncryptionKey(); const iv = crypto.randomBytes(this.IV_LENGTH); const cipher = crypto.createCipheriv( this.ENCRYPTION_ALGORITHM, - this.encryptionKey, + encryptionKey, iv ); @@ -764,11 +775,12 @@ export class ApiKeySecurityService implements IApiKeySecurityService { * @private */ private decrypt(encryptedData: string, ivHex: string, authTagHex: string): string { + const encryptionKey = this.requireEncryptionKey(); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const decipher = crypto.createDecipheriv( this.ENCRYPTION_ALGORITHM, - this.encryptionKey, + encryptionKey, iv ); decipher.setAuthTag(authTag); @@ -779,6 +791,22 @@ export class ApiKeySecurityService implements IApiKeySecurityService { 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格式 * diff --git a/src/core/zulip_core/zulip.config.ts b/src/core/zulip_core/zulip.config.ts index 8808196..6794e94 100644 --- a/src/core/zulip_core/zulip.config.ts +++ b/src/core/zulip_core/zulip.config.ts @@ -349,7 +349,11 @@ export function validateZulipConfig( // 生产环境特殊验证 if (isProduction) { if (!config.security.apiKeyEncryptionKey) { - errors.push('生产环境必须配置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)'); + if (config.errorHandling.degradedModeEnabled) { + warnings.push('降级模式未配置API Key加密密钥,Zulip API Key加密存取功能不可用'); + } else { + errors.push('生产环境必须配置API Key加密密钥 (ZULIP_API_KEY_ENCRYPTION_KEY)'); + } } else if (config.security.apiKeyEncryptionKey.length < 32) { errors.push('API Key加密密钥长度必须至少32字符'); } From 6fb977ceaf9d2859eb888f66c6a2170f2d757d02 Mon Sep 17 00:00:00 2001 From: xiangwang Date: Sat, 1 Aug 2026 01:43:53 +0800 Subject: [PATCH 3/3] fix: complete backend deployment hardening --- DEPLOYMENT.md | 113 ++++++++++++++++++ README.md | 6 +- deploy/nginx/whaletownend-v2.conf.example | 26 ++++ .../services/config_manager.service.ts | 4 +- src/core/zulip_core/zulip.config.ts | 2 +- 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 DEPLOYMENT.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4f18701 --- /dev/null +++ b/DEPLOYMENT.md @@ -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`。数据库结构变更必须使用对应版本的迁移或备份恢复方案,不能只回滚应用代码。 diff --git a/README.md b/README.md index 37f550d..3c1e03f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ pnpm run build 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`。 @@ -39,6 +39,10 @@ pnpm --filter whale-town-admin run build 管理端的 API 地址通过 `client/.env.local` 中的 `VITE_API_BASE_URL` 配置。 +## 部署 + +生产部署、Nginx、PM2、验收和回滚步骤见 [DEPLOYMENT.md](DEPLOYMENT.md)。 + ## 安全 仓库不包含 `.env`、访问令牌、SSH 私钥、数据库文件、Redis 数据、日志或生成资产。敏感配置必须通过部署环境注入。 diff --git a/deploy/nginx/whaletownend-v2.conf.example b/deploy/nginx/whaletownend-v2.conf.example index 5393e8b..843cc07 100644 --- a/deploy/nginx/whaletownend-v2.conf.example +++ b/deploy/nginx/whaletownend-v2.conf.example @@ -17,6 +17,32 @@ server { 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; diff --git a/src/core/zulip_core/services/config_manager.service.ts b/src/core/zulip_core/services/config_manager.service.ts index 343b8b4..a3c9fa4 100644 --- a/src/core/zulip_core/services/config_manager.service.ts +++ b/src/core/zulip_core/services/config_manager.service.ts @@ -360,7 +360,7 @@ export class ConfigManagerService implements OnModuleDestroy { zulipBotEmail: process.env.ZULIP_BOT_EMAIL || 'bot@example.com', 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', messageRateLimit: parseInt(process.env.MESSAGE_RATE_LIMIT || '10', 10), @@ -1189,7 +1189,7 @@ export class ConfigManagerService implements OnModuleDestroy { zulipServerUrl: 'https://your-zulip-server.com', zulipBotEmail: 'bot@example.com', zulipBotApiKey: '', - websocketPort: 3000, + websocketPort: 3001, websocketNamespace: '/game', messageRateLimit: 10, messageMaxLength: 1000, diff --git a/src/core/zulip_core/zulip.config.ts b/src/core/zulip_core/zulip.config.ts index 6794e94..93746ac 100644 --- a/src/core/zulip_core/zulip.config.ts +++ b/src/core/zulip_core/zulip.config.ts @@ -169,7 +169,7 @@ export const DEFAULT_ZULIP_CONFIG: ZulipConfiguration = { botApiKey: '', }, websocket: { - port: 3000, + port: 3001, namespace: '/game', pingInterval: 25000, pingTimeout: 5000,