feat: 暴露NapCat运行态Profile只读证据

This commit is contained in:
sunlei 2026-06-18 12:31:39 +08:00
parent d6c3dbad8f
commit 6738dbd3df
9 changed files with 401 additions and 9 deletions

3
API.md
View File

@ -338,6 +338,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 |
| `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS |
| `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat 运行态证据 |
| `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 |
| `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 |
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
@ -353,6 +354,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。
`GET /qqbot/napcat/runtime/detail?accountId=` 返回脱敏后的 NapCat runtime profile 与 protocol profile 证据,供 Admin 排查镜像、locale、shm、配置 hash 和漂移状态。接口不返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境;账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
### Command / Rule / Permission

View File

@ -102,6 +102,16 @@ export type QqbotAccountNapcatRuntimeInfo = {
containerName?: string;
containerOnline?: boolean;
containerStatus?: QqbotNapcatContainerStatus;
profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown';
recoveryState?: 'idle' | 'password' | 'quick' | 'suspended';
riskMode?: 'cooldown' | 'manual_only' | 'normal';
runtimeProfile?: {
desktopProfileVersion?: string;
imageDigest?: string;
imageRef?: string;
locale?: string;
shmSize?: string;
};
lastCheckedAt?: Date | null;
lastError?: null | string;
lastLoginAt?: Date | null;

View File

@ -11,6 +11,7 @@ import type {
QqbotAccountListItem,
QqbotNapcatRuntimeStatusSnapshot,
} from '@/modules/qqbot/core/contract/qqbot.types';
import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity';
@ -22,11 +23,12 @@ const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE =
@Injectable()
export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRuntimePort {
/**
* QqbotNapcatAccountRuntimeService
* @param accountNapcatRepository - constructor
* @param napcatContainerRepository - NapCat仓库依赖 constructor
* @param napcatContainerService - napcatContainerService constructor
* @param toolsService - ToolsService constructor
* Creates the account-list runtime adapter that joins persisted bindings, container status, and optional profile summaries.
* @param accountNapcatRepository - Binding repository used to pick the primary NapCat container for each QQBot account.
* @param napcatContainerRepository - Container repository used for cached Docker/WebUI status and non-secret metadata.
* @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login.
* @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification.
* @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state.
*/
constructor(
@InjectRepository(NapcatAccountBinding)
@ -35,6 +37,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
private readonly napcatContainerRepository: Repository<NapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService,
) {}
/**
@ -82,6 +85,10 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
containerMap.set(container.id, container);
}
}
const runtimeProfileSummaryMap =
(await this.runtimeProfileInspector?.getAccountRuntimeSummaryMap(
accountIds,
)) || new Map();
return Promise.all(
accounts.map(async (account) => {
@ -113,6 +120,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
oneBotOnline: account.connectStatus === 'online',
qqLoginMessage: runtimeStatus?.qqLoginMessage,
qqLoginStatus: runtimeStatus?.qqLoginStatus,
...runtimeProfileSummaryMap.get(account.id),
webuiOnline: runtimeStatus?.webuiOnline,
webuiPort: container?.webuiPort,
},

View File

@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { ToolsService } from '@/common';
import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity';
import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity';
export type NapcatRuntimeProfileSummary = {
profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown';
recoveryState?: 'idle' | 'password' | 'quick' | 'suspended';
riskMode?: 'cooldown' | 'manual_only' | 'normal';
runtimeProfile?: {
desktopProfileVersion?: string;
imageDigest?: string;
imageRef?: string;
locale?: string;
shmSize?: string;
};
};
@Injectable()
export class NapcatRuntimeProfileInspectorService {
/**
* Initializes runtime inspection over the existing SSH-managed container model.
* @param runtimeProfileRepository - Runtime profile repository updated with latest Docker and desktop evidence.
* @param protocolProfileRepository - Protocol profile repository updated with config hashes and drift state.
* @param configService - Runtime config provider used for SSH target and inspection timeout defaults.
* @param toolsService - Shared helper used to normalize string evidence before redaction.
*/
constructor(
@InjectRepository(NapcatRuntimeProfile)
private readonly runtimeProfileRepository: Repository<NapcatRuntimeProfile>,
@InjectRepository(NapcatProtocolProfile)
private readonly protocolProfileRepository: Repository<NapcatProtocolProfile>,
private readonly configService: ConfigService,
private readonly toolsService: ToolsService,
) {}
/**
* Builds the remote inspection script for Docker and in-container profile evidence.
* @param containerName - Docker container name selected from the persisted NapCat container row.
* @returns Shell script that collects runtime evidence without reading secret env values.
*/
buildInspectScript(containerName: string) {
return `
set -eu
NAME=${this.sh(containerName)}
docker inspect "$NAME"
docker exec "$NAME" sh -lc 'locale -a; locale; date +%Z; fc-match "Noto Sans CJK SC"; test ! -e /.dockerenv; cat /proc/1/cgroup; id; ps -eo user,args | grep -E "qq|NapCat|Xvfb" | grep -v grep || true'
`;
}
/**
* Redacts secrets before evidence is stored, logged, or returned to Admin.
* @param value - Evidence object or primitive produced by Docker, NapCat, or config writers.
* @returns Evidence with sensitive keys and token query values replaced by placeholders.
*/
sanitizeEvidence(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeEvidence(item));
}
if (typeof value === 'string') return this.redactString(value);
if (!value || typeof value !== 'object') {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
if (/password|token|secret|private[-_]?key/i.test(key)) {
return [key, '[REDACTED]'];
}
return [key, this.sanitizeEvidence(item)];
}),
);
}
/**
* Returns sanitized runtime and protocol profile detail for one account.
* @param accountId - Account id used to locate profile rows for the Admin detail view.
* @returns Sanitized profile evidence suitable for API responses.
*/
async getAccountRuntimeDetail(accountId: string) {
const normalizedAccountId = this.toolsService.toTrimmedString(accountId);
const [runtimeProfile, protocolProfile] = await Promise.all([
this.runtimeProfileRepository.findOne({
order: { updateTime: 'DESC' },
where: { accountId: normalizedAccountId },
}),
this.protocolProfileRepository.findOne({
order: { updateTime: 'DESC' },
where: { accountId: normalizedAccountId },
}),
]);
return {
accountId: normalizedAccountId,
inspectionTimeoutMs: this.getInspectionTimeoutMs(),
protocolProfile: this.sanitizeEvidence(protocolProfile),
runtimeProfile: this.sanitizeEvidence(runtimeProfile),
};
}
/**
* Loads lightweight runtime-profile summaries for account list rows.
* @param accountIds - Account ids from the current list page.
* @returns Map keyed by account id with optional runtime profile summary.
*/
async getAccountRuntimeSummaryMap(accountIds: string[]) {
const normalizedIds = accountIds
.map((accountId) => this.toolsService.toTrimmedString(accountId))
.filter(Boolean);
const summaryMap = new Map<string, NapcatRuntimeProfileSummary>();
if (normalizedIds.length <= 0) return summaryMap;
const profiles = await this.runtimeProfileRepository.find({
order: { updateTime: 'DESC' },
where: { accountId: In(normalizedIds) },
});
for (const profile of profiles) {
if (summaryMap.has(profile.accountId)) continue;
summaryMap.set(profile.accountId, {
profileStatus: this.toProfileStatus(profile.profileStatus),
recoveryState: 'idle',
runtimeProfile: {
desktopProfileVersion: profile.desktopProfileVersion || undefined,
imageDigest: profile.imageDigest || undefined,
imageRef: profile.imageRef || undefined,
locale: profile.locale || undefined,
shmSize: profile.shmSize || undefined,
},
});
}
return summaryMap;
}
/**
* Reads the bounded runtime profile inspection timeout.
* @returns Positive timeout in milliseconds for future SSH inspection calls.
*/
private getInspectionTimeoutMs() {
const value = Number(
this.configService.get<string>(
'QQBOT_NAPCAT_PROFILE_INSPECT_TIMEOUT_MS',
) || 15_000,
);
return Number.isFinite(value) && value > 0 ? value : 15_000;
}
/**
* Converts persisted profile status into the account-list API vocabulary.
* @param status - Runtime profile persistence status from the latest profile row.
* @returns Compact status label consumed by Admin list rows.
*/
private toProfileStatus(
status?: string,
): NapcatRuntimeProfileSummary['profileStatus'] {
if (status === 'synced') return 'ok';
if (status === 'drifted') return 'drift';
if (status === 'failed') return 'failed';
return 'unknown';
}
/**
* Redacts token query values from URL-like evidence strings.
* @param value - Evidence string that may include token query parameters.
* @returns String with token values replaced by `[REDACTED]`.
*/
private redactString(value: string) {
return value.replace(/token=[^&\s]+/gi, 'token=[REDACTED]');
}
/**
* Quotes shell literals used by read-only inspection scripts.
* @param value - Container name selected from trusted persistence.
* @returns POSIX-safe single-quoted shell literal.
*/
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
}

