feat: 迁移NapCat真实设备风格身份

This commit is contained in:
sunlei 2026-06-18 12:08:07 +08:00
parent 38cc5fb5bf
commit 923a383543
7 changed files with 260 additions and 39 deletions

View File

@ -70,8 +70,10 @@ CREATE TABLE IF NOT EXISTS `napcat_device_identity` (
`container_id` bigint DEFAULT NULL, `container_id` bigint DEFAULT NULL,
`data_dir` varchar(512) NOT NULL, `data_dir` varchar(512) NOT NULL,
`hostname` varchar(128) NOT NULL, `hostname` varchar(128) NOT NULL,
`hostname_strategy` varchar(64) NOT NULL DEFAULT 'legacy',
`machine_id_path` varchar(512) NOT NULL, `machine_id_path` varchar(512) NOT NULL,
`mac_address` varchar(64) NOT NULL, `mac_address` varchar(64) NOT NULL,
`mac_strategy` varchar(64) NOT NULL DEFAULT 'legacy',
`verification_status` varchar(32) NOT NULL, `verification_status` varchar(32) NOT NULL,
`last_login_evidence` json DEFAULT NULL, `last_login_evidence` json DEFAULT NULL,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),

View File

@ -951,8 +951,10 @@ CREATE TABLE IF NOT EXISTS napcat_device_identity (
container_id BIGINT NULL, container_id BIGINT NULL,
data_dir VARCHAR(512) NOT NULL, data_dir VARCHAR(512) NOT NULL,
hostname VARCHAR(128) NOT NULL, hostname VARCHAR(128) NOT NULL,
hostname_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy',
machine_id_path VARCHAR(512) NOT NULL, machine_id_path VARCHAR(512) NOT NULL,
mac_address VARCHAR(64) NOT NULL, mac_address VARCHAR(64) NOT NULL,
mac_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy',
verification_status VARCHAR(32) NOT NULL, verification_status VARCHAR(32) NOT NULL,
last_login_evidence JSON NULL, last_login_evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@ -183,3 +183,17 @@ WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_cleanup' AND table_name = 'napcat_runtime_cleanup'
AND column_name = 'session_id' AND column_name = 'session_id'
AND column_type = 'varchar(64)'; 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)';

View File

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

View File

