Compare commits

...

11 Commits

Author SHA1 Message Date
MengMengCode 8283b88ded release: v1.1.27 2026-07-24 19:00:12 +08:00
MengMengCode 6c9f24bb24 FIX port setting 2026-07-24 18:51:52 +08:00
MengMengCode f2fa2449e9 release: v1.1.26 2026-07-22 17:02:23 +08:00
MengMengCode 53d56be8f9 fix #33 2026-07-22 16:57:44 +08:00
MengMengCode ec38ab9136 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-21 21:26:09 +08:00
MengMengCode fdcd7df9e9 Update API endpoint. 2026-07-21 21:26:04 +08:00
Meng Meng d6d46296fe Merge pull request #32 from MengMengCode/dependabot/npm_and_yarn/frontend/axios-1.18.0
build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
2026-07-21 15:13:02 +08:00
dependabot[bot] 3f44c7565f build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 20:30:35 +00:00
Meng Meng 61d842d94c Fix star history image link in README.md
Updated the star history image link in the README.
2026-07-20 12:52:40 +08:00
Meng Meng ca303d33f6 Replace star history section with new embed link 2026-07-19 20:08:13 +08:00
MengMengCode 6bdeafccf2 修复剩余 CodeQL 高危告警 2026-07-18 21:31:31 +08:00
21 changed files with 1148 additions and 235 deletions
+1 -7
View File
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
## Star History ## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left"> [![MengMengCode/CLICD Star History](http://mengmeng.meteor-history.com/api/embed/MengMengCode/CLICD.svg?sig=YT8i1bxihL6_GcFAa0CWRbQb35-B0XXyh-ZAxIsmV0U&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=3)](https://meteor-history.com)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
+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"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
return return
} }
if cfg.WantsNAT() && cfg.PortMappingCount < 2 { if err := cfg.NormalizeCreateNATMappings(); err != nil {
cfg.PortMappingCount = 2 jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
} else if !cfg.WantsNAT() { return
cfg.PortMappingCount = 0 }
cfg.ExtraPorts = nil if err := lxc.ValidateCreateNATPortAvailability(cfg); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
} }
if cfg.PortMappingCount > 64 { if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 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() activeCreateNames := globalQueue.ActiveCreateNames()
requestNames := make(map[string]bool) requestNames := make(map[string]bool)
requestNATPorts := make(map[string]string)
for i := range req.Containers { for i := range req.Containers {
name := strings.TrimSpace(req.Containers[i].Name) name := strings.TrimSpace(req.Containers[i].Name)
req.Containers[i].Name = 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"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return return
} }
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 { if err := req.Containers[i].NormalizeCreateNATMappings(); err != nil {
req.Containers[i].PortMappingCount = 2 jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
} else if !req.Containers[i].WantsNAT() { return
req.Containers[i].PortMappingCount = 0 }
req.Containers[i].ExtraPorts = nil 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 { if req.Containers[i].PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 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 // AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() (int, error) { 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() allocationMu.Lock()
defer allocationMu.Unlock() defer allocationMu.Unlock()
used := collectAllHostPorts() used := collectAllHostPorts()
for _, port := range excluded {
if port > 0 {
used[port] = true
}
}
start, end := NATPortRange() start, end := NATPortRange()
port := AppConfig.NextSSHPort port := AppConfig.NextSSHPort
if port < start || port > end { 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) 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 { type savedTaskConfig struct {
Name string `json:"name"` Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"` Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"` StoragePoolID string `json:"storage_pool_id,omitempty"`
VCPU float64 `json:"vcpu"` VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"` CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"` DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"` NetworkBWMbps int `json:"network_bw_mbps"`
NetworkDownMbps int `json:"network_down_mbps"` NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"` NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"` MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"` TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"` TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"` IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"` IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"` IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` NATPortMappings []PortMapping `json:"nat_port_mappings,omitempty"`
AssignNAT *bool `json:"assign_nat,omitempty"` ManagementPort int `json:"management_port,omitempty"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"` PortMappingCount int `json:"port_mapping_count"`
LANInterface string `json:"lan_interface,omitempty"` AssignNAT *bool `json:"assign_nat,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"` LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"` LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"` LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
SnapshotLimit int `json:"snapshot_limit"` LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"` LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"` SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"` AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
IPv4Count int `json:"ipv4_count,omitempty"` ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"` AssignIPv4 bool `json:"assign_ipv4"`
AssignIPv6 bool `json:"assign_ipv6"` IPv4Count int `json:"ipv4_count,omitempty"`
IPv6Count int `json:"ipv6_count,omitempty"` PublicIPv4s []string `json:"public_ipv4s,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"` AssignIPv6 bool `json:"assign_ipv6"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"` IPv6Count int `json:"ipv6_count,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"` IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"` SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
ExpiresAt string `json:"expires_at"` SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
} }
func parseSavedTaskConfig(raw string) savedTaskConfig { func parseSavedTaskConfig(raw string) savedTaskConfig {
@@ -356,6 +358,7 @@ func ensureSchema() error {
cfg_io_speed_mbps INTEGER, cfg_io_speed_mbps INTEGER,
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0, cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
cfg_io_write_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_port_mapping_count INTEGER,
cfg_assign_nat INTEGER, cfg_assign_nat INTEGER,
cfg_lan_ipv4_mode TEXT, cfg_lan_ipv4_mode TEXT,
@@ -383,6 +386,15 @@ func ensureSchema() error {
port INTEGER NOT NULL, port INTEGER NOT NULL,
PRIMARY KEY (task_id, position) 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 ( `CREATE TABLE IF NOT EXISTS login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT, time TEXT,
@@ -433,6 +445,7 @@ func ensureSchemaMigrations() error {
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"}, {"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_io_read_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_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_management_port", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_assign_ipv4", "INTEGER"}, {"tasks", "cfg_assign_ipv4", "INTEGER"},
{"tasks", "cfg_ipv4_count", "INTEGER"}, {"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"}, {"tasks", "cfg_public_ipv4s", "TEXT"},
@@ -668,6 +681,7 @@ func saveConfigToDB() error {
"api_keys", "api_keys",
"audit_logs", "audit_logs",
"task_extra_ports", "task_extra_ports",
"task_nat_port_mappings",
"tasks", "tasks",
"login_logs", "login_logs",
"enabled_images", "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_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, 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_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_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_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 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, 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.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps, cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps, 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, cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s), boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
@@ -939,6 +953,14 @@ func saveTasksDB(tx *sql.Tx) error {
return err 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 return nil
} }
@@ -1238,7 +1260,7 @@ func loadTasks() ([]SavedTask, error) {
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps, cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, 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_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_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_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 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.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps, &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, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt, &sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
@@ -1312,6 +1334,10 @@ func loadTasks() ([]SavedTask, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
configs[i].NATPortMappings, err = loadTaskNATPortMappings(result[i].ID)
if err != nil {
return nil, err
}
result[i].Config = encodeSavedTaskConfig(configs[i]) result[i].Config = encodeSavedTaskConfig(configs[i])
} }
return result, nil return result, nil
@@ -1334,6 +1360,24 @@ func loadTaskExtraPorts(taskID string) ([]int, error) {
return result, rows.Err() 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) { func loadLoginLogs() ([]SavedLoginLog, error) {
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`) rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
if err != nil { if err != nil {
+7 -1
View File
@@ -63,7 +63,7 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
ContainerName: "ct2", ContainerName: "ct2",
Status: "pending", Status: "pending",
CreatedAt: "2026-06-07 17:29:02", 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"}, EnabledImages: []string{"debian-12"},
Snapshots: []Snapshot{{ 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]`) { 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) 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 { if cfg.TaskConcurrency != DefaultTaskConcurrency {
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency) t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
} }
+35 -63
View File
@@ -415,11 +415,8 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
} }
cfg.StoragePoolID = pool.ID cfg.StoragePoolID = pool.ID
m = NewManagerForStoragePool(pool.ID) m = NewManagerForStoragePool(pool.ID)
if cfg.WantsNAT() && cfg.PortMappingCount < 2 { if err := cfg.NormalizeCreateNATMappings(); err != nil {
cfg.PortMappingCount = 2 return err
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
} }
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -428,10 +425,19 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
cfg.AllowedImageIDs = []string{cfg.TemplateID} cfg.AllowedImageIDs = []string{cfg.TemplateID}
cfg.ImageLimitConfigured = true 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() id := config.AllocateContainerID()
vmName := fmt.Sprintf("vm-%d", id) 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 { if err != nil {
_ = m.cleanupVM(vmName) _ = m.cleanupVM(vmName)
return err return err
@@ -440,7 +446,7 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
return nil 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) image := FindImage(cfg.TemplateID)
if image == nil { if image == nil {
return nil, fmt.Errorf("KVM image not found: %s", cfg.TemplateID) 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{} portMappings := []config.PortMapping{}
if allocatePorts && cfg.WantsNAT() { if allocatePorts && cfg.WantsNAT() {
cfg.ReportProgress("nat", "分配并配置 NAT 端口") cfg.ReportProgress("nat", "分配并配置 NAT 端口")
sshPort, err = config.AllocateSSHPort() sshPort = managementPort
if err != nil { if sshPort <= 0 {
return nil, err return nil, fmt.Errorf("NAT management port was not reserved")
} }
if IsWindowsImage(image.ID) { if IsWindowsImage(image.ID) {
// Windows: RDP (3389) instead of SSH (22) // 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} tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts portMappings, err = lxc.SetupCreatePortMappings(tempC, cfg)
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { if err != nil {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) 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") 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 cfg.SSHPassword = sshAccess.Password
} }
next, err := m.defineContainer(id, name, cfg, false) next, err := m.defineContainer(id, name, cfg, false, 0)
if err != nil { if err != nil {
return err return err
} }
@@ -1724,12 +1717,12 @@ func ensureDefaultNetwork() error {
// Ensure libvirtd is running // Ensure libvirtd is running
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil { if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
// Non-systemd systems may use a different init, try virsh connect // Non-systemd systems may use a different init, try virsh connect
if exec.Command("virsh", "connect").Run() != nil { if virshCLocaleCommand("connect").Run() != nil {
return fmt.Errorf("libvirtd is not running and could not be started") return fmt.Errorf("libvirtd is not running and could not be started")
} }
} }
// Ensure default network is defined // Ensure default network is defined
if exec.Command("virsh", "net-info", "default").Run() != nil { if virshCLocaleCommand("net-info", "default").Run() != nil {
// Default network may not be defined; try to define it // Default network may not be defined; try to define it
netXML := `<network> netXML := `<network>
<name>default</name> <name>default</name>
@@ -1746,7 +1739,7 @@ func ensureDefaultNetwork() error {
return fmt.Errorf("failed to write default network XML: %v", err) return fmt.Errorf("failed to write default network XML: %v", err)
} }
defer os.Remove(tmpFile) defer os.Remove(tmpFile)
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil { if out, err := virshCLocaleCommand("net-define", tmpFile).CombinedOutput(); err != nil {
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out)) return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
} }
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil { if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
@@ -1754,19 +1747,27 @@ func ensureDefaultNetwork() error {
} }
} }
// Start and autostart the default network // Start and autostart the default network
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil { if out, err := virshCLocaleCommand("net-info", "default").Output(); err == nil {
if !libvirtNetworkActive(string(out)) { if !libvirtNetworkActive(string(out)) {
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil { if startOut, startErr := virshCLocaleCommand("net-start", "default").CombinedOutput(); startErr != nil {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut)) if verifyOut, verifyErr := virshCLocaleCommand("net-info", "default").Output(); verifyErr != nil || !libvirtNetworkActive(string(verifyOut)) {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
}
} }
} }
} }
if out, err := exec.Command("virsh", "net-autostart", "default").CombinedOutput(); err != nil { if out, err := virshCLocaleCommand("net-autostart", "default").CombinedOutput(); err != nil {
return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out)) return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out))
} }
return nil return nil
} }
func virshCLocaleCommand(args ...string) *exec.Cmd {
cmd := exec.Command("virsh", args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LC_MESSAGES=C", "LANG=C", "LANGUAGE=C")
return cmd
}
func libvirtNetworkActive(info string) bool { func libvirtNetworkActive(info string) bool {
for _, line := range strings.Split(info, "\n") { for _, line := range strings.Split(info, "\n") {
key, value, ok := strings.Cut(line, ":") key, value, ok := strings.Cut(line, ":")
@@ -4161,35 +4162,6 @@ func sshHostKeyFingerprint(key ssh.PublicKey) string {
return hex.EncodeToString(sum[:]) 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 { func runStdin(command string, stdin []byte, args ...string) error {
cmd := exec.Command(command, args...) cmd := exec.Command(command, args...)
cmd.Stdin = bytes.NewReader(stdin) cmd.Stdin = bytes.NewReader(stdin)
+29 -6
View File
@@ -3,6 +3,7 @@ package kvm
import ( import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"path/filepath"
"reflect" "reflect"
"testing" "testing"
@@ -11,14 +12,36 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
func TestLocalImageIDRejectsPathExpressions(t *testing.T) { func TestImagePathUsesAllowlistedImageID(t *testing.T) {
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute"} { for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
if got := localImageID(id); got != "__invalid_image_id__" { if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
t.Fatalf("localImageID(%q) = %q", id, got) t.Fatalf("ImagePath(%q) basename = %q", id, got)
} }
} }
if got := localImageID("debian-13-kvm"); got != "debian-13-kvm" { validID := GetImages()[0].ID
t.Fatalf("localImageID(valid) = %q", got) if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
}
}
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
tests := []struct {
name string
info string
want bool
}{
{name: "active", info: "Name: default\nActive: yes\n", want: true},
{name: "spacing and case", info: " Active : YES \r\n", want: true},
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := libvirtNetworkActive(tc.info); got != tc.want {
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
}
})
} }
} }
+5 -11
View File
@@ -4,7 +4,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"clicd/internal/config" "clicd/internal/config"
) )
@@ -193,10 +192,14 @@ func CacheDir() string {
func ImagePath(id string) string { func ImagePath(id string) string {
img := FindImage(id) img := FindImage(id)
ext := ".qcow2" ext := ".qcow2"
safeID := "__invalid_image_id__"
if img != nil {
safeID = img.ID
}
if img != nil && img.Distro == "windows" { if img != nil && img.Distro == "windows" {
ext = ".iso" ext = ".iso"
} }
fileName := localImageID(id) + ext fileName := safeID + ext
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) { for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
candidate := filepath.Join(pool.Path, "images", "kvm", fileName) candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() { if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
@@ -210,15 +213,6 @@ func ImagePath(id string) string {
return filepath.Join(CacheDir(), fileName) return filepath.Join(CacheDir(), fileName)
} }
func localImageID(id string) string {
trimmed := strings.TrimSpace(id)
local := filepath.Base(trimmed)
if trimmed == "" || local == "." || local == ".." || local != trimmed || strings.ContainsAny(trimmed, `/\\`) {
return "__invalid_image_id__"
}
return local
}
// IsWindowsImage returns true if the image distro is "windows". // IsWindowsImage returns true if the image distro is "windows".
func IsWindowsImage(id string) bool { func IsWindowsImage(id string) bool {
img := FindImage(id) img := FindImage(id)
+132 -33
View File
@@ -247,6 +247,8 @@ type ContainerConfig struct {
IOReadMBps int `json:"io_read_mbps"` IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"` IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"` 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"` PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"` AssignNAT *bool `json:"assign_nat,omitempty"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"` LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
@@ -319,6 +321,120 @@ func (cfg ContainerConfig) WantsNAT() bool {
return cfg.AssignNAT == nil || *cfg.AssignNAT 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 { func (cfg ContainerConfig) WantsLANDHCP() bool {
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP) return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP)
} }
@@ -339,11 +455,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil { if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID) return fmt.Errorf("template not found: %s", cfg.TemplateID)
} }
if cfg.WantsNAT() && cfg.PortMappingCount < 2 { if err := cfg.NormalizeCreateNATMappings(); err != nil {
cfg.PortMappingCount = 2 return err
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
} }
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -363,6 +476,15 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if err != nil { if err != nil {
return err 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 // Allocate ID and build LXC name
id := config.AllocateContainerID() id := config.AllocateContainerID()
@@ -443,40 +565,17 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
sshPassword := sshAccess.Password sshPassword := sshAccess.Password
sshPort := 0
portMappings := []config.PortMapping{} portMappings := []config.PortMapping{}
if cfg.WantsNAT() { if cfg.WantsNAT() {
sshPort, err = config.AllocateSSHPort()
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
// Setup default port mappings (SSH only) // Setup default port mappings (SSH only)
portMappings = SetupDefaultPortMappings(sshPort) portMappings = SetupDefaultPortMappings(sshPort)
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4. // 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} tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts portMappings, err = SetupCreatePortMappings(tempC, cfg)
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { if err != nil {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) _ = m.cleanupContainerStorage(lxcName)
} return err
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
} }
} }
@@ -708,7 +807,7 @@ func (m *Manager) applyLANIPv4Config(lxcName string, cfg ContainerConfig) (strin
values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway) values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway)
} }
seen := map[string]bool{} seen := map[string]bool{}
next := make([]string, 0, len(lines)+len(values)) next := make([]string, 0, len(lines))
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) { if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) {
+154
View File
@@ -6,6 +6,8 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"clicd/internal/config"
) )
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) { 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) { func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
base := t.TempDir() base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs") rootfs := filepath.Join(base, "ct-1", "rootfs")
+97
View File
@@ -6,10 +6,17 @@ import (
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"sync"
"clicd/internal/config" "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 // ApplyPortMappings applies iptables DNAT rules for a container's port mappings
func (m *Manager) ApplyPortMappings(id int) error { func (m *Manager) ApplyPortMappings(id int) error {
c := config.FindContainer(id) c := config.FindContainer(id)
@@ -509,6 +516,96 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
return pm, nil 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 { func allocateDefaultEqualPorts(c *config.Container, count int) []int {
if count <= 0 { if count <= 0 {
return nil return nil
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.25" Version = "1.1.27"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+6 -6
View File
@@ -1,17 +1,17 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"version": "1.1.19", "version": "1.1.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "clicd-frontend", "name": "clicd-frontend",
"version": "1.1.19", "version": "1.1.25",
"dependencies": { "dependencies": {
"@novnc/novnc": "1.5.0", "@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.18.0",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -957,9 +957,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.17.0", "version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.25", "version": "1.1.27",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,7 +12,7 @@
"@novnc/novnc": "1.5.0", "@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.18.0",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
+311 -45
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' 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 { 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 { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext' import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth' import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
@@ -33,6 +33,8 @@ const defaultForm: CreateContainerRequest = {
io_read_mbps: 0, io_read_mbps: 0,
io_write_mbps: 0, io_write_mbps: 0,
extra_ports: [], extra_ports: [],
nat_port_mappings: [],
management_port: 0,
port_mapping_count: 2, port_mapping_count: 2,
assign_nat: true, assign_nat: true,
lan_ipv4_mode: '', lan_ipv4_mode: '',
@@ -159,20 +161,28 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport]) const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
const defaultLANInterface = lanInterfaces[0]?.name || '' const defaultLANInterface = lanInterfaces[0]?.name || ''
const customNATPorts = form.extra_ports || [] const customNATMappings = form.nat_port_mappings || []
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0 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 linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPorts = useMemo(() => { const autoPortMappings = useMemo(() => {
if (!natEnabled) return [] if (!natEnabled) return []
const count = natPortCount 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]) }, [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 managementPort = Math.round(Number(form.management_port) || 0)
const sshPortPreview = 22000 // Automatic allocation starts around 22000; an explicit value is exact.
const sshPortPreview = managementPort || 22000
// Find next available batch index to avoid name conflicts // Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => { const batchStartIndex = useMemo(() => {
@@ -230,6 +240,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return return
} }
const natMappingError = natEnabled
? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount)
: ''
if (natMappingError) {
dialog.alert('NAT 端口配置有误', natMappingError)
return
}
const authError = validateSSHAuthInputs(form) const authError = validateSSHAuthInputs(form)
if (authError) { if (authError) {
dialog.alert('登录方式有误', authError) dialog.alert('登录方式有误', authError)
@@ -244,15 +262,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const startIndex = batchStartIndex const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) { for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name 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({ containers.push({
...boundedForm, ...boundedForm,
name, name,
assign_nat: wantsNAT, 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), snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0, 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, 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, ...form,
assign_ipv4: event.target.checked, assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [], 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" className="mt-1"
/> />
@@ -560,6 +586,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
assign_nat: checked ? false : form.assign_nat, assign_nat: checked ? false : form.assign_nat,
port_mapping_count: checked ? 0 : form.port_mapping_count, port_mapping_count: checked ? 0 : form.port_mapping_count,
extra_ports: checked ? [] : form.extra_ports, 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, assign_ipv4: checked ? false : form.assign_ipv4,
public_ipv4s: checked ? [] : form.public_ipv4s, public_ipv4s: checked ? [] : form.public_ipv4s,
ipv4_count: checked ? 0 : form.ipv4_count, ipv4_count: checked ? 0 : form.ipv4_count,
@@ -712,6 +740,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
assign_nat: checked, assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0, port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [], 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: '' } : {}), ...(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>
</span> </span>
</label> </label>
{natEnabled && customNATPorts.length === 0 && ( {natEnabled && customNATMappings.length === 0 && (
<span className="block w-24 shrink-0"> <span className="block w-24 shrink-0">
<NumberInput <NumberInput
value={natPortCount} value={natPortCount}
min={2} min={2}
max={64} 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> </span>
)} )}
</div> </div>
{natEnabled && ( {natEnabled && (
<div className="mt-2 space-y-2 pl-6"> <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"> <div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-xs text-gray-600"> <label className="flex items-center gap-2 text-xs text-gray-600">
<input <input
type="radio" type="radio"
checked={customNATPorts.length === 0} checked={customNATMappings.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })} 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>
<label className="flex items-center gap-2 text-xs text-gray-600"> <label className="flex items-center gap-2 text-xs text-gray-600">
<input <input
type="radio" type="radio"
checked={customNATPorts.length > 0} checked={customNATMappings.length > 0}
onChange={() => { onChange={() => {
const next = customNATPorts.length > 0 ? customNATPorts : [22002] const next = customNATMappings.length > 0
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true }) ? 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> </label>
</div> </div>
{customNATPorts.length > 0 && ( {customNATMappings.length > 0 && (
<textarea <div className="space-y-2">
value={customNATPorts.join('\n')} <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">
onChange={(event) => { <span>{language === 'en' ? 'Public source port' : '源端口(公网)'}</span>
const next = parsePortList(event.target.value) <span />
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true }) <span>{language === 'en' ? 'Container target port' : '目标端口(容器)'}</span>
}} <span>{language === 'en' ? 'Protocol' : '协议'}</span>
className={`${inputClass} min-h-16 font-mono text-xs`} <span />
placeholder={'22002\n8080\n8443'} </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"> <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"> <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} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22} {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
{managementPort === 0 ? (language === 'en' ? ' (auto)' : '(自动)') : ''}
</span> </span>
{natPreviewPorts.map((port, index) => ( {natPreviewMappings.map((mapping, index) => (
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono"> <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">
{port} -&gt; {port} {mapping.host_port} -&gt; {mapping.container_port}/{mapping.protocol.toUpperCase()}
</span> </span>
))} ))}
</div> </div>
@@ -1001,8 +1157,19 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
const wantsIPv6 = !!normalized.assign_ipv6 const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive // IPv4 and NAT are mutually exclusive
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : [] const legacyMappings = normalizePortList(normalized.extra_ports || []).map((port) => ({
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0 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 linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password' const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return { return {
@@ -1012,7 +1179,9 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
disk_gb: Math.round(normalized.disk_gb), disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT, assign_nat: wantsNAT,
port_mapping_count: portMappingCount, port_mapping_count: portMappingCount,
extra_ports: extraPorts, extra_ports: [],
nat_port_mappings: natPortMappings,
management_port: managementPort,
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''), lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '', lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').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) 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[]) { function normalizePortList(ports: number[]) {
const seen = new Set<number>() const seen = new Set<number>()
const result: number[] = [] const result: number[] = []
@@ -1097,6 +1258,111 @@ function normalizePortList(ports: number[]) {
return result 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) { function isIPv4Address(value: string) {
const parts = value.trim().split('.') const parts = value.trim().split('.')
return parts.length === 4 && parts.every((part) => { return parts.length === 4 && parts.every((part) => {
+207 -3
View File
@@ -81,7 +81,7 @@ const scopeGroups = [
['container:delete', '删除容器'], ['container:delete', '删除容器'],
['container:resize', '资源/到期'], ['container:resize', '资源/到期'],
['container:traffic', '流量管理'], ['container:traffic', '流量管理'],
['container:network', '端口映射'], ['container:network', '网络与端口映射'],
['container:password', '重置密码'], ['container:password', '重置密码'],
['ipv6:assign', '分配 IPv6'], ['ipv6:assign', '分配 IPv6'],
], ],
@@ -140,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
endpoints: [ endpoints: [
['GET', '/api/v1/dashboard', '控制面板统计'], ['GET', '/api/v1/dashboard', '控制面板统计'],
['GET', '/api/v1/host-info', '主机资源'], ['GET', '/api/v1/host-info', '主机资源'],
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
['GET', '/api/v1/host-report', '宿主机硬件、网络与运行环境探测报告'],
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'], ['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'], ['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'], ['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
@@ -161,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/containers/{id}/reinstall', '重装'], ['POST', '/api/v1/containers/{id}/reinstall', '重装'],
['DELETE', '/api/v1/containers/{id}/delete', '删除'], ['DELETE', '/api/v1/containers/{id}/delete', '删除'],
['GET', '/api/v1/containers/{id}/usage', '资源用量'], ['GET', '/api/v1/containers/{id}/usage', '资源用量'],
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
['GET', '/api/v1/containers/{id}/traffic', '流量统计'], ['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'], ['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'], ['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
@@ -168,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'], ['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'], ['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'], ['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
], ],
}, },
{ {
@@ -193,6 +198,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
endpoints: [ endpoints: [
['GET', '/api/v1/templates', '模板列表'], ['GET', '/api/v1/templates', '模板列表'],
['GET', '/api/v1/images', '镜像管理列表'], ['GET', '/api/v1/images', '镜像管理列表'],
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
['POST', '/api/v1/images/download', '下载镜像'], ['POST', '/api/v1/images/download', '下载镜像'],
['POST', '/api/v1/images/cancel', '取消镜像下载'], ['POST', '/api/v1/images/cancel', '取消镜像下载'],
['DELETE', '/api/v1/images/delete', '删除镜像缓存'], ['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
@@ -211,6 +217,21 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'], ['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
], ],
}, },
{
title: '主机与设置',
endpoints: [
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
['GET', '/api/v1/language', '面板语言'],
['PUT', '/api/v1/language', '更新面板语言'],
],
},
{ {
title: '账号与日志', title: '账号与日志',
endpoints: [ endpoints: [
@@ -728,6 +749,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
name: 'demo-lxc-01', name: 'demo-lxc-01',
virtualization: 'lxc', virtualization: 'lxc',
template_id: 'debian-bookworm', template_id: 'debian-bookworm',
storage_pool_id: 'disk-root',
vcpu: 1, vcpu: 1,
ram_mb: 512, ram_mb: 512,
disk_gb: 10, disk_gb: 10,
@@ -741,10 +763,26 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
io_speed_mbps: 0, io_speed_mbps: 0,
io_read_mbps: 80, io_read_mbps: 80,
io_write_mbps: 30, 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, port_mapping_count: 2,
assign_nat: true, assign_nat: true,
lan_ipv4_mode: '',
lan_interface: '',
lan_ipv4_address: '',
lan_ipv4_prefix_len: 24,
lan_ipv4_gateway: '',
snapshot_limit: 1, snapshot_limit: 1,
allowed_image_ids: ['debian-bookworm'],
image_limit_configured: true,
assign_ipv4: false, assign_ipv4: false,
ipv4_count: 1, ipv4_count: 1,
public_ipv4s: [], public_ipv4s: [],
@@ -780,6 +818,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
}, },
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' }, 'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' }, 'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
'PUT /api/v1/containers/{id}/public-ipv4': {
mode: 'random',
count: 1,
},
'PUT /api/v1/containers/{id}/ipv6-addresses': {
mode: 'custom',
addresses: ['2001:db8:100::1005'],
},
'POST /api/v1/containers/{id}/port-mappings': { 'POST /api/v1/containers/{id}/port-mappings': {
container_port: 8080, container_port: 8080,
host_port: 61320, host_port: 61320,
@@ -792,6 +838,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
protocol: 'tcp', protocol: 'tcp',
description: 'HTTP', description: 'HTTP',
}, },
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
'POST /api/v1/containers/{id}/snapshots/schedule': { 'POST /api/v1/containers/{id}/snapshots/schedule': {
enabled: true, enabled: true,
interval_hours: 24, interval_hours: 24,
@@ -802,6 +849,31 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' }, 'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' }, 'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true }, 'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
'PUT /api/v1/storage': {
pools: [
{
id: 'disk-root',
name: 'system (/)',
path: '/var/lib/clicd',
mount_point: '/',
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
enabled: true,
},
],
},
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
'PUT /api/v1/ssl': {
enabled: true,
mode: 'letsencrypt',
target: 'panel.example.com',
email: 'admin@example.com',
apply_now: false,
},
'PUT /api/v1/webssh-origins': {
origins: ['https://panel.example.com'],
},
'PUT /api/v1/language': { language: 'zh' },
'PUT /api/v1/routing': { 'PUT /api/v1/routing': {
items: [ items: [
{ {
@@ -850,7 +922,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
ram_mb: 512, ram_mb: 512,
disk_gb: 10, disk_gb: 10,
assign_nat: true, assign_nat: true,
management_port: 30022,
port_mapping_count: 2, port_mapping_count: 2,
nat_port_mappings: [
{
host_port: 30080,
container_port: 80,
protocol: 'tcp',
description: 'HTTP',
},
],
snapshot_limit: 1, snapshot_limit: 1,
assign_ipv4: false, assign_ipv4: false,
ipv4_count: 1, ipv4_count: 1,
@@ -910,6 +991,37 @@ const responseSamples: Record<string, unknown> = {
load: { load1: 0.01, load5: 0.03, load15: 0.01 }, load: { load1: 0.01, load5: 0.03, load15: 0.01 },
}, },
}, },
'GET /api/v1/host-history': {
success: true,
data: [
{
ts: 1784642400000,
cpu: 8.4,
memory: 21.3,
network: 12288,
network_rx: 10240,
network_tx: 2048,
disk_io: 1052672,
disk_read: 4096,
disk_write: 1048576,
disk_usage_pct: 18.8,
},
],
},
'GET /api/v1/host-report': {
success: true,
data: {
generated_at: '2026-07-21 14:00:00',
hostname: 'ubuntu',
os: 'Ubuntu 22.04.5 LTS',
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
ipv6_prefixes: [],
},
},
'GET /api/v1/routing': { 'GET /api/v1/routing': {
success: true, success: true,
data: { data: {
@@ -1016,6 +1128,12 @@ const responseSamples: Record<string, unknown> = {
load15: 0.01, load15: 0.01,
}, },
}, },
'GET /api/v1/containers/{id}/history': {
success: true,
data: [
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
],
},
'GET /api/v1/containers/{id}/traffic': { 'GET /api/v1/containers/{id}/traffic': {
success: true, success: true,
data: { data: {
@@ -1036,6 +1154,16 @@ const responseSamples: Record<string, unknown> = {
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' }, 'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } }, 'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } }, 'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
'PUT /api/v1/containers/{id}/public-ipv4': {
success: true,
message: 'Public IPv4 assignments updated',
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
},
'PUT /api/v1/containers/{id}/ipv6-addresses': {
success: true,
message: 'IPv6 assignments updated',
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
},
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } }, 'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
'POST /api/v1/containers/{id}/port-mappings': { 'POST /api/v1/containers/{id}/port-mappings': {
success: true, success: true,
@@ -1100,10 +1228,57 @@ const responseSamples: Record<string, unknown> = {
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 }, { id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
], ],
}, },
'GET /api/v1/images/enabled?type=lxc&container={id}': {
success: true,
data: [
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
],
},
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' }, 'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' }, 'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' }, 'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
'PUT /api/v1/images/toggle': { success: true, message: 'OK' }, 'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
'GET /api/v1/storage': {
success: true,
data: {
pools: [
{
id: 'disk-root',
name: 'system (/)',
path: '/var/lib/clicd',
mount_point: '/',
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
enabled: true,
available: true,
free_bytes: 54653493248,
},
],
disks: [
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
],
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
},
},
'PUT /api/v1/storage': {
success: true,
data: {
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
disks: [],
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
},
},
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
'GET /api/v1/ssl': {
success: true,
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
},
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
'GET /api/v1/security/alerts': { success: true, data: [] }, 'GET /api/v1/security/alerts': { success: true, data: [] },
'POST /api/v1/security/check': { success: true, message: 'Security check completed' }, 'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
'GET /api/v1/security/logs?container={name}': { success: true, data: [] }, 'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
@@ -1181,13 +1356,18 @@ function endpointNoteFor(key: string) {
const notes: string[] = [] const notes: string[] = []
if (key === 'POST /api/v1/containers') { 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('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('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.')
} }
if (key === 'POST /api/v1/containers/{id}/reinstall') { if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.') notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
} }
if (key === 'POST /api/v1/batch-create') { if (key === 'POST /api/v1/batch-create') {
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.') 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') { 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.') 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.')
@@ -1204,6 +1384,30 @@ function endpointNoteFor(key: string) {
if (key === 'POST /api/v1/routing/ipv4-scan') { if (key === 'POST /api/v1/routing/ipv4-scan') {
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.') notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
} }
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
}
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
}
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
}
if (key === 'POST /api/v1/containers/{id}/snapshots') {
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
}
if (key === 'PUT /api/v1/storage') {
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
}
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) {
notes.push('This endpoint requires an API key with admin:access.')
}
if (key === 'PUT /api/v1/task-queue/settings') {
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
}
if (key === 'PUT /api/v1/ssl') {
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
}
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".') if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.') if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.') if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.25</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.27</p>
</div> </div>
</div> </div>
) )
+2
View File
@@ -161,6 +161,8 @@ export interface CreateContainerRequest {
io_read_mbps: number io_read_mbps: number
io_write_mbps: number io_write_mbps: number
extra_ports: number[] extra_ports: number[]
nat_port_mappings?: PortMapping[]
management_port?: number
port_mapping_count: number port_mapping_count: number
assign_nat?: boolean assign_nat?: boolean
lan_ipv4_mode?: string lan_ipv4_mode?: string
+10 -2
View File
@@ -1326,7 +1326,9 @@ setup_runtime_services() {
libvirt_network_active() { libvirt_network_active() {
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes LC_ALL=C LANG=C virsh net-info default 2>/dev/null \
| awk -F: '$1 ~ /^[[:space:]]*Active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' \
| grep -qx yes
} }
setup_default_libvirt_network() { setup_default_libvirt_network() {
@@ -1355,7 +1357,13 @@ EOF
touch "$LIBVIRT_DEFAULT_MARKER" touch "$LIBVIRT_DEFAULT_MARKER"
fi fi
if ! libvirt_network_active; then if ! libvirt_network_active; then
virsh net-start default if ! start_output="$(LC_ALL=C LANG=C virsh net-start default 2>&1)"; then
# Another process may have activated the network after our check.
if ! libvirt_network_active; then
printf '%s\n' "$start_output" >&2
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
fi
fi
fi fi
virsh net-autostart default >/dev/null virsh net-autostart default >/dev/null
if ! libvirt_network_active; then if ! libvirt_network_active; then