fix: 接通TCP NATMap发布门禁

This commit is contained in:
sunlei 2026-07-26 16:11:52 +08:00
parent d18a489b4e
commit f27c82a082
8 changed files with 533 additions and 43 deletions

View File

@ -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;

View File

@ -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(

View File

@ -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,

View File

@ -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
);
}

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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<string, unknown>) => {
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',
});
});
});

View File

@ -12,11 +12,20 @@ function policy(values: Record<string, string | undefined>) {
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);
});
});