feat: 迁移NapCat真实设备风格身份
This commit is contained in:
parent
38cc5fb5bf
commit
923a383543
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)';
|
||||
|
||||
@ -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()),
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<NapcatDeviceIdentity>) => {
|
||||
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([
|
||||
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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user