fix: 修复端口转发组幂等与校验响应
This commit is contained in:
parent
f6ef9c9f28
commit
71dae3b38e
@ -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 &&
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]/);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user