FIX port setting

This commit is contained in:
MengMengCode
2026-07-24 18:51:52 +08:00
parent f2fa2449e9
commit 6c9f24bb24
13 changed files with 892 additions and 189 deletions
+7 -5
View File
@@ -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"})
+24 -5
View File
@@ -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"})
+11
View File
@@ -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 {
+18
View File
@@ -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)
}
}
+88 -44
View File
@@ -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 {
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+154
View File
@@ -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")
+97
View File
@@ -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