From f27c82a082ef7e9c8eedaa53f95ad535f7ba3749 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 26 Jul 2026 16:11:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8E=A5=E9=80=9ATCP=20NATMap=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../network-agent-mqtt.service.ts | 6 +- .../network-agent-v2.types.ts | 10 +- .../network-management.service.ts | 68 ++++++ .../network-tcp-release-policy.service.ts | 120 +++++++++-- .../network-agent-mqtt.service.spec.ts | 21 ++ .../network-agent-v2.types.spec.ts | 49 +++++ .../network-management.service.spec.ts | 200 +++++++++++++++++- ...network-tcp-release-policy.service.spec.ts | 102 ++++++++- 8 files changed, 533 insertions(+), 43 deletions(-) diff --git a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts index 1a7f356..98cb215 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts @@ -26,6 +26,7 @@ import { NetworkPortForwardGroup } from './network-port-forward-group.entity'; import { NetworkTcpReleasePolicyService } from './network-tcp-release-policy.service'; import { buildDesiredSnapshotV2, + NetworkV2MessageValidationError, parseStatusSnapshotV2, type NetworkStatusSnapshotV2, } from './network-agent-v2.types'; @@ -273,7 +274,10 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { await this.consumeMessage(topic, payload); callback(0); } catch (error) { - if (error instanceof NetworkMessageValidationError) { + if ( + error instanceof NetworkMessageValidationError || + error instanceof NetworkV2MessageValidationError + ) { this.logger.warn(error.message); callback(0); return; diff --git a/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts b/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts index 794d1e6..9d6f039 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts @@ -230,7 +230,13 @@ export function buildDesiredSnapshotV2( }; desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired); return desired; - }); + }).sort(compareDesiredChannelsV2); + if ( + new Set(desiredChannels.map((channel) => channel.channelId)).size !== + desiredChannels.length + ) { + invalid('desired duplicate channel'); + } const snapshot: NetworkDesiredSnapshotV2 = { agentId: state.agentId, channels: desiredChannels, @@ -243,7 +249,7 @@ export function buildDesiredSnapshotV2( ), }; snapshot.snapshotDigest = canonicalDesiredSnapshotDigestV2(snapshot); - return snapshot; + return parseDesiredSnapshotV2(JSON.stringify(snapshot)); } export function parseDesiredSnapshotV2( diff --git a/src/modules/admin/platform-config/network-management/network-management.service.ts b/src/modules/admin/platform-config/network-management/network-management.service.ts index 935b7e2..6ade128 100644 --- a/src/modules/admin/platform-config/network-management/network-management.service.ts +++ b/src/modules/admin/platform-config/network-management/network-management.service.ts @@ -18,6 +18,13 @@ import { isIpv4Address, portForwardActiveKey, } from './network-management.types'; +import { + NetworkTcpReleasePolicyError, + NetworkTcpReleasePolicyService, + type TcpProtocolMode, + type TcpReleaseMutation, + type TcpReleaseState, +} from './network-tcp-release-policy.service'; const DEFAULT_AGENT_ID = 'nas-main'; const DEFAULT_TARGET_IPV4 = '192.168.31.224'; @@ -34,6 +41,7 @@ export class NetworkManagementService { private readonly dataSource: DataSource, private readonly configService: ConfigService, private readonly mqttService: NetworkAgentMqttService, + private readonly tcpReleasePolicy: NetworkTcpReleasePolicyService, ) {} async list(query: NetworkPortForwardListQueryDto = {}) { @@ -42,6 +50,11 @@ export class NetworkManagementService { const builder = this.mappingRepository .createQueryBuilder('mapping') .where('mapping.isDeleted = :isDeleted', { isDeleted: false }); + if (!this.tcpReleasePolicy.isTcpVisibleToAdmin()) { + builder.andWhere('mapping.protocol <> :hiddenProtocol', { + hiddenProtocol: 'tcp', + }); + } if (query.name) { builder.andWhere('mapping.name LIKE :name', { name: `%${query.name.trim()}%`, @@ -66,6 +79,15 @@ export class NetworkManagementService { } async create(input: NetworkPortForwardCreateDto) { + this.assertReleaseMutation({ + after: this.releaseState({ + externalPort: input.externalPort, + internalPort: input.internalPort, + natmapDesiredEnabled: false, + protocol: input.protocol, + }), + kind: 'create', + }); const saved = await this.dataSource.transaction(async (manager) => { const state = await this.lockAgentState(manager); const repository = manager.getRepository(NetworkPortForward); @@ -128,6 +150,16 @@ export class NetworkManagementService { const protocol = input.protocol || mapping.protocol; const externalPort = input.externalPort || mapping.externalPort; const internalPort = input.internalPort || mapping.internalPort; + this.assertReleaseMutation({ + after: this.releaseState({ + externalPort, + internalPort, + natmapDesiredEnabled: mapping.natmapDesiredEnabled, + protocol, + }), + before: this.releaseState(mapping), + kind: 'update', + }); if ( mapping.keeperDesiredEnabled && (protocol !== 'udp' || externalPort !== internalPort) @@ -166,6 +198,10 @@ export class NetworkManagementService { if (mapping.desiredPresence === 'absent') { throwVbenError('端口转发正在删除', HttpStatus.CONFLICT); } + this.assertReleaseMutation({ + current: this.releaseState(mapping), + kind: 'delete', + }); mapping.desiredPresence = 'absent'; mapping.keeperDesiredEnabled = false; mapping.probeRequestId = null; @@ -176,6 +212,10 @@ export class NetworkManagementService { async retry(id: string) { return this.mutate(id, async (mapping) => { + this.assertReleaseMutation({ + current: this.releaseState(mapping), + kind: 'retry', + }); mapping.lastErrorCode = null; mapping.lastErrorMessage = null; mapping.syncStatus = @@ -344,6 +384,34 @@ export class NetworkManagementService { throwVbenError('Agent 状态行初始化失败', HttpStatus.INTERNAL_SERVER_ERROR); } + private assertReleaseMutation(mutation: TcpReleaseMutation): void { + try { + this.tcpReleasePolicy.assertMutationAllowed(mutation); + } catch (error) { + if (error instanceof NetworkTcpReleasePolicyError) { + throwVbenError( + '当前发布模式不允许该 TCP 操作', + HttpStatus.CONFLICT, + ); + } + throw error; + } + } + + private releaseState(value: { + externalPort: number; + internalPort: number; + natmapDesiredEnabled?: boolean; + protocol: TcpProtocolMode; + }): TcpReleaseState { + return { + externalPort: value.externalPort, + internalPort: value.internalPort, + natmapDesiredEnabled: value.natmapDesiredEnabled ?? false, + protocolMode: value.protocol, + }; + } + private advanceRevision( state: NetworkAgentState, mapping: NetworkPortForward, diff --git a/src/modules/admin/platform-config/network-management/network-tcp-release-policy.service.ts b/src/modules/admin/platform-config/network-management/network-tcp-release-policy.service.ts index 805519c..044853b 100644 --- a/src/modules/admin/platform-config/network-management/network-tcp-release-policy.service.ts +++ b/src/modules/admin/platform-config/network-management/network-tcp-release-policy.service.ts @@ -6,14 +6,27 @@ export type TcpProtocolMode = 'tcp' | 'tcp_udp' | 'udp'; export type TcpReleaseState = { externalPort: number; + internalPort: number; natmapDesiredEnabled: boolean; protocolMode: TcpProtocolMode; }; -export type TcpReleaseMutation = { - after?: TcpReleaseState; - before?: TcpReleaseState; -}; +export type TcpReleaseMutation = + | { after: TcpReleaseState; kind: 'create' } + | { after: TcpReleaseState; before: TcpReleaseState; kind: 'update' } + | { current: TcpReleaseState; kind: 'retry' } + | { current: TcpReleaseState; kind: 'natmap-enable' } + | { + after: TcpReleaseState; + before: TcpReleaseState; + kind: 'natmap-disable'; + } + | { + after: TcpReleaseState; + before: TcpReleaseState; + kind: 'protocol-shrink'; + } + | { current: TcpReleaseState; kind: 'delete' }; export class NetworkTcpReleasePolicyError extends Error {} @@ -70,18 +83,17 @@ export class NetworkTcpReleasePolicyService { } assertMutationAllowed(mutation: TcpReleaseMutation): void { - const before = mutation.before; - const after = mutation.after; - if (!this.hasTcp(before) && !this.hasTcp(after)) return; + this.assertMutationShape(mutation); + const safeCleanup = this.isSafeCleanup(mutation); + if (!this.touchesTcp(mutation)) return; const mode = this.readMode(); if (mode === 'on') return; + if (safeCleanup) return; if (mode === 'canary') { - const port = after?.externalPort ?? before?.externalPort; - if (port !== undefined && this.readCanaryPorts().has(port)) return; + if (this.readCanaryPorts().has(this.mutationPort(mutation))) return; throw new NetworkTcpReleasePolicyError('TCP NATMap release policy rejects this port'); } - if (this.isSafeCleanup(before, after)) return; throw new NetworkTcpReleasePolicyError('TCP NATMap release policy permits cleanup only'); } @@ -89,23 +101,85 @@ export class NetworkTcpReleasePolicyService { return value?.protocolMode === 'tcp' || value?.protocolMode === 'tcp_udp'; } - private isSafeCleanup( - before?: TcpReleaseState, - after?: TcpReleaseState, - ): boolean { - if (!before) return false; - if (!after) return before.protocolMode === 'tcp'; - if ( - before.protocolMode === 'tcp_udp' && - after.protocolMode === 'udp' - ) { + private assertMutationShape(mutation: TcpReleaseMutation): void { + const value = mutation as Partial<{ + after: TcpReleaseState; + before: TcpReleaseState; + current: TcpReleaseState; + kind: TcpReleaseMutation['kind']; + }>; + switch (value.kind) { + case 'create': + if (value.after) return; + break; + case 'update': + case 'natmap-disable': + case 'protocol-shrink': + if (value.before && value.after) return; + break; + case 'retry': + case 'natmap-enable': + case 'delete': + if (value.current) return; + break; + } + throw new NetworkTcpReleasePolicyError('Invalid TCP NATMap mutation'); + } + + private touchesTcp(mutation: TcpReleaseMutation): boolean { + if ('current' in mutation) return this.hasTcp(mutation.current); + return ( + this.hasTcp(mutation.after) || + ('before' in mutation && this.hasTcp(mutation.before)) + ); + } + + private isSafeCleanup(mutation: TcpReleaseMutation): boolean { + if (mutation.kind === 'delete') { + return mutation.current.protocolMode === 'tcp'; + } + if (mutation.kind === 'natmap-disable') { + const valid = + this.hasTcp(mutation.before) && + mutation.before.protocolMode === mutation.after.protocolMode && + this.portsUnchanged(mutation.before, mutation.after) && + mutation.before.natmapDesiredEnabled && + !mutation.after.natmapDesiredEnabled; + if (!valid) { + throw new NetworkTcpReleasePolicyError( + 'Invalid TCP NATMap disable mutation', + ); + } return true; } + if (mutation.kind === 'protocol-shrink') { + const valid = + mutation.before.protocolMode === 'tcp_udp' && + mutation.after.protocolMode === 'udp' && + this.portsUnchanged(mutation.before, mutation.after) && + !mutation.after.natmapDesiredEnabled; + if (!valid) { + throw new NetworkTcpReleasePolicyError( + 'Invalid TCP NATMap protocol shrink', + ); + } + return true; + } + return false; + } + + private mutationPort(mutation: TcpReleaseMutation): number { + if ('after' in mutation) return mutation.after.externalPort; + return mutation.current.externalPort; + } + + private portsUnchanged( + before: TcpReleaseState, + after: TcpReleaseState, + ): boolean { return ( - before.protocolMode === after.protocolMode && before.externalPort === after.externalPort && - before.natmapDesiredEnabled && - !after.natmapDesiredEnabled + before.internalPort === after.internalPort ); } diff --git a/test/admin/network-management/network-agent-mqtt.service.spec.ts b/test/admin/network-management/network-agent-mqtt.service.spec.ts index 97bf25b..f4b76a9 100644 --- a/test/admin/network-management/network-agent-mqtt.service.spec.ts +++ b/test/admin/network-management/network-agent-mqtt.service.spec.ts @@ -597,6 +597,27 @@ describe('NetworkAgentMqttService', () => { await harness.service.onModuleDestroy(); }); + it('acknowledges and drops malformed v2 status without reconnecting', async () => { + const harness = createHarness(); + harness.service.onModuleInit(); + const ack = createProtocolAck(harness.client); + + harness + .clientOptions() + .customHandleAcks?.( + 'kt/network/v2/agents/nas-main/status', + v2Status({ supportedSchemaVersions: [2] }), + { qos: 1 }, + ack, + ); + await flushPromises(); + + expect(ack).toHaveBeenCalledWith(0); + expect(harness.client.end).not.toHaveBeenCalled(); + expect(harness.client.reconnect).not.toHaveBeenCalled(); + await harness.service.onModuleDestroy(); + }); + it('recovers from SUBACK failure before restoring exact subscriptions and forced retained desired publication', async () => { const harness = createHarness(); harness.state.publishedRevision = harness.state.desiredRevision; diff --git a/test/admin/network-management/network-agent-v2.types.spec.ts b/test/admin/network-management/network-agent-v2.types.spec.ts index 6b5f833..cddffc0 100644 --- a/test/admin/network-management/network-agent-v2.types.spec.ts +++ b/test/admin/network-management/network-agent-v2.types.spec.ts @@ -221,4 +221,53 @@ describe('network agent MQTT v2 contract', () => { buildDesiredSnapshotV2({ ...state, desiredRevision: '9007199254740992' }, channels), ).toThrow(); }); + + it('sorts built channels canonically and returns a snapshot accepted by the strict parser', () => { + const state = { + agentId: 'nas-main', + desiredIssuedAt: new Date('2026-07-26T00:01:10Z'), + desiredRevision: '7', + }; + const channel = (id: string) => ({ + desiredPresence: 'present' as const, + desiredRevision: '7', + externalPort: 48213, + groupId: '1', + id, + internalPort: 48213, + keeperDesiredEnabled: false, + name: `channel-${id}`, + natmapDesiredEnabled: true, + protocol: 'tcp' as const, + }); + + const desired = buildDesiredSnapshotV2(state, [channel('20'), channel('10')]); + expect(desired.channels.map((item) => item.channelId)).toEqual(['10', '20']); + expect(parseDesiredSnapshotV2(JSON.stringify(desired))).toEqual(desired); + }); + + it('rejects duplicate channel IDs and rows invalid under the strict desired contract', () => { + const state = { + agentId: 'nas-main', + desiredIssuedAt: new Date('2026-07-26T00:01:10Z'), + desiredRevision: '7', + }; + const channel = { + desiredPresence: 'present' as const, + desiredRevision: '7', + externalPort: 48213, + groupId: '1', + id: '10', + internalPort: 48213, + keeperDesiredEnabled: false, + name: 'TCP NATMap', + natmapDesiredEnabled: true, + protocol: 'tcp' as const, + }; + + expect(() => buildDesiredSnapshotV2(state, [channel, { ...channel }])).toThrow(); + expect(() => + buildDesiredSnapshotV2(state, [{ ...channel, externalPort: 0 }]), + ).toThrow(); + }); }); diff --git a/test/admin/network-management/network-management.service.spec.ts b/test/admin/network-management/network-management.service.spec.ts index f1468fb..09c13a0 100644 --- a/test/admin/network-management/network-management.service.spec.ts +++ b/test/admin/network-management/network-management.service.spec.ts @@ -7,6 +7,7 @@ import { NetworkAgentState } from '../../../src/modules/admin/platform-config/ne import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.entity'; import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service'; +import { NetworkTcpReleasePolicyService } from '../../../src/modules/admin/platform-config/network-management/network-tcp-release-policy.service'; type Harness = { bootstrapOrder: string[]; @@ -18,7 +19,15 @@ type Harness = { state: NetworkAgentState; }; -function createHarness(initialMappings: NetworkPortForward[] = []): Harness { +type ReleaseConfig = { + canaryPorts?: string; + mode?: string; +}; + +function createHarness( + initialMappings: NetworkPortForward[] = [], + releaseConfig: ReleaseConfig = {}, +): Harness { const mappings = initialMappings; const histories: NetworkEndpointHistory[] = []; const bootstrapOrder: string[] = []; @@ -85,11 +94,12 @@ function createHarness(initialMappings: NetworkPortForward[] = []): Harness { } as unknown as DataSource; const configService = { get: (key) => - key === 'NETWORK_AGENT_TARGET_IPV4' - ? '192.168.31.224' - : key === 'NETWORK_AGENT_ID' - ? 'nas-main' - : undefined, + ({ + NETWORK_AGENT_ID: 'nas-main', + NETWORK_AGENT_TARGET_IPV4: '192.168.31.224', + NETWORK_TCP_NATMAP_CANARY_PORTS: releaseConfig.canaryPorts, + NETWORK_TCP_NATMAP_RELEASE_MODE: releaseConfig.mode, + })[key], } as ConfigService; const mqtt = { requestDesiredPublish: jest.fn(), @@ -101,6 +111,7 @@ function createHarness(initialMappings: NetworkPortForward[] = []): Harness { dataSource, configService, mqtt as unknown as NetworkAgentMqttService, + new NetworkTcpReleasePolicyService(configService), ); return { bootstrapExecute, @@ -141,12 +152,37 @@ function createMapping( } function createListBuilder(mappings: NetworkPortForward[]) { + let rows = mappings.filter((mapping) => !mapping.isDeleted); + let offset = 0; + let limit = rows.length; const builder = { - andWhere: () => builder, - getManyAndCount: async () => [mappings, mappings.length], + andWhere: (clause: string, values: Record) => { + if (clause.includes('mapping.protocol <>')) { + rows = rows.filter( + (mapping) => mapping.protocol !== values.hiddenProtocol, + ); + } else if (clause.includes('mapping.protocol =')) { + rows = rows.filter((mapping) => mapping.protocol === values.protocol); + } else if (clause.includes('mapping.syncStatus =')) { + rows = rows.filter( + (mapping) => mapping.syncStatus === values.syncStatus, + ); + } else if (clause.includes('mapping.name LIKE')) { + const name = String(values.name).replaceAll('%', ''); + rows = rows.filter((mapping) => mapping.name.includes(name)); + } + return builder; + }, + getManyAndCount: async () => [rows.slice(offset, offset + limit), rows.length], orderBy: () => builder, - skip: () => builder, - take: () => builder, + skip: (value: number) => { + offset = value; + return builder; + }, + take: (value: number) => { + limit = value; + return builder; + }, where: () => builder, }; return builder; @@ -374,4 +410,148 @@ describe('NetworkManagementService', () => { lastReconcileErrorCode: 'router_conflict', }); }); + + it('hides TCP mappings outside on mode while keeping the ordinary list shape and count', async () => { + const udpMapping = createMapping({ id: '100' }); + const tcpMapping = createMapping({ + activeKey: 'tcp:48213', + externalPort: 48213, + id: '101', + internalPort: 48213, + protocol: 'tcp', + }); + const hidden = createHarness([udpMapping, tcpMapping]); + const visible = createHarness([udpMapping, tcpMapping], { mode: 'on' }); + + await expect(hidden.service.list()).resolves.toMatchObject({ + items: [{ id: '100', protocol: 'udp' }], + total: 1, + }); + await expect(visible.service.list()).resolves.toMatchObject({ + items: [{ id: '100' }, { id: '101' }], + total: 2, + }); + }); + + it('gates TCP create by release mode and canary allowlist before persistence', async () => { + const off = createHarness(); + await expect( + off.service.create({ + externalPort: 48213, + internalPort: 48213, + name: 'tcp-off', + protocol: 'tcp', + }), + ).rejects.toMatchObject({ status: 409 }); + expect(off.state.desiredRevision).toBe('0'); + expect(off.mappings).toHaveLength(0); + + const rejectedCanary = createHarness([], { + canaryPorts: '48214', + mode: 'canary', + }); + await expect( + rejectedCanary.service.create({ + externalPort: 48213, + internalPort: 48213, + name: 'tcp-canary-rejected', + protocol: 'tcp', + }), + ).rejects.toMatchObject({ status: 409 }); + + const acceptedCanary = createHarness([], { + canaryPorts: '48213', + mode: 'canary', + }); + await expect( + acceptedCanary.service.create({ + externalPort: 48213, + internalPort: 48213, + name: 'tcp-canary', + protocol: 'tcp', + }), + ).resolves.toMatchObject({ protocol: 'tcp' }); + }); + + it('rejects TCP update and retry in off mode while permitting explicit TCP deletion cleanup', async () => { + const mapping = createMapping({ + activeKey: 'tcp:48213', + externalPort: 48213, + internalPort: 48213, + protocol: 'tcp', + }); + const harness = createHarness([mapping], { mode: 'off' }); + + await expect( + harness.service.update('100', { externalPort: 48214 }), + ).rejects.toMatchObject({ status: 409 }); + await expect(harness.service.retry('100')).rejects.toMatchObject({ + status: 409, + }); + expect(harness.state.desiredRevision).toBe('3'); + await expect(harness.service.remove('100')).resolves.toMatchObject({ + desiredPresence: 'absent', + protocol: 'tcp', + }); + }); + + it('allows canary TCP writes only on listed ports while cleanup survives allowlist removal', async () => { + const allowedMapping = createMapping({ + activeKey: 'tcp:48213', + externalPort: 48213, + internalPort: 48213, + protocol: 'tcp', + }); + const allowed = createHarness([allowedMapping], { + canaryPorts: '48213', + mode: 'canary', + }); + await expect( + allowed.service.update('100', { remark: 'canary' }), + ).resolves.toMatchObject({ remark: 'canary' }); + await expect(allowed.service.retry('100')).resolves.toMatchObject({ + protocol: 'tcp', + }); + + const removedMapping = createMapping({ + activeKey: 'tcp:48213', + externalPort: 48213, + internalPort: 48213, + protocol: 'tcp', + }); + const removed = createHarness([removedMapping], { + canaryPorts: '48214', + mode: 'canary', + }); + await expect(removed.service.retry('100')).rejects.toMatchObject({ + status: 409, + }); + await expect(removed.service.remove('100')).resolves.toMatchObject({ + desiredPresence: 'absent', + }); + }); + + it('keeps UDP update, retry, delete, Keeper, and probe paths unchanged while release is off', async () => { + const mapping = createMapping(); + const harness = createHarness([mapping], { mode: 'off' }); + + await expect( + harness.service.update('100', { remark: 'udp' }), + ).resolves.toMatchObject({ remark: 'udp' }); + await expect(harness.service.retry('100')).resolves.toMatchObject({ + protocol: 'udp', + }); + await expect(harness.service.enableKeeper('100')).resolves.toMatchObject({ + keeperDesiredEnabled: true, + }); + await expect(harness.service.probe('100')).resolves.toMatchObject({ + keeperDesiredEnabled: true, + }); + await expect(harness.service.disableKeeper('100')).resolves.toMatchObject({ + keeperDesiredEnabled: false, + }); + await expect(harness.service.remove('100')).resolves.toMatchObject({ + desiredPresence: 'absent', + }); + }); }); diff --git a/test/admin/network-management/network-tcp-release-policy.service.spec.ts b/test/admin/network-management/network-tcp-release-policy.service.spec.ts index 9cae7fd..26e1781 100644 --- a/test/admin/network-management/network-tcp-release-policy.service.spec.ts +++ b/test/admin/network-management/network-tcp-release-policy.service.spec.ts @@ -12,11 +12,20 @@ function policy(values: Record) { const tcp = (overrides = {}) => ({ externalPort: 48213, + internalPort: 48213, natmapDesiredEnabled: true, protocolMode: 'tcp' as const, ...overrides, }); +const udp = (overrides = {}) => ({ + externalPort: 8213, + internalPort: 8211, + natmapDesiredEnabled: false, + protocolMode: 'udp' as const, + ...overrides, +}); + describe('NetworkTcpReleasePolicyService', () => { it('fails closed by default and parses the four release modes', () => { expect(policy({}).readMode()).toBe('off'); @@ -53,9 +62,14 @@ describe('NetworkTcpReleasePolicyService', () => { NETWORK_TCP_NATMAP_CANARY_PORTS: '48213', NETWORK_TCP_NATMAP_RELEASE_MODE: 'canary', }); - expect(() => service.assertMutationAllowed({ after: tcp() })).not.toThrow(); expect(() => - service.assertMutationAllowed({ after: tcp({ externalPort: 48214 }) }), + service.assertMutationAllowed({ after: tcp(), kind: 'create' }), + ).not.toThrow(); + expect(() => + service.assertMutationAllowed({ + after: tcp({ externalPort: 48214 }), + kind: 'create', + }), ).toThrow(NetworkTcpReleasePolicyError); expect(service.isTcpVisibleToAdmin()).toBe(false); expect( @@ -79,35 +93,109 @@ describe('NetworkTcpReleasePolicyService', () => { const service = policy({ NETWORK_TCP_NATMAP_RELEASE_MODE: mode }); expect(() => service.assertMutationAllowed({ + kind: 'natmap-disable', before: tcp(), after: tcp({ natmapDesiredEnabled: false }), }), ).not.toThrow(); expect(() => service.assertMutationAllowed({ + kind: 'protocol-shrink', before: { ...tcp(), protocolMode: 'tcp_udp' }, after: { ...tcp(), natmapDesiredEnabled: false, protocolMode: 'udp' }, }), ).not.toThrow(); - expect(() => service.assertMutationAllowed({ before: tcp() })).not.toThrow(); expect(() => - service.assertMutationAllowed({ before: tcp(), after: { ...tcp(), protocolMode: 'udp' } }), + service.assertMutationAllowed({ current: tcp(), kind: 'delete' }), + ).not.toThrow(); + expect(() => + service.assertMutationAllowed({ + after: { ...tcp(), protocolMode: 'udp' }, + before: tcp(), + kind: 'update', + }), ).toThrow(NetworkTcpReleasePolicyError); expect(() => service.assertMutationAllowed({ + kind: 'update', before: { ...tcp(), protocolMode: 'tcp_udp' }, after: tcp(), }), ).toThrow(NetworkTcpReleasePolicyError); expect(() => - service.assertMutationAllowed({ after: tcp({ externalPort: 48214 }) }), + service.assertMutationAllowed({ current: tcp(), kind: 'retry' }), + ).toThrow(NetworkTcpReleasePolicyError); + expect(() => + service.assertMutationAllowed({ current: tcp(), kind: 'natmap-enable' }), ).toThrow(NetworkTcpReleasePolicyError); expect(() => service.assertMutationAllowed({ - before: { externalPort: 8213, natmapDesiredEnabled: false, protocolMode: 'udp' }, - after: { externalPort: 8214, natmapDesiredEnabled: false, protocolMode: 'udp' }, + after: udp({ externalPort: 8214 }), + before: udp(), + kind: 'update', }), ).not.toThrow(); }, ); + + it('requires an explicit safe cleanup kind and unchanged ports for protocol shrink', () => { + const service = policy({ NETWORK_TCP_NATMAP_RELEASE_MODE: 'off' }); + expect(() => + service.assertMutationAllowed({ + after: { + ...tcp(), + externalPort: 48214, + natmapDesiredEnabled: false, + protocolMode: 'udp', + }, + before: { ...tcp(), protocolMode: 'tcp_udp' }, + kind: 'protocol-shrink', + }), + ).toThrow(NetworkTcpReleasePolicyError); + expect(() => + service.assertMutationAllowed({ + after: { + ...tcp(), + internalPort: 48214, + natmapDesiredEnabled: false, + protocolMode: 'udp', + }, + before: { ...tcp(), protocolMode: 'tcp_udp' }, + kind: 'protocol-shrink', + }), + ).toThrow(NetworkTcpReleasePolicyError); + expect(() => + service.assertMutationAllowed({ + before: tcp(), + kind: 'update', + } as never), + ).toThrow(NetworkTcpReleasePolicyError); + expect(() => + service.assertMutationAllowed({ + after: tcp(), + before: tcp(), + kind: 'natmap-disable', + }), + ).toThrow(NetworkTcpReleasePolicyError); + }); + + it('allows cleanup after a canary port leaves the allowlist but still gates retry', () => { + const service = policy({ + NETWORK_TCP_NATMAP_CANARY_PORTS: '48214', + NETWORK_TCP_NATMAP_RELEASE_MODE: 'canary', + }); + expect(() => + service.assertMutationAllowed({ current: tcp(), kind: 'delete' }), + ).not.toThrow(); + expect(() => + service.assertMutationAllowed({ + after: tcp({ natmapDesiredEnabled: false }), + before: tcp(), + kind: 'natmap-disable', + }), + ).not.toThrow(); + expect(() => + service.assertMutationAllowed({ current: tcp(), kind: 'retry' }), + ).toThrow(NetworkTcpReleasePolicyError); + }); });