fix: 收敛STUN消息源契约

This commit is contained in:
sunlei 2026-07-24 05:25:23 +08:00
parent 3dbc37e500
commit ce60b45575
2 changed files with 381 additions and 49 deletions

View File

@ -20,7 +20,7 @@ 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})$/;
/^(\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;
@ -194,8 +194,9 @@ export class NetworkStunMessageSourceAdapter
valid: true,
};
} catch (error) {
if (!(error instanceof SystemMessageContractError)) throw error;
return {
invalidReasonCode: messageSourceErrorCode(error),
invalidReasonCode: error.code,
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
};
@ -256,7 +257,7 @@ export class NetworkStunMessageSourceAdapter
payload: Record<string, unknown>,
): Record<string, SystemMessageScalar> {
if (!isPlainRecord(payload)) {
throw new SystemMessageContractError('invalid_message_event_payload');
throw new SystemMessageContractError('invalid_source_config');
}
const changedAt = normalizeRfc3339(payload.changedAt);
const currentPort = normalizePort(payload.currentPort);
@ -267,7 +268,7 @@ export class NetworkStunMessageSourceAdapter
isIP(payload.publicIpv4) !== 4 ||
currentPort === previousPort
) {
throw new SystemMessageContractError('invalid_message_event_payload');
throw new SystemMessageContractError('invalid_source_config');
}
return {
changedAt,
@ -291,25 +292,27 @@ export class NetworkStunMessageSourceAdapter
try {
resolved = await this.resolveSubscription(input.subscriptionConfig);
} catch (error) {
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
if (!(error instanceof SystemMessageContractError)) throw error;
return { reasonCode: error.code, status: 'cancelled' };
}
let event: StunEventPayload;
try {
event = this.validateEventPayload(input.eventPayload) as StunEventPayload;
} catch (error) {
return { reasonCode: messageSourceErrorCode(error), status: 'cancelled' };
if (!(error instanceof SystemMessageContractError)) throw error;
return { reasonCode: error.code, status: 'cancelled' };
}
if (event.portForwardId !== resolved.config.portForwardId) {
return { reasonCode: 'event_resource_mismatch', status: 'cancelled' };
return { reasonCode: 'invalid_source_config', status: 'cancelled' };
}
if (!hasCurrentEndpoint(resolved.mapping)) {
return { reasonCode: 'endpoint_unavailable', status: 'superseded' };
return { reasonCode: 'endpoint_superseded', status: 'superseded' };
}
if (
resolved.mapping.currentPublicPort !== event.currentPort ||
resolved.mapping.currentPublicIpv4 !== event.publicIpv4
) {
return { reasonCode: 'endpoint_changed', status: 'superseded' };
return { reasonCode: 'endpoint_superseded', status: 'superseded' };
}
const variables = deliveryVariables(resolved, event);
if (
@ -334,7 +337,7 @@ export class NetworkStunMessageSourceAdapter
input: unknown,
): Promise<ResolvedSubscription> {
if (!isPlainRecord(input)) {
throw new SystemMessageContractError('invalid_message_source_config');
throw new SystemMessageContractError('invalid_source_config');
}
let config: StunSubscriptionConfig;
try {
@ -343,24 +346,28 @@ export class NetworkStunMessageSourceAdapter
portForwardId: normalizeSnowflakeId(input.portForwardId),
};
} catch {
throw new SystemMessageContractError('invalid_message_source_config');
throw new SystemMessageContractError('invalid_source_config');
}
const mapping = await this.mappingRepository.findOne({
where: { id: config.portForwardId },
});
if (!mapping) {
throw new SystemMessageContractError('source_not_found');
throw new SystemMessageContractError('mapping_not_found');
}
const sourceEligibility = classifyStunEndpointSource(mapping);
if (!sourceEligibility.eligible) {
throw new SystemMessageContractError(
sourceEligibility.disabledReasonCode as string,
messageSourceEligibilityReason(
sourceEligibility.disabledReasonCode as NonNullable<
typeof sourceEligibility.disabledReasonCode
>,
),
);
}
const ddnsRecord = await this.ddnsRepository.findOne({
where: { id: config.ddnsRecordId },
});
const ddnsReason = ddnsOptionReason(ddnsRecord, mapping);
const ddnsReason = ddnsMessageSourceReason(ddnsRecord, mapping);
if (ddnsReason) {
throw new SystemMessageContractError(ddnsReason);
}
@ -389,7 +396,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
*/
function normalizeSnowflakeId(value: unknown): string {
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
throw new SystemMessageContractError('invalid_message_source_config');
throw new SystemMessageContractError('invalid_source_config');
}
return value;
}
@ -406,7 +413,7 @@ function normalizePort(value: unknown): number {
value < 1 ||
value > 65_535
) {
throw new SystemMessageContractError('invalid_message_event_payload');
throw new SystemMessageContractError('invalid_source_config');
}
return value;
}
@ -417,16 +424,74 @@ function normalizePort(value: unknown): number {
* @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');
if (typeof value !== 'string') {
throw new SystemMessageContractError('invalid_source_config');
}
const match = RFC3339_PATTERN.exec(value);
if (!match || !isValidRfc3339Calendar(match)) {
throw new SystemMessageContractError('invalid_source_config');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new SystemMessageContractError('invalid_message_event_payload');
throw new SystemMessageContractError('invalid_source_config');
}
return date.toISOString();
}
/**
* Validates RFC3339 calendar and offset components before JavaScript can normalize them.
* @param match - Capture groups produced by the source timestamp pattern.
* @returns Whether all calendar, wall-clock, and offset fields are in range.
*/
function isValidRfc3339Calendar(match: RegExpExecArray): boolean {
const [
,
yearText,
monthText,
dayText,
hourText,
minuteText,
secondText,
,
,
offsetHourText,
offsetMinuteText,
] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
const offsetHour = offsetHourText ? Number(offsetHourText) : 0;
const offsetMinute = offsetMinuteText ? Number(offsetMinuteText) : 0;
const daysInMonth = [
31,
year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
return (
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= daysInMonth[month - 1] &&
hour <= 23 &&
minute <= 59 &&
second <= 59 &&
offsetHour <= 23 &&
offsetMinute <= 59
);
}
/**
* Determines whether a persisted DDNS record is a usable structural subscription target.
* @param record - Candidate A-record binding, possibly absent or deleted.
@ -450,6 +515,65 @@ function ddnsOptionReason(
return source.disabledReasonCode;
}
/**
* Converts a shared Network structural reason to the locked message-source contract.
* @param reason - Existing uppercase Network eligibility reason.
* @returns Lowercase reason code safe for message subscription views and delivery state.
*/
function messageSourceEligibilityReason(
reason: NonNullable<
ReturnType<typeof classifyStunEndpointSource>['disabledReasonCode']
>,
):
| 'keeper_disabled'
| 'mapping_not_managed'
| 'mapping_not_udp'
| 'mapping_port_mismatch' {
switch (reason) {
case 'KEEPER_DISABLED':
return 'keeper_disabled';
case 'PORT_MISMATCH':
return 'mapping_port_mismatch';
case 'SOURCE_DELETING':
return 'mapping_not_managed';
case 'UDP_REQUIRED':
return 'mapping_not_udp';
}
}
/**
* Determines the locked message-source reason for a DDNS record and its selected mapping.
* @param record - Candidate DDNS record, including deleted or absent state.
* @param mapping - Canonical selected mapping after its structural validation.
* @returns Null when valid or a locked lowercase message-source reason.
*/
function ddnsMessageSourceReason(
record: NetworkDdnsRecord | null | undefined,
mapping: NetworkPortForward,
):
| 'ddns_disabled'
| 'ddns_mapping_mismatch'
| 'ddns_not_found'
| 'ddns_not_ipv4'
| 'keeper_disabled'
| 'mapping_not_managed'
| 'mapping_not_udp'
| 'mapping_port_mismatch'
| null {
if (!record || record.isDeleted) return 'ddns_not_found';
if (!record.enabled) return 'ddns_disabled';
if (record.recordType !== 'A' || record.sourceType !== 'port_forward_ipv4') {
return 'ddns_not_ipv4';
}
if (String(record.portForwardId) !== String(mapping.id)) {
return 'ddns_mapping_mismatch';
}
const source = classifyStunEndpointSource(mapping);
return source.disabledReasonCode
? messageSourceEligibilityReason(source.disabledReasonCode)
: null;
}
/**
* Forms the normalized fully-qualified hostname from persisted server-owned DDNS fields.
* @param record - Canonical DDNS binding.
@ -520,14 +644,3 @@ function formatShanghaiDateTime(value: string): 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

@ -7,7 +7,9 @@ import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/app
type Harness = {
adapter: NetworkStunMessageSourceAdapter;
ddns: NetworkDdnsRecord;
ddnsRepository: Repository<NetworkDdnsRecord>;
mapping: NetworkPortForward;
mappingRepository: Repository<NetworkPortForward>;
registry: SystemMessageSourceRegistry;
};
@ -62,7 +64,9 @@ function createHarness(): Harness {
registry,
),
ddns,
ddnsRepository: recordRepository,
mapping,
mappingRepository,
registry,
};
}
@ -81,6 +85,15 @@ function eventPayload(overrides: Record<string, unknown> = {}) {
}
describe('NetworkStunMessageSourceAdapter', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-24T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('registers once and only unregisters its own source instance', () => {
const { adapter, registry } = createHarness();
adapter.onModuleInit();
@ -111,43 +124,94 @@ describe('NetworkStunMessageSourceAdapter', () => {
});
it.each([
['tcp', (harness: Harness) => (harness.mapping.protocol = 'tcp')],
['unequal ports', (harness: Harness) => (harness.mapping.internalPort = 1)],
[
'tcp',
'mapping_not_udp',
(harness: Harness) => (harness.mapping.protocol = 'tcp'),
],
[
'unequal ports',
'mapping_port_mismatch',
(harness: Harness) => (harness.mapping.internalPort = 1),
],
[
'disabled keeper',
'keeper_disabled',
(harness: Harness) => (harness.mapping.keeperDesiredEnabled = false),
],
[
'deleted mapping',
'mapping_not_managed',
(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')],
[
'disabled DDNS',
'ddns_disabled',
(harness: Harness) => (harness.ddns.enabled = false),
],
[
'deleted DDNS',
'ddns_not_found',
(harness: Harness) => (harness.ddns.isDeleted = true),
],
[
'non-A DDNS',
'ddns_not_ipv4',
(harness: Harness) => (harness.ddns.recordType = 'AAAA'),
],
[
'non-port-forward DDNS',
'ddns_not_ipv4',
(harness: Harness) => (harness.ddns.sourceType = 'agent_ipv6'),
],
[
'mismatched DDNS mapping',
'ddns_mapping_mismatch',
(harness: Harness) =>
(harness.ddns.portForwardId = '2041700000000000003'),
],
])('rejects %s subscriptions', async (_name, mutate) => {
])(
'rejects %s subscriptions with the locked %s code',
async (_name, code, mutate) => {
const harness = createHarness();
mutate(harness);
await expect(
harness.adapter.normalizeSubscriptionConfig({
const config = {
ddnsRecordId: harness.ddns.id,
portForwardId: harness.mapping.id,
}),
).rejects.toThrow();
};
await expect(
harness.adapter.normalizeSubscriptionConfig(config),
).rejects.toMatchObject({
code,
});
await expect(
harness.adapter.inspectSubscription(config),
).resolves.toEqual({
invalidReasonCode: code,
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
});
},
);
it('rejects malformed Snowflake IDs and strips unknown event/config fields', async () => {
it('rejects malformed subscription/event payloads and strips unknown fields', async () => {
const { adapter } = createHarness();
await expect(
adapter.normalizeSubscriptionConfig({
ddnsRecordId: 2041700000000000002,
portForwardId: 'not-an-id',
}),
).rejects.toThrow('invalid_message_source_config');
).rejects.toMatchObject({ code: 'invalid_source_config' });
await expect(
adapter.inspectSubscription({
ddnsRecordId: 2041700000000000002,
portForwardId: 'not-an-id',
}),
).resolves.toEqual({
invalidReasonCode: 'invalid_source_config',
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
});
expect(adapter.validateEventPayload(eventPayload())).toEqual({
changedAt: '2026-07-24T12:30:00.000Z',
currentPort: 38213,
@ -157,7 +221,88 @@ describe('NetworkStunMessageSourceAdapter', () => {
});
expect(() =>
adapter.validateEventPayload(eventPayload({ currentPort: '38213' })),
).toThrow('invalid_message_event_payload');
).toThrow('invalid_source_config');
expect(() =>
adapter.validateEventPayload(eventPayload({ publicIpv4: '2001:db8::1' })),
).toThrow('invalid_source_config');
expect(() => adapter.validateEventPayload(null as never)).toThrow(
'invalid_source_config',
);
});
it('maps an absent mapping to the locked subscription and inspection code', async () => {
const { adapter } = createHarness();
const config = {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000003',
};
await expect(
adapter.normalizeSubscriptionConfig(config),
).rejects.toMatchObject({
code: 'mapping_not_found',
});
await expect(adapter.inspectSubscription(config)).resolves.toEqual({
invalidReasonCode: 'mapping_not_found',
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
});
});
it('maps an absent DDNS record to the locked subscription and inspection code', async () => {
const { adapter } = createHarness();
const config = {
ddnsRecordId: '2041700000000000003',
portForwardId: '2041700000000000001',
};
await expect(
adapter.normalizeSubscriptionConfig(config),
).rejects.toMatchObject({
code: 'ddns_not_found',
});
await expect(adapter.inspectSubscription(config)).resolves.toEqual({
invalidReasonCode: 'ddns_not_found',
sourceSummary: '未选择有效的 STUN 映射与 DDNS',
valid: false,
});
});
it('preserves uppercase Network eligibility reasons in subscription options', async () => {
const { adapter, mapping } = createHarness();
mapping.protocol = 'tcp';
await expect(adapter.listSubscriptionOptions()).resolves.toMatchObject({
portForwards: [
{
disabledReasonCode: 'UDP_REQUIRED',
eligible: false,
externalPort: 8213,
id: mapping.id,
internalPort: 8213,
name: '帕鲁新世界',
protocol: 'tcp',
},
],
});
});
it.each([
['invalid month', '2026-13-01T12:00:00Z'],
['non-leap-day', '2026-02-29T12:00:00Z'],
['invalid February day', '2026-02-30T12:00:00Z'],
['invalid offset', '2026-02-28T12:00:00+24:00'],
])('rejects an RFC3339 timestamp with %s', (_name, changedAt) => {
const { adapter } = createHarness();
expect(() =>
adapter.validateEventPayload(eventPayload({ changedAt })),
).toThrow('invalid_source_config');
});
it('normalizes a leap-day RFC3339 timestamp with an offset', () => {
const { adapter } = createHarness();
expect(
adapter.validateEventPayload(
eventPayload({ changedAt: '2024-02-29T23:59:59.123+08:00' }),
),
).toMatchObject({ changedAt: '2024-02-29T15:59:59.123Z' });
});
it('returns ready variables derived from the server-owned DDNS FQDN and Shanghai time', async () => {
@ -185,7 +330,7 @@ describe('NetworkStunMessageSourceAdapter', () => {
});
});
it('waits for DDNS, supersedes a replaced endpoint, and cancels a changed relationship', async () => {
it('waits for DDNS, supersedes replaced or expired endpoints, and cancels a changed relationship', async () => {
const waiting = createHarness();
waiting.ddns.appliedAddress = null;
await expect(
@ -211,7 +356,25 @@ describe('NetworkStunMessageSourceAdapter', () => {
portForwardId: superseded.mapping.id,
},
}),
).resolves.toMatchObject({ status: 'superseded' });
).resolves.toMatchObject({
reasonCode: 'endpoint_superseded',
status: 'superseded',
});
const expired = createHarness();
expired.mapping.currentValidUntil = new Date('2026-07-24T11:59:59.999Z');
await expect(
expired.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: expired.ddns.id,
portForwardId: expired.mapping.id,
},
}),
).resolves.toEqual({
reasonCode: 'endpoint_superseded',
status: 'superseded',
});
const cancelled = createHarness();
cancelled.ddns.enabled = false;
@ -223,6 +386,62 @@ describe('NetworkStunMessageSourceAdapter', () => {
portForwardId: cancelled.mapping.id,
},
}),
).resolves.toMatchObject({ status: 'cancelled' });
).resolves.toMatchObject({
reasonCode: 'ddns_disabled',
status: 'cancelled',
});
});
it('cancels event mapping identity mismatches with invalid_source_config', async () => {
const { adapter } = createHarness();
await expect(
adapter.resolveDelivery({
eventPayload: eventPayload({ portForwardId: '2041700000000000003' }),
subscriptionConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
}),
).resolves.toEqual({
reasonCode: 'invalid_source_config',
status: 'cancelled',
});
});
it('cancels malformed event payloads with invalid_source_config', async () => {
const { adapter } = createHarness();
await expect(
adapter.resolveDelivery({
eventPayload: eventPayload({ currentPort: '38213' }),
subscriptionConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
}),
).resolves.toEqual({
reasonCode: 'invalid_source_config',
status: 'cancelled',
});
});
it.each([
['mapping', 'mappingRepository'],
['DDNS', 'ddnsRepository'],
] as const)(
'rethrows an unexpected %s repository error for delivery retry',
async (_name, repository) => {
const harness = createHarness();
const failure = new Error(`${repository} unavailable`);
jest.spyOn(harness[repository], 'findOne').mockRejectedValueOnce(failure);
await expect(
harness.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: {
ddnsRecordId: harness.ddns.id,
portForwardId: harness.mapping.id,
},
}),
).rejects.toBe(failure);
},
);
});