feat: 注册STUN系统消息源

This commit is contained in:
sunlei 2026-07-24 05:08:02 +08:00
parent 832046b3bc
commit 3dbc37e500
8 changed files with 941 additions and 18 deletions

View File

@ -18,6 +18,7 @@ import { NetworkManagementController } from '@/modules/admin/platform-config/net
import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service';
import { NetworkPortForward } from '@/modules/admin/platform-config/network-management/network-management.entity';
import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service';
import { NetworkStunMessageSourceAdapter } from '@/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
import { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller';
import { SystemLogService } from '@/modules/admin/platform-config/system-log/system-log.service';
import { AdminTimezoneController } from '@/modules/admin/platform-config/timezone/admin-timezone.controller';
@ -88,6 +89,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
NetworkManagementEventStreamService,
NetworkDnsPodClient,
NetworkDdnsService,
NetworkStunMessageSourceAdapter,
NetworkAgentMqttService,
];

View File

@ -17,6 +17,7 @@ import {
} from './network-dnspod.client';
import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
import { NetworkPortForward } from './network-management.entity';
import { classifyStunEndpointSource } from './network-source-eligibility';
import type {
NetworkDdnsListQuery,
NetworkDdnsRecordInput,
@ -757,21 +758,13 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
private portForwardSourceOption(
mapping: NetworkPortForward,
): NetworkDdnsSourceOption {
let disabledReasonCode: null | string = null;
if (mapping.isDeleted || mapping.desiredPresence !== 'present') {
disabledReasonCode = 'SOURCE_DELETING';
} else if (mapping.protocol !== 'udp') {
disabledReasonCode = 'UDP_REQUIRED';
} else if (mapping.externalPort !== mapping.internalPort) {
disabledReasonCode = 'PORT_MISMATCH';
} else if (!mapping.keeperDesiredEnabled) {
disabledReasonCode = 'KEEPER_DISABLED';
}
const { disabledReasonCode, eligible } =
classifyStunEndpointSource(mapping);
const leaseValid =
isIP(mapping.currentPublicIpv4 || '') === 4 &&
!!mapping.currentValidUntil &&
new Date(mapping.currentValidUntil).getTime() > Date.now();
const sourceUsable = disabledReasonCode === null && leaseValid;
const sourceUsable = eligible && leaseValid;
return {
currentAddress: sourceUsable ? mapping.currentPublicIpv4 || null : null,
disabledReasonCode,
@ -996,13 +989,7 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
const mapping = await this.mappingRepository.findOne({
where: { id: portForwardId as string, isDeleted: false },
});
if (
!mapping ||
mapping.desiredPresence !== 'present' ||
mapping.protocol !== 'udp' ||
mapping.externalPort !== mapping.internalPort ||
!mapping.keeperDesiredEnabled
) {
if (!mapping || !classifyStunEndpointSource(mapping).eligible) {
throwVbenError(
'A 记录来源必须是已启用 Keeper 的同端口 UDP 转发',
HttpStatus.BAD_REQUEST,

View File

@ -0,0 +1,46 @@
import type { NetworkPortForward } from './network-management.entity';
/** Stable reason returned when a mapping cannot structurally act as a STUN endpoint source. */
export type StunEndpointSourceDisabledReason =
| 'KEEPER_DISABLED'
| 'PORT_MISMATCH'
| 'SOURCE_DELETING'
| 'UDP_REQUIRED';
/** Structural, lease-independent STUN source classification shared by DDNS and message delivery. */
export type StunEndpointSourceEligibility = {
disabledReasonCode: null | StunEndpointSourceDisabledReason;
eligible: boolean;
};
/**
* Classifies whether a mapping may own a UDP STUN endpoint, excluding its current lease.
* @param mapping - Persisted port-forward mapping including desired Keeper configuration.
* @returns Stable structural eligibility and existing DDNS-compatible reason codes.
*/
export function classifyStunEndpointSource(
mapping: Pick<
NetworkPortForward,
| 'desiredPresence'
| 'externalPort'
| 'internalPort'
| 'isDeleted'
| 'keeperDesiredEnabled'
| 'protocol'
>,
): StunEndpointSourceEligibility {
let disabledReasonCode: null | StunEndpointSourceDisabledReason = null;
if (mapping.isDeleted || mapping.desiredPresence !== 'present') {
disabledReasonCode = 'SOURCE_DELETING';
} else if (mapping.protocol !== 'udp') {
disabledReasonCode = 'UDP_REQUIRED';
} else if (mapping.externalPort !== mapping.internalPort) {
disabledReasonCode = 'PORT_MISMATCH';
} else if (!mapping.keeperDesiredEnabled) {
disabledReasonCode = 'KEEPER_DISABLED';
}
return {
disabledReasonCode,
eligible: disabledReasonCode === null,
};
}

View File

@ -0,0 +1,533 @@
import { isIP } from 'node:net';
import {
Injectable,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
SystemMessageContractError,
type SystemMessageDeliveryReadiness,
type SystemMessageScalar,
type SystemMessageSourceDefinition,
} from '@/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import { SystemMessageSourceRegistry } from '@/modules/qqbot/core/application/message-push/system-message-source.registry';
import { NetworkDdnsRecord } from './network-ddns.entity';
import { NetworkPortForward } from './network-management.entity';
import { classifyStunEndpointSource } from './network-source-eligibility';
const SOURCE_KEY = 'network.stun.mapping-port-changed';
const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
const RFC3339_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
type StunSubscriptionConfig = {
ddnsRecordId: string;
portForwardId: string;
};
type StunEventPayload = {
changedAt: string;
currentPort: number;
portForwardId: string;
previousPort: number;
publicIpv4: string;
};
type ResolvedSubscription = {
config: StunSubscriptionConfig;
ddnsRecord: NetworkDdnsRecord;
mapping: NetworkPortForward;
sourceSummary: string;
};
/**
* Adapts Network-owned STUN mappings and DDNS records to the QQBot source contract.
*
* It deliberately owns no event staging or delivery queue state; every delivery is
* recomputed from the current Network records to prevent stale endpoint sends.
*/
@Injectable()
export class NetworkStunMessageSourceAdapter
implements OnModuleDestroy, OnModuleInit
{
private registered = false;
/** Immutable public schema for the first Network-owned system message source. */
readonly definition: SystemMessageSourceDefinition = {
description: '当 UDP STUN 映射端口变更且 IPv4 DDNS 已同步时发送消息。',
displayName: 'STUN 映射端口变更',
sourceKey: SOURCE_KEY,
subscriptionFields: [
{
key: 'portForwardId',
label: 'STUN 端口转发',
optionCollection: 'portForwards',
required: true,
type: 'select',
},
{
dependsOn: 'portForwardId',
key: 'ddnsRecordId',
label: 'IPv4 DDNS 记录',
optionCollection: 'ddnsRecords',
required: true,
type: 'select',
},
],
variables: [
{
description: '所选 DDNS 完整域名',
example: 'pal.kwitsukasa.top',
key: 'domain',
label: '域名',
type: 'string',
},
{
description: '新的公网映射端口',
example: '38213',
key: 'port',
label: '端口',
type: 'number',
},
{
description: '服务端组合的域名与端口',
example: 'pal.kwitsukasa.top:38213',
key: 'endpoint',
label: '访问端点',
type: 'string',
},
{
description: '端口转发显示名称',
example: '帕鲁新世界',
key: 'mappingName',
label: '映射名称',
type: 'string',
},
{
description: '变化前的公网映射端口',
example: '8213',
key: 'previousPort',
label: '原端口',
type: 'number',
},
{
description: '事件对应的公网 IPv4',
example: '203.0.113.10',
key: 'publicIpv4',
label: '公网 IPv4',
type: 'string',
},
{
description: '按上海时区格式化的变化时间',
example: '2026-07-23 20:30:00',
key: 'changedAt',
label: '变化时间',
type: 'string',
},
],
version: 1,
};
/**
* Creates the Network-owned source adapter.
* @param mappingRepository - Current port-forward and Keeper state reader.
* @param ddnsRepository - Current A-record DDNS binding reader.
* @param sourceRegistry - Core-owned process registry exported to Network.
*/
constructor(
@InjectRepository(NetworkPortForward)
private readonly mappingRepository: Repository<NetworkPortForward>,
@InjectRepository(NetworkDdnsRecord)
private readonly ddnsRepository: Repository<NetworkDdnsRecord>,
private readonly sourceRegistry: SystemMessageSourceRegistry,
) {}
/** Registers this instance once after its owning Network module initializes. */
onModuleInit(): void {
if (this.registered) return;
this.sourceRegistry.register(this);
this.registered = true;
}
/** Unregisters only this instance during Network module teardown. */
onModuleDestroy(): void {
if (!this.registered) return;
this.sourceRegistry.unregister(this.definition.sourceKey, this);
this.registered = false;
}
/**
* Normalizes a submitted subscription to the two supported string identifiers.
* @param input - Untrusted admin configuration; unknown fields are intentionally discarded.
* @returns Canonical IDs, the mapping resource key, and a server-derived summary.
*/
async normalizeSubscriptionConfig(input: unknown): Promise<{
canonicalConfig: Record<string, string>;
resourceKey: string;
sourceSummary: string;
}> {
const resolved = await this.resolveSubscription(input);
return {
canonicalConfig: resolved.config,
resourceKey: resolved.config.portForwardId,
sourceSummary: resolved.sourceSummary,
};
}
/**
* Checks current subscription validity without writing subscription or Network state.
* @param config - Persisted or submitted source configuration.
* @returns Stable validity result with a safe source summary for management UI.
*/
async inspectSubscription(config: Record<string, unknown>): Promise<{
invalidReasonCode: null | string;
sourceSummary: string;
valid: boolean;
}> {
try {
const resolved = await this.resolveSubscription(config);
return {
invalidReasonCode: null,
sourceSummary: resolved.sourceSummary,
valid: true,
};
} catch (error) {
return {
invalidReasonCode: messageSourceErrorCode(error),
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
};
}
}
/**
* Lists current mapping and DDNS candidates with structural, lease-independent eligibility.
* @returns Locked `portForwards` and `ddnsRecords` option collections.
*/
async listSubscriptionOptions(): Promise<Record<string, unknown>> {
const [mappings, records] = await Promise.all([
this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
]);
const mappingsById = new Map(
mappings.map((mapping) => [String(mapping.id), mapping]),
);
return {
ddnsRecords: records.map((record) => {
const mapping = record.portForwardId
? mappingsById.get(String(record.portForwardId))
: undefined;
const disabledReasonCode = ddnsOptionReason(record, mapping);
return {
disabledReasonCode,
eligible: disabledReasonCode === null,
fqdn: ddnsFqdn(record),
id: String(record.id),
name: record.name,
portForwardId: record.portForwardId
? String(record.portForwardId)
: '',
};
}),
portForwards: mappings.map((mapping) => {
const { disabledReasonCode, eligible } =
classifyStunEndpointSource(mapping);
return {
disabledReasonCode,
eligible,
externalPort: mapping.externalPort,
id: String(mapping.id),
internalPort: mapping.internalPort,
name: mapping.name,
protocol: mapping.protocol,
};
}),
};
}
/**
* Rejects unsafe event data and retains only the source's permitted scalar fields.
* @param payload - Network event candidate before it is staged in the QQBot Outbox.
* @returns Canonical event variables with no client-controlled endpoint field.
*/
validateEventPayload(
payload: Record<string, unknown>,
): Record<string, SystemMessageScalar> {
if (!isPlainRecord(payload)) {
throw new SystemMessageContractError('invalid_message_event_payload');
}
const changedAt = normalizeRfc3339(payload.changedAt);
const currentPort = normalizePort(payload.currentPort);
const previousPort = normalizePort(payload.previousPort);
const portForwardId = normalizeSnowflakeId(payload.portForwardId);
if (
typeof payload.publicIpv4 !== 'string' ||
isIP(payload.publicIpv4) !== 4 ||
currentPort === previousPort
) {
throw new SystemMessageContractError('invalid_message_event_payload');
}
return {
changedAt,
currentPort,
portForwardId,
previousPort,
publicIpv4: payload.publicIpv4,
};
}
/**
* Re-evaluates a frozen event against the current mapping and DDNS state before sending.
* @param input - Event payload and persisted subscription configuration.
* @returns Ready variables, a DDNS wait result, or terminal cancellation/supersession.
*/
async resolveDelivery(input: {
eventPayload: Record<string, SystemMessageScalar>;
subscriptionConfig: Record<string, unknown>;
}): Promise<SystemMessageDeliveryReadiness> {
let resolved: ResolvedSubscription;
try {
resolved = await this.resolveSubscription(input.subscriptionConfig);
} catch (error) {
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
}
let event: StunEventPayload;
try {
event = this.validateEventPayload(input.eventPayload) as StunEventPayload;
} catch (error) {
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
}
if (event.portForwardId !== resolved.config.portForwardId) {
return { reasonCode: 'event_resource_mismatch', status: 'cancelled' };
}
if (!hasCurrentEndpoint(resolved.mapping)) {
return { reasonCode: 'endpoint_unavailable', status: 'superseded' };
}
if (
resolved.mapping.currentPublicPort !== event.currentPort ||
resolved.mapping.currentPublicIpv4 !== event.publicIpv4
) {
return { reasonCode: 'endpoint_changed', status: 'superseded' };
}
const variables = deliveryVariables(resolved, event);
if (
resolved.ddnsRecord.syncStatus !== 'synced' ||
resolved.ddnsRecord.appliedAddress !== event.publicIpv4
) {
return {
reasonCode: 'ddns_not_synced',
status: 'waiting_ddns',
variables,
};
}
return { reasonCode: null, status: 'ready', variables };
}
/**
* Resolves and structurally validates both persisted resources selected by a subscription.
* @param input - Unknown configuration from an Admin request or persisted subscription.
* @returns Current resource rows and canonical identity when their relationship is valid.
*/
private async resolveSubscription(
input: unknown,
): Promise<ResolvedSubscription> {
if (!isPlainRecord(input)) {
throw new SystemMessageContractError('invalid_message_source_config');
}
let config: StunSubscriptionConfig;
try {
config = {
ddnsRecordId: normalizeSnowflakeId(input.ddnsRecordId),
portForwardId: normalizeSnowflakeId(input.portForwardId),
};
} catch {
throw new SystemMessageContractError('invalid_message_source_config');
}
const mapping = await this.mappingRepository.findOne({
where: { id: config.portForwardId },
});
if (!mapping) {
throw new SystemMessageContractError('source_not_found');
}
const sourceEligibility = classifyStunEndpointSource(mapping);
if (!sourceEligibility.eligible) {
throw new SystemMessageContractError(
sourceEligibility.disabledReasonCode as string,
);
}
const ddnsRecord = await this.ddnsRepository.findOne({
where: { id: config.ddnsRecordId },
});
const ddnsReason = ddnsOptionReason(ddnsRecord, mapping);
if (ddnsReason) {
throw new SystemMessageContractError(ddnsReason);
}
return {
config,
ddnsRecord: ddnsRecord as NetworkDdnsRecord,
mapping,
sourceSummary: `${mapping.name} · ${ddnsFqdn(ddnsRecord as NetworkDdnsRecord)}`,
};
}
}
/**
* Verifies that a value is a non-array object with string-addressable own values.
* @param value - Untrusted input at an adapter boundary.
* @returns Whether the value is safe to inspect as a plain record.
*/
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
/**
* Validates a Snowflake at JSON boundaries without converting it to an unsafe number.
* @param value - Candidate string identifier.
* @returns The unchanged decimal string.
*/
function normalizeSnowflakeId(value: unknown): string {
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
throw new SystemMessageContractError('invalid_message_source_config');
}
return value;
}
/**
* Validates an event port as an integer transport port.
* @param value - Candidate event scalar.
* @returns The safe port number.
*/
function normalizePort(value: unknown): number {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 1 ||
value > 65_535
) {
throw new SystemMessageContractError('invalid_message_event_payload');
}
return value;
}
/**
* Normalizes a timezone-qualified event timestamp to canonical ISO UTC text.
* @param value - Candidate RFC3339 timestamp.
* @returns Canonical ISO string safe for later Shanghai rendering.
*/
function normalizeRfc3339(value: unknown): string {
if (typeof value !== 'string' || !RFC3339_PATTERN.test(value)) {
throw new SystemMessageContractError('invalid_message_event_payload');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new SystemMessageContractError('invalid_message_event_payload');
}
return date.toISOString();
}
/**
* Determines whether a persisted DDNS record is a usable structural subscription target.
* @param record - Candidate A-record binding, possibly absent or deleted.
* @param mapping - Mapping selected by the subscription, if currently present.
* @returns Null when structurally valid or a stable invalid-reason code.
*/
function ddnsOptionReason(
record: NetworkDdnsRecord | null | undefined,
mapping: NetworkPortForward | undefined,
): null | string {
if (!record) return 'ddns_not_found';
if (record.isDeleted) return 'ddns_deleted';
if (!record.enabled) return 'ddns_disabled';
if (record.recordType !== 'A') return 'ddns_a_required';
if (record.sourceType !== 'port_forward_ipv4')
return 'ddns_source_type_invalid';
if (!mapping || String(record.portForwardId) !== String(mapping.id)) {
return 'ddns_mapping_mismatch';
}
const source = classifyStunEndpointSource(mapping);
return source.disabledReasonCode;
}
/**
* Forms the normalized fully-qualified hostname from persisted server-owned DDNS fields.
* @param record - Canonical DDNS binding.
* @returns Lowercase FQDN without a trailing dot.
*/
function ddnsFqdn(record: NetworkDdnsRecord): string {
const domain = record.domain.trim().toLowerCase().replace(/\.$/, '');
const subDomain = record.subDomain.trim().toLowerCase().replace(/\.$/, '');
return subDomain === '@' ? domain : `${subDomain}.${domain}`;
}
/**
* Checks current Keeper endpoint readiness independently of structural subscription validity.
* @param mapping - Current mapping row after structural eligibility passed.
* @returns Whether a live public IPv4 endpoint is available at this instant.
*/
function hasCurrentEndpoint(mapping: NetworkPortForward): boolean {
return (
isIP(mapping.currentPublicIpv4 || '') === 4 &&
typeof mapping.currentPublicPort === 'number' &&
Number.isInteger(mapping.currentPublicPort) &&
mapping.currentPublicPort >= 1 &&
mapping.currentPublicPort <= 65_535 &&
!!mapping.currentValidUntil &&
new Date(mapping.currentValidUntil).getTime() > Date.now()
);
}
/**
* Builds the whitelisted template variables from current Network records and frozen event data.
* @param resolved - Current structurally valid subscription resources.
* @param event - Validated immutable event payload.
* @returns Server-derived scalar values ready for formal template rendering.
*/
function deliveryVariables(
resolved: ResolvedSubscription,
event: StunEventPayload,
): Record<string, boolean | number | string> {
const domain = ddnsFqdn(resolved.ddnsRecord);
return {
changedAt: formatShanghaiDateTime(event.changedAt),
domain,
endpoint: `${domain}:${event.currentPort}`,
mappingName: resolved.mapping.name,
port: event.currentPort,
previousPort: event.previousPort,
publicIpv4: event.publicIpv4,
};
}
/**
* Formats an event timestamp as the locked Asia/Shanghai template variable value.
* @param value - Valid RFC3339 event timestamp.
* @returns `YYYY-MM-DD HH:mm:ss` assembled without locale punctuation assumptions.
*/
function formatShanghaiDateTime(value: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
}).formatToParts(new Date(value));
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((item) => item.type === type)?.value || '';
return `${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')}`;
}
/**
* Converts adapter validation failures to one stable, non-sensitive reason code.
* @param error - Unknown caught adapter or repository error.
* @returns Domain code for expected contract failures or a generic source-unavailable code.
*/
function messageSourceErrorCode(error: unknown): string {
return error instanceof SystemMessageContractError
? error.code
: 'message_source_unavailable';
}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import {
SystemMessageContractError,
type SystemMessageSourceAdapter,
type SystemMessageSourceDefinition,
} from '../../contract/message-push/qqbot-message-push.types';
/**
* Holds the process-local system message-source adapters registered by owning modules.
*
* Definitions are cloned on listing so management callers cannot mutate adapter state.
*/
@Injectable()
export class SystemMessageSourceRegistry {
private readonly adapters = new Map<string, SystemMessageSourceAdapter>();
/**
* Registers one source adapter once for the current Nest process.
* @param adapter - Network, Core, or future module-owned source implementation.
* @throws {SystemMessageContractError} When its source key is already registered.
*/
register(adapter: SystemMessageSourceAdapter): void {
const key = adapter.definition.sourceKey;
if (this.adapters.has(key)) {
throw new SystemMessageContractError('duplicate_message_source');
}
this.adapters.set(key, adapter);
}
/**
* Removes only the exact adapter instance that registered the source key.
* @param sourceKey - Registered source identity.
* @param adapter - Instance performing module teardown.
*/
unregister(sourceKey: string, adapter: SystemMessageSourceAdapter): void {
if (this.adapters.get(sourceKey) === adapter) {
this.adapters.delete(sourceKey);
}
}
/**
* Returns the adapter for a known source key.
* @param sourceKey - Stable source identity stored by subscriptions.
* @returns The registered adapter instance.
* @throws {SystemMessageContractError} When no source owns the requested key.
*/
get(sourceKey: string): SystemMessageSourceAdapter {
const adapter = this.adapters.get(sourceKey);
if (!adapter) {
throw new SystemMessageContractError('unknown_message_source');
}
return adapter;
}
/**
* Lists source definitions in stable source-key order without exposing internal objects.
* @returns Detached definition snapshots for read-only management use.
*/
list(): SystemMessageSourceDefinition[] {
return [...this.adapters.values()]
.map(({ definition }) => structuredClone(definition))
.sort((left, right) => left.sourceKey.localeCompare(right.sourceKey));
}
}

View File

@ -47,6 +47,7 @@ import { QqbotMessagePublishBinding } from '@/modules/qqbot/core/infrastructure/
import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-target.entity';
import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
@ -82,6 +83,7 @@ export const QQBOT_CORE_CONTROLLERS = [
];
export const QQBOT_CORE_PROVIDERS = [
SystemMessageSourceRegistry,
QqbotAccountService,
QqbotBusService,
QqbotCommandEngineService,
@ -102,6 +104,7 @@ export const QQBOT_CORE_PROVIDERS = [
];
export const QQBOT_CORE_EXPORTS = [
SystemMessageSourceRegistry,
QqbotAccountService,
QqbotConfigService,
QqbotDashboardService,

View File

@ -0,0 +1,228 @@
import type { Repository } from 'typeorm';
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
import { NetworkStunMessageSourceAdapter } from '../../../src/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
type Harness = {
adapter: NetworkStunMessageSourceAdapter;
ddns: NetworkDdnsRecord;
mapping: NetworkPortForward;
registry: SystemMessageSourceRegistry;
};
/** Creates a mutable in-memory repository harness for the adapter contract. */
function createHarness(): Harness {
const mapping = Object.assign(new NetworkPortForward(), {
currentPublicIpv4: '203.0.113.10',
currentPublicPort: 38213,
currentValidUntil: new Date('2026-07-24T13:00:00.000Z'),
desiredPresence: 'present' as const,
externalPort: 8213,
id: '2041700000000000001',
internalPort: 8213,
isDeleted: false,
keeperDesiredEnabled: true,
name: '帕鲁新世界',
protocol: 'udp' as const,
});
const ddns = Object.assign(new NetworkDdnsRecord(), {
appliedAddress: '203.0.113.10',
domain: 'kwitsukasa.top',
enabled: true,
id: '2041700000000000002',
isDeleted: false,
name: '帕鲁域名',
portForwardId: mapping.id,
recordType: 'A' as const,
sourceType: 'port_forward_ipv4' as const,
subDomain: 'pal',
syncStatus: 'synced' as const,
});
const mappings = [mapping];
const records = [ddns];
const mappingRepository = {
find: jest.fn(async () => mappings),
findOne: jest.fn(
async ({ where }) =>
mappings.find((item) => item.id === where.id) || null,
),
} as unknown as Repository<NetworkPortForward>;
const recordRepository = {
find: jest.fn(async () => records),
findOne: jest.fn(
async ({ where }) => records.find((item) => item.id === where.id) || null,
),
} as unknown as Repository<NetworkDdnsRecord>;
const registry = new SystemMessageSourceRegistry();
return {
adapter: new NetworkStunMessageSourceAdapter(
mappingRepository,
recordRepository,
registry,
),
ddns,
mapping,
registry,
};
}
/** Returns a valid event payload, with optional untrusted-field overrides. */
function eventPayload(overrides: Record<string, unknown> = {}) {
return {
changedAt: '2026-07-24T12:30:00.000Z',
currentPort: 38213,
endpoint: 'attacker.example:1',
portForwardId: '2041700000000000001',
previousPort: 8213,
publicIpv4: '203.0.113.10',
...overrides,
};
}
describe('NetworkStunMessageSourceAdapter', () => {
it('registers once and only unregisters its own source instance', () => {
const { adapter, registry } = createHarness();
adapter.onModuleInit();
adapter.onModuleInit();
expect(registry.get(adapter.definition.sourceKey)).toBe(adapter);
adapter.onModuleDestroy();
expect(() => registry.get(adapter.definition.sourceKey)).toThrow(
'unknown_message_source',
);
});
it('accepts only an enabled equal-port UDP Keeper and its linked enabled A record', async () => {
const { adapter } = createHarness();
await expect(
adapter.normalizeSubscriptionConfig({
ddnsRecordId: '2041700000000000002',
ignored: 'removed',
portForwardId: '2041700000000000001',
}),
).resolves.toEqual({
canonicalConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
resourceKey: '2041700000000000001',
sourceSummary: '帕鲁新世界 · pal.kwitsukasa.top',
});
});
it.each([
['tcp', (harness: Harness) => (harness.mapping.protocol = 'tcp')],
['unequal ports', (harness: Harness) => (harness.mapping.internalPort = 1)],
[
'disabled keeper',
(harness: Harness) => (harness.mapping.keeperDesiredEnabled = false),
],
[
'deleted mapping',
(harness: Harness) => (harness.mapping.isDeleted = true),
],
['disabled DDNS', (harness: Harness) => (harness.ddns.enabled = false)],
['deleted DDNS', (harness: Harness) => (harness.ddns.isDeleted = true)],
['non-A DDNS', (harness: Harness) => (harness.ddns.recordType = 'AAAA')],
[
'mismatched DDNS mapping',
(harness: Harness) =>
(harness.ddns.portForwardId = '2041700000000000003'),
],
])('rejects %s subscriptions', async (_name, mutate) => {
const harness = createHarness();
mutate(harness);
await expect(
harness.adapter.normalizeSubscriptionConfig({
ddnsRecordId: harness.ddns.id,
portForwardId: harness.mapping.id,
}),
).rejects.toThrow();
});
it('rejects malformed Snowflake IDs and strips unknown event/config fields', async () => {
const { adapter } = createHarness();
await expect(
adapter.normalizeSubscriptionConfig({
ddnsRecordId: 2041700000000000002,
portForwardId: 'not-an-id',
}),
).rejects.toThrow('invalid_message_source_config');
expect(adapter.validateEventPayload(eventPayload())).toEqual({
changedAt: '2026-07-24T12:30:00.000Z',
currentPort: 38213,
portForwardId: '2041700000000000001',
previousPort: 8213,
publicIpv4: '203.0.113.10',
});
expect(() =>
adapter.validateEventPayload(eventPayload({ currentPort: '38213' })),
).toThrow('invalid_message_event_payload');
});
it('returns ready variables derived from the server-owned DDNS FQDN and Shanghai time', async () => {
const { adapter } = createHarness();
await expect(
adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
}),
).resolves.toEqual({
reasonCode: null,
status: 'ready',
variables: {
changedAt: '2026-07-24 20:30:00',
domain: 'pal.kwitsukasa.top',
endpoint: 'pal.kwitsukasa.top:38213',
mappingName: '帕鲁新世界',
port: 38213,
previousPort: 8213,
publicIpv4: '203.0.113.10',
},
});
});
it('waits for DDNS, supersedes a replaced endpoint, and cancels a changed relationship', async () => {
const waiting = createHarness();
waiting.ddns.appliedAddress = null;
await expect(
waiting.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: waiting.ddns.id,
portForwardId: waiting.mapping.id,
},
}),
).resolves.toMatchObject({
reasonCode: 'ddns_not_synced',
status: 'waiting_ddns',
});
const superseded = createHarness();
superseded.mapping.currentPublicPort = 39000;
await expect(
superseded.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: superseded.ddns.id,
portForwardId: superseded.mapping.id,
},
}),
).resolves.toMatchObject({ status: 'superseded' });
const cancelled = createHarness();
cancelled.ddns.enabled = false;
await expect(
cancelled.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: cancelled.ddns.id,
portForwardId: cancelled.mapping.id,
},
}),
).resolves.toMatchObject({ status: 'cancelled' });
});
});

