fix: 修复端口转发组幂等与校验响应

This commit is contained in:
sunlei 2026-07-26 19:37:01 +08:00
parent f6ef9c9f28
commit 71dae3b38e
7 changed files with 126 additions and 52 deletions

View File

@ -1,4 +1,3 @@
import { randomUUID } from 'node:crypto';
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
@ -103,33 +102,15 @@ export class NetworkManagementService {
}
async enableKeeper(id: string) {
return this.mutate(id, async (mapping) => {
this.assertKeeperCapable(mapping);
mapping.keeperDesiredEnabled = true;
mapping.probeRequestId = randomUUID();
mapping.syncStatus = 'pending';
});
return this.groupService.enableKeeperV1(id);
}
async disableKeeper(id: string) {
return this.mutate(id, async (mapping) => {
this.assertKeeperCapable(mapping);
mapping.keeperDesiredEnabled = false;
mapping.probeRequestId = null;
mapping.syncStatus = 'pending';
this.withdrawCurrentEndpoint(mapping);
});
return this.groupService.disableKeeperV1(id);
}
async probe(id: string) {
return this.mutate(id, async (mapping) => {
this.assertKeeperCapable(mapping);
if (!mapping.keeperDesiredEnabled) {
throwVbenError('请先启用 UDP Keeper', HttpStatus.BAD_REQUEST);
}
mapping.probeRequestId = randomUUID();
mapping.syncStatus = 'pending';
});
return this.groupService.probeV1(id);
}
async endpointHistory(
@ -300,28 +281,6 @@ export class NetworkManagementService {
mapping.desiredIssuedAt = issuedAt;
}
private assertKeeperCapable(mapping: NetworkPortForward): void {
if (mapping.desiredPresence !== 'present') {
throwVbenError('删除中的记录不能操作 Keeper', HttpStatus.CONFLICT);
}
if (mapping.protocol !== 'udp') {
throwVbenError('TCP 仅支持端口转发 CRUD', HttpStatus.BAD_REQUEST);
}
if (mapping.externalPort !== mapping.internalPort) {
throwVbenError(
'UDP Keeper 要求外部端口与内部端口一致',
HttpStatus.BAD_REQUEST,
);
}
}
private withdrawCurrentEndpoint(mapping: NetworkPortForward): void {
mapping.currentPublicIpv4 = null;
mapping.currentPublicPort = null;
mapping.currentObservedAt = null;
mapping.currentValidUntil = null;
}
private serialize(mapping: NetworkPortForward) {
const leaseValid =
!!mapping.currentPublicIpv4 &&

View File

@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpException,
HttpCode,
HttpStatus,
Param,
@ -34,6 +35,14 @@ import { NetworkPortForwardGroupService } from './network-port-forward-group.ser
@UseGuards(JwtAuthGuard, AdminSuperGuard)
@UsePipes(
new ValidationPipe({
exceptionFactory: () =>
new HttpException(
{
err: '请求参数不符合接口约束',
msg: '请求参数校验失败',
},
HttpStatus.BAD_REQUEST,
),
forbidNonWhitelisted: true,
transform: true,
whitelist: true,

View File

@ -20,7 +20,7 @@ export class NetworkPortForwardGroupCreateDto {
@ApiProperty({ maxLength: 100 })
@IsString()
@Length(1, 100)
@Matches(/\S/, { message: 'name must contain a non-whitespace character' })
@Matches(/\S/, { message: '名称必须包含非空白字符' })
name: string;
@ApiPropertyOptional({ maxLength: 500 })
@ -51,7 +51,7 @@ export class NetworkPortForwardGroupUpdateDto {
@ValidateIf(isProvided)
@IsString()
@Length(1, 100)
@Matches(/\S/, { message: 'name must contain a non-whitespace character' })
@Matches(/\S/, { message: '名称必须包含非空白字符' })
name?: string;
@ApiPropertyOptional({ maxLength: 500, nullable: true })

View File

@ -268,8 +268,8 @@ export class NetworkPortForwardGroupService {
groupId,
'udp',
async (_, channel, channels) => {
this.assertKeeperPorts(channel);
if (channel.keeperDesiredEnabled) return false;
this.assertKeeperPorts(channel);
this.assertMechanismTransitionAllowed(channels);
channel.keeperDesiredEnabled = true;
channel.probeRequestId = randomUUID();
@ -285,8 +285,8 @@ export class NetworkPortForwardGroupService {
groupId,
'udp',
async (_, channel, channels) => {
this.assertKeeperPorts(channel);
if (!channel.keeperDesiredEnabled) return false;
this.assertKeeperPorts(channel);
this.assertMechanismTransitionAllowed(channels);
channel.keeperDesiredEnabled = false;
channel.probeRequestId = null;
@ -298,6 +298,24 @@ export class NetworkPortForwardGroupService {
);
}
async enableKeeperV1(channelId: string) {
return this.enableKeeper(
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
);
}
async disableKeeperV1(channelId: string) {
return this.disableKeeper(
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
);
}
async probeV1(channelId: string) {
return this.probe(
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
);
}
async probe(groupId: string) {
return this.mutateChannel(
groupId,
@ -622,6 +640,25 @@ export class NetworkPortForwardGroupService {
return group;
}
private async resolveV1GroupId(
channelId: string,
protocol: PortForwardProtocol,
action: string,
): Promise<string> {
this.assertId(channelId, '端口转发');
const channel = await this.mappingRepository.findOne({
where: { id: channelId, isDeleted: false },
});
if (!channel) throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND);
if (channel.protocol !== protocol) {
throwVbenError(
`${channel.protocol.toUpperCase()} 通道不支持 ${action}`,
HttpStatus.BAD_REQUEST,
);
}
return String(channel.groupId);
}
private async findChannels(
manager: EntityManager,
groupId: string,

View File

@ -629,12 +629,15 @@ describe('NetworkManagementService', () => {
await expect(harness.service.retry('100')).resolves.toMatchObject({
protocol: 'udp',
});
mapping.syncStatus = 'synced';
await expect(harness.service.enableKeeper('100')).resolves.toMatchObject({
keeperDesiredEnabled: true,
});
mapping.syncStatus = 'synced';
await expect(harness.service.probe('100')).resolves.toMatchObject({
keeperDesiredEnabled: true,
});
mapping.syncStatus = 'synced';
await expect(harness.service.disableKeeper('100')).resolves.toMatchObject({
keeperDesiredEnabled: false,
});
@ -643,4 +646,32 @@ describe('NetworkManagementService', () => {
desiredPresence: 'absent',
});
});
it('keeps repeated v1 Keeper switches idempotent while preserving channel-ID semantics', async () => {
const mapping = createMapping();
const harness = createHarness([mapping], { mode: 'off' });
await harness.service.enableKeeper('100');
const enabledRevision = harness.state.desiredRevision;
const enabledChannelRevision = mapping.desiredRevision;
const probeRequestId = mapping.probeRequestId;
await harness.service.enableKeeper('100');
expect(mapping.probeRequestId).toBe(probeRequestId);
expect(mapping.desiredRevision).toBe(enabledChannelRevision);
expect(harness.state.desiredRevision).toBe(enabledRevision);
expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(1);
mapping.syncStatus = 'synced';
await harness.service.disableKeeper('100');
const disabledRevision = harness.state.desiredRevision;
const disabledChannelRevision = mapping.desiredRevision;
await harness.service.disableKeeper('100');
expect(mapping.desiredRevision).toBe(disabledChannelRevision);
expect(harness.state.desiredRevision).toBe(disabledRevision);
expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(2);
await expect(harness.service.enableKeeper('200')).rejects.toMatchObject({
status: 404,
});
});
});

View File

@ -89,7 +89,7 @@ describe('NetworkPortForwardGroupController', () => {
)
.expect(200)
.expect('Cache-Control', 'no-store');
await request(apiUrl)
const invalidProtocol = await request(apiUrl)
.post('/system/network/port-forward-group')
.send({
externalPort: 9000,
@ -98,7 +98,8 @@ describe('NetworkPortForwardGroupController', () => {
protocolMode: 'icmp',
})
.expect(400);
await request(apiUrl)
expectChineseVbenValidation(invalidProtocol.body);
const forbiddenField = await request(apiUrl)
.post('/system/network/port-forward-group')
.send({
externalPort: 9000,
@ -108,6 +109,9 @@ describe('NetworkPortForwardGroupController', () => {
routerPassword: 'forbidden',
})
.expect(400);
expectChineseVbenValidation(forbiddenField.body);
expect(JSON.stringify(forbiddenField.body)).not.toContain('forbidden');
expect(JSON.stringify(forbiddenField.body)).not.toContain('routerPassword');
const created = await request(apiUrl)
.post('/system/network/port-forward-group')
@ -229,14 +233,26 @@ describe('NetworkPortForwardGroupController', () => {
});
it('rejects invalid group IDs and protocols before calling the service', async () => {
await request(apiUrl)
const invalidId = await request(apiUrl)
.post(
'/system/network/port-forward-group/not-a-number/channels/tcp/retry',
)
.expect(400);
await request(apiUrl)
expectChineseVbenValidation(invalidId.body);
const invalidProtocol = await request(apiUrl)
.post('/system/network/port-forward-group/200/channels/icmp/retry')
.expect(400);
expectChineseVbenValidation(invalidProtocol.body);
expect(service.retry).not.toHaveBeenCalled();
});
});
function expectChineseVbenValidation(body: unknown): void {
expect(body).toEqual({
err: expect.any(String),
msg: expect.any(String),
});
const response = body as { err: string; msg: string };
expect(response.msg).toMatch(/[\u4e00-\u9fff]/);
expect(response.err).toMatch(/[\u4e00-\u9fff]/);
}

View File

@ -488,4 +488,26 @@ describe('NetworkPortForwardGroupService', () => {
.enableNatmap('200')
.catch((error) => expect(errorStatus(error)).toBe(400));
});
it('treats an already-disabled asymmetric UDP Keeper switch as a no-op', async () => {
const group = createGroup({
externalPort: 9000,
internalPort: 9001,
protocolMode: 'udp',
});
const udp = createMapping({
externalPort: 9000,
internalPort: 9001,
keeperDesiredEnabled: false,
protocol: 'udp',
});
const harness = createHarness([group], [udp]);
await expect(harness.service.disableKeeper('200')).resolves.toMatchObject({
desiredRevision: '3',
keeperDesiredEnabled: false,
});
expect(harness.state.desiredRevision).toBe('3');
expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled();
});
});