From ce60b45575811c3681a09940267d59df37f5c35a Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 05:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9BSTUN=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=BA=90=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../network-stun-message-source.adapter.ts | 173 ++++++++++-- ...etwork-stun-message-source.adapter.spec.ts | 257 ++++++++++++++++-- 2 files changed, 381 insertions(+), 49 deletions(-) diff --git a/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts b/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts index 975bc47..7fe737c 100644 --- a/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts +++ b/src/modules/admin/platform-config/network-management/network-stun-message-source.adapter.ts @@ -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, ): Record { 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 { 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 { */ 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['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'; -} diff --git a/test/admin/network-management/network-stun-message-source.adapter.spec.ts b/test/admin/network-management/network-stun-message-source.adapter.spec.ts index abb6550..391ab77 100644 --- a/test/admin/network-management/network-stun-message-source.adapter.spec.ts +++ b/test/admin/network-management/network-stun-message-source.adapter.spec.ts @@ -7,7 +7,9 @@ import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/app type Harness = { adapter: NetworkStunMessageSourceAdapter; ddns: NetworkDdnsRecord; + ddnsRepository: Repository; mapping: NetworkPortForward; + mappingRepository: Repository; registry: SystemMessageSourceRegistry; }; @@ -62,7 +64,9 @@ function createHarness(): Harness { registry, ), ddns, + ddnsRepository: recordRepository, mapping, + mappingRepository, registry, }; } @@ -81,6 +85,15 @@ function eventPayload(overrides: Record = {}) { } 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) => { - const harness = createHarness(); - mutate(harness); - await expect( - harness.adapter.normalizeSubscriptionConfig({ + ])( + 'rejects %s subscriptions with the locked %s code', + async (_name, code, mutate) => { + const harness = createHarness(); + mutate(harness); + 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); + }, + ); });