From 923a38354348178728dad9090b6c5f35a6245f1c Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 12:08:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BBNapCat=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E8=AE=BE=E5=A4=87=E9=A3=8E=E6=A0=BC=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/qqbot-init.sql | 2 + sql/refactor-v3/00-full-schema.sql | 2 + sql/refactor-v3/99-verify.sql | 14 ++ .../runtime/napcat-physical-oui-catalog.ts | 40 ++++++ .../device/napcat-device-identity.service.ts | 129 +++++++++++++----- .../napcat-device-identity.entity.ts | 6 + .../qqbot/napcat/device-identity.spec.ts | 106 ++++++++++++-- 7 files changed, 260 insertions(+), 39 deletions(-) create mode 100644 src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 97f099e..9926196 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -70,8 +70,10 @@ CREATE TABLE IF NOT EXISTS `napcat_device_identity` ( `container_id` bigint DEFAULT NULL, `data_dir` varchar(512) NOT NULL, `hostname` varchar(128) NOT NULL, + `hostname_strategy` varchar(64) NOT NULL DEFAULT 'legacy', `machine_id_path` varchar(512) NOT NULL, `mac_address` varchar(64) NOT NULL, + `mac_strategy` varchar(64) NOT NULL DEFAULT 'legacy', `verification_status` varchar(32) NOT NULL, `last_login_evidence` json DEFAULT NULL, `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 60bc22b..f4acf30 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -951,8 +951,10 @@ CREATE TABLE IF NOT EXISTS napcat_device_identity ( container_id BIGINT NULL, data_dir VARCHAR(512) NOT NULL, hostname VARCHAR(128) NOT NULL, + hostname_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy', machine_id_path VARCHAR(512) NOT NULL, mac_address VARCHAR(64) NOT NULL, + mac_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy', verification_status VARCHAR(32) NOT NULL, last_login_evidence JSON NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 98ffad8..d490dc5 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -183,3 +183,17 @@ WHERE table_schema = DATABASE() AND table_name = 'napcat_runtime_cleanup' AND column_name = 'session_id' AND column_type = 'varchar(64)'; + +SELECT 'column_napcat_device_identity_hostname_strategy' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'napcat_device_identity' + AND column_name = 'hostname_strategy' + AND column_type = 'varchar(64)'; + +SELECT 'column_napcat_device_identity_mac_strategy' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'napcat_device_identity' + AND column_name = 'mac_strategy' + AND column_type = 'varchar(64)'; diff --git a/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts b/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts new file mode 100644 index 0000000..1d1d64d --- /dev/null +++ b/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts @@ -0,0 +1,40 @@ +export const NAPCAT_PHYSICAL_OUI_PREFIXES = [ + '00:1A:2B', + '00:1B:21', + '00:1E:67', + '00:22:68', + '00:24:D7', + '00:25:90', + '00:26:B9', + '3C:97:0E', + '44:8A:5B', + '58:11:22', + '6C:88:14', + '70:85:C2', + '84:2B:2B', + 'A0:36:9F', + 'B4:2E:99', +] as const; + +export const NAPCAT_REJECTED_VIRTUAL_OUI_PREFIXES = [ + '02:42', + '52:54:00', + '00:05:69', + '00:0C:29', + '00:1C:14', + '00:50:56', + '00:15:5D', + '00:03:FF', +] as const; + +/** + * Checks whether a generated MAC starts with a Docker or VM-style prefix. + * @param macAddress - Stable MAC candidate generated from account/device seed. + * @returns True when the prefix belongs to a rejected container or virtualization range. + */ +export function isRejectedVirtualMacPrefix(macAddress: string) { + const normalized = macAddress.toUpperCase(); + return NAPCAT_REJECTED_VIRTUAL_OUI_PREFIXES.some((prefix) => + normalized.startsWith(prefix.toUpperCase()), + ); +} 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 9375903..8ae973d 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,6 +4,10 @@ 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'; type ResolveNapcatDeviceIdentityInput = { @@ -26,17 +30,23 @@ export class NapcatDeviceIdentityService { ) {} /** - * 解析For Account。 - * @param input - input 输入;使用 `accountId`、`containerId`、`selfId` 字段生成结果。 + * Resolves the stable device identity used when an account creates or rebuilds its NapCat container. + * @param input - Account id selects the persistent identity row, container id updates the active binding, and self id seeds non-visible deterministic device values. */ async resolveForAccount(input: ResolveNapcatDeviceIdentityInput) { const accountId = `${input.accountId}`.trim(); + const containerName = this.buildContainerName(input.selfId || accountId); const existing = await this.identityRepository.findOne({ where: { accountId }, }); if (existing) { const containerId = input.containerId || null; + await this.migrateLegacyIdentityIfNeeded(existing, { + accountId, + containerName, + selfId: input.selfId || '', + }); if (containerId && existing.containerId !== containerId) { await this.identityRepository.update( { id: existing.id }, @@ -47,15 +57,16 @@ export class NapcatDeviceIdentityService { return existing; } - const containerName = this.buildContainerName(input.selfId || accountId); const dataDir = `${this.getRootDir()}/${containerName}`; const identity = this.identityRepository.create({ accountId, containerId: input.containerId || null, dataDir, - hostname: this.buildHostname(containerName), + hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`), + hostnameStrategy: 'desktop-hostname-v1', lastLoginEvidence: null, - macAddress: this.buildMacAddress(accountId, containerName), + macAddress: this.buildPhysicalMacAddress(accountId, containerName), + macStrategy: 'physical-oui-v1', machineIdPath: `${dataDir}/machine-id`, verificationStatus: 'pending', }); @@ -65,8 +76,8 @@ export class NapcatDeviceIdentityService { } /** - * 创建 NapCat 登录运行态对象或配置。 - * @param seed - seed 输入;生成 NapCat对象。 + * Builds the stable container directory name used for data-dir ownership. + * @param seed - QQ self id or account id used in container path compatibility, not in the public hostname. */ private buildContainerName(seed: string) { const prefix = this.getConfig( @@ -80,37 +91,93 @@ export class NapcatDeviceIdentityService { } /** - * 创建 NapCat 登录运行态对象或配置。 - * @param containerName - containerName 输入;驱动 `createHash()` 的 NapCat步骤。 + * Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms. + * @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text. */ - private buildHostname(containerName: string) { - const normalized = containerName - .replace(/[^a-zA-Z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - if (normalized.length <= 63) return normalized; - - const hash = createHash('sha256').update(containerName).digest('hex'); - return `${normalized.slice(0, 50)}-${hash.slice(0, 12)}`.slice(0, 63); + private buildDesktopHostname(seed: string) { + const hash = createHash('sha256').update(seed).digest('hex'); + return `ubuntu-pc-${hash.slice(0, 10)}`; } /** - * 创建 NapCat 登录运行态对象或配置。 - * @param accountId - 账号 ID;定位本次读取、更新、删除或关联的账号。 - * @param containerName - containerName 输入;生成 NapCat对象。 + * Builds a stable MAC using a physical-device-style OUI prefix. + * @param accountId - Account id used as a deterministic seed, not as visible output. + * @param containerName - Container name mixed into the deterministic seed. */ - private buildMacAddress(accountId: string, containerName: string) { + private buildPhysicalMacAddress(accountId: string, containerName: string) { const hash = createHash('sha256') - .update(`${accountId}:${containerName}`) + .update(`${accountId}:${containerName}:physical-oui-v1`) .digest('hex'); - return [ - '02', - '42', - hash.slice(0, 2), - hash.slice(2, 4), - hash.slice(4, 6), - hash.slice(6, 8), - ].join(':'); + 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; + } + + /** + * Migrates an existing Docker-style identity to the stable desktop profile once. + * @param identity - Persisted identity row loaded for the account being prepared. + * @param input - Current account/container seed used to derive deterministic target values. + */ + private async migrateLegacyIdentityIfNeeded( + identity: NapcatDeviceIdentity, + input: { + accountId: string; + containerName: string; + selfId: string; + }, + ) { + const nextHostname = this.buildDesktopHostname( + `${input.accountId}:${input.selfId}`, + ); + const nextMacAddress = this.buildPhysicalMacAddress( + input.accountId, + input.containerName, + ); + const needsMigration = + identity.hostname !== nextHostname || + isRejectedVirtualMacPrefix(identity.macAddress); + + if (!needsMigration) { + return; + } + + const migrationEvidence = { + migration: { + fromHostname: identity.hostname, + fromMacAddress: identity.macAddress, + strategy: 'physical-oui-v1', + toHostname: nextHostname, + toMacAddress: nextMacAddress, + trigger: 'legacy-docker-identity-upgrade', + }, + }; + + await this.identityRepository.update( + { id: identity.id }, + { + hostname: nextHostname, + hostnameStrategy: 'desktop-hostname-v1', + lastLoginEvidence: migrationEvidence, + macAddress: nextMacAddress, + macStrategy: 'physical-oui-v1', + }, + ); + Object.assign(identity, { + hostname: nextHostname, + hostnameStrategy: 'desktop-hostname-v1', + lastLoginEvidence: migrationEvidence, + macAddress: nextMacAddress, + macStrategy: 'physical-oui-v1', + }); } /** diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts index c7a5720..78f725d 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts @@ -36,12 +36,18 @@ export class NapcatDeviceIdentity { @Column({ length: 128 }) hostname: string; + @Column({ default: 'legacy', length: 64, name: 'hostname_strategy' }) + hostnameStrategy: string; + @Column({ length: 512, name: 'machine_id_path' }) machineIdPath: string; @Column({ length: 64, name: 'mac_address' }) macAddress: string; + @Column({ default: 'legacy', length: 64, name: 'mac_strategy' }) + macStrategy: string; + @Column({ length: 32, name: 'verification_status' }) verificationStatus: NapcatDeviceVerificationStatus; diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index 290f5c9..279b1dd 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -51,6 +51,13 @@ const createIdentityRepository = () => { identities.set(identity.accountId, identity); return identity; }), + /** + * Seeds a persisted identity so migration tests can start from legacy Docker-style values. + * @param identity - In-memory row keyed by account id for the repository fake. + */ + seedIdentity: jest.fn((identity: NapcatDeviceIdentity) => { + identities.set(identity.accountId, identity); + }), update: jest.fn( async ({ id }: { id: string }, input: Partial) => { const current = [...identities.values()].find((item) => item.id === id); @@ -79,14 +86,21 @@ describe('NapCat device identity persistence', () => { const schema = readRefactorV3SqlSchema(); it('declares Batch 7 NapCat runtime persistence tables', () => { - expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual([ - 'napcat_container', - 'napcat_device_identity', - 'napcat_account_binding', - 'napcat_login_session', - 'napcat_login_challenge', - 'napcat_runtime_cleanup', - ]); + expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual( + expect.arrayContaining([ + 'napcat_container', + 'napcat_device_identity', + 'napcat_account_binding', + 'napcat_login_session', + 'napcat_login_challenge', + 'napcat_runtime_cleanup', + 'napcat_runtime_profile', + 'napcat_protocol_profile', + 'napcat_session_behavior_profile', + 'napcat_login_event', + 'napcat_risk_mode', + ]), + ); for (const table of NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables) { expect(schema.hasTable(table)).toBe(true); @@ -158,6 +172,82 @@ describe('NapCat device identity persistence', () => { ); }); + it('generates a real-device style hostname without QQBot or container words', async () => { + const repository = createIdentityRepository(); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + + const identity = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-first', + selfId: '10001', + }); + + expect(identity.hostname).toMatch(/^(ubuntu|linux)-pc-[a-f0-9]{8,12}$/); + expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i); + }); + + it('generates a stable MAC from approved physical OUI prefixes', async () => { + const repository = createIdentityRepository(); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + + const identity = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-first', + 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).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'); + }); + + it('records migration evidence when an existing Docker-style identity is upgraded', async () => { + const repository = createIdentityRepository(); + repository.seedIdentity({ + accountId: 'account-10001', + containerId: 'container-first', + dataDir: + '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', + hostname: 'kt-qqbot-napcat-10001', + id: 'identity-1', + lastLoginEvidence: null, + macAddress: '02:42:aa:bb:cc:dd', + machineIdPath: + '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001/machine-id', + verificationStatus: 'pending', + } as NapcatDeviceIdentity); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + + const identity = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-rebuilt', + selfId: '10001', + }); + + 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', + }, + }); + }); + it('turns a persisted device identity into Docker device options', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService(