feat: 增加网络端口转发组与通道模型

This commit is contained in:
sunlei 2026-07-26 15:23:19 +08:00
parent 9cea77ff49
commit e51168d669
12 changed files with 809 additions and 3 deletions

View File

@ -2,39 +2,72 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `network_port_forward_group` (
`id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`remark` TEXT NULL,
`external_port` INT UNSIGNED NOT NULL,
`internal_port` INT UNSIGNED NOT NULL,
`protocol_mode` VARCHAR(8) NOT NULL,
`target_ipv4` VARCHAR(15) NOT NULL,
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0,
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `network_port_forward` (
`id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`remark` TEXT NULL,
`group_id` BIGINT NOT NULL,
`protocol` VARCHAR(8) NOT NULL,
`external_port` INT UNSIGNED NOT NULL,
`internal_port` INT UNSIGNED NOT NULL,
`active_key` VARCHAR(32) NULL,
`active_group_protocol_key` VARCHAR(64) NULL,
`target_ipv4` VARCHAR(15) NOT NULL,
`desired_presence` VARCHAR(16) NOT NULL DEFAULT 'present',
`keeper_desired_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`natmap_desired_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`probe_request_id` VARCHAR(64) NULL,
`desired_revision` BIGINT NOT NULL DEFAULT 0,
`desired_issued_at` DATETIME(6) NOT NULL,
`reported_revision` BIGINT NOT NULL DEFAULT 0,
`sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
`keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled',
`natmap_status` VARCHAR(16) NOT NULL DEFAULT 'disabled',
`current_public_ipv4` VARCHAR(15) NULL,
`current_public_port` INT NULL,
`current_observed_at` DATETIME(6) NULL,
`current_validated_at` DATETIME(6) NULL,
`current_valid_until` DATETIME(6) NULL,
`last_observed_ipv4` VARCHAR(15) NULL,
`last_observed_port` INT NULL,
`last_observed_at` DATETIME(6) NULL,
`last_observed_validated_at` DATETIME(6) NULL,
`candidate_public_ipv4` VARCHAR(15) NULL,
`candidate_public_port` INT NULL,
`candidate_observed_at` DATETIME(6) NULL,
`candidate_validated_at` DATETIME(6) NULL,
`last_published_public_ipv4` VARCHAR(15) NULL,
`last_published_public_port` INT NULL,
`last_published_at` DATETIME(6) NULL,
`last_error_code` VARCHAR(64) NULL,
`last_error_message` VARCHAR(512) NULL,
`keeper_last_error_code` VARCHAR(64) NULL,
`keeper_last_error_message` VARCHAR(512) NULL,
`natmap_last_error_code` VARCHAR(64) NULL,
`natmap_last_error_message` VARCHAR(512) NULL,
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0,
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_network_port_forward_active_key` (`active_key`),
UNIQUE KEY `uk_network_port_forward_active_group_protocol_key` (`active_group_protocol_key`),
KEY `idx_network_port_forward_status` (`is_deleted`, `sync_status`),
KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`)
KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`),
KEY `idx_network_port_forward_group` (`group_id`, `is_deleted`, `protocol`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `network_ddns_record` (
@ -73,6 +106,10 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` (
`desired_revision` BIGINT NOT NULL DEFAULT 0,
`desired_issued_at` DATETIME(6) NOT NULL,
`published_revision` BIGINT NOT NULL DEFAULT 0,
`desired_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
`published_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
`max_supported_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
`tcp_natmap_capable` TINYINT(1) NOT NULL DEFAULT 0,
`applied_revision` BIGINT NOT NULL DEFAULT 0,
`online` TINYINT(1) NOT NULL DEFAULT 0,
`version` VARCHAR(64) NULL,
@ -126,6 +163,7 @@ CREATE TABLE IF NOT EXISTS `network_endpoint_history` (
`event_id` VARCHAR(128) NOT NULL,
`mapping_id` BIGINT NOT NULL,
`event_type` VARCHAR(16) NOT NULL,
`mechanism` VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
`public_ipv4` VARCHAR(15) NULL,
`public_port` INT NULL,
`first_observed_at` DATETIME(6) NOT NULL,

View File

@ -0,0 +1,43 @@
-- Read-only post-migration verification for TCP NATMap v2.
SELECT 'network_port_forward_group' AS table_name, COUNT(*) AS row_count
FROM `network_port_forward_group`;
SELECT 'network_port_forward_group_id_null_count' AS check_name, COUNT(*) AS matched_rows
FROM `network_port_forward`
WHERE `group_id` IS NULL;
SELECT 'network_port_forward_active_group_protocol_key_conflicts' AS check_name,
COUNT(*) AS matched_rows
FROM (
SELECT `active_group_protocol_key`
FROM `network_port_forward`
WHERE `active_group_protocol_key` IS NOT NULL
GROUP BY `active_group_protocol_key`
HAVING COUNT(*) > 1
) conflicts;
SELECT 'network_port_forward_group_id' AS check_name, COUNT(*) AS matched_rows
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'group_id'
AND COLUMN_TYPE = 'bigint'
AND IS_NULLABLE = 'NO';
SELECT 'network_port_forward_v2_indexes' AS check_name, INDEX_NAME, NON_UNIQUE
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'network_port_forward'
AND INDEX_NAME IN (
'uk_network_port_forward_active_group_protocol_key',
'idx_network_port_forward_group'
);
SELECT 'network_endpoint_history_mechanism' AS check_name, COUNT(*) AS matched_rows
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'network_endpoint_history'
AND COLUMN_NAME = 'mechanism'
AND COLUMN_TYPE = 'varchar(16)'
AND IS_NULLABLE = 'NO';

View File

@ -0,0 +1,247 @@
-- Additive TCP NATMap v2 group/channel migration. Safe to rerun.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `network_port_forward_group` (
`id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`remark` TEXT NULL,
`external_port` INT UNSIGNED NOT NULL,
`internal_port` INT UNSIGNED NOT NULL,
`protocol_mode` VARCHAR(8) NOT NULL,
`target_ipv4` VARCHAR(15) NOT NULL,
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0,
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @network_port_forward_group_id_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'group_id'
);
SET @network_port_forward_group_id_sql := IF(
@network_port_forward_group_id_exists = 0,
'ALTER TABLE `network_port_forward` ADD COLUMN `group_id` BIGINT NULL AFTER `remark`',
'SELECT 1'
);
PREPARE network_port_forward_group_id_stmt FROM @network_port_forward_group_id_sql;
EXECUTE network_port_forward_group_id_stmt;
DEALLOCATE PREPARE network_port_forward_group_id_stmt;
SET @network_port_forward_active_group_protocol_key_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'active_group_protocol_key'
);
SET @network_port_forward_active_group_protocol_key_sql := IF(
@network_port_forward_active_group_protocol_key_exists = 0,
'ALTER TABLE `network_port_forward` ADD COLUMN `active_group_protocol_key` VARCHAR(64) NULL AFTER `active_key`',
'SELECT 1'
);
PREPARE network_port_forward_active_group_protocol_key_stmt FROM @network_port_forward_active_group_protocol_key_sql;
EXECUTE network_port_forward_active_group_protocol_key_stmt;
DEALLOCATE PREPARE network_port_forward_active_group_protocol_key_stmt;
SET @network_port_forward_natmap_desired_enabled_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'natmap_desired_enabled'
);
SET @network_port_forward_natmap_desired_enabled_sql := IF(
@network_port_forward_natmap_desired_enabled_exists = 0,
'ALTER TABLE `network_port_forward` ADD COLUMN `natmap_desired_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `keeper_desired_enabled`',
'SELECT 1'
);
PREPARE network_port_forward_natmap_desired_enabled_stmt FROM @network_port_forward_natmap_desired_enabled_sql;
EXECUTE network_port_forward_natmap_desired_enabled_stmt;
DEALLOCATE PREPARE network_port_forward_natmap_desired_enabled_stmt;
SET @network_port_forward_natmap_status_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'natmap_status'
);
SET @network_port_forward_natmap_status_sql := IF(
@network_port_forward_natmap_status_exists = 0,
'ALTER TABLE `network_port_forward` ADD COLUMN `natmap_status` VARCHAR(16) NOT NULL DEFAULT ''disabled'' AFTER `keeper_status`',
'SELECT 1'
);
PREPARE network_port_forward_natmap_status_stmt FROM @network_port_forward_natmap_status_sql;
EXECUTE network_port_forward_natmap_status_stmt;
DEALLOCATE PREPARE network_port_forward_natmap_status_stmt;
SET @network_port_forward_runtime_columns_sql := (
SELECT GROUP_CONCAT(CONCAT(
'ADD COLUMN `', column_name, '` ', column_definition, ' ', column_position
) SEPARATOR ', ')
FROM (
SELECT 'natmap_last_error_code' AS column_name, 'VARCHAR(64) NULL' AS column_definition, 'AFTER `last_error_message`' AS column_position UNION ALL
SELECT 'natmap_last_error_message', 'VARCHAR(512) NULL', 'AFTER `last_error_message`' UNION ALL
SELECT 'keeper_last_error_code', 'VARCHAR(64) NULL', 'AFTER `last_error_message`' UNION ALL
SELECT 'keeper_last_error_message', 'VARCHAR(512) NULL', 'AFTER `last_error_message`' UNION ALL
SELECT 'candidate_public_ipv4', 'VARCHAR(15) NULL', 'AFTER `current_public_port`' UNION ALL
SELECT 'candidate_public_port', 'INT NULL', 'AFTER `current_public_port`' UNION ALL
SELECT 'candidate_observed_at', 'DATETIME(6) NULL', 'AFTER `current_public_port`' UNION ALL
SELECT 'candidate_validated_at', 'DATETIME(6) NULL', 'AFTER `current_public_port`' UNION ALL
SELECT 'current_validated_at', 'DATETIME(6) NULL', 'AFTER `current_observed_at`' UNION ALL
SELECT 'last_observed_validated_at', 'DATETIME(6) NULL', 'AFTER `last_observed_at`' UNION ALL
SELECT 'last_published_public_ipv4', 'VARCHAR(15) NULL', 'AFTER `last_observed_at`' UNION ALL
SELECT 'last_published_public_port', 'INT NULL', 'AFTER `last_observed_at`' UNION ALL
SELECT 'last_published_at', 'DATETIME(6) NULL', 'AFTER `last_observed_at`'
) expected
WHERE NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS actual
WHERE actual.TABLE_SCHEMA = DATABASE()
AND actual.TABLE_NAME = 'network_port_forward'
AND actual.COLUMN_NAME = expected.column_name
)
);
SET @network_port_forward_runtime_columns_sql := IF(
@network_port_forward_runtime_columns_sql IS NULL,
'SELECT 1',
CONCAT('ALTER TABLE `network_port_forward` ', @network_port_forward_runtime_columns_sql)
);
PREPARE network_port_forward_runtime_columns_stmt FROM @network_port_forward_runtime_columns_sql;
EXECUTE network_port_forward_runtime_columns_stmt;
DEALLOCATE PREPARE network_port_forward_runtime_columns_stmt;
SET @network_agent_state_v2_columns_sql := (
SELECT GROUP_CONCAT(CONCAT(
'ADD COLUMN `', column_name, '` ', column_definition, ' ', column_position
) SEPARATOR ', ')
FROM (
SELECT 'desired_schema_version' AS column_name, 'INT UNSIGNED NOT NULL DEFAULT 1' AS column_definition, 'AFTER `desired_revision`' AS column_position UNION ALL
SELECT 'published_schema_version', 'INT UNSIGNED NOT NULL DEFAULT 1', 'AFTER `published_revision`' UNION ALL
SELECT 'max_supported_schema_version', 'INT UNSIGNED NOT NULL DEFAULT 1', 'AFTER `published_revision`' UNION ALL
SELECT 'tcp_natmap_capable', 'TINYINT(1) NOT NULL DEFAULT 0', 'AFTER `published_revision`'
) expected
WHERE NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS actual
WHERE actual.TABLE_SCHEMA = DATABASE()
AND actual.TABLE_NAME = 'network_agent_state'
AND actual.COLUMN_NAME = expected.column_name
)
);
SET @network_agent_state_v2_columns_sql := IF(
@network_agent_state_v2_columns_sql IS NULL,
'SELECT 1',
CONCAT('ALTER TABLE `network_agent_state` ', @network_agent_state_v2_columns_sql)
);
PREPARE network_agent_state_v2_columns_stmt FROM @network_agent_state_v2_columns_sql;
EXECUTE network_agent_state_v2_columns_stmt;
DEALLOCATE PREPARE network_agent_state_v2_columns_stmt;
SET @network_endpoint_history_mechanism_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
AND COLUMN_NAME = 'mechanism'
);
SET @network_endpoint_history_mechanism_sql := IF(
@network_endpoint_history_mechanism_exists = 0,
'ALTER TABLE `network_endpoint_history` ADD COLUMN `mechanism` VARCHAR(16) NULL AFTER `event_type`',
'SELECT 1'
);
PREPARE network_endpoint_history_mechanism_stmt FROM @network_endpoint_history_mechanism_sql;
EXECUTE network_endpoint_history_mechanism_stmt;
DEALLOCATE PREPARE network_endpoint_history_mechanism_stmt;
INSERT INTO `network_port_forward_group` (
`id`, `name`, `remark`, `external_port`, `internal_port`, `protocol_mode`,
`target_ipv4`, `is_deleted`, `create_time`, `update_time`
)
SELECT channel.id, channel.name, channel.remark, channel.external_port,
channel.internal_port, 'udp', channel.target_ipv4, channel.is_deleted,
channel.create_time, channel.update_time
FROM `network_port_forward` channel
LEFT JOIN `network_port_forward_group` grouped ON grouped.id = channel.id
WHERE grouped.id IS NULL;
UPDATE `network_port_forward` channel
SET `group_id` = channel.id
WHERE channel.group_id IS NULL;
UPDATE `network_port_forward` channel
SET `active_group_protocol_key` = CASE
WHEN channel.is_deleted = 0 THEN CONCAT(channel.group_id, ':', channel.protocol)
ELSE NULL
END
WHERE channel.active_group_protocol_key IS NULL
OR channel.is_deleted <> 0;
UPDATE `network_endpoint_history`
SET `mechanism` = 'udp_stun'
WHERE `mechanism` IS NULL;
SET @network_endpoint_history_mechanism_nullable := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
AND COLUMN_NAME = 'mechanism' AND IS_NULLABLE = 'YES'
);
SET @network_endpoint_history_mechanism_sql := IF(
@network_endpoint_history_mechanism_nullable = 1,
'ALTER TABLE `network_endpoint_history` MODIFY COLUMN `mechanism` VARCHAR(16) NOT NULL DEFAULT ''udp_stun''',
'SELECT 1'
);
PREPARE network_endpoint_history_mechanism_not_null_stmt FROM @network_endpoint_history_mechanism_sql;
EXECUTE network_endpoint_history_mechanism_not_null_stmt;
DEALLOCATE PREPARE network_endpoint_history_mechanism_not_null_stmt;
SET @network_port_forward_group_id_null_count := (
SELECT COUNT(*) FROM `network_port_forward` WHERE `group_id` IS NULL
);
SELECT @network_port_forward_group_id_null_count AS network_port_forward_group_id_null_count;
SET @network_port_forward_group_id_nullable := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND COLUMN_NAME = 'group_id' AND IS_NULLABLE = 'YES'
);
SET @network_port_forward_group_id_not_null_sql := IF(
@network_port_forward_group_id_null_count = 0
AND @network_port_forward_group_id_nullable = 1,
'ALTER TABLE `network_port_forward` MODIFY COLUMN `group_id` BIGINT NOT NULL',
'SELECT 1'
);
PREPARE network_port_forward_group_id_not_null_stmt FROM @network_port_forward_group_id_not_null_sql;
EXECUTE network_port_forward_group_id_not_null_stmt;
DEALLOCATE PREPARE network_port_forward_group_id_not_null_stmt;
SET @network_port_forward_active_group_protocol_key_conflicts := (
SELECT COUNT(*) FROM (
SELECT `active_group_protocol_key`
FROM `network_port_forward`
WHERE `active_group_protocol_key` IS NOT NULL
GROUP BY `active_group_protocol_key`
HAVING COUNT(*) > 1
) conflicts
);
SELECT @network_port_forward_active_group_protocol_key_conflicts AS network_port_forward_active_group_protocol_key_conflicts;
SET @network_port_forward_active_group_protocol_key_index_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND INDEX_NAME = 'uk_network_port_forward_active_group_protocol_key'
);
SET @network_port_forward_active_group_protocol_key_index_sql := IF(
@network_port_forward_active_group_protocol_key_conflicts = 0
AND @network_port_forward_active_group_protocol_key_index_exists = 0,
'ALTER TABLE `network_port_forward` ADD UNIQUE KEY `uk_network_port_forward_active_group_protocol_key` (`active_group_protocol_key`)',
'SELECT 1'
);
PREPARE network_port_forward_active_group_protocol_key_index_stmt FROM @network_port_forward_active_group_protocol_key_index_sql;
EXECUTE network_port_forward_active_group_protocol_key_index_stmt;
DEALLOCATE PREPARE network_port_forward_active_group_protocol_key_index_stmt;
SET @network_port_forward_group_index_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
AND INDEX_NAME = 'idx_network_port_forward_group'
);
SET @network_port_forward_group_index_sql := IF(
@network_port_forward_group_index_exists = 0,
'ALTER TABLE `network_port_forward` ADD KEY `idx_network_port_forward_group` (`group_id`, `is_deleted`, `protocol`)',
'SELECT 1'
);
PREPARE network_port_forward_group_index_stmt FROM @network_port_forward_group_index_sql;
EXECUTE network_port_forward_group_index_stmt;
DEALLOCATE PREPARE network_port_forward_group_index_stmt;

