From eaf22706d8220b6eea24fd3f681a9b7c0db4211d Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 18:45:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=BA=AB=E4=BB=BD=E5=92=8C=E6=97=B6=E5=8C=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + API.md | 2 +- README.md | 2 +- ...cat-device-name-timezone-hardening-plan.md | 306 ++++++++++++++++++ sql/refactor-v3/99-verify.sql | 7 + src/app.module.ts | 41 ++- .../runtime/napcat-runtime-profile.service.ts | 130 +++++++- .../container/napcat-docker-device-options.ts | 21 +- .../qqbot-napcat-container.service.ts | 120 ++++++- .../device/napcat-device-identity.service.ts | 75 ++--- src/runtime/config/runtime-config.service.ts | 2 + src/runtime/config/runtime-config.types.ts | 1 + test/app/typeorm-timezone-config.spec.ts | 41 +++ .../qqbot/napcat/device-identity.spec.ts | 41 ++- .../napcat/runtime-protocol-profile.spec.ts | 88 +++++ test/runtime/runtime-config.service.spec.ts | 24 ++ test/runtime/runtime-health.service.spec.ts | 1 + 17 files changed, 835 insertions(+), 68 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-qqbot-napcat-device-name-timezone-hardening-plan.md create mode 100644 test/app/typeorm-timezone-config.spec.ts diff --git a/.env.example b/.env.example index 86088f9..c8e2fc0 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ DB_USERNAME=root DB_PASSWORD= DB_DATABASE=shy_template DB_SYNC=true +DB_TIMEZONE=+08:00 MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/API.md b/API.md index 50fddaa..542665c 100644 --- a/API.md +++ b/API.md @@ -352,7 +352,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 -托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。 +托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 ### NapCat Runtime Profile diff --git a/README.md b/README.md index 70cf11a..5402cf1 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 - NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 -- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。 +- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。 diff --git a/docs/superpowers/plans/2026-06-18-qqbot-napcat-device-name-timezone-hardening-plan.md b/docs/superpowers/plans/2026-06-18-qqbot-napcat-device-name-timezone-hardening-plan.md new file mode 100644 index 0000000..34c8dae --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-qqbot-napcat-device-name-timezone-hardening-plan.md @@ -0,0 +1,306 @@ +# QQBot NapCat Device Name And Timezone Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore a visible QQ security-page device name while keeping NapCat device identity persistent, fix mixed UTC/Asia-Shanghai timestamps, and make runtime/profile evidence persist online. + +**Architecture:** Use upstream NapCat evidence as the source of truth: QQNT Linux reads `os.hostname()` for `hostName`/`devName`, new-device OIDB sends `str_dev_name: os.hostname()`, and Linux GUID is based on `/etc/machine-id + machine-info MAC`. The online regression evidence shows the new container exposes `ubuntu-pc-*`, but Docker MAC was changed to a physical OUI while QQNT `machine-info` still stored the old `02:42:*` MAC, creating inconsistent device identity. Generate a short stable QQNT-safe hostname that the upstream Docker entrypoint will not rewrite, return MAC strategy to upstream-compatible stable Docker bridge `02:42:*`, write matching `machine-info`, persist profile rows after container creation, and set MySQL connection timezone explicitly. + +**Tech Stack:** NestJS, TypeORM, MySQL, NapCat-Docker, Jest, PowerShell/NAS SSH smoke. + +--- + +## File Map + +- Modify `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts` + - Generate short stable hostname `pc-<8 hex>` to avoid NapCat-Docker entrypoint reset and avoid long desktop names that regress to `未知设备`. + - Generate stable upstream-compatible Docker bridge MAC `02:42:*`. + - Migrate existing `desktop-hostname-v1` / `physical-oui-v1` identities back to QQNT-safe defaults with evidence. +- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts` + - Carry `machineInfoPath` and hyphen MAC for QQNT Linux `machine-info`. +- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts` + - Write `device.env`, `/etc/machine-id`, and QQNT `machine-info` consistently. + - Create and mount a persistent `XDG_RUNTIME_DIR`. + - Update `napcat_account_binding.device_identity_id` after create/rebuild. + - Persist runtime/protocol profile after successful container creation. +- Modify `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts` + - Persist planned runtime and protocol profile rows when repositories are injected. +- Modify `src/app.module.ts` + - Set TypeORM MySQL timezone from `DB_TIMEZONE`, default `+08:00`. +- Modify `src/runtime/config/runtime-config.types.ts` and `src/runtime/config/runtime-config.service.ts` + - Expose DB timezone in runtime health evidence. +- Modify `ci/napcat-desktop-cn/Dockerfile` + - Ensure the derived image keeps `zh_CN.UTF-8` available and does not get shadowed by upstream `C.UTF-8`. +- Modify `.env.example`, `README.md`, `API.md`, `sql/qqbot-init.sql`, `sql/refactor-v3/00-full-schema.sql`, `sql/refactor-v3/99-verify.sql` + - Document/align defaults and schema verification. +- Modify tests: + - `test/modules/qqbot/napcat/device-identity.spec.ts` + - `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts` + - `test/runtime/runtime-config.service.spec.ts` + - Add `test/app/typeorm-timezone-config.spec.ts` + +## Task 1: Device Identity Regression RED/GREEN + +**Files:** +- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts` +- Modify: `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts` +- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts` + +- [x] **Step 1: Write failing tests** + +Add expectations that new identities use a QQNT-safe visible hostname and upstream-compatible MAC: + +```ts +expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/); +expect(identity.hostname).not.toMatch(/^[a-f0-9]{12,}$/); +expect(identity.macAddress).toMatch(/^02:42:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}$/); +expect(identity.hostnameStrategy).toBe('qqnt-visible-hostname-v1'); +expect(identity.macStrategy).toBe('docker-bridge-mac-v1'); +``` + +Also update the migration test so a legacy `ubuntu-pc-*` / physical OUI identity migrates to the new strategies and records `trigger: 'qqnt-device-name-regression-repair'`. + +- [x] **Step 2: Run RED** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand +``` + +Expected: FAIL because current code emits `ubuntu-pc-*` and physical OUI MAC. + +- [x] **Step 3: Implement identity generation** + +Change the service to: + +```ts +private buildQqntVisibleHostname(seed: string) { + const hash = createHash('sha256').update(seed).digest('hex'); + return `pc-${hash.slice(0, 8)}`; +} + +private buildDockerBridgeMacAddress(accountId: string, containerName: string) { + const hash = createHash('sha256') + .update(`${accountId}:${containerName}:docker-bridge-mac-v1`) + .digest('hex'); + return `02:42:${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}`.toLowerCase(); +} +``` + +Update migration predicates to migrate too-long hostnames, pure 12+ hex hostnames, `desktop-hostname-v1`, `physical-oui-v1`, rejected virtual prefixes outside `02:42`, and values containing `qq|bot|napcat|docker|container|lxc`. + +- [x] **Step 4: Run GREEN** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand +``` + +Expected: PASS. + +## Task 2: Docker Script Machine-Info And Binding RED/GREEN + +**Files:** +- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts` +- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts` +- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts` + +- [x] **Step 1: Write failing tests** + +Assert the generated script writes QQNT machine-info with a backup, mounts persistent runtime dir, and updates binding `deviceIdentityId`: + +```ts +expect(createScript).toContain('MACHINE_INFO_PATH='); +expect(createScript).toContain('MAC_HYPHEN='); +expect(createScript).toContain("printf '\\\\000\\\\000\\\\000\\\\021'"); +expect(createScript).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'"); +expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"'); +expect(bindingRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ accountId: 'account-10001', containerId: 'container-created' }), + expect.objectContaining({ deviceIdentityId: expect.any(String) }), +); +``` + +- [x] **Step 2: Run RED** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand +``` + +Expected: FAIL because `machine-info` and binding updates are not implemented. + +- [x] **Step 3: Implement script and binding update** + +Add `machineInfoPath` and `macAddressHyphen` to `NapcatDockerDeviceOptions`. In `buildRemoteCreateScript`, write: + +```sh +MACHINE_INFO_PATH="$DATA_DIR/QQ/nt_qq/global/nt_data/msf/machine-info" +NAPCAT_MAC_HYPHEN="$(printf '%s' "$NAPCAT_MAC_ADDRESS" | tr ':' '-')" +mkdir -p "$(dirname "$MACHINE_INFO_PATH")" +if [ -s "$MACHINE_INFO_PATH" ]; then + CURRENT_MACHINE_INFO="$(mktemp)" + cp "$MACHINE_INFO_PATH" "$CURRENT_MACHINE_INFO" +fi +{ + printf '\000\000\000\021' + printf '%s' "$MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m' +} > "$MACHINE_INFO_PATH" +``` + +After successful create/rebuild, update `napcat_account_binding.device_identity_id` for the account/container pair. + +- [x] **Step 4: Run GREEN** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand +``` + +Expected: PASS. + +## Task 3: Runtime/Profile Persistence RED/GREEN + +**Files:** +- Modify: `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts` +- Modify: `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts` +- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts` + +- [x] **Step 1: Write failing tests** + +Add a test that calls `recordPlannedProfiles()` and expects both repositories to save rows: + +```ts +expect(runtimeProfileRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + deviceIdentityId: 'identity-1', + hostnameStrategy: 'qqnt-visible-hostname-v1', + macStrategy: 'docker-bridge-mac-v1', + profileStatus: 'pending', + }), +); +expect(protocolProfileRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + o3HookMode: 1, + packetBackend: 'auto', + profileStatus: 'pending', + }), +); +``` + +- [x] **Step 2: Run RED** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand +``` + +Expected: FAIL because the service currently does not persist generated profiles. + +- [x] **Step 3: Implement persistence** + +Add optional injected repositories to `NapcatRuntimeProfileService`, implement `recordPlannedProfiles(input)`, and call it after successful Docker create/rebuild with the runtime snapshot and config bundle hashes. + +- [x] **Step 4: Run GREEN** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand +``` + +Expected: PASS. + +## Task 4: MySQL Timezone RED/GREEN + +**Files:** +- Add: `test/app/typeorm-timezone-config.spec.ts` +- Modify: `src/app.module.ts` +- Modify: `src/runtime/config/runtime-config.types.ts` +- Modify: `src/runtime/config/runtime-config.service.ts` + +- [x] **Step 1: Write failing tests** + +Extract a small exported helper from `app.module.ts` or test the factory through metadata so the returned TypeORM options include: + +```ts +expect(options.timezone).toBe('+08:00'); +``` + +Also assert `RuntimeConfigService.readDatabaseProfile()` reports `timezone: '+08:00'`. + +- [x] **Step 2: Run RED** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand +``` + +Expected: FAIL because no DB timezone is configured or exposed. + +- [x] **Step 3: Implement timezone config** + +Set TypeORM MySQL `timezone` to `configService.get('DB_TIMEZONE') || '+08:00'`, and expose the same field from runtime health config. + +- [x] **Step 4: Run GREEN** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand +``` + +Expected: PASS. + +## Task 5: Image, SQL, Docs, And Verification + +**Files:** +- Modify: `ci/napcat-desktop-cn/Dockerfile` +- Modify: `.env.example` +- Modify: `README.md` +- Modify: `API.md` +- Modify: `sql/qqbot-init.sql` +- Modify: `sql/refactor-v3/00-full-schema.sql` +- Modify: `sql/refactor-v3/99-verify.sql` +- Modify: `TASKS.md` + +- [x] **Step 1: Keep derived image actually Chinese-locale capable** + +Ensure the Dockerfile installs `locales`, generates `zh_CN.UTF-8`, and sets `LC_ALL=zh_CN.UTF-8`. The verification script must fail if `locale -a` lacks `zh_CN.utf8`. + +- [x] **Step 2: Align SQL defaults and docs** + +Document `DB_TIMEZONE=+08:00`, `QQBOT_NAPCAT_IMAGE` derived image usage, and device identity strategy names. Update SQL verify checks for `device_identity_id`, strategy columns, and profile tables. + +- [x] **Step 3: Run focused validation** + +Run: + +```powershell +pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand +pnpm run typecheck +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 4: Online controlled repair after deploy** + +After commit/push/deploy, run a scoped online repair: + +1. Backup `napcat_device_identity`, `napcat_account_binding`, `napcat_runtime_profile`, `napcat_protocol_profile`, `napcat_login_session`. +2. Restart/recreate NapCat containers through the managed API path so code writes DB binding/profile rows and Docker machine-info. +3. Verify: + - `napcat_account_binding.device_identity_id` is not null for enabled accounts. + - profile tables have rows for enabled accounts. + - each running container has hostname `pc-<8 hex>`, Docker MAC `02:42:*`, `/etc/machine-id`, decoded `machine-info` MAC equal to Docker MAC, `TZ=Asia/Shanghai`, writable XDG runtime dir, and `locale -a` includes `zh_CN.utf8`. + - MySQL `NOW()` offset or API session rows no longer mix UTC and CST fields. +4. Ask user to scan one login QR if QQ requires new-device verification; then confirm QQ mobile page shows a non-unknown device name. diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index d490dc5..f61dfa9 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -197,3 +197,10 @@ WHERE table_schema = DATABASE() AND table_name = 'napcat_device_identity' AND column_name = 'mac_strategy' AND column_type = 'varchar(64)'; + +SELECT 'column_napcat_account_binding_device_identity_id' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'napcat_account_binding' + AND column_name = 'device_identity_id' + AND column_type = 'bigint'; diff --git a/src/app.module.ts b/src/app.module.ts index 242c079..0df2f0d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,29 @@ import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugi import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module'; import { RuntimeModule } from './runtime'; +/** + * Builds TypeORM MySQL options from runtime config with an explicit session timezone. + * @param configService - Nest ConfigService source for DB connection fields and DB_TIMEZONE override. + * @returns TypeORM MySQL options consumed by AppModule startup. + */ +export function buildTypeOrmOptions(configService: ConfigService) { + return { + type: 'mysql' as const, + host: configService.get('DB_HOST'), + port: configService.get('DB_PORT'), + username: configService.get('DB_USERNAME'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_DATABASE'), + timezone: configService.get('DB_TIMEZONE') || '+08:00', + synchronize: configService.get('DB_SYNC') === 'true', + entities: [ + __dirname + '/**/*.entity{.ts,.js}', + __dirname + '/**/*.entities{.ts,.js}', + ], + subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'], + }; +} + @Module({ imports: [ ConfigModule.forRoot({ @@ -44,22 +67,8 @@ import { RuntimeModule } from './runtime'; * 创建 模块依赖注入工厂产物。 * @param configService - Nest ConfigService 依赖;使用 `get` 字段生成结果。 */ - useFactory: async (configService: ConfigService) => { - return { - type: 'mysql', - host: configService.get('DB_HOST'), - port: configService.get('DB_PORT'), - username: configService.get('DB_USERNAME'), - password: configService.get('DB_PASSWORD'), - database: configService.get('DB_DATABASE'), - synchronize: configService.get('DB_SYNC') === 'true', - entities: [ - __dirname + '/**/*.entity{.ts,.js}', - __dirname + '/**/*.entities{.ts,.js}', - ], - subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'], - }; - }, + useFactory: async (configService: ConfigService) => + buildTypeOrmOptions(configService), inject: [ConfigService], }), MinioModule.registerAsync({ diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 6e19990..71a57f0 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -1,6 +1,35 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types'; +import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity'; +import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity'; + +type RecordPlannedProfilesInput = { + accountId: string; + containerId?: string; + dataDir: string; + deviceIdentity?: { + deviceIdentityId?: string; + hostname: string; + hostnameStrategy?: string; + machineInfoPath: string; + macAddress: string; + macStrategy?: string; + }; + protocolProfile: { + napcatConfigHash?: string; + napcatConfigJson?: Record; + o3HookGrayEnabled?: boolean; + o3HookMode?: 0 | 1; + onebotConfigHash?: string; + onebotConfigJson?: Record; + packetBackend?: string; + packetServer?: string; + }; + runtimeProfile: NapcatRuntimeProfileSnapshot; +}; @Injectable() export class NapcatRuntimeProfileService { @@ -8,7 +37,15 @@ export class NapcatRuntimeProfileService { * Initializes the profile resolver used before managed runtime script generation. * @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version. */ - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + @Optional() + @InjectRepository(NapcatRuntimeProfile) + private readonly runtimeProfileRepository?: Repository, + @Optional() + @InjectRepository(NapcatProtocolProfile) + private readonly protocolProfileRepository?: Repository, + ) {} /** * Resolves the runtime profile for an account-owned NapCat container. @@ -45,6 +82,95 @@ export class NapcatRuntimeProfileService { }; } + /** + * Persists the planned runtime and protocol profile after Docker accepts a managed container rebuild. + * @param input - Account/container identity, generated runtime profile, and config hashes that establish the expected state before live inspection. + */ + async recordPlannedProfiles(input: RecordPlannedProfilesInput) { + const accountId = `${input.accountId || ''}`.trim(); + if (!accountId) return; + + if (this.runtimeProfileRepository) { + await this.runtimeProfileRepository.save( + this.runtimeProfileRepository.create({ + accountId, + baseImageDigest: null, + containerId: input.containerId || null, + desktopProfileVersion: input.runtimeProfile.desktopProfileVersion, + deviceIdentityId: + input.deviceIdentity?.deviceIdentityId || + input.runtimeProfile.deviceIdentityId || + null, + fontconfigEvidence: null, + hostnameStrategy: + input.deviceIdentity?.hostnameStrategy || + 'qqnt-visible-hostname-v1', + imageDigest: null, + imageRef: input.runtimeProfile.imageRef, + lastCheckEvidence: { + dataDir: input.dataDir, + deviceIdentityId: + input.deviceIdentity?.deviceIdentityId || + input.runtimeProfile.deviceIdentityId || + null, + hostname: input.deviceIdentity?.hostname || null, + machineInfoPath: input.deviceIdentity?.machineInfoPath || null, + macAddress: input.deviceIdentity?.macAddress || null, + }, + lastCheckedAt: null, + locale: input.runtimeProfile.locale, + localeAvailable: false, + macStrategy: + input.deviceIdentity?.macStrategy || 'docker-bridge-mac-v1', + migrateDeviceIdentity: !!input.deviceIdentity, + persistCache: input.runtimeProfile.persistCache, + persistLocalShare: input.runtimeProfile.persistLocalShare, + persistLogs: input.runtimeProfile.persistLogs, + profileStatus: 'pending', + profileVersion: this.getString( + 'QQBOT_NAPCAT_PROFILE_VERSION', + 'napcat-runtime-profile-v1', + ), + runtimeGid: input.runtimeProfile.runtimeGid, + runtimeUid: input.runtimeProfile.runtimeUid, + shmSize: input.runtimeProfile.shmSize, + timezoneEvidence: { + expectedTimezone: input.runtimeProfile.timezone, + }, + xdgCacheHome: input.runtimeProfile.xdgCacheHome, + xdgConfigHome: input.runtimeProfile.xdgConfigHome, + xdgDataHome: input.runtimeProfile.xdgDataHome, + }), + ); + } + + if (this.protocolProfileRepository) { + await this.protocolProfileRepository.save( + this.protocolProfileRepository.create({ + accountId, + containerId: input.containerId || null, + lastCheckEvidence: { + configSource: 'managed-create-script', + }, + lastCheckedAt: null, + napcatConfigHash: input.protocolProfile.napcatConfigHash || null, + napcatConfigJson: input.protocolProfile.napcatConfigJson || null, + o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled, + o3HookMode: input.protocolProfile.o3HookMode ?? 1, + onebotConfigHash: input.protocolProfile.onebotConfigHash || null, + onebotConfigJson: input.protocolProfile.onebotConfigJson || null, + packetBackend: input.protocolProfile.packetBackend || 'auto', + packetServer: input.protocolProfile.packetServer || '', + profileStatus: 'pending', + profileVersion: this.getString( + 'QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION', + 'napcat-protocol-profile-v1', + ), + }), + ); + } + } + /** * Reads a trimmed string config value for profile generation. * @param key - Environment key that controls NapCat runtime profile generation. diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts index 4b789db..ec0c17d 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts @@ -1,12 +1,17 @@ import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity'; export type NapcatDockerDeviceOptions = { + accountId: string; dataDir: string; deviceEnvPath: string; deviceIdentityId?: string; hostname: string; machineIdPath: string; + machineInfoPath: string; macAddress: string; + macAddressHyphen: string; + hostnameStrategy?: string; + macStrategy?: string; runFlags: string[]; }; @@ -18,16 +23,30 @@ export type NapcatDockerDeviceOptions = { export function toNapcatDockerDeviceOptions( identity: Pick< NapcatDeviceIdentity, - 'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress' + | 'accountId' + | 'dataDir' + | 'hostname' + | 'hostnameStrategy' + | 'id' + | 'machineIdPath' + | 'macAddress' + | 'macStrategy' >, ): NapcatDockerDeviceOptions { + const machineInfoPath = `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`; + return { + accountId: identity.accountId, dataDir: identity.dataDir, deviceEnvPath: `${identity.dataDir}/device.env`, deviceIdentityId: identity.id, hostname: identity.hostname, + hostnameStrategy: identity.hostnameStrategy, machineIdPath: identity.machineIdPath, + machineInfoPath, macAddress: identity.macAddress, + macAddressHyphen: identity.macAddress.replace(/:/g, '-').toLowerCase(), + macStrategy: identity.macStrategy, runFlags: [ '--hostname "$NAPCAT_HOSTNAME"', '--mac-address "$NAPCAT_MAC_ADDRESS"', diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 0b839c6..207427b 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -177,6 +177,8 @@ export class QqbotNapcatContainerService { ); await this.createRemoteDockerContainer({ account, + accountId: deviceIdentity?.accountId, + containerId: container.id, dataDir: deviceIdentity?.dataDir || container.dataDir || @@ -246,6 +248,24 @@ export class QqbotNapcatContainerService { return toNapcatDockerDeviceOptions(identity); } + /** + * Resolves the device identity id that belongs to an account/container binding. + * @param accountId - Internal QQBot account id used to select the persistent device identity. + * @param containerId - Managed NapCat container id that should be stored on the identity row. + * @returns Device identity id when the identity service is available. + */ + private async resolveBindingDeviceIdentityId( + accountId: string, + containerId: string, + ) { + if (!this.deviceIdentityService) return undefined; + const identity = await this.deviceIdentityService.resolveForAccount({ + accountId, + containerId, + }); + return identity.id; + } + /** * 执行 NapCat 登录运行态流程。 * @param container - container 输入;使用 `id` 字段生成结果。 @@ -611,6 +631,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" { accountId, isDeleted: false }, { isPrimary: false }, ); + const deviceIdentityId = await this.resolveBindingDeviceIdentityId( + accountId, + containerId, + ); const existing = await this.bindingRepository.findOne({ where: { @@ -627,6 +651,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" isDeleted: false, lastLoginAt: new Date(), remark: '', + ...(deviceIdentityId ? { deviceIdentityId } : {}), }, ); await this.removeOtherAccountContainers(accountId, containerId); @@ -638,6 +663,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" accountId, bindStatus: 'bound', containerId, + ...(deviceIdentityId ? { deviceIdentityId } : {}), isPrimary: true, lastLoginAt: new Date(), remark: '', @@ -1319,6 +1345,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true try { await this.createRemoteDockerContainer({ account: selfId, + accountId, + containerId: container.id, dataDir, deviceIdentity, image, @@ -1337,11 +1365,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true }, ); if (accountId && this.deviceIdentityService) { - await this.deviceIdentityService.resolveForAccount({ + const identity = await this.deviceIdentityService.resolveForAccount({ accountId, containerId: container.id, selfId, }); + await this.bindingRepository.update( + { accountId, containerId: container.id, isDeleted: false }, + { deviceIdentityId: identity.id }, + ); } return { baseUrl, @@ -1370,6 +1402,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true */ private async createRemoteDockerContainer(input: { account?: string; + accountId?: string; + containerId?: string; dataDir: string; deviceIdentity?: NapcatDockerDeviceOptions; image: string; @@ -1382,6 +1416,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true }) { const script = this.buildRemoteCreateScript(input); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); + await this.recordPlannedProfiles(input); } /** @@ -1390,6 +1425,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true */ private buildRemoteCreateScript(input: { account?: string; + accountId?: string; containerId?: string; dataDir: string; deviceIdentity?: NapcatDockerDeviceOptions; @@ -1433,20 +1469,36 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true ? [ `NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`, `NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`, + `NAPCAT_MAC_HYPHEN=${this.sh(input.deviceIdentity.macAddressHyphen)}`, `MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`, + `MACHINE_INFO_PATH=${this.sh(input.deviceIdentity.machineInfoPath)}`, `DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`, ].join('\n') + '\n' : ''; const devicePrepareScript = input.deviceIdentity ? ` +mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")" cat > "$DEVICE_ENV_PATH" < "$MACHINE_ID_PATH" fi +if [ -s "$MACHINE_INFO_PATH" ]; then + CURRENT_MACHINE_INFO_MAC="$(dd if="$MACHINE_INFO_PATH" bs=1 skip=4 2>/dev/null | tr 'A-Za-z' 'N-ZA-Mn-za-m' || true)" + if [ "$CURRENT_MACHINE_INFO_MAC" != "$NAPCAT_MAC_HYPHEN" ]; then + cp "$MACHINE_INFO_PATH" "$MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)" + fi +fi +MACHINE_INFO_TMP="$MACHINE_INFO_PATH.tmp" +printf '\\000\\000\\000\\021' > "$MACHINE_INFO_TMP" +printf '%s' "$NAPCAT_MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m' >> "$MACHINE_INFO_TMP" +mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH" +chmod 644 "$MACHINE_INFO_PATH" ` : ''; const deviceRunFlags = input.deviceIdentity @@ -1467,8 +1519,9 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)} ${accountHeader} ${passwordHeader} ${deviceHeader} -mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" +mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime" chmod 700 "$DATA_DIR" +chmod 700 "$DATA_DIR/runtime" ${devicePrepareScript} ${configWriteScript} @@ -1497,11 +1550,74 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\ -v "$DATA_DIR/cache:/app/.cache" \\ -v "$DATA_DIR/local-share:/app/.local/share" \\ + -v "$DATA_DIR/runtime:/tmp/runtime-napcat" \\ -v "$DATA_DIR/logs:/app/napcat/logs" \\ "$IMAGE" >/dev/null `; } + /** + * Records the expected runtime and protocol state for a successfully created managed container. + * @param input - Docker creation request whose identity and config values define the planned profile. + */ + private async recordPlannedProfiles(input: { + account?: string; + accountId?: string; + containerId?: string; + dataDir: string; + deviceIdentity?: NapcatDockerDeviceOptions; + image: string; + loginPassword?: string; + name: string; + port: number; + reverseWsUrl: string; + skipPull?: boolean; + token: string; + }) { + const accountId = + this.toolsService.toTrimmedString(input.accountId) || + this.toolsService.toTrimmedString(input.deviceIdentity?.accountId); + if (!accountId || !input.deviceIdentity) return; + + const account = `${input.account || ''}`.trim(); + const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({ + accountId, + containerId: input.containerId, + dataDir: input.dataDir, + deviceIdentityId: input.deviceIdentity.deviceIdentityId, + }); + const configBundle = this.configWriterService.buildConfigFiles({ + account, + reverseWsUrl: input.reverseWsUrl, + token: input.token, + }); + + await this.runtimeProfileService.recordPlannedProfiles({ + accountId, + containerId: input.containerId, + dataDir: input.dataDir, + deviceIdentity: { + deviceIdentityId: input.deviceIdentity.deviceIdentityId, + hostname: input.deviceIdentity.hostname, + hostnameStrategy: input.deviceIdentity.hostnameStrategy, + machineInfoPath: input.deviceIdentity.machineInfoPath, + macAddress: input.deviceIdentity.macAddress, + macStrategy: input.deviceIdentity.macStrategy, + }, + protocolProfile: { + napcatConfigHash: configBundle.napcatConfigHash, + napcatConfigJson: configBundle.napcatConfig, + o3HookGrayEnabled: false, + o3HookMode: configBundle.napcatConfig.o3HookMode, + onebotConfigHash: configBundle.onebotConfigHash, + onebotConfigJson: configBundle.onebotConfig, + packetBackend: configBundle.napcatConfig.packetBackend, + packetServer: configBundle.napcatConfig.packetServer, + }, + runtimeProfile, + }); + } + /** * Renders NapCat config files as shell here-doc writes under the account config directory. * @param files - Config files generated by `NapcatConfigWriterService` for this container. diff --git a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts index 8ae973d..9746778 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts @@ -4,12 +4,11 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ensureSnowflakeId } from '@/common'; -import { - isRejectedVirtualMacPrefix, - NAPCAT_PHYSICAL_OUI_PREFIXES, -} from '../../../domain/runtime/napcat-physical-oui-catalog'; import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity'; +const QQNT_VISIBLE_HOSTNAME_STRATEGY = 'qqnt-visible-hostname-v1'; +const QQNT_DOCKER_BRIDGE_MAC_STRATEGY = 'docker-bridge-mac-v1'; + type ResolveNapcatDeviceIdentityInput = { accountId: string; containerId?: string; @@ -62,11 +61,13 @@ export class NapcatDeviceIdentityService { accountId, containerId: input.containerId || null, dataDir, - hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`), - hostnameStrategy: 'desktop-hostname-v1', + hostname: this.buildQqntVisibleHostname( + `${accountId}:${input.selfId || ''}`, + ), + hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: null, - macAddress: this.buildPhysicalMacAddress(accountId, containerName), - macStrategy: 'physical-oui-v1', + macAddress: this.buildDockerBridgeMacAddress(accountId, containerName), + macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, machineIdPath: `${dataDir}/machine-id`, verificationStatus: 'pending', }); @@ -91,35 +92,33 @@ export class NapcatDeviceIdentityService { } /** - * Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms. + * Builds a short stable hostname that survives the upstream Docker entrypoint rewrite and remains visible to QQNT. * @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text. */ - private buildDesktopHostname(seed: string) { + private buildQqntVisibleHostname(seed: string) { const hash = createHash('sha256').update(seed).digest('hex'); - return `ubuntu-pc-${hash.slice(0, 10)}`; + return `pc-${hash.slice(0, 8)}`; } /** - * Builds a stable MAC using a physical-device-style OUI prefix. + * Builds a stable Docker bridge MAC that can be mirrored into QQNT machine-info. * @param accountId - Account id used as a deterministic seed, not as visible output. * @param containerName - Container name mixed into the deterministic seed. */ - private buildPhysicalMacAddress(accountId: string, containerName: string) { + private buildDockerBridgeMacAddress( + accountId: string, + containerName: string, + ) { const hash = createHash('sha256') - .update(`${accountId}:${containerName}:physical-oui-v1`) + .update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`) .digest('hex'); - const prefixIndex = - Number.parseInt(hash.slice(0, 4), 16) % - NAPCAT_PHYSICAL_OUI_PREFIXES.length; - const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex]; - const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)]; - const mac = `${prefix}:${suffix.join(':')}`.toLowerCase(); - - if (isRejectedVirtualMacPrefix(mac)) { - throw new Error(`Rejected generated virtual MAC prefix: ${mac}`); - } - - return mac; + const suffix = [ + hash.slice(0, 2), + hash.slice(2, 4), + hash.slice(4, 6), + hash.slice(6, 8), + ]; + return `02:42:${suffix.join(':')}`.toLowerCase(); } /** @@ -135,16 +134,20 @@ export class NapcatDeviceIdentityService { selfId: string; }, ) { - const nextHostname = this.buildDesktopHostname( + const nextHostname = this.buildQqntVisibleHostname( `${input.accountId}:${input.selfId}`, ); - const nextMacAddress = this.buildPhysicalMacAddress( + const nextMacAddress = this.buildDockerBridgeMacAddress( input.accountId, input.containerName, ); const needsMigration = - identity.hostname !== nextHostname || - isRejectedVirtualMacPrefix(identity.macAddress); + identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY || + identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY || + !/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') || + !/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test( + identity.macAddress || '', + ); if (!needsMigration) { return; @@ -154,10 +157,10 @@ export class NapcatDeviceIdentityService { migration: { fromHostname: identity.hostname, fromMacAddress: identity.macAddress, - strategy: 'physical-oui-v1', + strategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, toHostname: nextHostname, toMacAddress: nextMacAddress, - trigger: 'legacy-docker-identity-upgrade', + trigger: 'qqnt-device-name-regression-repair', }, }; @@ -165,18 +168,18 @@ export class NapcatDeviceIdentityService { { id: identity.id }, { hostname: nextHostname, - hostnameStrategy: 'desktop-hostname-v1', + hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: migrationEvidence, macAddress: nextMacAddress, - macStrategy: 'physical-oui-v1', + macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, }, ); Object.assign(identity, { hostname: nextHostname, - hostnameStrategy: 'desktop-hostname-v1', + hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: migrationEvidence, macAddress: nextMacAddress, - macStrategy: 'physical-oui-v1', + macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, }); } diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts index fecbb63..6eb1dd4 100644 --- a/src/runtime/config/runtime-config.service.ts +++ b/src/runtime/config/runtime-config.service.ts @@ -23,6 +23,7 @@ const REQUIRED_CONFIG_KEYS = [ ] as const; const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ + 'DB_TIMEZONE', 'MINIO_ENDPOINT', 'MINIO_PORT', 'MINIO_ACCESS_KEY', @@ -95,6 +96,7 @@ export class RuntimeConfigService { database: this.getString('DB_DATABASE'), username: this.getString('DB_USERNAME'), synchronize: this.getBoolean('DB_SYNC', false), + timezone: this.getString('DB_TIMEZONE', '+08:00'), }; } diff --git a/src/runtime/config/runtime-config.types.ts b/src/runtime/config/runtime-config.types.ts index 7265a79..cac9efd 100644 --- a/src/runtime/config/runtime-config.types.ts +++ b/src/runtime/config/runtime-config.types.ts @@ -19,6 +19,7 @@ export interface RuntimeDatabaseConfig { database: string; username: string; synchronize: boolean; + timezone: string; } export interface RuntimeLokiConfig { diff --git a/test/app/typeorm-timezone-config.spec.ts b/test/app/typeorm-timezone-config.spec.ts new file mode 100644 index 0000000..b457c58 --- /dev/null +++ b/test/app/typeorm-timezone-config.spec.ts @@ -0,0 +1,41 @@ +import { ConfigService } from '@nestjs/config'; +import { buildTypeOrmOptions } from '../../src/app.module'; + +/** + * Creates a minimal ConfigService test double for TypeORM option generation. + * @param values - Environment-like values returned by ConfigService.get(). + */ +function createConfig(values: Record) { + return { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; +} + +describe('AppModule TypeORM options', () => { + it('uses +08:00 as the default MySQL session timezone to keep persisted timestamps in China time', () => { + const options = buildTypeOrmOptions( + createConfig({ + DB_HOST: 'mysql', + DB_PORT: 3306, + DB_USERNAME: 'kt', + DB_PASSWORD: 'password', + DB_DATABASE: 'kt', + }), + ); + + expect(options).toMatchObject({ + timezone: '+08:00', + type: 'mysql', + }); + }); + + it('allows DB_TIMEZONE to override the default when a deployment needs a different MySQL session timezone', () => { + const options = buildTypeOrmOptions( + createConfig({ + DB_TIMEZONE: 'Z', + }), + ); + + expect(options.timezone).toBe('Z'); + }); +}); diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index 279b1dd..cc0b039 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -172,7 +172,7 @@ describe('NapCat device identity persistence', () => { ); }); - it('generates a real-device style hostname without QQBot or container words', async () => { + it('generates a short QQNT-visible hostname without QQBot or container words', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, @@ -185,11 +185,11 @@ describe('NapCat device identity persistence', () => { selfId: '10001', }); - expect(identity.hostname).toMatch(/^(ubuntu|linux)-pc-[a-f0-9]{8,12}$/); + expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/); expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i); }); - it('generates a stable MAC from approved physical OUI prefixes', async () => { + it('generates a stable Docker bridge MAC that matches QQNT machine-info expectations', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, @@ -202,16 +202,15 @@ describe('NapCat device identity persistence', () => { selfId: '10001', }); - expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/); - expect(identity.macAddress).not.toMatch(/^02:42/i); + expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/); expect(identity.macAddress).not.toMatch(/^52:54:00/i); expect(identity.macAddress).not.toMatch( /^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i, ); - expect(identity.macStrategy).toBe('physical-oui-v1'); + expect(identity.macStrategy).toBe('docker-bridge-mac-v1'); }); - it('records migration evidence when an existing Docker-style identity is upgraded', async () => { + it('records regression-repair evidence when an existing identity is realigned for QQNT', async () => { const repository = createIdentityRepository(); repository.seedIdentity({ accountId: 'account-10001', @@ -237,13 +236,14 @@ describe('NapCat device identity persistence', () => { selfId: '10001', }); + expect(identity.macAddress).toMatch(/^02:42:/i); expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd'); expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001'); expect(identity.lastLoginEvidence).toMatchObject({ migration: { fromMacAddress: '02:42:aa:bb:cc:dd', - strategy: 'physical-oui-v1', - trigger: 'legacy-docker-identity-upgrade', + strategy: 'docker-bridge-mac-v1', + trigger: 'qqnt-device-name-regression-repair', }, }); }); @@ -268,7 +268,9 @@ describe('NapCat device identity persistence', () => { deviceEnvPath: `${identity.dataDir}/device.env`, hostname: identity.hostname, machineIdPath: identity.machineIdPath, + machineInfoPath: `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`, macAddress: identity.macAddress, + macAddressHyphen: identity.macAddress.replace(/:/g, '-'), }), ); expect(dockerOptions.runFlags).toEqual( @@ -311,10 +313,20 @@ describe('NapCat device identity persistence', () => { expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`); expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`); expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`); + expect(script).toContain( + `MACHINE_INFO_PATH='${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info'`, + ); + expect(script).toContain( + `NAPCAT_MAC_HYPHEN='${identity.macAddress.replace(/:/g, '-')}'`, + ); expect(script).toContain('cat > "$DEVICE_ENV_PATH" < { @@ -330,6 +342,7 @@ describe('NapCat device identity persistence', () => { where: jest.fn().mockReturnThis(), })), findOne: jest.fn().mockResolvedValue(null), + update: jest.fn(), }; const containerRepository = { create: jest.fn((input) => input), @@ -386,9 +399,19 @@ describe('NapCat device identity persistence', () => { }), ); const createScript = containerService.runProcess.mock.calls[0][2]; + expect(bindingRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-10001', + containerId: 'container-created', + }), + expect.objectContaining({ + deviceIdentityId: expect.any(String), + }), + ); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); + expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"'); }); it('reuses persisted device identity when rebuilding an existing account container login env', async () => { diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 6b09fd9..8a8f101 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -135,6 +135,94 @@ describe('NapCat runtime profile generation', () => { expect(profile.locale).not.toBe('C.UTF-8'); }); + it('persists planned runtime and protocol profiles when a managed container is rebuilt', async () => { + const runtimeProfileRepository = { + create: jest.fn((input) => ({ ...input })), + save: jest.fn(async (input) => input), + }; + const protocolProfileRepository = { + create: jest.fn((input) => ({ ...input })), + save: jest.fn(async (input) => input), + }; + const service = new (NapcatRuntimeProfileService as any)( + { + get: jest.fn((key: string, defaultValue?: string) => { + const values: Record = { + QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest', + QQBOT_NAPCAT_PROFILE_VERSION: 'napcat-runtime-profile-v1', + QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION: 'napcat-protocol-profile-v1', + }; + return values[key] || defaultValue || ''; + }), + }, + runtimeProfileRepository, + protocolProfileRepository, + ); + const runtimeProfile = service.resolveRuntimeProfile({ + accountId: 'account-1', + containerId: 'container-1', + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4', + deviceIdentityId: 'identity-1', + }); + + await service.recordPlannedProfiles({ + accountId: 'account-1', + containerId: 'container-1', + dataDir: runtimeProfile.dataDir, + deviceIdentity: { + deviceIdentityId: 'identity-1', + hostname: 'pc-a1b2c3d4', + hostnameStrategy: 'qqnt-visible-hostname-v1', + machineInfoPath: + '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info', + macAddress: '02:42:aa:bb:cc:dd', + macStrategy: 'docker-bridge-mac-v1', + }, + protocolProfile: { + napcatConfigHash: 'napcat-hash', + napcatConfigJson: { o3HookMode: 1 }, + o3HookGrayEnabled: false, + o3HookMode: 1, + onebotConfigHash: 'onebot-hash', + onebotConfigJson: { network: { websocketClients: [] } }, + packetBackend: 'auto', + packetServer: '', + }, + runtimeProfile, + }); + + expect(runtimeProfileRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + containerId: 'container-1', + deviceIdentityId: 'identity-1', + hostnameStrategy: 'qqnt-visible-hostname-v1', + lastCheckEvidence: expect.objectContaining({ + dataDir: runtimeProfile.dataDir, + machineInfoPath: + '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info', + macAddress: '02:42:aa:bb:cc:dd', + }), + macStrategy: 'docker-bridge-mac-v1', + profileStatus: 'pending', + profileVersion: 'napcat-runtime-profile-v1', + timezoneEvidence: { + expectedTimezone: 'Asia/Shanghai', + }, + }), + ); + expect(protocolProfileRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + containerId: 'container-1', + napcatConfigHash: 'napcat-hash', + onebotConfigHash: 'onebot-hash', + profileStatus: 'pending', + profileVersion: 'napcat-protocol-profile-v1', + }), + ); + }); + it('writes account-level NapCat and OneBot configs with minimal reverse WS only', () => { const writer = new NapcatConfigWriterService(new ToolsService()); const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE'; diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts index 260c342..068f7e6 100644 --- a/test/runtime/runtime-config.service.spec.ts +++ b/test/runtime/runtime-config.service.spec.ts @@ -36,9 +36,33 @@ describe('RuntimeConfigService', () => { database: 'kt', username: 'admin', synchronize: true, + timezone: '+08:00', }); }); + it('surfaces DB_TIMEZONE in the safe runtime database profile and config checks', () => { + const service = createService({ + DB_HOST: '127.0.0.1', + DB_PORT: '3307', + DB_DATABASE: 'kt', + DB_USERNAME: 'admin', + DB_TIMEZONE: 'Z', + }); + + expect(service.readDatabaseProfile()).toEqual( + expect.objectContaining({ + timezone: 'Z', + }), + ); + expect(service.getConfigChecks()).toContainEqual( + expect.objectContaining({ + key: 'DB_TIMEZONE', + level: 'optional', + present: true, + }), + ); + }); + it('masks secrets in checks and snapshots', () => { const service = createService({ ADMIN_TOKEN_SECRET: 'abcdef123456', diff --git a/test/runtime/runtime-health.service.spec.ts b/test/runtime/runtime-health.service.spec.ts index c69f101..955f9b1 100644 --- a/test/runtime/runtime-health.service.spec.ts +++ b/test/runtime/runtime-health.service.spec.ts @@ -17,6 +17,7 @@ function createSnapshot( database: 'kt', username: 'root', synchronize: false, + timezone: '+08:00', }, loki: { transportEnabled: true,