feat: 增加逻辑端口转发组接口
This commit is contained in:
parent
760a9693e7
commit
f6ef9c9f28
@ -18,6 +18,8 @@ import { NetworkManagementController } from '@/modules/admin/platform-config/net
|
||||
import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service';
|
||||
import { NetworkPortForward } from '@/modules/admin/platform-config/network-management/network-management.entity';
|
||||
import { NetworkPortForwardGroup } from '@/modules/admin/platform-config/network-management/network-port-forward-group.entity';
|
||||
import { NetworkPortForwardGroupController } from '@/modules/admin/platform-config/network-management/network-port-forward-group.controller';
|
||||
import { NetworkPortForwardGroupService } from '@/modules/admin/platform-config/network-management/network-port-forward-group.service';
|
||||
import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service';
|
||||
import { NetworkStunMessageSourceAdapter } from '@/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
|
||||
import { NetworkTcpReleasePolicyService } from '@/modules/admin/platform-config/network-management/network-tcp-release-policy.service';
|
||||
@ -55,6 +57,7 @@ export const ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS = [
|
||||
AdminTimezoneController,
|
||||
EnvironmentDashboardController,
|
||||
NetworkManagementController,
|
||||
NetworkPortForwardGroupController,
|
||||
];
|
||||
|
||||
export const ADMIN_PLATFORM_CONFIG_IMPORTED_CONTROLLERS = [
|
||||
@ -88,6 +91,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
||||
EnvironmentEventMaterializer,
|
||||
EnvironmentEventStreamService,
|
||||
NetworkManagementService,
|
||||
NetworkPortForwardGroupService,
|
||||
NetworkManagementEventStreamService,
|
||||
NetworkDnsPodClient,
|
||||
NetworkDdnsService,
|
||||
|
||||
@ -14,10 +14,7 @@ import {
|
||||
NetworkPortForwardUpdateDto,
|
||||
} from './network-management.dto';
|
||||
import { NetworkPortForward } from './network-management.entity';
|
||||
import {
|
||||
isIpv4Address,
|
||||
portForwardActiveKey,
|
||||
} from './network-management.types';
|
||||
import { isIpv4Address } from './network-management.types';
|
||||
import {
|
||||
NetworkTcpReleasePolicyError,
|
||||
NetworkTcpReleasePolicyService,
|
||||
@ -25,6 +22,7 @@ import {
|
||||
type TcpReleaseMutation,
|
||||
type TcpReleaseState,
|
||||
} from './network-tcp-release-policy.service';
|
||||
import { NetworkPortForwardGroupService } from './network-port-forward-group.service';
|
||||
|
||||
const DEFAULT_AGENT_ID = 'nas-main';
|
||||
const DEFAULT_TARGET_IPV4 = '192.168.31.224';
|
||||
@ -42,6 +40,7 @@ export class NetworkManagementService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mqttService: NetworkAgentMqttService,
|
||||
private readonly tcpReleasePolicy: NetworkTcpReleasePolicyService,
|
||||
private readonly groupService: NetworkPortForwardGroupService,
|
||||
) {}
|
||||
|
||||
async list(query: NetworkPortForwardListQueryDto = {}) {
|
||||
@ -79,135 +78,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);
|
||||
if ((await repository.count({ where: { isDeleted: false } })) >= 64) {
|
||||
throwVbenError('端口转发记录已达到 64 条上限', HttpStatus.CONFLICT);
|
||||
}
|
||||
const activeKey = portForwardActiveKey(
|
||||
input.protocol,
|
||||
input.externalPort,
|
||||
);
|
||||
if (await repository.findOne({ where: { activeKey } })) {
|
||||
throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT);
|
||||
}
|
||||
const mapping = repository.create({
|
||||
activeKey,
|
||||
currentObservedAt: null,
|
||||
currentPublicIpv4: null,
|
||||
currentPublicPort: null,
|
||||
currentValidUntil: null,
|
||||
desiredPresence: 'present',
|
||||
externalPort: input.externalPort,
|
||||
internalPort: input.internalPort,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
keeperStatus: 'disabled',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
name: this.normalizeName(input.name),
|
||||
probeRequestId: null,
|
||||
protocol: input.protocol,
|
||||
remark: input.remark?.trim() || null,
|
||||
reportedRevision: '0',
|
||||
syncStatus: 'pending',
|
||||
targetIpv4: state.targetIpv4,
|
||||
});
|
||||
this.advanceRevision(state, mapping);
|
||||
try {
|
||||
await repository.save(mapping);
|
||||
await manager.getRepository(NetworkAgentState).save(state);
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) {
|
||||
throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
this.notifyDesiredChanged();
|
||||
return this.serialize(saved);
|
||||
return this.groupService.createV1(input);
|
||||
}
|
||||
|
||||
async update(id: string, input: NetworkPortForwardUpdateDto) {
|
||||
if (Object.keys(input).length === 0) {
|
||||
throwVbenError('至少提供一个修改字段', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.mutate(id, async (mapping, manager) => {
|
||||
if (mapping.desiredPresence === 'absent') {
|
||||
throwVbenError('端口转发正在删除', HttpStatus.CONFLICT);
|
||||
}
|
||||
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)
|
||||
) {
|
||||
throwVbenError(
|
||||
'请先停用 Keeper 再修改协议或同源端口',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
const activeKey = portForwardActiveKey(protocol, externalPort);
|
||||
if (activeKey !== mapping.activeKey) {
|
||||
const conflict = await manager
|
||||
.getRepository(NetworkPortForward)
|
||||
.findOne({ where: { activeKey } });
|
||||
if (conflict && conflict.id !== mapping.id) {
|
||||
throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
if (input.name !== undefined) {
|
||||
mapping.name = this.normalizeName(input.name);
|
||||
}
|
||||
mapping.remark =
|
||||
input.remark === undefined
|
||||
? mapping.remark
|
||||
: input.remark.trim() || null;
|
||||
mapping.protocol = protocol;
|
||||
mapping.externalPort = externalPort;
|
||||
mapping.internalPort = internalPort;
|
||||
mapping.activeKey = activeKey;
|
||||
mapping.syncStatus = 'pending';
|
||||
});
|
||||
return this.groupService.updateV1(id, input);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
return this.mutate(id, async (mapping) => {
|
||||
if (mapping.desiredPresence === 'absent') {
|
||||
throwVbenError('端口转发正在删除', HttpStatus.CONFLICT);
|
||||
}
|
||||
this.assertReleaseMutation({
|
||||
current: this.releaseState(mapping),
|
||||
kind: 'delete',
|
||||
});
|
||||
mapping.desiredPresence = 'absent';
|
||||
mapping.keeperDesiredEnabled = false;
|
||||
mapping.probeRequestId = null;
|
||||
mapping.syncStatus = 'deleting';
|
||||
this.withdrawCurrentEndpoint(mapping);
|
||||
});
|
||||
return this.groupService.removeV1(id);
|
||||
}
|
||||
|
||||
async retry(id: string) {
|
||||
@ -389,10 +268,7 @@ export class NetworkManagementService {
|
||||
this.tcpReleasePolicy.assertMutationAllowed(mutation);
|
||||
} catch (error) {
|
||||
if (error instanceof NetworkTcpReleasePolicyError) {
|
||||
throwVbenError(
|
||||
'当前发布模式不允许该 TCP 操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
throwVbenError('当前发布模式不允许该 TCP 操作', HttpStatus.CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -454,6 +330,7 @@ export class NetworkManagementService {
|
||||
new Date(mapping.currentValidUntil).getTime() > Date.now();
|
||||
return {
|
||||
id: String(mapping.id),
|
||||
groupId: String(mapping.groupId),
|
||||
name: mapping.name,
|
||||
remark: mapping.remark || null,
|
||||
protocol: mapping.protocol,
|
||||
@ -462,12 +339,14 @@ export class NetworkManagementService {
|
||||
targetIpv4: mapping.targetIpv4,
|
||||
desiredPresence: mapping.desiredPresence,
|
||||
keeperDesiredEnabled: mapping.keeperDesiredEnabled,
|
||||
natmapDesiredEnabled: mapping.natmapDesiredEnabled,
|
||||
probeRequestId: mapping.probeRequestId || null,
|
||||
desiredRevision: String(mapping.desiredRevision),
|
||||
desiredIssuedAt: mapping.desiredIssuedAt,
|
||||
reportedRevision: String(mapping.reportedRevision),
|
||||
syncStatus: mapping.syncStatus,
|
||||
keeperStatus: mapping.keeperStatus,
|
||||
natmapStatus: mapping.natmapStatus,
|
||||
currentPublicIpv4: leaseValid ? mapping.currentPublicIpv4 : null,
|
||||
currentPublicPort: leaseValid ? mapping.currentPublicPort : null,
|
||||
currentPublicEndpoint: leaseValid
|
||||
@ -535,17 +414,6 @@ export class NetworkManagementService {
|
||||
return target;
|
||||
}
|
||||
|
||||
private normalizeName(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || Buffer.byteLength(normalized, 'utf8') > 128) {
|
||||
throwVbenError(
|
||||
'规则名称超出 Agent UTF-8 长度限制',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private isDuplicateKeyError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const record = error as { code?: unknown; errno?: unknown };
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { vbenPage, vbenSuccess } from '@/common';
|
||||
import { AdminSuperGuard } from '@/modules/admin/identity/auth/admin-super.guard';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { NetworkEndpointHistoryQueryDto } from './network-management.dto';
|
||||
import {
|
||||
NetworkPortForwardGroupChannelParamsDto,
|
||||
NetworkPortForwardGroupCreateDto,
|
||||
NetworkPortForwardGroupListQueryDto,
|
||||
NetworkPortForwardGroupParamsDto,
|
||||
NetworkPortForwardGroupUpdateDto,
|
||||
} from './network-port-forward-group.dto';
|
||||
import { NetworkPortForwardGroupService } from './network-port-forward-group.service';
|
||||
|
||||
@ApiTags('Admin - 网络逻辑端口转发组')
|
||||
@Controller('system/network/port-forward-group')
|
||||
@UseGuards(JwtAuthGuard, AdminSuperGuard)
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
}),
|
||||
)
|
||||
export class NetworkPortForwardGroupController {
|
||||
constructor(private readonly service: NetworkPortForwardGroupService) {}
|
||||
|
||||
@Get('list')
|
||||
@ApiOperation({ summary: '分页查询逻辑端口转发组' })
|
||||
async list(
|
||||
@Query() query: NetworkPortForwardGroupListQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
const page = await this.service.list(query);
|
||||
return vbenPage(page.items, page.total);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '新增逻辑端口转发组' })
|
||||
async create(
|
||||
@Body() body: NetworkPortForwardGroupCreateDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.create(body));
|
||||
}
|
||||
|
||||
@Put(':groupId')
|
||||
@ApiOperation({ summary: '修改逻辑端口转发组' })
|
||||
async update(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Body() body: NetworkPortForwardGroupUpdateDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.update(params.groupId, body));
|
||||
}
|
||||
|
||||
@Delete(':groupId')
|
||||
@ApiOperation({ summary: '删除逻辑端口转发组' })
|
||||
async remove(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.remove(params.groupId));
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/:protocol/retry')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '重试协议通道同步' })
|
||||
async retry(
|
||||
@Param() params: NetworkPortForwardGroupChannelParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(
|
||||
await this.service.retry(params.groupId, params.protocol),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':groupId/channels/:protocol/endpoint-history')
|
||||
@ApiOperation({ summary: '查询协议通道公网端点历史' })
|
||||
async endpointHistory(
|
||||
@Param() params: NetworkPortForwardGroupChannelParamsDto,
|
||||
@Query() query: NetworkEndpointHistoryQueryDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
const page = await this.service.endpointHistory(
|
||||
params.groupId,
|
||||
params.protocol,
|
||||
query,
|
||||
);
|
||||
return vbenPage(page.items, page.total);
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/tcp/natmap/enable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '启用 TCP NATMap' })
|
||||
async enableNatmap(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.enableNatmap(params.groupId));
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/tcp/natmap/disable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '停用 TCP NATMap' })
|
||||
async disableNatmap(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.disableNatmap(params.groupId));
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/udp/keeper/enable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '启用 UDP STUN Keeper' })
|
||||
async enableKeeper(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.enableKeeper(params.groupId));
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/udp/keeper/disable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '停用 UDP STUN Keeper' })
|
||||
async disableKeeper(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.disableKeeper(params.groupId));
|
||||
}
|
||||
|
||||
@Post(':groupId/channels/udp/keeper/probe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '立即刷新 UDP 公网端点' })
|
||||
async probe(
|
||||
@Param() params: NetworkPortForwardGroupParamsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
this.noStore(response);
|
||||
return vbenSuccess(await this.service.probe(params.groupId));
|
||||
}
|
||||
|
||||
private noStore(response: Response): void {
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import type { TcpProtocolMode } from './network-tcp-release-policy.service';
|
||||
|
||||
const DECIMAL_ID_PATTERN = /^\d{1,24}$/;
|
||||
|
||||
export class NetworkPortForwardGroupCreateDto {
|
||||
@ApiProperty({ maxLength: 100 })
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
@Matches(/\S/, { message: 'name must contain a non-whitespace character' })
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 500 })
|
||||
@ValidateIf(isProvided)
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
remark?: string;
|
||||
|
||||
@ApiProperty({ enum: ['tcp', 'tcp_udp', 'udp'] })
|
||||
@IsIn(['tcp', 'tcp_udp', 'udp'])
|
||||
protocolMode: TcpProtocolMode;
|
||||
|
||||
@ApiProperty({ maximum: 65535, minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
externalPort: number;
|
||||
|
||||
@ApiProperty({ maximum: 65535, minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
internalPort: number;
|
||||
}
|
||||
|
||||
export class NetworkPortForwardGroupUpdateDto {
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@ValidateIf(isProvided)
|
||||
@IsString()
|
||||
@Length(1, 100)
|
||||
@Matches(/\S/, { message: 'name must contain a non-whitespace character' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 500, nullable: true })
|
||||
@ValidateIf(isProvided)
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
remark?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['tcp', 'tcp_udp', 'udp'] })
|
||||
@ValidateIf(isProvided)
|
||||
@IsIn(['tcp', 'tcp_udp', 'udp'])
|
||||
protocolMode?: TcpProtocolMode;
|
||||
|
||||
@ApiPropertyOptional({ maximum: 65535, minimum: 1 })
|
||||
@ValidateIf(isProvided)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
externalPort?: number;
|
||||
|
||||
@ApiPropertyOptional({ maximum: 65535, minimum: 1 })
|
||||
@ValidateIf(isProvided)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
internalPort?: number;
|
||||
}
|
||||
|
||||
export class NetworkPortForwardGroupListQueryDto {
|
||||
@ApiPropertyOptional({ minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageNo?: number;
|
||||
|
||||
@ApiPropertyOptional({ maximum: 100, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['tcp', 'tcp_udp', 'udp'] })
|
||||
@IsOptional()
|
||||
@IsIn(['tcp', 'tcp_udp', 'udp'])
|
||||
protocolMode?: TcpProtocolMode;
|
||||
}
|
||||
|
||||
export class NetworkPortForwardGroupParamsDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(DECIMAL_ID_PATTERN)
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export class NetworkPortForwardGroupChannelParamsDto extends NetworkPortForwardGroupParamsDto {
|
||||
@ApiProperty({ enum: ['tcp', 'udp'] })
|
||||
@IsIn(['tcp', 'udp'])
|
||||
protocol: 'tcp' | 'udp';
|
||||
}
|
||||
|
||||
function isProvided(_object: object, value: unknown): boolean {
|
||||
return value !== undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -159,6 +159,30 @@ describe('NetworkManagementController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps v1 responses flat and forwards only the channel ID', async () => {
|
||||
service.update.mockResolvedValue({
|
||||
groupId: '200',
|
||||
id: '100',
|
||||
protocol: 'udp',
|
||||
});
|
||||
|
||||
const response = await request(apiUrl)
|
||||
.put('/system/network/port-forward/100')
|
||||
.send({ remark: 'legacy' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toMatchObject({
|
||||
groupId: '200',
|
||||
id: '100',
|
||||
protocol: 'udp',
|
||||
});
|
||||
expect(response.body.data.channels).toBeUndefined();
|
||||
expect(service.update).toHaveBeenCalledWith(
|
||||
'100',
|
||||
expect.objectContaining({ remark: 'legacy' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes asynchronous actions, history, and agent status routes', async () => {
|
||||
service.retry.mockResolvedValue({ id: '100' });
|
||||
service.enableKeeper.mockResolvedValue({ id: '100' });
|
||||
|
||||
@ -7,11 +7,14 @@ 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 { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.entity';
|
||||
import { NetworkPortForwardGroupService } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.service';
|
||||
import { NetworkTcpReleasePolicyService } from '../../../src/modules/admin/platform-config/network-management/network-tcp-release-policy.service';
|
||||
|
||||
type Harness = {
|
||||
bootstrapOrder: string[];
|
||||
bootstrapExecute: jest.Mock;
|
||||
groups: NetworkPortForwardGroup[];
|
||||
histories: NetworkEndpointHistory[];
|
||||
mappings: NetworkPortForward[];
|
||||
mqtt: jest.Mocked<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
|
||||
@ -29,6 +32,24 @@ function createHarness(
|
||||
releaseConfig: ReleaseConfig = {},
|
||||
): Harness {
|
||||
const mappings = initialMappings;
|
||||
const groups = Array.from(
|
||||
new Set(mappings.map((mapping) => mapping.groupId)),
|
||||
).map((groupId) => {
|
||||
const channels = mappings.filter((mapping) => mapping.groupId === groupId);
|
||||
const protocols = new Set(channels.map((mapping) => mapping.protocol));
|
||||
const first = channels[0];
|
||||
return Object.assign(new NetworkPortForwardGroup(), {
|
||||
externalPort: first.externalPort,
|
||||
id: groupId,
|
||||
internalPort: first.internalPort,
|
||||
isDeleted: false,
|
||||
name: first.name,
|
||||
protocolMode:
|
||||
protocols.size === 2 ? 'tcp_udp' : protocols.has('tcp') ? 'tcp' : 'udp',
|
||||
remark: first.remark || null,
|
||||
targetIpv4: first.targetIpv4,
|
||||
});
|
||||
});
|
||||
const histories: NetworkEndpointHistory[] = [];
|
||||
const bootstrapOrder: string[] = [];
|
||||
const bootstrapExecute = jest.fn().mockImplementation(async () => {
|
||||
@ -49,17 +70,41 @@ function createHarness(
|
||||
create: (input) =>
|
||||
Object.assign(new NetworkPortForward(), { id: '100' }, input),
|
||||
createQueryBuilder: () => createListBuilder(mappings),
|
||||
find: async ({ where }) =>
|
||||
mappings.filter((mapping) =>
|
||||
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
||||
),
|
||||
findOne: async ({ where }) =>
|
||||
mappings.find((mapping) =>
|
||||
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
||||
) || null,
|
||||
save: async (mapping) => {
|
||||
const index = mappings.findIndex((item) => item.id === mapping.id);
|
||||
if (index >= 0) mappings[index] = mapping;
|
||||
else mappings.push(mapping);
|
||||
return mapping;
|
||||
save: async (value) => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const mapping of values) {
|
||||
const index = mappings.findIndex((item) => item.id === mapping.id);
|
||||
if (index >= 0) mappings[index] = mapping;
|
||||
else mappings.push(mapping);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
} as unknown as Repository<NetworkPortForward>;
|
||||
const groupRepository = {
|
||||
create: (input) =>
|
||||
Object.assign(new NetworkPortForwardGroup(), { id: '200' }, input),
|
||||
findOne: async ({ where }) =>
|
||||
groups.find((group) =>
|
||||
Object.entries(where).every(([key, value]) => group[key] === value),
|
||||
) || null,
|
||||
save: async (value) => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const group of values) {
|
||||
const index = groups.findIndex((item) => item.id === group.id);
|
||||
if (index >= 0) groups[index] = group;
|
||||
else groups.push(group);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
} as unknown as Repository<NetworkPortForwardGroup>;
|
||||
const stateRepository = {
|
||||
create: (input) => Object.assign(new NetworkAgentState(), input),
|
||||
createQueryBuilder: () => {
|
||||
@ -84,6 +129,7 @@ function createHarness(
|
||||
const manager = {
|
||||
getRepository: (entity) => {
|
||||
if (entity === NetworkPortForward) return mappingRepository;
|
||||
if (entity === NetworkPortForwardGroup) return groupRepository;
|
||||
if (entity === NetworkAgentState) return stateRepository;
|
||||
if (entity === NetworkEndpointHistory) return historyRepository;
|
||||
throw new Error('unexpected repository');
|
||||
@ -112,10 +158,20 @@ function createHarness(
|
||||
configService,
|
||||
mqtt as unknown as NetworkAgentMqttService,
|
||||
new NetworkTcpReleasePolicyService(configService),
|
||||
new NetworkPortForwardGroupService(
|
||||
groupRepository,
|
||||
mappingRepository,
|
||||
historyRepository,
|
||||
dataSource,
|
||||
configService,
|
||||
mqtt as unknown as NetworkAgentMqttService,
|
||||
new NetworkTcpReleasePolicyService(configService),
|
||||
),
|
||||
);
|
||||
return {
|
||||
bootstrapExecute,
|
||||
bootstrapOrder,
|
||||
groups,
|
||||
histories,
|
||||
mappings,
|
||||
mqtt,
|
||||
@ -129,6 +185,7 @@ function createMapping(
|
||||
): NetworkPortForward {
|
||||
return Object.assign(new NetworkPortForward(), {
|
||||
activeKey: 'udp:9000',
|
||||
activeGroupProtocolKey: '200:udp',
|
||||
currentObservedAt: null,
|
||||
currentPublicIpv4: null,
|
||||
currentPublicPort: null,
|
||||
@ -137,12 +194,15 @@ function createMapping(
|
||||
desiredPresence: 'present',
|
||||
desiredRevision: '3',
|
||||
externalPort: 9000,
|
||||
groupId: '200',
|
||||
id: '100',
|
||||
internalPort: 9000,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
keeperStatus: 'disabled',
|
||||
name: 'rule',
|
||||
natmapDesiredEnabled: false,
|
||||
natmapStatus: 'disabled',
|
||||
protocol: 'udp',
|
||||
reportedRevision: '0',
|
||||
syncStatus: 'synced',
|
||||
@ -173,7 +233,10 @@ function createListBuilder(mappings: NetworkPortForward[]) {
|
||||
}
|
||||
return builder;
|
||||
},
|
||||
getManyAndCount: async () => [rows.slice(offset, offset + limit), rows.length],
|
||||
getManyAndCount: async () => [
|
||||
rows.slice(offset, offset + limit),
|
||||
rows.length,
|
||||
],
|
||||
orderBy: () => builder,
|
||||
skip: (value: number) => {
|
||||
offset = value;
|
||||
@ -212,8 +275,10 @@ describe('NetworkManagementService', () => {
|
||||
});
|
||||
expect(harness.state.desiredRevision).toBe('1');
|
||||
expect(harness.mappings[0]).toMatchObject({
|
||||
activeGroupProtocolKey: expect.any(String),
|
||||
activeKey: 'udp:9000',
|
||||
desiredRevision: '1',
|
||||
groupId: expect.any(String),
|
||||
});
|
||||
expect(harness.mappings[0].desiredIssuedAt.toISOString()).toBe(
|
||||
harness.state.desiredIssuedAt.toISOString(),
|
||||
@ -222,6 +287,29 @@ describe('NetworkManagementService', () => {
|
||||
expect(harness.bootstrapExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps v1 channel-ID update and delete from mutating a multi-channel group', async () => {
|
||||
const udp = createMapping({
|
||||
activeGroupProtocolKey: '200:udp',
|
||||
groupId: '200',
|
||||
});
|
||||
const tcp = createMapping({
|
||||
activeGroupProtocolKey: '200:tcp',
|
||||
activeKey: 'tcp:9000',
|
||||
groupId: '200',
|
||||
id: '101',
|
||||
protocol: 'tcp',
|
||||
});
|
||||
const harness = createHarness([udp, tcp], { mode: 'on' });
|
||||
|
||||
await expect(
|
||||
harness.service.update('100', { remark: 'legacy update' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await expect(harness.service.remove('100')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(harness.state.desiredRevision).toBe('3');
|
||||
});
|
||||
|
||||
it('uses insert-ignore bootstrap before the singleton pessimistic lock', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
@ -550,6 +638,7 @@ describe('NetworkManagementService', () => {
|
||||
await expect(harness.service.disableKeeper('100')).resolves.toMatchObject({
|
||||
keeperDesiredEnabled: false,
|
||||
});
|
||||
mapping.syncStatus = 'synced';
|
||||
await expect(harness.service.remove('100')).resolves.toMatchObject({
|
||||
desiredPresence: 'absent',
|
||||
});
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
INestApplication,
|
||||
} from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { AdminSuperGuard } from '../../../src/modules/admin/identity/auth/admin-super.guard';
|
||||
import { JwtAuthGuard } from '../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { NetworkPortForwardGroupController } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.controller';
|
||||
import { NetworkPortForwardGroupService } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.service';
|
||||
|
||||
describe('NetworkPortForwardGroupController', () => {
|
||||
let app: INestApplication;
|
||||
let apiUrl: string;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
disableKeeper: jest.fn(),
|
||||
disableNatmap: jest.fn(),
|
||||
enableKeeper: jest.fn(),
|
||||
enableNatmap: jest.fn(),
|
||||
endpointHistory: jest.fn(),
|
||||
list: jest.fn(),
|
||||
probe: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
retry: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext) {
|
||||
const requestValue = context.switchToHttp().getRequest();
|
||||
const roleCode = requestValue.headers['x-test-role'] || 'super';
|
||||
requestValue.adminUser = {
|
||||
roles: [{ isDeleted: false, roleCode, status: 1 }],
|
||||
};
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [NetworkPortForwardGroupController],
|
||||
providers: [
|
||||
AdminSuperGuard,
|
||||
{ provide: NetworkPortForwardGroupService, useValue: service },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.listen(0, '127.0.0.1');
|
||||
apiUrl = await app.getUrl();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service.list.mockResolvedValue({ items: [], total: 0 });
|
||||
service.create.mockResolvedValue({
|
||||
appliedProtocolMode: null,
|
||||
channels: { tcp: { id: '100' }, udp: { id: '101' } },
|
||||
id: '90071992547409930',
|
||||
protocolMode: 'tcp_udp',
|
||||
});
|
||||
service.update.mockResolvedValue({ id: '90071992547409930' });
|
||||
service.remove.mockResolvedValue({ id: '90071992547409930' });
|
||||
service.retry.mockResolvedValue({ id: '100' });
|
||||
service.enableNatmap.mockResolvedValue({ id: '100' });
|
||||
service.disableNatmap.mockResolvedValue({ id: '100' });
|
||||
service.enableKeeper.mockResolvedValue({ id: '101' });
|
||||
service.disableKeeper.mockResolvedValue({ id: '101' });
|
||||
service.probe.mockResolvedValue({ id: '101' });
|
||||
service.endpointHistory.mockResolvedValue({
|
||||
items: [{ mechanism: 'tcp_natmap', portForwardId: '100' }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('serves exact v2 CRUD routes with strict DTO validation and string IDs over real HTTP', async () => {
|
||||
await request(apiUrl)
|
||||
.get(
|
||||
'/system/network/port-forward-group/list?pageNo=1&pageSize=20&protocolMode=tcp_udp',
|
||||
)
|
||||
.expect(200)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
await request(apiUrl)
|
||||
.post('/system/network/port-forward-group')
|
||||
.send({
|
||||
externalPort: 9000,
|
||||
internalPort: 9000,
|
||||
name: 'dual',
|
||||
protocolMode: 'icmp',
|
||||
})
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post('/system/network/port-forward-group')
|
||||
.send({
|
||||
externalPort: 9000,
|
||||
internalPort: 9000,
|
||||
name: 'dual',
|
||||
protocolMode: 'tcp_udp',
|
||||
routerPassword: 'forbidden',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const created = await request(apiUrl)
|
||||
.post('/system/network/port-forward-group')
|
||||
.send({
|
||||
externalPort: 9000,
|
||||
internalPort: 9000,
|
||||
name: 'dual',
|
||||
protocolMode: 'tcp_udp',
|
||||
})
|
||||
.expect(201)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
expect(created.body.data.id).toBe('90071992547409930');
|
||||
expect(typeof created.body.data.id).toBe('string');
|
||||
await request(apiUrl)
|
||||
.put('/system/network/port-forward-group/90071992547409930')
|
||||
.send({ remark: 'updated' })
|
||||
.expect(200)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
await request(apiUrl)
|
||||
.delete('/system/network/port-forward-group/90071992547409930')
|
||||
.expect(200)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
expect(service.update).toHaveBeenCalledWith(
|
||||
'90071992547409930',
|
||||
expect.objectContaining({ remark: 'updated' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('serves every protocol-scoped action and history route over real HTTP', async () => {
|
||||
const groupId = '90071992547409930';
|
||||
await request(apiUrl)
|
||||
.post(`/system/network/port-forward-group/${groupId}/channels/tcp/retry`)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(`/system/network/port-forward-group/${groupId}/channels/udp/retry`)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
`/system/network/port-forward-group/${groupId}/channels/tcp/natmap/enable`,
|
||||
)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
`/system/network/port-forward-group/${groupId}/channels/tcp/natmap/disable`,
|
||||
)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
`/system/network/port-forward-group/${groupId}/channels/udp/keeper/enable`,
|
||||
)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
`/system/network/port-forward-group/${groupId}/channels/udp/keeper/disable`,
|
||||
)
|
||||
.expect(200);
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
`/system/network/port-forward-group/${groupId}/channels/udp/keeper/probe`,
|
||||
)
|
||||
.expect(200);
|
||||
const tcpHistory = await request(apiUrl)
|
||||
.get(
|
||||
`/system/network/port-forward-group/${groupId}/channels/tcp/endpoint-history`,
|
||||
)
|
||||
.expect(200)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
const udpHistory = await request(apiUrl)
|
||||
.get(
|
||||
`/system/network/port-forward-group/${groupId}/channels/udp/endpoint-history`,
|
||||
)
|
||||
.expect(200);
|
||||
expect(tcpHistory.body.data.items[0].mechanism).toBe('tcp_natmap');
|
||||
expect(service.retry).toHaveBeenNthCalledWith(1, groupId, 'tcp');
|
||||
expect(service.retry).toHaveBeenNthCalledWith(2, groupId, 'udp');
|
||||
expect(service.endpointHistory).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
groupId,
|
||||
'tcp',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(service.endpointHistory).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
groupId,
|
||||
'udp',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(udpHistory.body.data.total).toBe(1);
|
||||
});
|
||||
|
||||
it('enforces super-admin permission and maps service 400/409 errors to Chinese Vben msg', async () => {
|
||||
await request(apiUrl)
|
||||
.get('/system/network/port-forward-group/list')
|
||||
.set('X-Test-Role', 'member')
|
||||
.expect(403);
|
||||
|
||||
service.enableNatmap.mockRejectedValueOnce(
|
||||
new HttpException(
|
||||
{ code: HttpStatus.BAD_REQUEST, msg: 'TCP NATMap 状态不允许启用' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
),
|
||||
);
|
||||
const badRequest = await request(apiUrl)
|
||||
.post('/system/network/port-forward-group/200/channels/tcp/natmap/enable')
|
||||
.expect(400);
|
||||
expect(badRequest.body.msg).toMatch(/TCP NATMap/);
|
||||
|
||||
service.update.mockRejectedValueOnce(
|
||||
new HttpException(
|
||||
{ code: HttpStatus.CONFLICT, msg: '逻辑组正在协调中' },
|
||||
HttpStatus.CONFLICT,
|
||||
),
|
||||
);
|
||||
const conflict = await request(apiUrl)
|
||||
.put('/system/network/port-forward-group/200')
|
||||
.send({ protocolMode: 'udp' })
|
||||
.expect(409);
|
||||
expect(conflict.body.msg).toMatch(/协调/);
|
||||
});
|
||||
|
||||
it('rejects invalid group IDs and protocols before calling the service', async () => {
|
||||
await request(apiUrl)
|
||||
.post(
|
||||
'/system/network/port-forward-group/not-a-number/channels/tcp/retry',
|
||||
)
|
||||
.expect(400);
|
||||
await request(apiUrl)
|
||||
.post('/system/network/port-forward-group/200/channels/icmp/retry')
|
||||
.expect(400);
|
||||
expect(service.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,491 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { KtDateTime } from '../../../src/common';
|
||||
import type { NetworkAgentMqttService } from '../../../src/modules/admin/platform-config/network-management/network-agent-mqtt.service';
|
||||
import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity';
|
||||
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 { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.entity';
|
||||
import { NetworkPortForwardGroupService } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.service';
|
||||
import { NetworkTcpReleasePolicyService } from '../../../src/modules/admin/platform-config/network-management/network-tcp-release-policy.service';
|
||||
|
||||
type Harness = {
|
||||
groups: NetworkPortForwardGroup[];
|
||||
histories: NetworkEndpointHistory[];
|
||||
mappings: NetworkPortForward[];
|
||||
mqtt: jest.Mocked<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
|
||||
service: NetworkPortForwardGroupService;
|
||||
state: NetworkAgentState;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
initialGroups: NetworkPortForwardGroup[] = [],
|
||||
initialMappings: NetworkPortForward[] = [],
|
||||
): Harness {
|
||||
const groups = initialGroups;
|
||||
const mappings = initialMappings;
|
||||
const histories: NetworkEndpointHistory[] = [];
|
||||
let nextGroupId = 200;
|
||||
let nextMappingId = 100;
|
||||
const state = Object.assign(new NetworkAgentState(), {
|
||||
agentId: 'nas-main',
|
||||
appliedRevision: '0',
|
||||
desiredIssuedAt: new KtDateTime('2026-07-26T00:00:00.000Z'),
|
||||
desiredRevision: initialMappings.length ? '3' : '0',
|
||||
online: false,
|
||||
publishedRevision: '0',
|
||||
targetIpv4: '192.168.31.224',
|
||||
});
|
||||
const groupRepository = {
|
||||
count: async () => groups.filter((group) => !group.isDeleted).length,
|
||||
create: (input) => Object.assign(new NetworkPortForwardGroup(), input),
|
||||
createQueryBuilder: () => createGroupListBuilder(groups),
|
||||
findOne: async ({ where }) => findOne(groups, where),
|
||||
save: async (value) => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const group of values) {
|
||||
if (!group.id) group.id = String(nextGroupId++);
|
||||
const index = groups.findIndex((item) => item.id === group.id);
|
||||
if (index >= 0) groups[index] = group;
|
||||
else groups.push(group);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
} as unknown as Repository<NetworkPortForwardGroup>;
|
||||
const mappingRepository = {
|
||||
count: async () => mappings.filter((mapping) => !mapping.isDeleted).length,
|
||||
create: (input) => Object.assign(new NetworkPortForward(), input),
|
||||
find: async ({ where }) =>
|
||||
mappings.filter((mapping) => matches(mapping, where)),
|
||||
findOne: async ({ where }) => findOne(mappings, where),
|
||||
save: async (value) => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const mapping of values) {
|
||||
if (!mapping.id) mapping.id = String(nextMappingId++);
|
||||
const duplicate = mappings.find(
|
||||
(item) =>
|
||||
item.id !== mapping.id &&
|
||||
((mapping.activeKey && item.activeKey === mapping.activeKey) ||
|
||||
(mapping.activeGroupProtocolKey &&
|
||||
item.activeGroupProtocolKey ===
|
||||
mapping.activeGroupProtocolKey)),
|
||||
);
|
||||
if (duplicate) {
|
||||
const error = new Error('duplicate') as Error & { code: string };
|
||||
error.code = 'ER_DUP_ENTRY';
|
||||
throw error;
|
||||
}
|
||||
const index = mappings.findIndex((item) => item.id === mapping.id);
|
||||
if (index >= 0) mappings[index] = mapping;
|
||||
else mappings.push(mapping);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
} as unknown as Repository<NetworkPortForward>;
|
||||
const historyRepository = {
|
||||
findAndCount: jest.fn(async ({ where }) => {
|
||||
const rows = histories.filter((history) => matches(history, where));
|
||||
return [rows, rows.length];
|
||||
}),
|
||||
} as unknown as Repository<NetworkEndpointHistory>;
|
||||
const stateRepository = {
|
||||
createQueryBuilder: () => {
|
||||
const builder = {
|
||||
execute: async () => ({ identifiers: [] }),
|
||||
insert: () => builder,
|
||||
into: () => builder,
|
||||
orIgnore: () => builder,
|
||||
values: () => builder,
|
||||
};
|
||||
return builder;
|
||||
},
|
||||
findOne: async () => state,
|
||||
save: async (value) => Object.assign(state, value),
|
||||
} as unknown as Repository<NetworkAgentState>;
|
||||
const manager = {
|
||||
getRepository: (entity) => {
|
||||
if (entity === NetworkPortForwardGroup) return groupRepository;
|
||||
if (entity === NetworkPortForward) return mappingRepository;
|
||||
if (entity === NetworkEndpointHistory) return historyRepository;
|
||||
if (entity === NetworkAgentState) return stateRepository;
|
||||
throw new Error('unexpected repository');
|
||||
},
|
||||
} as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
transaction: async (work) => {
|
||||
const groupSnapshot = groups.map((group) => ({ ...group }));
|
||||
const mappingSnapshot = mappings.map((mapping) => ({ ...mapping }));
|
||||
const stateSnapshot = { ...state };
|
||||
try {
|
||||
return await work(manager);
|
||||
} catch (error) {
|
||||
groups.splice(
|
||||
0,
|
||||
groups.length,
|
||||
...groupSnapshot.map((group) =>
|
||||
Object.assign(new NetworkPortForwardGroup(), group),
|
||||
),
|
||||
);
|
||||
mappings.splice(
|
||||
0,
|
||||
mappings.length,
|
||||
...mappingSnapshot.map((mapping) =>
|
||||
Object.assign(new NetworkPortForward(), mapping),
|
||||
),
|
||||
);
|
||||
Object.assign(state, stateSnapshot);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
} as unknown as DataSource;
|
||||
const configService = {
|
||||
get: (key) =>
|
||||
({
|
||||
NETWORK_AGENT_ID: 'nas-main',
|
||||
NETWORK_AGENT_TARGET_IPV4: '192.168.31.224',
|
||||
NETWORK_TCP_NATMAP_RELEASE_MODE: 'on',
|
||||
})[key],
|
||||
} as ConfigService;
|
||||
const mqtt = {
|
||||
requestDesiredPublish: jest.fn(),
|
||||
} as jest.Mocked<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
|
||||
const service = new NetworkPortForwardGroupService(
|
||||
groupRepository,
|
||||
mappingRepository,
|
||||
historyRepository,
|
||||
dataSource,
|
||||
configService,
|
||||
mqtt as unknown as NetworkAgentMqttService,
|
||||
new NetworkTcpReleasePolicyService(configService),
|
||||
);
|
||||
return { groups, histories, mappings, mqtt, service, state };
|
||||
}
|
||||
|
||||
function createGroup(
|
||||
patch: Partial<NetworkPortForwardGroup> = {},
|
||||
): NetworkPortForwardGroup {
|
||||
return Object.assign(new NetworkPortForwardGroup(), {
|
||||
externalPort: 9000,
|
||||
id: '200',
|
||||
internalPort: 9000,
|
||||
isDeleted: false,
|
||||
name: 'group',
|
||||
protocolMode: 'tcp_udp',
|
||||
remark: null,
|
||||
targetIpv4: '192.168.31.224',
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function createMapping(
|
||||
patch: Partial<NetworkPortForward> = {},
|
||||
): NetworkPortForward {
|
||||
const protocol = patch.protocol || 'udp';
|
||||
const id = patch.id || (protocol === 'tcp' ? '101' : '100');
|
||||
return Object.assign(new NetworkPortForward(), {
|
||||
activeGroupProtocolKey: `200:${protocol}`,
|
||||
activeKey: `${protocol}:9000`,
|
||||
currentObservedAt: null,
|
||||
currentPublicIpv4: null,
|
||||
currentPublicPort: null,
|
||||
currentValidUntil: null,
|
||||
desiredIssuedAt: new KtDateTime('2026-07-26T00:00:00.000Z'),
|
||||
desiredPresence: 'present',
|
||||
desiredRevision: '3',
|
||||
externalPort: 9000,
|
||||
groupId: '200',
|
||||
id,
|
||||
internalPort: 9000,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
keeperStatus: 'disabled',
|
||||
name: 'group',
|
||||
natmapDesiredEnabled: false,
|
||||
natmapStatus: 'disabled',
|
||||
probeRequestId: null,
|
||||
protocol,
|
||||
remark: null,
|
||||
reportedRevision: '3',
|
||||
syncStatus: 'synced',
|
||||
targetIpv4: '192.168.31.224',
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function matches(value: object, where: Record<string, unknown>): boolean {
|
||||
return Object.entries(where).every(
|
||||
([key, expected]) => value[key] === expected,
|
||||
);
|
||||
}
|
||||
|
||||
function findOne<T extends object>(
|
||||
values: T[],
|
||||
where: Record<string, unknown>,
|
||||
): T | null {
|
||||
return values.find((value) => matches(value, where)) || null;
|
||||
}
|
||||
|
||||
function createGroupListBuilder(groups: NetworkPortForwardGroup[]) {
|
||||
let rows = groups.filter((group) => !group.isDeleted);
|
||||
let offset = 0;
|
||||
let limit = rows.length;
|
||||
const builder = {
|
||||
andWhere: (clause: string, values: Record<string, unknown>) => {
|
||||
if (clause.includes('group.name LIKE')) {
|
||||
const name = String(values.name).replaceAll('%', '');
|
||||
rows = rows.filter((group) => group.name.includes(name));
|
||||
} else if (clause.includes('group.protocolMode =')) {
|
||||
rows = rows.filter(
|
||||
(group) => group.protocolMode === values.protocolMode,
|
||||
);
|
||||
}
|
||||
return builder;
|
||||
},
|
||||
getManyAndCount: async () => [
|
||||
rows.slice(offset, offset + limit),
|
||||
rows.length,
|
||||
],
|
||||
orderBy: () => builder,
|
||||
skip: (value: number) => {
|
||||
offset = value;
|
||||
return builder;
|
||||
},
|
||||
take: (value: number) => {
|
||||
limit = value;
|
||||
return builder;
|
||||
},
|
||||
where: () => builder,
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
return error instanceof HttpException ? error.getStatus() : 0;
|
||||
}
|
||||
|
||||
describe('NetworkPortForwardGroupService', () => {
|
||||
it('creates tcp_udp atomically with one group authority and one global revision', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
await expect(
|
||||
harness.service.create({
|
||||
externalPort: 9000,
|
||||
internalPort: 9000,
|
||||
name: ' Dual Rule ',
|
||||
protocolMode: 'tcp_udp',
|
||||
remark: 'both',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
appliedProtocolMode: null,
|
||||
channels: {
|
||||
tcp: { desiredRevision: '1', id: expect.any(String) },
|
||||
udp: { desiredRevision: '1', id: expect.any(String) },
|
||||
},
|
||||
id: expect.any(String),
|
||||
name: 'Dual Rule',
|
||||
protocolMode: 'tcp_udp',
|
||||
});
|
||||
expect(harness.groups).toHaveLength(1);
|
||||
expect(harness.mappings).toHaveLength(2);
|
||||
expect(harness.state.desiredRevision).toBe('1');
|
||||
expect(harness.mappings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
activeGroupProtocolKey: '200:tcp',
|
||||
activeKey: 'tcp:9000',
|
||||
groupId: '200',
|
||||
name: 'Dual Rule',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
activeGroupProtocolKey: '200:udp',
|
||||
activeKey: 'udp:9000',
|
||||
groupId: '200',
|
||||
name: 'Dual Rule',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('projects group authority while preserving channel-specific revisions and derives appliedProtocolMode', async () => {
|
||||
const group = createGroup();
|
||||
const tcp = createMapping({ protocol: 'tcp' });
|
||||
const udp = createMapping({ protocol: 'udp' });
|
||||
const harness = createHarness([group], [tcp, udp]);
|
||||
|
||||
await expect(harness.service.list()).resolves.toMatchObject({
|
||||
items: [{ appliedProtocolMode: 'tcp_udp' }],
|
||||
});
|
||||
await harness.service.enableNatmap('200');
|
||||
expect(tcp.desiredRevision).toBe('4');
|
||||
expect(udp.desiredRevision).toBe('3');
|
||||
await harness.service.enableNatmap('200');
|
||||
expect(harness.state.desiredRevision).toBe('4');
|
||||
expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(1);
|
||||
|
||||
tcp.reportedRevision = '4';
|
||||
await harness.service.update('200', { name: 'Projected' });
|
||||
expect(harness.state.desiredRevision).toBe('5');
|
||||
expect(tcp).toMatchObject({ desiredRevision: '5', name: 'Projected' });
|
||||
expect(udp).toMatchObject({ desiredRevision: '5', name: 'Projected' });
|
||||
expect(group.name).toBe('Projected');
|
||||
await expect(harness.service.list()).resolves.toMatchObject({
|
||||
items: [{ appliedProtocolMode: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps removed protocols as tombstones and never resurrects a prior channel ID', async () => {
|
||||
const group = createGroup();
|
||||
const tcp = createMapping({ protocol: 'tcp' });
|
||||
const udp = createMapping({ protocol: 'udp' });
|
||||
const harness = createHarness([group], [tcp, udp]);
|
||||
|
||||
await harness.service.update('200', { protocolMode: 'udp' });
|
||||
expect(tcp).toMatchObject({
|
||||
activeGroupProtocolKey: '200:tcp',
|
||||
activeKey: 'tcp:9000',
|
||||
desiredPresence: 'absent',
|
||||
desiredRevision: '4',
|
||||
isDeleted: false,
|
||||
syncStatus: 'deleting',
|
||||
});
|
||||
expect(udp.desiredRevision).toBe('3');
|
||||
expect(group.isDeleted).toBe(false);
|
||||
|
||||
await harness.service
|
||||
.update('200', { protocolMode: 'tcp_udp' })
|
||||
.catch((error) => expect(errorStatus(error)).toBe(409));
|
||||
expect(harness.mappings).toHaveLength(2);
|
||||
|
||||
const persistedTcp = harness.mappings.find(
|
||||
(mapping) => mapping.protocol === 'tcp',
|
||||
)!;
|
||||
const persistedUdp = harness.mappings.find(
|
||||
(mapping) => mapping.protocol === 'udp',
|
||||
)!;
|
||||
persistedTcp.isDeleted = true;
|
||||
persistedTcp.activeKey = null;
|
||||
persistedTcp.activeGroupProtocolKey = null;
|
||||
persistedUdp.syncStatus = 'synced';
|
||||
await harness.service.update('200', { protocolMode: 'tcp_udp' });
|
||||
const replacement = harness.mappings.find(
|
||||
(mapping) => !mapping.isDeleted && mapping.protocol === 'tcp',
|
||||
);
|
||||
expect(replacement?.id).not.toBe('101');
|
||||
expect(replacement).toMatchObject({
|
||||
activeGroupProtocolKey: '200:tcp',
|
||||
activeKey: 'tcp:9000',
|
||||
desiredPresence: 'present',
|
||||
isDeleted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks every channel absent on group delete without soft-deleting channels or the group', async () => {
|
||||
const group = createGroup();
|
||||
const tcp = createMapping({
|
||||
currentPublicIpv4: '8.8.8.8',
|
||||
currentPublicPort: 45000,
|
||||
natmapDesiredEnabled: true,
|
||||
protocol: 'tcp',
|
||||
});
|
||||
const udp = createMapping({
|
||||
currentPublicIpv4: '1.1.1.1',
|
||||
currentPublicPort: 45001,
|
||||
keeperDesiredEnabled: true,
|
||||
protocol: 'udp',
|
||||
});
|
||||
const harness = createHarness([group], [tcp, udp]);
|
||||
|
||||
await harness.service.remove('200');
|
||||
expect(group.isDeleted).toBe(false);
|
||||
expect(harness.mappings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
desiredPresence: 'absent',
|
||||
isDeleted: false,
|
||||
natmapDesiredEnabled: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
desiredPresence: 'absent',
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(harness.mappings.every((mapping) => mapping.activeKey)).toBe(true);
|
||||
tcp.reportedRevision = tcp.desiredRevision;
|
||||
udp.reportedRevision = udp.desiredRevision;
|
||||
await harness.service.list();
|
||||
expect(group.isDeleted).toBe(false);
|
||||
expect(harness.mappings.every((mapping) => !mapping.isDeleted)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails an atomic dual-channel create without partial state at the 64-channel limit', async () => {
|
||||
const existing = Array.from({ length: 63 }, (_, index) =>
|
||||
createMapping({
|
||||
activeGroupProtocolKey: `${300 + index}:udp`,
|
||||
activeKey: `udp:${10000 + index}`,
|
||||
externalPort: 10000 + index,
|
||||
groupId: String(300 + index),
|
||||
id: String(1000 + index),
|
||||
internalPort: 10000 + index,
|
||||
}),
|
||||
);
|
||||
const harness = createHarness([], existing);
|
||||
|
||||
await harness.service
|
||||
.create({
|
||||
externalPort: 9000,
|
||||
internalPort: 9000,
|
||||
name: 'too many',
|
||||
protocolMode: 'tcp_udp',
|
||||
})
|
||||
.catch((error) => expect(errorStatus(error)).toBe(409));
|
||||
expect(harness.groups).toHaveLength(0);
|
||||
expect(harness.mappings).toHaveLength(63);
|
||||
expect(harness.state.desiredRevision).toBe('3');
|
||||
expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps UDP Keeper switches idempotent and filters history by channel mechanism', async () => {
|
||||
const group = createGroup({ protocolMode: 'udp' });
|
||||
const udp = createMapping({ protocol: 'udp' });
|
||||
const harness = createHarness([group], [udp]);
|
||||
harness.histories.push(
|
||||
Object.assign(new NetworkEndpointHistory(), {
|
||||
eventId: 'udp-event',
|
||||
id: '300',
|
||||
mappingId: '100',
|
||||
mechanism: 'udp_stun',
|
||||
}),
|
||||
Object.assign(new NetworkEndpointHistory(), {
|
||||
eventId: 'wrong-mechanism',
|
||||
id: '301',
|
||||
mappingId: '100',
|
||||
mechanism: 'tcp_natmap',
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.service.enableKeeper('200');
|
||||
const enableRevision = harness.state.desiredRevision;
|
||||
await harness.service.enableKeeper('200');
|
||||
expect(harness.state.desiredRevision).toBe(enableRevision);
|
||||
udp.syncStatus = 'synced';
|
||||
await harness.service.disableKeeper('200');
|
||||
const disableRevision = harness.state.desiredRevision;
|
||||
await harness.service.disableKeeper('200');
|
||||
expect(harness.state.desiredRevision).toBe(disableRevision);
|
||||
expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expect(
|
||||
harness.service.endpointHistory('200', 'udp'),
|
||||
).resolves.toMatchObject({
|
||||
items: [{ eventId: 'udp-event', mechanism: 'udp_stun' }],
|
||||
total: 1,
|
||||
});
|
||||
await harness.service
|
||||
.enableNatmap('200')
|
||||
.catch((error) => expect(errorStatus(error)).toBe(400));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user