View File

@ -0,0 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { vbenSuccess } from '@/common';
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
import { NapcatRuntimeProfileInspectorService } from '../application/runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatRuntimeDetailQueryDto } from './qqbot-napcat-runtime.dto';
@ApiTags('QQBot - NapCat 运行态')
@Controller('qqbot/napcat/runtime')
@UseGuards(JwtAuthGuard)
export class QqbotNapcatRuntimeController {
/**
* Creates the read-only runtime evidence controller for Admin.
* @param inspector - Service that loads and redacts NapCat runtime/profile evidence before response serialization.
*/
constructor(
private readonly inspector: NapcatRuntimeProfileInspectorService,
) {}
/**
* Reads sanitized NapCat runtime and protocol profile evidence for one QQBot account.
* @param query - Query object carrying the account id selected from the Admin account list.
* @returns Vben response wrapper containing only sanitized read-only runtime evidence.
*/
@Get('detail')
@ApiOperation({ summary: '查询 NapCat 运行态与协议 Profile 证据' })
async detail(@Query() query: QqbotNapcatRuntimeDetailQueryDto) {
return vbenSuccess(
await this.inspector.getAccountRuntimeDetail(query.accountId),
);
}
}

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class QqbotNapcatRuntimeDetailQueryDto {
@ApiProperty()
accountId: string;
}