@ -4,6 +4,10 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ensureSnowflakeId } from '@/common'; 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'; import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
type ResolveNapcatDeviceIdentityInput = { type ResolveNapcatDeviceIdentityInput = {
@ -26,17 +30,23 @@ export class NapcatDeviceIdentityService {
) {} ) {}
/** /**
* For Account * Resolves the stable device identity used when an account creates or rebuilds its NapCat container.
* @param input - input 使 `accountId``containerId``selfId` * @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) { async resolveForAccount(input: ResolveNapcatDeviceIdentityInput) {
const accountId = `${input.accountId}`.trim(); const accountId = `${input.accountId}`.trim();
const containerName = this.buildContainerName(input.selfId || accountId);
const existing = await this.identityRepository.findOne({ const existing = await this.identityRepository.findOne({
where: { accountId }, where: { accountId },
}); });
if (existing) { if (existing) {
const containerId = input.containerId || null; const containerId = input.containerId || null;
await this.migrateLegacyIdentityIfNeeded(existing, {
accountId,
containerName,
selfId: input.selfId || '',
});
if (containerId && existing.containerId !== containerId) { if (containerId && existing.containerId !== containerId) {
await this.identityRepository.update( await this.identityRepository.update(
{ id: existing.id }, { id: existing.id },
@ -47,15 +57,16 @@ export class NapcatDeviceIdentityService {
return existing; return existing;
} }
const containerName = this.buildContainerName(input.selfId || accountId);
const dataDir = `${this.getRootDir()}/${containerName}`; const dataDir = `${this.getRootDir()}/${containerName}`;
const identity = this.identityRepository.create({ const identity = this.identityRepository.create({
accountId, accountId,
containerId: input.containerId || null, containerId: input.containerId || null,
dataDir, dataDir,
hostname: this.buildHostname(containerName), hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`),
hostnameStrategy: 'desktop-hostname-v1',
lastLoginEvidence: null, lastLoginEvidence: null,
macAddress: this.buildMacAddress(accountId, containerName), macAddress: this.buildPhysicalMacAddress(accountId, containerName),
macStrategy: 'physical-oui-v1',
machineIdPath: `${dataDir}/machine-id`, machineIdPath: `${dataDir}/machine-id`,
verificationStatus: 'pending', verificationStatus: 'pending',
}); });
@ -65,8 +76,8 @@ export class NapcatDeviceIdentityService {
} }
/** /**
* NapCat * Builds the stable container directory name used for data-dir ownership.
* @param seed - seed NapCat对象 * @param seed - QQ self id or account id used in container path compatibility, not in the public hostname.
*/ */
private buildContainerName(seed: string) { private buildContainerName(seed: string) {
const prefix = this.getConfig( const prefix = this.getConfig(
@ -80,37 +91,93 @@ export class NapcatDeviceIdentityService {
} }
/** /**
* NapCat * Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms.
* @param containerName - containerName `createHash()` NapCat步骤 * @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text.
*/ */
private buildHostname(containerName: string) { private buildDesktopHostname(seed: string) {
const normalized = containerName const hash = createHash('sha256').update(seed).digest('hex');
.replace(/[^a-zA-Z0-9-]/g, '-') return `ubuntu-pc-${hash.slice(0, 10)}`;
.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);
} }
/** /**
* NapCat * Builds a stable MAC using a physical-device-style OUI prefix.
* @param accountId - ID * @param accountId - Account id used as a deterministic seed, not as visible output.
* @param containerName - containerName NapCat对象 * @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') const hash = createHash('sha256')
.update(`${accountId}:${containerName}`) .update(`${accountId}:${containerName}:physical-oui-v1`)
.digest('hex'); .digest('hex');
return [ const prefixIndex =
'02', Number.parseInt(hash.slice(0, 4), 16) %
'42', NAPCAT_PHYSICAL_OUI_PREFIXES.length;
hash.slice(0, 2), const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex];
hash.slice(2, 4), const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)];
hash.slice(4, 6), const mac = `${prefix}:${suffix.join(':')}`.toLowerCase();
hash.slice(6, 8),
].join(':'); 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',
});
} }
/** /**

View File

@ -36,12 +36,18 @@ export class NapcatDeviceIdentity {
@Column({ length: 128 }) @Column({ length: 128 })
hostname: string; hostname: string;
@Column({ default: 'legacy', length: 64, name: 'hostname_strategy' })
hostnameStrategy: string;
@Column({ length: 512, name: 'machine_id_path' }) @Column({ length: 512, name: 'machine_id_path' })
machineIdPath: string; machineIdPath: string;
@Column({ length: 64, name: 'mac_address' }) @Column({ length: 64, name: 'mac_address' })
macAddress: string; macAddress: string;
@Column({ default: 'legacy', length: 64, name: 'mac_strategy' })
macStrategy: string;
@Column({ length: 32, name: 'verification_status' }) @Column({ length: 32, name: 'verification_status' })
verificationStatus: NapcatDeviceVerificationStatus; verificationStatus: NapcatDeviceVerificationStatus;

View File

@ -51,6 +51,13 @@ const createIdentityRepository = () => {
identities.set(identity.accountId, identity); identities.set(identity.accountId, identity);
return 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( update: jest.fn(
async ({ id }: { id: string }, input: Partial<NapcatDeviceIdentity>) => { async ({ id }: { id: string }, input: Partial<NapcatDeviceIdentity>) => {
const current = [...identities.values()].find((item) => item.id === id); const current = [...identities.values()].find((item) => item.id === id);
@ -79,14 +86,21 @@ describe('NapCat device identity persistence', () => {
const schema = readRefactorV3SqlSchema(); const schema = readRefactorV3SqlSchema();
it('declares Batch 7 NapCat runtime persistence tables', () => { it('declares Batch 7 NapCat runtime persistence tables', () => {
expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual([ expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual(
expect.arrayContaining([
'napcat_container', 'napcat_container',
'napcat_device_identity', 'napcat_device_identity',
'napcat_account_binding', 'napcat_account_binding',
'napcat_login_session', 'napcat_login_session',
'napcat_login_challenge', 'napcat_login_challenge',
'napcat_runtime_cleanup', '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) { for (const table of NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables) {
expect(schema.hasTable(table)).toBe(true); 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 () => { it('turns a persisted device identity into Docker device options', async () => {
const repository = createIdentityRepository(); const repository = createIdentityRepository();
const service = new NapcatDeviceIdentityService( const service = new NapcatDeviceIdentityService(