fix: 修复NapCat设备身份和时区配置

This commit is contained in:
sunlei 2026-06-18 18:45:20 +08:00
parent 674d238f6f
commit eaf22706d8
17 changed files with 835 additions and 68 deletions

View File

@ -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

2
API.md
View File

@ -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

View File

@ -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:*` MACDocker 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` 目录壳。

View File

@ -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.

View File

@ -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';

View File

@ -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<string>('DB_TIMEZONE') || '+08:00',
synchronize: configService.get<string>('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<string>('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({

View File

@ -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<string, unknown>;
o3HookGrayEnabled?: boolean;
o3HookMode?: 0 | 1;
onebotConfigHash?: string;
onebotConfigJson?: Record<string, unknown>;
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<NapcatRuntimeProfile>,
@Optional()
@InjectRepository(NapcatProtocolProfile)
private readonly protocolProfileRepository?: Repository<NapcatProtocolProfile>,
) {}
/**
* 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.

View File

@ -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"',

View File

@ -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" <<EOF
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
NAPCAT_MAC_HYPHEN=$NAPCAT_MAC_HYPHEN
MACHINE_ID_PATH=$MACHINE_ID_PATH
MACHINE_INFO_PATH=$MACHINE_INFO_PATH
EOF
if [ ! -s "$MACHINE_ID_PATH" ]; then
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$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.

View File

@ -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,
});
}

View File

@ -23,6 +23,7 @@ const REQUIRED_CONFIG_KEYS = [
] as const;
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
'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'),
};
}

View File

@ -19,6 +19,7 @@ export interface RuntimeDatabaseConfig {
database: string;
username: string;
synchronize: boolean;
timezone: string;
}
export interface RuntimeLokiConfig {

View File

@ -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<string, unknown>) {
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');
});
});

View File

@ -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" <<EOF');
expect(script).toContain('MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)');
expect(script).toContain("printf '\\000\\000\\000\\021'");
expect(script).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'");
expect(script).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(script).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(script).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
expect(script).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
});
it('uses persisted device identity when preparing an account managed container', async () => {
@ -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 () => {

View File

@ -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<string, string> = {
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';

View File

@ -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',

View File

@ -17,6 +17,7 @@ function createSnapshot(
database: 'kt',
username: 'root',
synchronize: false,
timezone: '+08:00',
},
loki: {
transportEnabled: true,