mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
FIX port setting
This commit is contained in:
@@ -274,11 +274,13 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := lxc.ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
|
||||
@@ -750,6 +750,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
activeCreateNames := globalQueue.ActiveCreateNames()
|
||||
requestNames := make(map[string]bool)
|
||||
requestNATPorts := make(map[string]string)
|
||||
for i := range req.Containers {
|
||||
name := strings.TrimSpace(req.Containers[i].Name)
|
||||
req.Containers[i].Name = name
|
||||
@@ -806,11 +807,29 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
} else if !req.Containers[i].WantsNAT() {
|
||||
req.Containers[i].PortMappingCount = 0
|
||||
req.Containers[i].ExtraPorts = nil
|
||||
if err := req.Containers[i].NormalizeCreateNATMappings(); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := lxc.ValidateCreateNATPortAvailability(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].ManagementPort > 0 {
|
||||
key := fmt.Sprintf("%d/tcp", req.Containers[i].ManagementPort)
|
||||
if owner := requestNATPorts[key]; owner != "" {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT management port %s is also requested by %s", name, key, owner)})
|
||||
return
|
||||
}
|
||||
requestNATPorts[key] = name
|
||||
}
|
||||
for _, mapping := range req.Containers[i].NATPortMappings {
|
||||
key := fmt.Sprintf("%d/%s", mapping.HostPort, mapping.Protocol)
|
||||
if owner := requestNATPorts[key]; owner != "" {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT host port %s is also requested by %s", name, key, owner)})
|
||||
return
|
||||
}
|
||||
requestNATPorts[key] = name
|
||||
}
|
||||
if req.Containers[i].PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||
|
||||
@@ -1728,9 +1728,20 @@ func normalizeNATPortRangeDefaults() bool {
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() (int, error) {
|
||||
return AllocateSSHPortExcluding(nil)
|
||||
}
|
||||
|
||||
// AllocateSSHPortExcluding allocates a management port while reserving
|
||||
// user-requested NAT host ports for the container being created.
|
||||
func AllocateSSHPortExcluding(excluded []int) (int, error) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
used := collectAllHostPorts()
|
||||
for _, port := range excluded {
|
||||
if port > 0 {
|
||||
used[port] = true
|
||||
}
|
||||
}
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
|
||||
@@ -44,3 +44,21 @@ func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
|
||||
t.Fatalf("expected exhausted NAT range error, got port %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) {
|
||||
previous := AppConfig
|
||||
t.Cleanup(func() { AppConfig = previous })
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 32000,
|
||||
NATPortEnd: 32002,
|
||||
NextSSHPort: 32000,
|
||||
}
|
||||
|
||||
port, err := AllocateSSHPortExcluding([]int{32000, 32001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 32002 {
|
||||
t.Fatalf("allocated port = %d, want 32002", port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,45 +20,47 @@ var (
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
NATPortMappings []PortMapping `json:"nat_port_mappings,omitempty"`
|
||||
ManagementPort int `json:"management_port,omitempty"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
@@ -356,6 +358,7 @@ func ensureSchema() error {
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_management_port INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_assign_nat INTEGER,
|
||||
cfg_lan_ipv4_mode TEXT,
|
||||
@@ -383,6 +386,15 @@ func ensureSchema() error {
|
||||
port INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_nat_port_mappings (
|
||||
task_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
protocol TEXT,
|
||||
description TEXT,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
@@ -433,6 +445,7 @@ func ensureSchemaMigrations() error {
|
||||
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_management_port", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||
@@ -668,6 +681,7 @@ func saveConfigToDB() error {
|
||||
"api_keys",
|
||||
"audit_logs",
|
||||
"task_extra_ports",
|
||||
"task_nat_port_mappings",
|
||||
"tasks",
|
||||
"login_logs",
|
||||
"enabled_images",
|
||||
@@ -916,17 +930,17 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_management_port, cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||
cfg.ManagementPort, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||
cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||
@@ -939,6 +953,14 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, mapping := range cfg.NATPortMappings {
|
||||
if _, err := tx.Exec(`INSERT INTO task_nat_port_mappings(task_id, position, host_port, container_port, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, i, mapping.HostPort, mapping.ContainerPort, mapping.Protocol, mapping.Description,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1238,7 +1260,7 @@ func loadTasks() ([]SavedTask, error) {
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_management_port, cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||
@@ -1262,7 +1284,7 @@ func loadTasks() ([]SavedTask, error) {
|
||||
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
||||
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||
&cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||
&cfg.ManagementPort, &cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||
&lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
|
||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
|
||||
@@ -1312,6 +1334,10 @@ func loadTasks() ([]SavedTask, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs[i].NATPortMappings, err = loadTaskNATPortMappings(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].Config = encodeSavedTaskConfig(configs[i])
|
||||
}
|
||||
return result, nil
|
||||
@@ -1334,6 +1360,24 @@ func loadTaskExtraPorts(taskID string) ([]int, error) {
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadTaskNATPortMappings(taskID string) ([]PortMapping, error) {
|
||||
rows, err := db.Query(`SELECT host_port, container_port, protocol, description
|
||||
FROM task_nat_port_mappings WHERE task_id = ? ORDER BY position`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []PortMapping{}
|
||||
for rows.Next() {
|
||||
var mapping PortMapping
|
||||
if err := rows.Scan(&mapping.HostPort, &mapping.ContainerPort, &mapping.Protocol, &mapping.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, mapping)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLoginLogs() ([]SavedLoginLog, error) {
|
||||
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
ContainerName: "ct2",
|
||||
Status: "pending",
|
||||
CreatedAt: "2026-06-07 17:29:02",
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`,
|
||||
}},
|
||||
EnabledImages: []string{"debian-12"},
|
||||
Snapshots: []Snapshot{{
|
||||
@@ -93,6 +93,12 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||
}
|
||||
if !strings.Contains(cfg.Tasks[0].Config, `"nat_port_mappings":[{"host_port":30080,"container_port":80`) {
|
||||
t.Fatalf("task NAT mappings were not restored from sqlite: %+v", cfg.Tasks)
|
||||
}
|
||||
if !strings.Contains(cfg.Tasks[0].Config, `"management_port":30022`) {
|
||||
t.Fatalf("task management port was not restored from sqlite: %+v", cfg.Tasks)
|
||||
}
|
||||
if cfg.TaskConcurrency != DefaultTaskConcurrency {
|
||||
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
|
||||
}
|
||||
|
||||
+20
-56
@@ -415,11 +415,8 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
}
|
||||
cfg.StoragePoolID = pool.ID
|
||||
m = NewManagerForStoragePool(pool.ID)
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -428,10 +425,19 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
cfg.AllowedImageIDs = []string{cfg.TemplateID}
|
||||
cfg.ImageLimitConfigured = true
|
||||
}
|
||||
managementPort := 0
|
||||
releaseNATReservation := func() {}
|
||||
if cfg.WantsNAT() {
|
||||
managementPort, releaseNATReservation, err = lxc.ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseNATReservation()
|
||||
}
|
||||
|
||||
id := config.AllocateContainerID()
|
||||
vmName := fmt.Sprintf("vm-%d", id)
|
||||
c, err := m.defineContainer(id, vmName, cfg, true)
|
||||
c, err := m.defineContainer(id, vmName, cfg, true, managementPort)
|
||||
if err != nil {
|
||||
_ = m.cleanupVM(vmName)
|
||||
return err
|
||||
@@ -440,7 +446,7 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool) (*config.Container, error) {
|
||||
func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool, managementPort int) (*config.Container, error) {
|
||||
image := FindImage(cfg.TemplateID)
|
||||
if image == nil {
|
||||
return nil, fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
|
||||
@@ -547,9 +553,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
portMappings := []config.PortMapping{}
|
||||
if allocatePorts && cfg.WantsNAT() {
|
||||
cfg.ReportProgress("nat", "分配并配置 NAT 端口")
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
sshPort = managementPort
|
||||
if sshPort <= 0 {
|
||||
return nil, fmt.Errorf("NAT management port was not reserved")
|
||||
}
|
||||
if IsWindowsImage(image.ID) {
|
||||
// Windows: RDP (3389) instead of SSH (22)
|
||||
@@ -569,23 +575,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
}
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
portMappings, err = lxc.SetupCreatePortMappings(tempC, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, port := range extraPorts {
|
||||
if port <= 0 {
|
||||
continue
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
||||
ContainerPort: port,
|
||||
HostPort: port,
|
||||
HostIP: defaultHostIP,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
portMappings = tempC.PortMappings
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
@@ -886,7 +879,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lx
|
||||
}
|
||||
cfg.SSHPassword = sshAccess.Password
|
||||
}
|
||||
next, err := m.defineContainer(id, name, cfg, false)
|
||||
next, err := m.defineContainer(id, name, cfg, false, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4169,35 +4162,6 @@ func sshHostKeyFingerprint(key ssh.PublicKey) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
used := map[int]bool{}
|
||||
// Mark current container's ports
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
// Also mark all other containers' host ports (LXC + KVM)
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
if !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func runStdin(command string, stdin []byte, args ...string) error {
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
|
||||
+131
-32
@@ -247,6 +247,8 @@ type ContainerConfig struct {
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
NATPortMappings []config.PortMapping `json:"nat_port_mappings,omitempty"`
|
||||
ManagementPort int `json:"management_port,omitempty"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
@@ -319,6 +321,120 @@ func (cfg ContainerConfig) WantsNAT() bool {
|
||||
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||
}
|
||||
|
||||
func (cfg *ContainerConfig) NormalizeCreateNATMappings() error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.WantsNAT() {
|
||||
cfg.ExtraPorts = nil
|
||||
cfg.NATPortMappings = nil
|
||||
cfg.ManagementPort = 0
|
||||
cfg.PortMappingCount = 0
|
||||
return nil
|
||||
}
|
||||
if cfg.ManagementPort < 0 || cfg.ManagementPort > 65535 {
|
||||
return fmt.Errorf("management_port must be 1-65535 or 0 for automatic allocation")
|
||||
}
|
||||
if cfg.ManagementPort > 0 && !config.NATPortInRange(cfg.ManagementPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return fmt.Errorf("management_port must be within configured NAT4 range %d-%d", start, end)
|
||||
}
|
||||
|
||||
mappings := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if len(mappings) == 0 && len(cfg.ExtraPorts) > 0 {
|
||||
mappings = make([]config.PortMapping, 0, len(cfg.ExtraPorts))
|
||||
for _, port := range cfg.ExtraPorts {
|
||||
mappings = append(mappings, config.PortMapping{
|
||||
HostPort: port,
|
||||
ContainerPort: port,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
cfg.ExtraPorts = nil
|
||||
if cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(mappings) > 63 {
|
||||
return fmt.Errorf("custom NAT port mappings cannot exceed 63")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
if cfg.ManagementPort > 0 {
|
||||
seen[fmt.Sprintf("%d/tcp", cfg.ManagementPort)] = true
|
||||
}
|
||||
for i := range mappings {
|
||||
pm := &mappings[i]
|
||||
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||
if pm.HostIP != "" {
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_ip is not supported during creation", i)
|
||||
}
|
||||
if pm.HostPort < 1 || pm.HostPort > 65535 {
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_port must be 1-65535", i)
|
||||
}
|
||||
if !config.NATPortInRange(pm.HostPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_port must be within configured NAT4 range %d-%d", i, start, end)
|
||||
}
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return fmt.Errorf("nat_port_mappings[%d].container_port must be 1-65535", i)
|
||||
}
|
||||
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||
if pm.Protocol == "" {
|
||||
pm.Protocol = "tcp"
|
||||
}
|
||||
if pm.Protocol != "tcp" && pm.Protocol != "udp" {
|
||||
return fmt.Errorf("nat_port_mappings[%d].protocol must be tcp or udp", i)
|
||||
}
|
||||
key := fmt.Sprintf("%d/%s", pm.HostPort, pm.Protocol)
|
||||
if seen[key] {
|
||||
if pm.HostPort == cfg.ManagementPort && pm.Protocol == "tcp" {
|
||||
return fmt.Errorf("NAT host port mapping %s conflicts with management_port", key)
|
||||
}
|
||||
return fmt.Errorf("duplicate NAT host port mapping: %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
pm.Description = strings.TrimSpace(pm.Description)
|
||||
if pm.Description == "" {
|
||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.NATPortMappings = mappings
|
||||
cfg.ExtraPorts = nil
|
||||
cfg.PortMappingCount = len(mappings) + 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg ContainerConfig) RequestedNATHostPorts() []int {
|
||||
ports := make([]int, 0, len(cfg.NATPortMappings)+1)
|
||||
if cfg.ManagementPort > 0 {
|
||||
ports = append(ports, cfg.ManagementPort)
|
||||
}
|
||||
for _, pm := range cfg.NATPortMappings {
|
||||
if pm.HostPort > 0 {
|
||||
ports = append(ports, pm.HostPort)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func ValidateCreateNATPortAvailability(cfg ContainerConfig) error {
|
||||
candidate := &config.Container{ID: -1}
|
||||
if cfg.ManagementPort > 0 && !HostPortAvailable(candidate, "", cfg.ManagementPort, "tcp") {
|
||||
return fmt.Errorf("NAT management port %d/tcp is already in use", cfg.ManagementPort)
|
||||
}
|
||||
for _, pm := range cfg.NATPortMappings {
|
||||
if !HostPortAvailable(candidate, "", pm.HostPort, pm.Protocol) {
|
||||
return fmt.Errorf("NAT host port %d/%s is already in use", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg ContainerConfig) WantsLANDHCP() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP)
|
||||
}
|
||||
@@ -339,11 +455,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -363,6 +476,15 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshPort := 0
|
||||
releaseNATReservation := func() {}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort, releaseNATReservation, err = ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseNATReservation()
|
||||
}
|
||||
|
||||
// Allocate ID and build LXC name
|
||||
id := config.AllocateContainerID()
|
||||
@@ -443,40 +565,17 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
sshPassword := sshAccess.Password
|
||||
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4.
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
}
|
||||
for _, containerPort := range extraPorts {
|
||||
if containerPort <= 0 {
|
||||
continue
|
||||
}
|
||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: containerPort,
|
||||
HostIP: "",
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||
portMappings = tempC.PortMappings
|
||||
portMappings, err = SetupCreatePortMappings(tempC, cfg)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
@@ -27,6 +29,158 @@ func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsSupportsDifferentHostAndContainerPorts(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{
|
||||
PortMappingCount: 2,
|
||||
NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 30080,
|
||||
ContainerPort: 80,
|
||||
Protocol: "TCP",
|
||||
}},
|
||||
}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.PortMappingCount != 2 || len(cfg.NATPortMappings) != 1 {
|
||||
t.Fatalf("normalized config = %+v", cfg)
|
||||
}
|
||||
mapping := cfg.NATPortMappings[0]
|
||||
if mapping.HostPort != 30080 || mapping.ContainerPort != 80 || mapping.Protocol != "tcp" {
|
||||
t.Fatalf("normalized mapping = %+v", mapping)
|
||||
}
|
||||
|
||||
container := &config.Container{
|
||||
ID: -1,
|
||||
PortMappings: []config.PortMapping{{
|
||||
HostPort: 22000,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}
|
||||
mappings, err := SetupCreatePortMappings(container, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mappings) != 2 || mappings[1].HostPort != 30080 || mappings[1].ContainerPort != 80 {
|
||||
t.Fatalf("created mappings = %+v", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsKeepsLegacyExtraPortsCompatible(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{ExtraPorts: []int{30080, 30443}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.ExtraPorts) != 0 || len(cfg.NATPortMappings) != 2 {
|
||||
t.Fatalf("legacy ports were not converted: %+v", cfg)
|
||||
}
|
||||
for _, mapping := range cfg.NATPortMappings {
|
||||
if mapping.HostPort != mapping.ContainerPort {
|
||||
t.Fatalf("legacy mapping changed semantics: %+v", mapping)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsRejectsDuplicateHostPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{
|
||||
{HostPort: 30080, ContainerPort: 80, Protocol: "tcp"},
|
||||
{HostPort: 30080, ContainerPort: 8080, Protocol: "tcp"},
|
||||
}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err == nil {
|
||||
t.Fatal("duplicate host port was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{
|
||||
ManagementPort: 30022,
|
||||
NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 30022,
|
||||
ContainerPort: 8080,
|
||||
Protocol: "tcp",
|
||||
}},
|
||||
}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err == nil || !strings.Contains(err.Error(), "management_port") {
|
||||
t.Fatalf("management port conflict returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 20000,
|
||||
NATPortEnd: 65535,
|
||||
NextSSHPort: 22000,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 22000,
|
||||
ContainerPort: 80,
|
||||
Protocol: "tcp",
|
||||
}}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
managementPort, release, err := ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if managementPort == 22000 {
|
||||
t.Fatal("management port collided with the requested custom host port")
|
||||
}
|
||||
if _, _, err := ReserveCreateNATPorts(cfg); err == nil {
|
||||
t.Fatal("concurrent task reserved an already reserved custom host port")
|
||||
}
|
||||
|
||||
release()
|
||||
if _, releaseAgain, err := ReserveCreateNATPorts(cfg); err != nil {
|
||||
t.Fatalf("released custom host port remained reserved: %v", err)
|
||||
} else {
|
||||
releaseAgain()
|
||||
}
|
||||
|
||||
explicit := ContainerConfig{ManagementPort: 30022}
|
||||
if err := explicit.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port, releaseExplicit, err := ReserveCreateNATPorts(explicit); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
defer releaseExplicit()
|
||||
if port != explicit.ManagementPort {
|
||||
t.Fatalf("reserved management port = %d, want %d", port, explicit.ManagementPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
|
||||
@@ -6,10 +6,17 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
createNATReservationMu sync.Mutex
|
||||
createNATReservationNextID uint64
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
)
|
||||
|
||||
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
||||
func (m *Manager) ApplyPortMappings(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
@@ -509,6 +516,96 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
// SetupCreatePortMappings appends validated custom or automatically allocated
|
||||
// mappings to a container's management port mapping.
|
||||
func SetupCreatePortMappings(c *config.Container, cfg ContainerConfig) ([]config.PortMapping, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container is required")
|
||||
}
|
||||
requested := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if len(requested) == 0 && cfg.PortMappingCount > 1 {
|
||||
for _, port := range allocateDefaultEqualPorts(c, cfg.PortMappingCount-1) {
|
||||
requested = append(requested, config.PortMapping{
|
||||
ContainerPort: port,
|
||||
HostPort: port,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, mapping := range requested {
|
||||
pm, err := normalizePortMapping(c, -1, mapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings, pm)
|
||||
}
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
// ReserveCreateNATPorts keeps concurrent create tasks from selecting each
|
||||
// other's custom or management ports before their containers are persisted.
|
||||
func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||
if !cfg.WantsNAT() {
|
||||
return 0, func() {}, nil
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
defer createNATReservationMu.Unlock()
|
||||
|
||||
if err := ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
requestedReservations := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if cfg.ManagementPort > 0 {
|
||||
requestedReservations = append(requestedReservations, config.PortMapping{
|
||||
HostPort: cfg.ManagementPort,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
}
|
||||
for _, requested := range requestedReservations {
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
if requested.HostPort == reserved.HostPort && protocolsOverlap(requested.Protocol, reserved.Protocol) {
|
||||
return 0, nil, fmt.Errorf("NAT host port %d/%s is reserved by another create task", requested.HostPort, requested.Protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
excluded := cfg.RequestedNATHostPorts()
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
excluded = append(excluded, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
managementPort := cfg.ManagementPort
|
||||
if managementPort == 0 {
|
||||
var err error
|
||||
managementPort, err = config.AllocateSSHPortExcluding(excluded)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
createNATReservationNextID++
|
||||
reservationID := createNATReservationNextID
|
||||
reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings)+1)
|
||||
reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"})
|
||||
reservations = append(reservations, cfg.NATPortMappings...)
|
||||
createNATReservations[reservationID] = reservations
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
createNATReservationMu.Lock()
|
||||
delete(createNATReservations, reservationID)
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
}
|
||||
return managementPort, release, nil
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||
import { ArrowRight, CalendarClock, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, StorageInfo, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||
@@ -33,6 +33,8 @@ const defaultForm: CreateContainerRequest = {
|
||||
io_read_mbps: 0,
|
||||
io_write_mbps: 0,
|
||||
extra_ports: [],
|
||||
nat_port_mappings: [],
|
||||
management_port: 0,
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
@@ -159,20 +161,28 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
||||
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
||||
const defaultLANInterface = lanInterfaces[0]?.name || ''
|
||||
const customNATPorts = form.extra_ports || []
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0
|
||||
const customNATMappings = form.nat_port_mappings || []
|
||||
const natPortCount = natEnabled
|
||||
? (customNATMappings.length > 0 ? customNATMappings.length + 1 : Math.max(2, form.port_mapping_count || 2))
|
||||
: 0
|
||||
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const autoPortMappings = useMemo(() => {
|
||||
if (!natEnabled) return []
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
return Array.from({ length: count - 1 }, (_, index) => ({
|
||||
host_port: 22002 + index,
|
||||
container_port: 22002 + index,
|
||||
protocol: 'tcp',
|
||||
description: `Port-${22002 + index}`,
|
||||
}))
|
||||
}, [natEnabled, natPortCount])
|
||||
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
|
||||
const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
const managementPort = Math.round(Number(form.management_port) || 0)
|
||||
// Automatic allocation starts around 22000; an explicit value is exact.
|
||||
const sshPortPreview = managementPort || 22000
|
||||
|
||||
// Find next available batch index to avoid name conflicts
|
||||
const batchStartIndex = useMemo(() => {
|
||||
@@ -230,6 +240,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
const natMappingError = natEnabled
|
||||
? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount)
|
||||
: ''
|
||||
if (natMappingError) {
|
||||
dialog.alert('NAT 端口配置有误', natMappingError)
|
||||
return
|
||||
}
|
||||
|
||||
const authError = validateSSHAuthInputs(form)
|
||||
if (authError) {
|
||||
dialog.alert('登录方式有误', authError)
|
||||
@@ -244,15 +262,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const startIndex = batchStartIndex
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
|
||||
const expandedNAT = wantsNAT
|
||||
? expandBatchNATConfig(boundedForm.nat_port_mappings || [], boundedForm.management_port || 0, i, batchCount)
|
||||
: { mappings: [], managementPort: 0 }
|
||||
const natPortMappings = expandedNAT.mappings
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0,
|
||||
port_mapping_count: wantsNAT
|
||||
? (natPortMappings.length > 0 ? natPortMappings.length + 1 : Math.max(2, boundedForm.port_mapping_count || 2))
|
||||
: 0,
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
||||
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||
extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
|
||||
extra_ports: [],
|
||||
nat_port_mappings: natPortMappings,
|
||||
management_port: expandedNAT.managementPort,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -478,7 +504,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
...form,
|
||||
assign_ipv4: event.target.checked,
|
||||
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], nat_port_mappings: [], management_port: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
@@ -560,6 +586,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
assign_nat: checked ? false : form.assign_nat,
|
||||
port_mapping_count: checked ? 0 : form.port_mapping_count,
|
||||
extra_ports: checked ? [] : form.extra_ports,
|
||||
nat_port_mappings: checked ? [] : form.nat_port_mappings,
|
||||
management_port: checked ? 0 : form.management_port,
|
||||
assign_ipv4: checked ? false : form.assign_ipv4,
|
||||
public_ipv4s: checked ? [] : form.public_ipv4s,
|
||||
ipv4_count: checked ? 0 : form.ipv4_count,
|
||||
@@ -712,6 +740,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
nat_port_mappings: [],
|
||||
management_port: checked ? form.management_port : 0,
|
||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||
})
|
||||
}}
|
||||
@@ -724,58 +754,184 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{natEnabled && customNATPorts.length === 0 && (
|
||||
{natEnabled && customNATMappings.length === 0 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [], nat_port_mappings: [] })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{natEnabled && (
|
||||
<div className="mt-2 space-y-2 pl-6">
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_56px] items-end gap-2">
|
||||
<label className="min-w-0 text-[11px] text-gray-500">
|
||||
<span className="mb-1 block">
|
||||
{language === 'en'
|
||||
? `${isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'} public source port`
|
||||
: `${isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'} 公网源端口`}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={managementPort || ''}
|
||||
onChange={(event) => setForm({
|
||||
...form,
|
||||
management_port: Number(event.target.value) || 0,
|
||||
assign_nat: true,
|
||||
})}
|
||||
className={`${inputClass} min-w-0 font-mono text-xs`}
|
||||
placeholder={language === 'en' ? 'Auto' : '自动'}
|
||||
/>
|
||||
</label>
|
||||
<ArrowRight className="mb-3 h-4 w-4 text-gray-400" />
|
||||
<label className="min-w-0 text-[11px] text-gray-500">
|
||||
<span className="mb-1 block">{language === 'en' ? 'Container target port' : '容器目标端口'}</span>
|
||||
<input
|
||||
type="number"
|
||||
readOnly
|
||||
value={isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
className={`${inputClass} min-w-0 bg-gray-50 font-mono text-xs text-gray-500`}
|
||||
/>
|
||||
</label>
|
||||
<span className="mb-1 inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-xs text-gray-600">
|
||||
TCP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customNATPorts.length === 0}
|
||||
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
|
||||
checked={customNATMappings.length === 0}
|
||||
onChange={() => setForm({ ...form, extra_ports: [], nat_port_mappings: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
|
||||
/>
|
||||
Auto ports
|
||||
{language === 'en' ? 'Auto ports' : '自动端口'}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customNATPorts.length > 0}
|
||||
checked={customNATMappings.length > 0}
|
||||
onChange={() => {
|
||||
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
|
||||
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||
const next = customNATMappings.length > 0
|
||||
? customNATMappings
|
||||
: [{ host_port: 22002, container_port: 22002, protocol: 'tcp', description: 'Port-22002' }]
|
||||
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1, assign_nat: true })
|
||||
}}
|
||||
/>
|
||||
Custom ports
|
||||
{language === 'en' ? 'Custom mappings' : '自定义映射'}
|
||||
</label>
|
||||
</div>
|
||||
{customNATPorts.length > 0 && (
|
||||
<textarea
|
||||
value={customNATPorts.join('\n')}
|
||||
onChange={(event) => {
|
||||
const next = parsePortList(event.target.value)
|
||||
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||
}}
|
||||
className={`${inputClass} min-h-16 font-mono text-xs`}
|
||||
placeholder={'22002\n8080\n8443'}
|
||||
/>
|
||||
{customNATMappings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_72px_32px] items-center gap-2 px-1 text-[11px] text-gray-500">
|
||||
<span>{language === 'en' ? 'Public source port' : '源端口(公网)'}</span>
|
||||
<span />
|
||||
<span>{language === 'en' ? 'Container target port' : '目标端口(容器)'}</span>
|
||||
<span>{language === 'en' ? 'Protocol' : '协议'}</span>
|
||||
<span />
|
||||
</div>
|
||||
{customNATMappings.map((mapping, index) => (
|
||||
<div key={index} className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_72px_32px] items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={mapping.host_port || ''}
|
||||
onChange={(event) => {
|
||||
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
|
||||
? { ...item, host_port: Number(event.target.value) }
|
||||
: item)
|
||||
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1 })
|
||||
}}
|
||||
className={`${inputClass} min-w-0 font-mono text-xs`}
|
||||
placeholder="30080"
|
||||
/>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={mapping.container_port || ''}
|
||||
onChange={(event) => {
|
||||
const targetPort = Number(event.target.value)
|
||||
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
|
||||
? { ...item, container_port: targetPort, description: `Port-${targetPort}` }
|
||||
: item)
|
||||
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1 })
|
||||
}}
|
||||
className={`${inputClass} min-w-0 font-mono text-xs`}
|
||||
placeholder="80"
|
||||
/>
|
||||
<select
|
||||
value={mapping.protocol || 'tcp'}
|
||||
onChange={(event) => {
|
||||
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
|
||||
? { ...item, protocol: event.target.value }
|
||||
: item)
|
||||
setForm({ ...form, nat_port_mappings: next })
|
||||
}}
|
||||
className="h-10 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={customNATMappings.length <= 1}
|
||||
onClick={() => {
|
||||
const next = customNATMappings.filter((_, itemIndex) => itemIndex !== index)
|
||||
setForm({ ...form, nat_port_mappings: next, port_mapping_count: next.length + 1 })
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title={language === 'en' ? 'Remove mapping' : '删除映射'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={customNATMappings.length >= 63}
|
||||
onClick={() => {
|
||||
const previous = customNATMappings[customNATMappings.length - 1]
|
||||
const hostPort = Math.min(65535, (previous?.host_port || 22001) + 1)
|
||||
const containerPort = Math.min(65535, (previous?.container_port || 22001) + 1)
|
||||
const next = [...customNATMappings, {
|
||||
host_port: hostPort,
|
||||
container_port: containerPort,
|
||||
protocol: 'tcp',
|
||||
description: `Port-${containerPort}`,
|
||||
}]
|
||||
setForm({ ...form, nat_port_mappings: next, port_mapping_count: next.length + 1 })
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{language === 'en' ? 'Add mapping' : '添加映射'}
|
||||
</button>
|
||||
{batchCount > 1 && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{language === 'en'
|
||||
? 'Batch mode shifts the public source-port group for each container; target ports stay unchanged.'
|
||||
: '批量创建时,每台容器使用不重叠的公网源端口组,容器目标端口保持不变。'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
{managementPort === 0 ? (language === 'en' ? ' (auto)' : '(自动)') : ''}
|
||||
</span>
|
||||
{natPreviewPorts.map((port, index) => (
|
||||
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
{natPreviewMappings.map((mapping, index) => (
|
||||
<span key={`${mapping.host_port}-${mapping.container_port}-${mapping.protocol}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{mapping.host_port} -> {mapping.container_port}/{mapping.protocol.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1001,8 +1157,19 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
// IPv4 and NAT are mutually exclusive
|
||||
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
|
||||
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
|
||||
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
|
||||
const legacyMappings = normalizePortList(normalized.extra_ports || []).map((port) => ({
|
||||
host_port: port,
|
||||
container_port: port,
|
||||
protocol: 'tcp',
|
||||
description: `Port-${port}`,
|
||||
}))
|
||||
const natPortMappings = wantsNAT
|
||||
? normalizeNATPortMappings((normalized.nat_port_mappings?.length ? normalized.nat_port_mappings : legacyMappings))
|
||||
: []
|
||||
const managementPort = wantsNAT ? Math.round(Number(normalized.management_port) || 0) : 0
|
||||
const portMappingCount = wantsNAT
|
||||
? (natPortMappings.length > 0 ? natPortMappings.length + 1 : clampInt(normalized.port_mapping_count || 2, 2, 64, 2))
|
||||
: 0
|
||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
@@ -1012,7 +1179,9 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: portMappingCount,
|
||||
extra_ports: extraPorts,
|
||||
extra_ports: [],
|
||||
nat_port_mappings: natPortMappings,
|
||||
management_port: managementPort,
|
||||
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
|
||||
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
|
||||
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
|
||||
@@ -1075,14 +1244,6 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
function parsePortList(value: string) {
|
||||
return normalizePortList(
|
||||
value
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((item) => Number(item.trim()))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePortList(ports: number[]) {
|
||||
const seen = new Set<number>()
|
||||
const result: number[] = []
|
||||
@@ -1097,6 +1258,111 @@ function normalizePortList(ports: number[]) {
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeNATPortMappings(mappings: PortMapping[]) {
|
||||
return mappings.slice(0, 63).map((mapping) => {
|
||||
const hostPort = Math.round(Number(mapping.host_port) || 0)
|
||||
const containerPort = Math.round(Number(mapping.container_port) || 0)
|
||||
const protocol = (mapping.protocol || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp'
|
||||
return {
|
||||
host_port: hostPort,
|
||||
container_port: containerPort,
|
||||
protocol,
|
||||
description: mapping.description?.trim() || `Port-${containerPort}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number, batchCount: number) {
|
||||
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount)
|
||||
const offset = batchIndex * stride
|
||||
return {
|
||||
mappings: mappings.map((mapping) => ({
|
||||
...mapping,
|
||||
host_port: mapping.host_port + offset,
|
||||
})),
|
||||
managementPort: managementPort > 0 ? managementPort + offset : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function batchNATSourceMappings(mappings: PortMapping[], managementPort: number) {
|
||||
if (managementPort <= 0) return mappings
|
||||
return [
|
||||
{
|
||||
host_port: managementPort,
|
||||
container_port: 0,
|
||||
protocol: 'tcp',
|
||||
description: 'Management',
|
||||
},
|
||||
...mappings,
|
||||
]
|
||||
}
|
||||
|
||||
function batchNATPortStride(mappings: PortMapping[], batchCount: number) {
|
||||
if (mappings.length === 0 || batchCount <= 1) return 1
|
||||
const invalid = new Set<number>()
|
||||
for (let left = 0; left < mappings.length; left++) {
|
||||
for (let right = left + 1; right < mappings.length; right++) {
|
||||
const leftProtocol = (mappings[left].protocol || 'tcp').toLowerCase()
|
||||
const rightProtocol = (mappings[right].protocol || 'tcp').toLowerCase()
|
||||
if (leftProtocol !== rightProtocol) continue
|
||||
const difference = Math.abs(
|
||||
Math.round(Number(mappings[left].host_port) || 0)
|
||||
- Math.round(Number(mappings[right].host_port) || 0)
|
||||
)
|
||||
for (let distance = 1; difference > 0 && distance < batchCount; distance++) {
|
||||
if (difference % distance === 0) invalid.add(difference / distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
let stride = 1
|
||||
while (invalid.has(stride)) stride++
|
||||
return stride
|
||||
}
|
||||
|
||||
function validateBatchNATPortMappings(mappings: PortMapping[], managementPort: number, batchCount: number) {
|
||||
if (mappings.length === 0 && managementPort === 0) return ''
|
||||
if (managementPort < 0 || managementPort > 65535) {
|
||||
return 'SSH/RDP 公网源端口必须在 1-65535 之间,留空则自动分配'
|
||||
}
|
||||
if (mappings.length > 63) return '每个容器最多可配置 63 条自定义 NAT 映射'
|
||||
|
||||
const used = new Map<string, string>()
|
||||
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount)
|
||||
for (let batchIndex = 0; batchIndex < batchCount; batchIndex++) {
|
||||
if (managementPort > 0) {
|
||||
const expandedManagementPort = managementPort + batchIndex * stride
|
||||
if (expandedManagementPort > 65535) {
|
||||
return '批量展开后的 SSH/RDP 公网源端口超出 1-65535'
|
||||
}
|
||||
const managementKey = `${expandedManagementPort}/tcp`
|
||||
const managementOwner = used.get(managementKey)
|
||||
if (managementOwner) {
|
||||
return `批量端口冲突:${managementKey} 同时被 ${managementOwner} 和第 ${batchIndex + 1} 台容器的管理端口使用`
|
||||
}
|
||||
used.set(managementKey, `第 ${batchIndex + 1} 台容器的管理端口`)
|
||||
}
|
||||
for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) {
|
||||
const mapping = mappings[mappingIndex]
|
||||
const hostPort = Math.round(Number(mapping.host_port) || 0) + batchIndex * stride
|
||||
const containerPort = Math.round(Number(mapping.container_port) || 0)
|
||||
const protocol = (mapping.protocol || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp'
|
||||
if (hostPort < 1 || hostPort > 65535) {
|
||||
return `第 ${mappingIndex + 1} 条映射展开后的公网端口超出 1-65535`
|
||||
}
|
||||
if (containerPort < 1 || containerPort > 65535) {
|
||||
return `第 ${mappingIndex + 1} 条映射的容器端口必须在 1-65535 之间`
|
||||
}
|
||||
const key = `${hostPort}/${protocol}`
|
||||
const owner = used.get(key)
|
||||
if (owner) {
|
||||
return `批量端口冲突:${key} 同时被 ${owner} 和第 ${batchIndex + 1} 台容器使用`
|
||||
}
|
||||
used.set(key, `第 ${batchIndex + 1} 台容器`)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string) {
|
||||
const parts = value.trim().split('.')
|
||||
return parts.length === 4 && parts.every((part) => {
|
||||
|
||||
@@ -763,7 +763,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
extra_ports: [8080],
|
||||
extra_ports: [],
|
||||
nat_port_mappings: [
|
||||
{
|
||||
host_port: 30080,
|
||||
container_port: 80,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
],
|
||||
management_port: 30022,
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
@@ -913,7 +922,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
assign_nat: true,
|
||||
management_port: 30022,
|
||||
port_mapping_count: 2,
|
||||
nat_port_mappings: [
|
||||
{
|
||||
host_port: 30080,
|
||||
container_port: 80,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
],
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
@@ -1338,6 +1356,8 @@ function endpointNoteFor(key: string) {
|
||||
const notes: string[] = []
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Set management_port to choose the public/source port for SSH (target 22) or Windows RDP (target 3389). Omit it or pass 0 for automatic allocation.')
|
||||
notes.push('For other custom NAT rules, use nat_port_mappings with host_port (public/source port), container_port (target port), and protocol=tcp|udp. extra_ports remains accepted for compatibility and maps each port to the same port inside the container.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
|
||||
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
|
||||
@@ -1347,6 +1367,7 @@ function endpointNoteFor(key: string) {
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
|
||||
notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel shifts each source-port group for later containers while keeping target ports unchanged; direct API clients should submit the expanded values explicitly.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
|
||||
@@ -161,6 +161,8 @@ export interface CreateContainerRequest {
|
||||
io_read_mbps: number
|
||||
io_write_mbps: number
|
||||
extra_ports: number[]
|
||||
nat_port_mappings?: PortMapping[]
|
||||
management_port?: number
|
||||
port_mapping_count: number
|
||||
assign_nat?: boolean
|
||||
lan_ipv4_mode?: string
|
||||
|
||||
Reference in New Issue
Block a user