View File

@ -0,0 +1,60 @@
import type { SystemMessageSourceAdapter } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
/** Creates a minimal adapter whose definition can be safely registered. */
function createAdapter(sourceKey: string): SystemMessageSourceAdapter {
return {
definition: {
description: 'test',
displayName: sourceKey,
sourceKey,
subscriptionFields: [],
variables: [],
version: 1,
},
inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(),
resolveDelivery: jest.fn(),
validateEventPayload: jest.fn(),
};
}
describe('SystemMessageSourceRegistry', () => {
it('rejects duplicate source registration and returns immutable definitions', () => {
const registry = new SystemMessageSourceRegistry();
const adapter = createAdapter('network.stun.mapping-port-changed');
registry.register(adapter);
expect(() => registry.register(adapter)).toThrow(
'duplicate_message_source',
);
expect(registry.list()).toEqual([adapter.definition]);
expect(() => registry.get('missing')).toThrow('unknown_message_source');
const [definition] = registry.list();
definition.displayName = 'mutated';
expect(
registry.get(adapter.definition.sourceKey).definition.displayName,
).toBe(adapter.definition.displayName);
});
it('sorts definitions and unregisters only the same adapter instance', () => {
const registry = new SystemMessageSourceRegistry();
const first = createAdapter('z.source');
const second = createAdapter('a.source');
const replacement = createAdapter('z.source');
registry.register(first);
registry.register(second);
registry.unregister('z.source', replacement);
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
'a.source',
'z.source',
]);
registry.unregister('z.source', first);
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
'a.source',
]);
});
});