fix: 接通TCP NATMap发布门禁
This commit is contained in:
parent
d18a489b4e
commit
f27c82a082
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user