diff --git a/sql/network-management-init.sql b/sql/network-management-init.sql index 799ca9f..e443f2f 100644 --- a/sql/network-management-init.sql +++ b/sql/network-management-init.sql @@ -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, diff --git a/sql/network-tcp-natmap-v2-verify.sql b/sql/network-tcp-natmap-v2-verify.sql new file mode 100644 index 0000000..6cc3ab4 --- /dev/null +++ b/sql/network-tcp-natmap-v2-verify.sql @@ -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'; diff --git a/sql/network-tcp-natmap-v2.sql b/sql/network-tcp-natmap-v2.sql new file mode 100644 index 0000000..fbaff17 --- /dev/null +++ b/sql/network-tcp-natmap-v2.sql @@ -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; diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index cc7875d..5494100 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -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, diff --git a/src/modules/admin/platform-config/admin-platform-config.module.ts b/src/modules/admin/platform-config/admin-platform-config.module.ts index e53a45d..bd6ff25 100644 --- a/src/modules/admin/platform-config/admin-platform-config.module.ts +++ b/src/modules/admin/platform-config/admin-platform-config.module.ts @@ -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, diff --git a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts index 8b60dd1..48be067 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts @@ -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; diff --git a/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts index fc55f69..701a02e 100644 --- a/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts @@ -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; diff --git a/src/modules/admin/platform-config/network-management/network-management.entity.ts b/src/modules/admin/platform-config/network-management/network-management.entity.ts index 6cfe602..139a271 100644 --- a/src/modules/admin/platform-config/network-management/network-management.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-management.entity.ts @@ -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; diff --git a/src/modules/admin/platform-config/network-management/network-port-forward-group.entity.ts b/src/modules/admin/platform-config/network-management/network-port-forward-group.entity.ts new file mode 100644 index 0000000..377e455 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-port-forward-group.entity.ts @@ -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); + } +} diff --git a/test/admin/network-management/fixtures/network-tcp-natmap-v2-existing-udp.sql b/test/admin/network-management/fixtures/network-tcp-natmap-v2-existing-udp.sql new file mode 100644 index 0000000..30d0e81 --- /dev/null +++ b/test/admin/network-management/fixtures/network-tcp-natmap-v2-existing-udp.sql @@ -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' +); diff --git a/test/admin/network-management/network-management.entity.spec.ts b/test/admin/network-management/network-management.entity.spec.ts index 22e0dc5..89f8e44 100644 --- a/test/admin/network-management/network-management.entity.spec.ts +++ b/test/admin/network-management/network-management.entity.spec.ts @@ -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, diff --git a/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts b/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts new file mode 100644 index 0000000..14f311e --- /dev/null +++ b/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts @@ -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'); + }); +});