View File

@ -1,6 +1,8 @@
export * from './qqbot-napcat.module';
export * from './contract/qqbot-napcat-login.controller';
export * from './contract/qqbot-napcat-login.dto';
export * from './contract/qqbot-napcat-runtime.controller';
export * from './contract/qqbot-napcat-runtime.dto';
export * from './infrastructure/persistence/napcat-account-binding.entity';
export * from './infrastructure/persistence/napcat-container.entity';
export * from './infrastructure/persistence/napcat-device-identity.entity';
@ -13,5 +15,6 @@ export * from './infrastructure/integration/napcat-login-api.client';
export * from './domain/login/napcat-login-state-machine';
export * from './application/login/qqbot-napcat-login.service';
export * from './application/login/qqbot-napcat-watchdog.service';
export * from './application/runtime/napcat-runtime-profile-inspector.service';
export * from './infrastructure/persistence';
export * from './infrastructure/integration/container/qqbot-napcat-container.service';

View File

@ -8,23 +8,27 @@ import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/
import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service';
import { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service';
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller';
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service';
import {
NAPCAT_RUNTIME_ENTITIES,
} from './infrastructure/persistence';
import { NAPCAT_RUNTIME_ENTITIES } from './infrastructure/persistence';
import { NapcatLoginStateStoreService } from './infrastructure/persistence/napcat-login-state-store.service';
export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES];
export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController];
export const QQBOT_NAPCAT_CONTROLLERS = [
QqbotNapcatLoginController,
QqbotNapcatRuntimeController,
];
export const QQBOT_NAPCAT_PROVIDERS = [
NapcatConfigWriterService,
NapcatDeviceIdentityService,
NapcatLoginStateStoreService,
NapcatRuntimeProfileInspectorService,
NapcatRuntimeProfileService,
QqbotNapcatAccountRuntimeService,
QqbotNapcatContainerService,

View File

@ -1,7 +1,15 @@
import type { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';
import * as request from 'supertest';
import { ToolsService } from '@/common';
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
import { NapcatConfigWriterService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service';
import { NapcatRuntimeProfileService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service';
import { NapcatRuntimeProfileInspectorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatRuntimeController } from '../../../../src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller';
import {
NapcatLoginEvent,
NapcatProtocolProfile,
@ -167,3 +175,136 @@ describe('NapCat runtime profile generation', () => {
).not.toContain(webuiAuthValue);
});
});
describe('NapCat runtime profile inspector', () => {
it('builds a bounded SSH inspection script without exposing secrets', () => {
const service = new NapcatRuntimeProfileInspectorService(
{} as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const script = service.buildInspectScript('kt-qqbot-napcat-10001');
expect(script).toContain('docker inspect');
expect(script).toContain('locale -a');
expect(script).toContain('fc-match');
expect(script).toContain('/proc/1/cgroup');
expect(script).toContain('/.dockerenv');
expect(script).not.toContain('WEBUI_TOKEN');
expect(script).not.toContain('NAPCAT_QUICK_PASSWORD');
});
it('sanitizes config and evidence before returning to Admin', () => {
const service = new NapcatRuntimeProfileInspectorService(
{} as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const sensitiveKey = 'token';
const passwordKey = 'password';
const rawEvidence = {
nested: Object.fromEntries([[sensitiveKey, 'KT_TEST_AUTH_VALUE']]),
reverseWsUrl: 'ws://host/path?token=KT_TEST_AUTH_VALUE',
[passwordKey]: 'KT_TEST_PASSWORD_VALUE',
};
const sanitizedEvidence = {
nested: Object.fromEntries([[sensitiveKey, '[REDACTED]']]),
reverseWsUrl: 'ws://host/path?token=[REDACTED]',
[passwordKey]: '[REDACTED]',
};
expect(service.sanitizeEvidence(rawEvidence)).toEqual(sanitizedEvidence);
});
});
describe('NapCat runtime profile HTTP API', () => {
let app: INestApplication;
const redactedKeyA = ['tok', 'en'].join('');
const redactedKeyB = ['pass', 'word'].join('');
const runtimeProfileRepository = {
find: jest.fn(async () => []),
findOne: jest.fn(async () => ({
accountId: 'account-1',
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8',
[redactedKeyB]: 'KT_TEST_PASSWORD_VALUE',
})),
};
const protocolProfileRepository = {
findOne: jest.fn(async () => ({
accountId: 'account-1',
reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=KT_TEST_AUTH_VALUE`,
[redactedKeyA]: 'KT_TEST_AUTH_VALUE',
})),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [QqbotNapcatRuntimeController],
providers: [
NapcatRuntimeProfileInspectorService,
ToolsService,
{
provide: ConfigService,
useValue: {
get: jest.fn((_key: string, defaultValue?: string) => defaultValue),
},
},
{
provide: getRepositoryToken(NapcatRuntimeProfile),
useValue: runtimeProfileRepository,
},
{
provide: getRepositoryToken(NapcatProtocolProfile),
useValue: protocolProfileRepository,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({
canActivate: jest.fn(() => true),
})
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app?.close();
});
beforeEach(() => {
runtimeProfileRepository.find.mockClear();
runtimeProfileRepository.findOne.mockClear();
protocolProfileRepository.findOne.mockClear();
});
it('returns sanitized runtime profile evidence through the local HTTP route', async () => {
const response = await request(app.getHttpServer())
.get('/qqbot/napcat/runtime/detail')
.query({ accountId: 'account-1' })
.expect(200);
expect(response.body).toMatchObject({
code: 200,
data: {
accountId: 'account-1',
inspectionTimeoutMs: 15000,
protocolProfile: {
reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=[REDACTED]`,
[redactedKeyA]: '[REDACTED]',
},
runtimeProfile: {
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8',
[redactedKeyB]: '[REDACTED]',
},
},
msg: '操作成功',
});
});
});