View File

@ -126,38 +126,70 @@ CREATE TABLE IF NOT EXISTS admin_notice (
KEY idx_admin_notice_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS network_port_forward_group (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
remark TEXT NULL,
external_port INT UNSIGNED NOT NULL,
internal_port INT UNSIGNED NOT NULL,
protocol_mode VARCHAR(8) NOT NULL,
target_ipv4 VARCHAR(15) NOT NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS network_port_forward (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
remark TEXT NULL,
group_id BIGINT NOT NULL,
protocol VARCHAR(8) NOT NULL,
external_port INT UNSIGNED NOT NULL,
internal_port INT UNSIGNED NOT NULL,
active_key VARCHAR(32) NULL,
active_group_protocol_key VARCHAR(64) NULL,
target_ipv4 VARCHAR(15) NOT NULL,
desired_presence VARCHAR(16) NOT NULL DEFAULT 'present',
keeper_desired_enabled TINYINT(1) NOT NULL DEFAULT 0,
natmap_desired_enabled TINYINT(1) NOT NULL DEFAULT 0,
probe_request_id VARCHAR(64) NULL,
desired_revision BIGINT NOT NULL DEFAULT 0,
desired_issued_at DATETIME(6) NOT NULL,
reported_revision BIGINT NOT NULL DEFAULT 0,
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled',
natmap_status VARCHAR(16) NOT NULL DEFAULT 'disabled',
current_public_ipv4 VARCHAR(15) NULL,
current_public_port INT NULL,
current_observed_at DATETIME(6) NULL,
current_validated_at DATETIME(6) NULL,
current_valid_until DATETIME(6) NULL,
last_observed_ipv4 VARCHAR(15) NULL,
last_observed_port INT NULL,
last_observed_at DATETIME(6) NULL,
last_observed_validated_at DATETIME(6) NULL,
candidate_public_ipv4 VARCHAR(15) NULL,
candidate_public_port INT NULL,
candidate_observed_at DATETIME(6) NULL,
candidate_validated_at DATETIME(6) NULL,
last_published_public_ipv4 VARCHAR(15) NULL,
last_published_public_port INT NULL,
last_published_at DATETIME(6) NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(512) NULL,
keeper_last_error_code VARCHAR(64) NULL,
keeper_last_error_message VARCHAR(512) NULL,
natmap_last_error_code VARCHAR(64) NULL,
natmap_last_error_message VARCHAR(512) NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_network_port_forward_active_key (active_key),
UNIQUE KEY uk_network_port_forward_active_group_protocol_key (active_group_protocol_key),
KEY idx_network_port_forward_status (is_deleted, sync_status),
KEY idx_network_port_forward_protocol (protocol, external_port)
KEY idx_network_port_forward_protocol (protocol, external_port),
KEY idx_network_port_forward_group (group_id, is_deleted, protocol)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS network_ddns_record (
@ -195,6 +227,10 @@ CREATE TABLE IF NOT EXISTS network_agent_state (
desired_revision BIGINT NOT NULL DEFAULT 0,
desired_issued_at DATETIME(6) NOT NULL,
published_revision BIGINT NOT NULL DEFAULT 0,
desired_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
published_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
max_supported_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
tcp_natmap_capable TINYINT(1) NOT NULL DEFAULT 0,
applied_revision BIGINT NOT NULL DEFAULT 0,
online TINYINT(1) NOT NULL DEFAULT 0,
version VARCHAR(64) NULL,
@ -215,6 +251,7 @@ CREATE TABLE IF NOT EXISTS network_endpoint_history (
event_id VARCHAR(128) NOT NULL,
mapping_id BIGINT NOT NULL,
event_type VARCHAR(16) NOT NULL,
mechanism VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
public_ipv4 VARCHAR(15) NULL,
public_port INT NULL,
first_observed_at DATETIME(6) NOT NULL,

View File

@ -17,6 +17,7 @@ import { NetworkEndpointHistory } from '@/modules/admin/platform-config/network-
import { NetworkManagementController } from '@/modules/admin/platform-config/network-management/network-management.controller';
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 { 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 { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller';
@ -99,6 +100,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
Component,
AdminUser,
NetworkPortForward,
NetworkPortForwardGroup,
NetworkAgentState,
NetworkEndpointHistory,
NetworkDdnsRecord,

View File

@ -23,6 +23,33 @@ export class NetworkAgentState {
@Column({ default: '0', name: 'published_revision', type: 'bigint' })
publishedRevision: string;
@Column({
default: 1,
name: 'desired_schema_version',
type: 'int',
unsigned: true,
})
desiredSchemaVersion: number;
@Column({
default: 1,
name: 'published_schema_version',
type: 'int',
unsigned: true,
})
publishedSchemaVersion: number;
@Column({
default: 1,
name: 'max_supported_schema_version',
type: 'int',
unsigned: true,
})
maxSupportedSchemaVersion: number;
@Column({ default: false, name: 'tcp_natmap_capable', type: 'boolean' })
tcpNatmapCapable: boolean;
@Column({ default: '0', name: 'applied_revision', type: 'bigint' })
appliedRevision: string;

View File

@ -23,6 +23,9 @@ export class NetworkEndpointHistory {
@Column({ length: 16, name: 'event_type' })
eventType: EndpointEventType;
@Column({ default: 'udp_stun', length: 16, name: 'mechanism' })
mechanism: string;
@Column({ length: 15, name: 'public_ipv4', nullable: true })
publicIpv4?: string | null;

View File

@ -15,6 +15,10 @@ import type {
@Entity('network_port_forward')
@Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true })
@Index('uk_network_port_forward_active_group_protocol_key', [
'activeGroupProtocolKey',
], { unique: true })
@Index('idx_network_port_forward_group', ['groupId', 'isDeleted', 'protocol'])
export class NetworkPortForward {
@PrimaryColumn({ type: 'bigint' })
id: string;
@ -25,6 +29,9 @@ export class NetworkPortForward {
@Column({ nullable: true, type: 'text' })
remark?: string | null;
@Column({ name: 'group_id', type: 'bigint' })
groupId: string;
@Column({ length: 8 })
protocol: PortForwardProtocol;
@ -37,6 +44,13 @@ export class NetworkPortForward {
@Column({ length: 32, name: 'active_key', nullable: true })
activeKey?: string | null;
@Column({
length: 64,
name: 'active_group_protocol_key',
nullable: true,
})
activeGroupProtocolKey?: string | null;
@Column({ length: 15, name: 'target_ipv4' })
targetIpv4: string;
@ -50,6 +64,13 @@ export class NetworkPortForward {
})
keeperDesiredEnabled: boolean;
@Column({
default: false,
name: 'natmap_desired_enabled',
type: 'boolean',
})
natmapDesiredEnabled: boolean;
@Column({ length: 64, name: 'probe_request_id', nullable: true })
probeRequestId?: string | null;
@ -68,6 +89,9 @@ export class NetworkPortForward {
@Column({ default: 'disabled', length: 16, name: 'keeper_status' })
keeperStatus: KeeperStatus;
@Column({ default: 'disabled', length: 16, name: 'natmap_status' })
natmapStatus: string;
@Column({ length: 15, name: 'current_public_ipv4', nullable: true })
currentPublicIpv4?: string | null;
@ -81,6 +105,14 @@ export class NetworkPortForward {
})
currentObservedAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'current_validated_at',
nullable: true,
precision: 6,
type: 'datetime',
})
currentValidatedAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'current_valid_until',
nullable: true,
@ -101,12 +133,68 @@ export class NetworkPortForward {
})
lastObservedAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'last_observed_validated_at',
nullable: true,
precision: 6,
type: 'datetime',
})
lastObservedValidatedAt?: KtDateTime | null;
@Column({ length: 15, name: 'candidate_public_ipv4', nullable: true })
candidatePublicIpv4?: string | null;
@Column({ name: 'candidate_public_port', nullable: true, type: 'int' })
candidatePublicPort?: number | null;
@KtDateTimeColumn({
name: 'candidate_observed_at',
nullable: true,
precision: 6,
type: 'datetime',
})
candidateObservedAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'candidate_validated_at',
nullable: true,
precision: 6,
type: 'datetime',
})
candidateValidatedAt?: KtDateTime | null;
@Column({ length: 15, name: 'last_published_public_ipv4', nullable: true })
lastPublishedPublicIpv4?: string | null;
@Column({ name: 'last_published_public_port', nullable: true, type: 'int' })
lastPublishedPublicPort?: number | null;
@KtDateTimeColumn({
name: 'last_published_at',
nullable: true,
precision: 6,
type: 'datetime',
})
lastPublishedAt?: KtDateTime | null;
@Column({ length: 64, name: 'last_error_code', nullable: true })
lastErrorCode?: string | null;
@Column({ length: 512, name: 'last_error_message', nullable: true })
lastErrorMessage?: string | null;
@Column({ length: 64, name: 'keeper_last_error_code', nullable: true })
keeperLastErrorCode?: string | null;
@Column({ length: 512, name: 'keeper_last_error_message', nullable: true })
keeperLastErrorMessage?: string | null;
@Column({ length: 64, name: 'natmap_last_error_code', nullable: true })
natmapLastErrorCode?: string | null;
@Column({ length: 512, name: 'natmap_last_error_message', nullable: true })
natmapLastErrorMessage?: string | null;
@Column({ default: false, name: 'is_deleted', type: 'boolean' })
isDeleted: boolean;

View File

@ -0,0 +1,45 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
@Entity('network_port_forward_group')
export class NetworkPortForwardGroup {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ length: 100 })
name: string;
@Column({ nullable: true, type: 'text' })
remark?: string | null;
@Column({ name: 'external_port', type: 'int', unsigned: true })
externalPort: number;
@Column({ name: 'internal_port', type: 'int', unsigned: true })
internalPort: number;
@Column({ length: 8, name: 'protocol_mode' })
protocolMode: string;
@Column({ length: 15, name: 'target_ipv4' })
targetIpv4: string;
@Column({ default: false, name: 'is_deleted', type: 'boolean' })
isDeleted: boolean;
@KtCreateDateColumn({ name: 'create_time', precision: 6 })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time', precision: 6 })
updateTime: KtDateTime;
@BeforeInsert()
createId(): string {
return ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,37 @@
-- Sanitized offline legacy fixture. It is never applied outside tests.
INSERT INTO network_port_forward (
id, name, remark, protocol, external_port, internal_port, active_key,
target_ipv4, desired_presence, keeper_desired_enabled, probe_request_id,
desired_revision, desired_issued_at, reported_revision, sync_status,
keeper_status, current_public_ipv4, current_public_port, current_observed_at,
current_valid_until, last_observed_ipv4, last_observed_port, last_observed_at,
last_error_code, last_error_message, is_deleted, create_time, update_time
) VALUES (
2041600000000008213, 'Palworld UDP', 'sanitized existing UDP channel', 'udp',
8213, 8211, 'udp:8213', '192.168.31.224', 'present', 1, 'probe-8213', 17,
'2026-07-26 08:00:00.000000', 17, 'synced', 'active', '203.0.113.13', 8213,
'2026-07-26 08:01:00.000000', '2026-07-26 08:06:00.000000', '203.0.113.13',
8213, '2026-07-26 08:01:00.000000', NULL, NULL, 0,
'2026-07-26 07:00:00.000000', '2026-07-26 08:01:00.000000'
);
INSERT INTO network_endpoint_history (
id, event_id, mapping_id, event_type, public_ipv4, public_port,
first_observed_at, last_observed_at, occurred_at, reason, create_time
) VALUES (
2041600000000008214, 'network-8213-published', 2041600000000008213,
'published', '203.0.113.13', 8213, '2026-07-26 08:01:00.000000',
'2026-07-26 08:01:00.000000', '2026-07-26 08:01:00.000000', NULL,
'2026-07-26 08:01:00.000000'
);
INSERT INTO network_ddns_record (
id, name, remark, record_type, source_type, port_forward_id, domain,
sub_domain, active_key, enabled, sync_status, is_deleted, create_time,
update_time
) VALUES (
2041600000000008215, 'sanitized 8213 DDNS', NULL, 'A', 'port_forward_ipv4',
2041600000000008213, 'example.test', 'palworld',
'A:port_forward_ipv4:2041600000000008213:example.test:palworld', 1, 'synced',
0, '2026-07-26 08:01:00.000000', '2026-07-26 08:01:00.000000'
);

View File

@ -1,4 +1,5 @@
import { MODULE_METADATA } from '@nestjs/common/constants';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';
import {
ADMIN_PLATFORM_CONFIG_PROVIDERS,
@ -10,17 +11,19 @@ import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/ne
import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service';
import { NetworkDnsPodClient } from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.entity';
import { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.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';
describe('network management persistence module', () => {
it('registers the four exact database entity tables', () => {
it('registers the five exact database entity tables', () => {
const tables = getMetadataArgsStorage().tables.filter((table) =>
[
NetworkPortForward,
NetworkAgentState,
NetworkEndpointHistory,
NetworkDdnsRecord,
NetworkPortForwardGroup,
].includes(table.target as never),
);
expect(tables.map((table) => table.name).sort()).toEqual([
@ -28,9 +31,103 @@ describe('network management persistence module', () => {
'network_ddns_record',
'network_endpoint_history',
'network_port_forward',
'network_port_forward_group',
]);
});
it('keeps v2 group/channel metadata and datetime decorators in TypeORM storage', () => {
const storage = getMetadataArgsStorage();
const groupColumns = storage.columns.filter(
(column) => column.target === NetworkPortForwardGroup,
);
const channelColumns = storage.columns.filter(
(column) => column.target === NetworkPortForward,
);
const agentColumns = storage.columns.filter(
(column) => column.target === NetworkAgentState,
);
const historyColumns = storage.columns.filter(
(column) => column.target === NetworkEndpointHistory,
);
const featureProviders = (
Reflect.getMetadata(MODULE_METADATA.IMPORTS, AdminPlatformConfigModule) as {
module?: unknown;
providers?: { provide?: unknown }[];
}[]
).flatMap((imported) => imported.providers ?? []);
expect(groupColumns.map((column) => column.options.name)).toEqual(
expect.arrayContaining([
'create_time',
'external_port',
'internal_port',
'protocol_mode',
'target_ipv4',
'update_time',
]),
);
expect(
groupColumns.find((column) => column.propertyName === 'createTime')
?.mode,
).toBe('createDate');
expect(
groupColumns.find((column) => column.propertyName === 'updateTime')
?.mode,
).toBe('updateDate');
expect(channelColumns.map((column) => column.options.name)).toEqual(
expect.arrayContaining([
'active_group_protocol_key',
'candidate_observed_at',
'candidate_validated_at',
'current_validated_at',
'group_id',
'last_observed_validated_at',
'last_published_at',
'natmap_desired_enabled',
]),
);
expect(
channelColumns.find((column) => column.propertyName === 'groupId')?.options
.type,
).toBe('bigint');
expect(
channelColumns.find(
(column) => column.propertyName === 'candidateObservedAt',
)?.options.type,
).toBe('datetime');
expect(agentColumns.map((column) => column.options.name)).toEqual(
expect.arrayContaining([
'desired_schema_version',
'published_schema_version',
'max_supported_schema_version',
'tcp_natmap_capable',
]),
);
expect(
historyColumns.find((column) => column.propertyName === 'mechanism')
?.options.name,
).toBe('mechanism');
expect(featureProviders.map((provider) => provider.provide)).toContain(
getRepositoryToken(NetworkPortForwardGroup),
);
expect(
storage.indices.some(
(index) =>
index.target === NetworkPortForward &&
index.name === 'uk_network_port_forward_active_group_protocol_key' &&
index.unique === true,
),
).toBe(true);
expect(
storage.relations.filter(
(relation) =>
[NetworkPortForward, NetworkPortForwardGroup].includes(
relation.target as never,
),
),
).toHaveLength(0);
});
it('registers the persisted service and dedicated MQTT bridge without router-specific clients', () => {
const providers = Reflect.getMetadata(
MODULE_METADATA.PROVIDERS,

View File

@ -0,0 +1,142 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
const REPO_ROOT = resolve(__dirname, '../../..');
function readNormalizedSql(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8')
.replace(/`/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function extractCreateTable(sql: string, tableName: string): string {
const match = sql.match(
new RegExp(
`create table if not exists ${tableName} \\(([\\s\\S]*?)\\) engine=innodb`,
),
);
expect(match).not.toBeNull();
return match?.[1] ?? '';
}
function expectFinalSchema(sql: string): void {
const group = extractCreateTable(sql, 'network_port_forward_group');
const channel = extractCreateTable(sql, 'network_port_forward');
const agent = extractCreateTable(sql, 'network_agent_state');
const history = extractCreateTable(sql, 'network_endpoint_history');
for (const column of [
'id bigint not null',
'name varchar(100) not null',
'remark text null',
'external_port int unsigned not null',
'internal_port int unsigned not null',
'protocol_mode varchar(8) not null',
'target_ipv4 varchar(15) not null',
'is_deleted tinyint(1) not null default 0',
'create_time datetime(6) not null',
'update_time datetime(6) not null',
]) {
expect(group).toContain(column);
}
for (const column of [
'group_id bigint not null',
'active_group_protocol_key varchar(64) null',
'natmap_desired_enabled tinyint(1) not null default 0',
"natmap_status varchar(16) not null default 'disabled'",
'natmap_last_error_code varchar(64) null',
'natmap_last_error_message varchar(512) null',
'keeper_last_error_code varchar(64) null',
'keeper_last_error_message varchar(512) null',
'candidate_public_ipv4 varchar(15) null',
'candidate_public_port int null',
'candidate_observed_at datetime(6) null',
'candidate_validated_at datetime(6) null',
'current_validated_at datetime(6) null',
'last_observed_validated_at datetime(6) null',
'last_published_public_ipv4 varchar(15) null',
'last_published_public_port int null',
'last_published_at datetime(6) null',
]) {
expect(channel).toContain(column);
}
expect(channel).toContain(
'unique key uk_network_port_forward_active_group_protocol_key (active_group_protocol_key)',
);
expect(channel).toContain(
'key idx_network_port_forward_group (group_id, is_deleted, protocol)',
);
for (const column of [
'desired_schema_version int unsigned not null default 1',
'published_schema_version int unsigned not null default 1',
'max_supported_schema_version int unsigned not null default 1',
'tcp_natmap_capable tinyint(1) not null default 0',
]) {
expect(agent).toContain(column);
}
expect(history).toContain("mechanism varchar(16) not null default 'udp_stun'");
}
describe('TCP NATMap v2 schema SQL', () => {
it('keeps clean init and refactor-v3 schema contracts aligned', () => {
expectFinalSchema(readNormalizedSql('sql/network-management-init.sql'));
expectFinalSchema(readNormalizedSql('sql/refactor-v3/00-full-schema.sql'));
});
it('uses an additive idempotent migration with ordered group backfill', () => {
const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql');
expect(migration).toContain('create table if not exists network_port_forward_group');
expect(migration).toContain('information_schema.columns');
expect(migration).toContain('information_schema.statistics');
expect(migration).toContain('insert into network_port_forward_group');
expect(migration).toContain('select channel.id, channel.name, channel.remark');
expect(migration).toContain("'udp'");
expect(migration).toContain('update network_port_forward channel set group_id = channel.id');
expect(migration).toContain(
"active_group_protocol_key = case when channel.is_deleted = 0 then concat(channel.group_id, ':', channel.protocol) else null end",
);
expect(migration).toContain('network_port_forward_group_id_null_count');
expect(migration).toContain('modify column group_id bigint not null');
expect(
migration.indexOf('network_port_forward_group_id_null_count'),
).toBeLessThan(migration.indexOf('modify column group_id bigint not null'));
expect(migration).not.toMatch(/\b(drop|truncate|delete)\b/);
expect(migration).not.toMatch(/\b(rename\s+(table|column))\b/);
expect(migration).not.toMatch(/foreign\s+key/);
expect(migration).not.toMatch(
/update network_port_forward(?:\s+\w+)?\s+set\s+(?:id|protocol|external_port|internal_port|desired_revision|reported_revision|keeper_status|current_public_ipv4|current_public_port|last_observed_ipv4|last_observed_port)\s*=/,
);
});
it('ships a read-only verification query for final constraints', () => {
const verify = readNormalizedSql('sql/network-tcp-natmap-v2-verify.sql');
expect(verify).toContain('from network_port_forward_group');
expect(verify).toContain("column_name = 'group_id'");
expect(verify).toContain('uk_network_port_forward_active_group_protocol_key');
expect(verify).toContain('idx_network_port_forward_group');
expect(verify).not.toMatch(/\b(insert|update|delete|alter|drop|truncate)\b/);
});
it('preserves the sanitized existing 8213 UDP channel, history, and DDNS binding', () => {
const fixture = readNormalizedSql(
'test/admin/network-management/fixtures/network-tcp-natmap-v2-existing-udp.sql',
);
expect(fixture).toContain('2041600000000008213');
expect(fixture).toContain("'udp', 8213, 8211, 'udp:8213'");
expect(fixture).toContain("17, '2026-07-26 08:00:00.000000', 17, 'synced', 'active'");
expect(fixture).toContain("'203.0.113.13', 8213");
expect(fixture).toContain('network_endpoint_history');
expect(fixture).toContain('network_ddns_record');
expect(fixture).toContain("'port_forward_ipv4', 2041600000000008213");
expect(fixture).not.toContain('tcp');
});
});