diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 88610d1..58b87eb 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -228,13 +228,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) { if cfg.DiskGB < 1 { cfg.DiskGB = 5 } - if cfg.PortMappingCount < 2 { + if cfg.PortMappingCount < 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"}) + return + } + if cfg.WantsNAT() && cfg.PortMappingCount < 2 { cfg.PortMappingCount = 2 + } else if !cfg.WantsNAT() { + cfg.PortMappingCount = 0 + cfg.ExtraPorts = nil } if cfg.PortMappingCount > 64 { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"}) return } + if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"}) + return + } + if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"}) + return + } + if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 { + cfg.IPv4Count = 0 + } + if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 { + cfg.IPv6Count = 0 + } + if !hasRequestedNetwork(cfg) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage}) + return + } if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit } @@ -405,24 +430,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } - // Find a random unused port between 10000-65535 - used := map[int]bool{} - for _, pm := range c.PortMappings { - used[pm.HostPort] = true - } - // Also check all containers - for _, oc := range config.AppConfig.Containers { - if oc.ID == id { - continue - } - for _, pm := range oc.PortMappings { - used[pm.HostPort] = true - } - } + hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip")) // Try random ports for tries := 0; tries < 100; tries++ { port := 10000 + (int(time.Now().UnixNano()) % 55535) - if !used[port] { + if lxc.HostPortAvailable(c, hostIP, port, "tcp") { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}}) return } diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index 1f57cf7..430f647 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -85,6 +85,7 @@ type HostDiskProbe struct { Serial string `json:"serial"` SizeBytes uint64 `json:"size_bytes"` Type string `json:"type"` + Virtual bool `json:"virtual"` Rotational bool `json:"rotational"` Mountpoints []string `json:"mountpoints"` Health string `json:"health"` @@ -201,6 +202,7 @@ type NetworkInfo struct { TXBps float64 `json:"tx_bps"` PublicIPv4 string `json:"public_ipv4"` PublicIPv4Interface string `json:"public_ipv4_interface"` + PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"` PublicIPv6 string `json:"public_ipv6"` PublicIPv6Interface string `json:"public_ipv6_interface"` IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` @@ -398,7 +400,8 @@ func getHostRates() (NetworkInfo, DiskIOInfo) { publicIPv4 := lxc.DetectPublicIPv4() network.PublicIPv4 = publicIPv4.Address network.PublicIPv4Interface = publicIPv4.Interface - network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes() + network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) + network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes() if len(network.IPv6Prefixes) > 0 { network.PublicIPv6 = network.IPv6Prefixes[0].Address network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface @@ -540,7 +543,7 @@ func getHostProbeReport() HostProbeReport { Disks: detectHostDisks(), NetworkInterfaces: detectHostNICs(), PublicIPv4: detectAllPublicIPv4(), - IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(), + IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(), Gateways: detectGateways(), GPUs: detectGPUs(), System: detectSystemProbe(), @@ -676,17 +679,23 @@ func detectHostDisks() []HostDiskProbe { } base := filepath.Join("/sys/block", name) path := "/dev/" + name + model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))) + vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor"))) + virtual := isVirtualBlockDevice(name, model, vendor) disk := HostDiskProbe{ Name: name, Path: path, - Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))), + Model: model, Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))), SizeBytes: readUintFile(filepath.Join(base, "size")) * 512, - Type: detectDiskType(base, name), + Type: detectDiskType(base, name, virtual), + Virtual: virtual, Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1", Mountpoints: mounts[name], } - disk.SMART = detectDiskSMART(path) + if !virtual { + disk.SMART = detectDiskSMART(path) + } disk.Health = disk.SMARTHealth() disk.HealthDetail = disk.SMARTDetail() disks = append(disks, disk) @@ -695,7 +704,10 @@ func detectHostDisks() []HostDiskProbe { return disks } -func detectDiskType(base, name string) string { +func detectDiskType(base, name string, virtual bool) string { + if virtual { + return "Virtual" + } if strings.HasPrefix(name, "nvme") { return "NVMe" } @@ -705,7 +717,26 @@ func detectDiskType(base, name string) string { return "SSD" } +func isVirtualBlockDevice(name, model, vendor string) bool { + lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor)) + if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") { + return true + } + for _, token := range []string{ + "qemu", "virtio", "virtual", "vmware", "vbox", "xen", + "amazon elastic block store", "google persistentdisk", "microsoft", + } { + if strings.Contains(lower, token) { + return true + } + } + return false +} + func (disk HostDiskProbe) SMARTHealth() string { + if disk.Virtual { + return "virtual" + } if disk.SMART.Available && disk.Health != "" { return disk.Health } @@ -713,6 +744,9 @@ func (disk HostDiskProbe) SMARTHealth() string { } func (disk HostDiskProbe) SMARTDetail() string { + if disk.Virtual { + return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看" + } return disk.SMART.Detail() } @@ -1437,7 +1471,7 @@ func commandCheck(key, label string, required bool, cmd string, fallback string) ok := commandExists(cmd) detail := "missing" if ok { - detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1")) + detail = commandVersionDetail(cmd) if detail == "" { detail = "installed" } @@ -1447,6 +1481,15 @@ func commandCheck(key, label string, required bool, cmd string, fallback string) return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail} } +func commandVersionDetail(cmd string) string { + switch cmd { + case "ip": + return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1")) + default: + return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1")) + } +} + func certbotCheck() HostEnvCheck { check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"} if !commandExists("certbot") { diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go index ddccab1..88dc72d 100644 --- a/backend/internal/api/routing.go +++ b/backend/internal/api/routing.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "net/netip" "sort" "strconv" @@ -21,12 +23,24 @@ type nat4Route struct { LXCName string `json:"lxc_name"` Status string `json:"status"` IP string `json:"ip"` + HostIP string `json:"host_ip"` HostPort int `json:"host_port"` ContainerPort int `json:"container_port"` Protocol string `json:"protocol"` Description string `json:"description"` } +type ipv4Route struct { + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + LXCName string `json:"lxc_name"` + Status string `json:"status"` + Address string `json:"address"` + Interface string `json:"interface"` + PrefixLen int `json:"prefix_len,omitempty"` + Gateway string `json:"gateway,omitempty"` +} + type ipv6Route struct { ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` @@ -38,30 +52,78 @@ type ipv6Route struct { } type routingResponse struct { - NAT4 routeCapacity `json:"nat4"` - IPv6 routeCapacity `json:"ipv6"` - NAT4Mappings []nat4Route `json:"nat4_mappings"` - IPv6Assignments []ipv6Route `json:"ipv6_assignments"` - IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` + NAT4 routeCapacity `json:"nat4"` + IPv4 routeCapacity `json:"ipv4"` + IPv6 routeCapacity `json:"ipv6"` + HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"` + PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"` + IPv4Assignments []ipv4Route `json:"ipv4_assignments"` + NAT4Mappings []nat4Route `json:"nat4_mappings"` + IPv6Assignments []ipv6Route `json:"ipv6_assignments"` + IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` +} + +type routingPoolsRequest struct { + Addresses *[]string `json:"addresses"` + Items *[]config.PublicIPv4Assignment `json:"items"` + IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"` +} + +type publicIPv4ScanRequest struct { + CIDR string `json:"cidr"` + Interface string `json:"interface"` + Gateway string `json:"gateway"` + Verify bool `json:"verify"` + Limit int `json:"limit"` } func HandleRouting(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + switch r.Method { + case http.MethodGet: + handleRoutingGet(w, r) + case http.MethodPut: + handleRoutingPoolsUpdate(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "routing:write") { + return + } + var req publicIPv4ScanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results}) +} + +func handleRoutingGet(w http.ResponseWriter, r *http.Request) { if !requireScope(w, r, "routing:read") { return } nat4Mappings := make([]nat4Route, 0) usedPorts := map[int]bool{} + ipv4Assignments := make([]ipv4Route, 0) ipv6Assignments := make([]ipv6Route, 0) const nat4StartPort = 20000 const nat4EndPort = 65535 - for _, c := range config.AppConfig.Containers { + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] for _, pm := range c.PortMappings { if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort { usedPorts[pm.HostPort] = true @@ -72,30 +134,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) { LXCName: c.LxcName(), Status: c.Status, IP: c.IP, + HostIP: pm.HostIP, HostPort: pm.HostPort, ContainerPort: pm.ContainerPort, Protocol: pm.Protocol, Description: pm.Description, }) } - if c.IPv6 != "" { + for _, ip := range c.PublicIPv4s { + if ip.Address == "" { + continue + } + ipv4Assignments = append(ipv4Assignments, ipv4Route{ + ContainerID: c.ID, + ContainerName: c.Name, + LXCName: c.LxcName(), + Status: c.Status, + Address: ip.Address, + Interface: ip.Interface, + PrefixLen: ip.PrefixLen, + Gateway: ip.Gateway, + }) + } + c.NormalizeNetworkAssignments() + for _, ip := range c.IPv6Addresses { + if ip.Address == "" { + continue + } ipv6Assignments = append(ipv6Assignments, ipv6Route{ ContainerID: c.ID, ContainerName: c.Name, LXCName: c.LxcName(), Status: c.Status, - Address: c.IPv6, - PrefixLen: c.IPv6PrefixLen, - Interface: c.IPv6Interface, + Address: ip.Address, + PrefixLen: ip.PrefixLen, + Interface: ip.Interface, }) } } sort.SliceStable(nat4Mappings, func(i, j int) bool { if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort { + if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP { + return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP + } return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName } return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort }) + sort.SliceStable(ipv4Assignments, func(i, j int) bool { + return ipv4Assignments[i].Address < ipv4Assignments[j].Address + }) sort.SliceStable(ipv6Assignments, func(i, j int) bool { return ipv6Assignments[i].Address < ipv6Assignments[j].Address }) @@ -108,12 +196,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) { } prefixes := lxc.DetectPublicIPv6Prefixes() - ipv6Total := "0" - ipv6Remaining := "0" - if len(prefixes) > 0 { - ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen) - ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments)) + hostPublicIPv4 := lxc.DetectPublicIPv4() + publicIPv4s := lxc.DetectPublicIPv4Candidates() + ipv4Total := len(publicIPv4s) + ipv4Used := len(ipv4Assignments) + ipv4Remaining := ipv4Total - ipv4Used + if ipv4Remaining < 0 { + ipv4Remaining = 0 } + ipv6Total := totalIPv6Capacity(prefixes) + ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments)) jsonResponse(w, http.StatusOK, APIResponse{ Success: true, @@ -123,18 +215,143 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) { Remaining: strconv.Itoa(nat4Remaining), Total: strconv.Itoa(totalNAT4Ports), }, + IPv4: routeCapacity{ + Used: ipv4Used, + Remaining: strconv.Itoa(ipv4Remaining), + Total: strconv.Itoa(ipv4Total), + }, IPv6: routeCapacity{ Used: len(ipv6Assignments), Remaining: ipv6Remaining, Total: ipv6Total, }, - NAT4Mappings: nat4Mappings, - IPv6Assignments: ipv6Assignments, - IPv6Prefixes: prefixes, + HostPublicIPv4: hostPublicIPv4, + PublicIPv4Addresses: publicIPv4s, + IPv4Assignments: ipv4Assignments, + NAT4Mappings: nat4Mappings, + IPv6Assignments: ipv6Assignments, + IPv6Prefixes: prefixes, }, }) } +func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) { + if !requireScope(w, r, "routing:write") { + return + } + var req routingPoolsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + if req.Items != nil || req.Addresses != nil { + items := []config.PublicIPv4Assignment{} + if req.Items != nil { + items = *req.Items + } else if req.Addresses != nil { + items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses)) + for _, address := range *req.Addresses { + items = append(items, config.PublicIPv4Assignment{Address: address}) + } + } + normalized, err := lxc.NormalizePublicIPv4Pool(items) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + allowed := map[string]bool{} + for _, item := range normalized { + allowed[item.Address] = true + } + for _, c := range config.AppConfig.Containers { + for _, item := range c.PublicIPv4s { + if item.Address != "" && !allowed[item.Address] { + jsonResponse(w, http.StatusBadRequest, APIResponse{ + Success: false, + Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool", + }) + return + } + } + } + config.AppConfig.PublicIPv4Pool = normalized + } + + if req.IPv6Prefixes != nil { + normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + parsedPrefixes := make([]netip.Prefix, 0, len(normalized)) + for _, item := range normalized { + prefix, err := netip.ParsePrefix(item.Prefix) + if err == nil { + parsedPrefixes = append(parsedPrefixes, prefix) + } + } + for _, c := range config.AppConfig.Containers { + c.NormalizeNetworkAssignments() + for _, item := range c.IPv6Addresses { + if item.Address == "" { + continue + } + addr, err := netip.ParseAddr(item.Address) + if err != nil { + continue + } + contained := false + for _, prefix := range parsedPrefixes { + if prefix.Contains(addr) { + contained = true + break + } + } + if !contained { + jsonResponse(w, http.StatusBadRequest, APIResponse{ + Success: false, + Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool", + }) + return + } + } + } + config.AppConfig.PublicIPv6Prefixes = normalized + } + + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"}) + return + } + handleRoutingGet(w, r) +} + +func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string { + if len(prefixes) == 0 { + return "0" + } + var total uint64 + for _, prefix := range prefixes { + capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen) + if capacity == "large" { + return "large" + } + parsed, err := strconv.ParseUint(capacity, 10, 64) + if err != nil { + continue + } + if ^uint64(0)-total < parsed { + return "large" + } + total += parsed + } + if total == 0 { + return "0" + } + return strconv.FormatUint(total, 10) +} + func subtractCapacity(total string, used int) string { if total == "" || total == "0" { return "0" diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go index 9b6a63f..13a0727 100644 --- a/backend/internal/api/runtime.go +++ b/backend/internal/api/runtime.go @@ -13,10 +13,16 @@ import ( var kvmManager = kvm.NewManager() +const noNetworkSelectedMessage = "请勾选任意一个可用网络" + func runtimeFromRequest(value string) string { return config.NormalizeVirtualization(value) } +func hasRequestedNetwork(cfg lxc.ContainerConfig) bool { + return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 +} + func runtimeFromTemplateID(templateID string) string { if kvm.FindImage(templateID) != nil { return config.VirtualizationKVM diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index fc5e086..9420c80 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -575,8 +575,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) return } - if req.Containers[i].PortMappingCount < 2 { + if req.Containers[i].PortMappingCount < 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"}) + return + } + if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 { req.Containers[i].PortMappingCount = 2 + } else if !req.Containers[i].WantsNAT() { + req.Containers[i].PortMappingCount = 0 + req.Containers[i].ExtraPorts = nil + } + if req.Containers[i].PortMappingCount > 64 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"}) + return + } + if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"}) + return + } + if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"}) + return + } + if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 { + req.Containers[i].IPv4Count = 0 + } + if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 { + req.Containers[i].IPv6Count = 0 + } + if !hasRequestedNetwork(req.Containers[i]) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage}) + return } if req.Containers[i].SnapshotLimit <= 0 { req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3130f8b..a14bdc4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,10 +17,32 @@ import ( type PortMapping struct { ContainerPort int `json:"container_port"` HostPort int `json:"host_port"` + HostIP string `json:"host_ip,omitempty"` Protocol string `json:"protocol"` Description string `json:"description"` } +type PublicIPv4Assignment struct { + Address string `json:"address"` + Interface string `json:"interface,omitempty"` + PrefixLen int `json:"prefix_len,omitempty"` + Gateway string `json:"gateway,omitempty"` +} + +type IPv6Assignment struct { + Address string `json:"address"` + PrefixLen int `json:"prefix_len"` + Interface string `json:"interface,omitempty"` +} + +type PublicIPv6Prefix struct { + Address string `json:"address"` + Prefix string `json:"prefix,omitempty"` + PrefixLen int `json:"prefix_len"` + Interface string `json:"interface,omitempty"` + Gateway string `json:"gateway,omitempty"` +} + // SavedTask for persisting task queue across restarts type SavedTask struct { ID string `json:"id"` @@ -68,50 +90,52 @@ type VMReadinessCheck struct { // Container represents an LXC container configuration type Container struct { - ID int `json:"id"` - UUID string `json:"uuid"` - Name string `json:"name"` - Virtualization string `json:"virtualization,omitempty"` - LXCName string `json:"lxc_name,omitempty"` - KVMName string `json:"kvm_name,omitempty"` - DiskImage string `json:"disk_image,omitempty"` - MACAddress string `json:"mac_address,omitempty"` - Template string `json:"template"` - VCPU float64 `json:"vcpu"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` // "total" or "in_out" - TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited - TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited - TrafficUsedRX int64 `json:"traffic_used_rx"` - TrafficUsedTX int64 `json:"traffic_used_tx"` - TrafficResetDate string `json:"traffic_reset_date"` - IOSpeedMBps int `json:"io_speed_mbps"` - Status string `json:"status"` - IP string `json:"ip"` - IPv6 string `json:"ipv6"` - IPv6PrefixLen int `json:"ipv6_prefix_len"` - IPv6Interface string `json:"ipv6_interface"` - VNCPort int `json:"vnc_port"` - SSHPort int `json:"ssh_port"` - SSHPassword string `json:"ssh_password"` - SSHHostKey string `json:"ssh_host_key,omitempty"` - PortMappings []PortMapping `json:"port_mappings"` - PortMappingLimit int `json:"port_mapping_limit"` - SnapshotLimit int `json:"snapshot_limit"` - CreatedAt string `json:"created_at"` - ExpiresAt string `json:"expires_at"` - SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"` - SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"` - SnapshotScheduleTime string `json:"snapshot_schedule_time"` - SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` - SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` - SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` - PolicyBlocked bool `json:"policy_blocked"` - PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"` - PolicyBlockedAt string `json:"policy_blocked_at,omitempty"` + ID int `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + LXCName string `json:"lxc_name,omitempty"` + KVMName string `json:"kvm_name,omitempty"` + DiskImage string `json:"disk_image,omitempty"` + MACAddress string `json:"mac_address,omitempty"` + Template string `json:"template"` + VCPU float64 `json:"vcpu"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited + TrafficUsedRX int64 `json:"traffic_used_rx"` + TrafficUsedTX int64 `json:"traffic_used_tx"` + TrafficResetDate string `json:"traffic_reset_date"` + IOSpeedMBps int `json:"io_speed_mbps"` + Status string `json:"status"` + IP string `json:"ip"` + PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"` + IPv6 string `json:"ipv6"` + IPv6PrefixLen int `json:"ipv6_prefix_len"` + IPv6Interface string `json:"ipv6_interface"` + IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"` + VNCPort int `json:"vnc_port"` + SSHPort int `json:"ssh_port"` + SSHPassword string `json:"ssh_password"` + SSHHostKey string `json:"ssh_host_key,omitempty"` + PortMappings []PortMapping `json:"port_mappings"` + PortMappingLimit int `json:"port_mapping_limit"` + SnapshotLimit int `json:"snapshot_limit"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"` + SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"` + SnapshotScheduleTime string `json:"snapshot_schedule_time"` + SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` + SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` + SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` + PolicyBlocked bool `json:"policy_blocked"` + PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"` + PolicyBlockedAt string `json:"policy_blocked_at,omitempty"` } const ( @@ -136,6 +160,101 @@ func (c *Container) IsKVM() bool { return c.Runtime() == VirtualizationKVM } +func (c *Container) NormalizeNetworkAssignments() bool { + changed := false + seenIPv4 := map[string]bool{} + filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s)) + for _, item := range c.PublicIPv4s { + item.Address = strings.TrimSpace(item.Address) + item.Interface = strings.TrimSpace(item.Interface) + item.Gateway = strings.TrimSpace(item.Gateway) + if item.Address == "" || seenIPv4[item.Address] { + if item.Address != "" { + changed = true + } + continue + } + seenIPv4[item.Address] = true + filteredIPv4 = append(filteredIPv4, item) + } + if len(filteredIPv4) != len(c.PublicIPv4s) { + changed = true + } + c.PublicIPv4s = filteredIPv4 + + seenIPv6 := map[string]bool{} + filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1) + for _, item := range c.IPv6Addresses { + item.Address = strings.TrimSpace(item.Address) + item.Interface = strings.TrimSpace(item.Interface) + if item.Address == "" || seenIPv6[item.Address] { + if item.Address != "" { + changed = true + } + continue + } + seenIPv6[item.Address] = true + filteredIPv6 = append(filteredIPv6, item) + } + if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] { + filteredIPv6 = append([]IPv6Assignment{{ + Address: c.IPv6, + PrefixLen: c.IPv6PrefixLen, + Interface: c.IPv6Interface, + }}, filteredIPv6...) + changed = true + } + if len(filteredIPv6) != len(c.IPv6Addresses) { + changed = true + } + c.IPv6Addresses = filteredIPv6 + if len(c.IPv6Addresses) > 0 { + first := c.IPv6Addresses[0] + if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface { + c.IPv6 = first.Address + c.IPv6PrefixLen = first.PrefixLen + c.IPv6Interface = first.Interface + changed = true + } + } else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" { + c.IPv6 = "" + c.IPv6PrefixLen = 0 + c.IPv6Interface = "" + changed = true + } + return changed +} + +func (c *Container) PublicIPv4Addresses() []string { + values := make([]string, 0, len(c.PublicIPv4s)) + for _, item := range c.PublicIPv4s { + if item.Address != "" { + values = append(values, item.Address) + } + } + return values +} + +func (c *Container) PrimaryPublicIPv4() string { + if len(c.PublicIPv4s) == 0 { + return "" + } + return c.PublicIPv4s[0].Address +} + +func (c *Container) IPv6AddressStrings() []string { + values := make([]string, 0, len(c.IPv6Addresses)) + for _, item := range c.IPv6Addresses { + if item.Address != "" { + values = append(values, item.Address) + } + } + if len(values) == 0 && c.IPv6 != "" { + values = append(values, c.IPv6) + } + return values +} + // LxcName returns the internal LXC container name (ct-{id}) func (c *Container) LxcName() string { if c.LXCName != "" { @@ -225,27 +344,29 @@ type SSLConfig struct { // ClicdConfig is the main configuration structure type ClicdConfig struct { - AdminUser string `json:"admin_user"` - AdminPassHash string `json:"admin_pass_hash"` - JWTSecret string `json:"jwt_secret"` - Port int `json:"port"` - DataDir string `json:"data_dir"` - Containers []Container `json:"containers"` - NextContainerID int `json:"next_container_id"` - NextVNCPort int `json:"next_vnc_port"` - NextSSHPort int `json:"next_ssh_port"` - SetupComplete bool `json:"setup_complete"` - SubUsers []SubUser `json:"sub_users"` - ApiKeys []ApiKeyConfig `json:"api_keys"` - AuditLogs []AuditLog `json:"audit_logs"` - Tasks []SavedTask `json:"tasks"` - LoginLogs []SavedLoginLog `json:"login_logs"` - EnabledImages []string `json:"enabled_images"` - Snapshots []Snapshot `json:"snapshots"` - SecurityAutoShutdown bool `json:"security_auto_shutdown"` - Language string `json:"language"` - SSL SSLConfig `json:"ssl"` - SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` + AdminUser string `json:"admin_user"` + AdminPassHash string `json:"admin_pass_hash"` + JWTSecret string `json:"jwt_secret"` + Port int `json:"port"` + DataDir string `json:"data_dir"` + Containers []Container `json:"containers"` + NextContainerID int `json:"next_container_id"` + NextVNCPort int `json:"next_vnc_port"` + NextSSHPort int `json:"next_ssh_port"` + SetupComplete bool `json:"setup_complete"` + SubUsers []SubUser `json:"sub_users"` + ApiKeys []ApiKeyConfig `json:"api_keys"` + AuditLogs []AuditLog `json:"audit_logs"` + Tasks []SavedTask `json:"tasks"` + LoginLogs []SavedLoginLog `json:"login_logs"` + EnabledImages []string `json:"enabled_images"` + Snapshots []Snapshot `json:"snapshots"` + PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"` + PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"` + SecurityAutoShutdown bool `json:"security_auto_shutdown"` + Language string `json:"language"` + SSL SSLConfig `json:"ssl"` + SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` } var configPath string @@ -359,21 +480,23 @@ func InitConfig() (*ClicdConfig, error) { } AppConfig = &ClicdConfig{ - AdminUser: adminUser, - AdminPassHash: string(hash), - JWTSecret: jwtSecret, - Port: 8999, - DataDir: dataDir, - Containers: []Container{}, - NextContainerID: 1, - NextVNCPort: 5900, - NextSSHPort: 22000, - SetupComplete: false, - SubUsers: []SubUser{}, - AuditLogs: []AuditLog{}, - Tasks: []SavedTask{}, - LoginLogs: []SavedLoginLog{}, - Snapshots: []Snapshot{}, + AdminUser: adminUser, + AdminPassHash: string(hash), + JWTSecret: jwtSecret, + Port: 8999, + DataDir: dataDir, + Containers: []Container{}, + NextContainerID: 1, + NextVNCPort: 5900, + NextSSHPort: 22000, + SetupComplete: false, + SubUsers: []SubUser{}, + AuditLogs: []AuditLog{}, + Tasks: []SavedTask{}, + LoginLogs: []SavedLoginLog{}, + Snapshots: []Snapshot{}, + PublicIPv4Pool: []PublicIPv4Assignment{}, + PublicIPv6Prefixes: []PublicIPv6Prefix{}, } if err := SaveConfig(); err != nil { @@ -424,6 +547,14 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.Snapshots = make([]Snapshot, 0) changed = true } + if AppConfig.PublicIPv4Pool == nil { + AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0) + changed = true + } + if AppConfig.PublicIPv6Prefixes == nil { + AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0) + changed = true + } if AppConfig.SubUsers == nil { AppConfig.SubUsers = make([]SubUser, 0) changed = true @@ -546,6 +677,9 @@ func migrateLoadedConfig() bool { if ensureContainerSnapshotLimits() { changed = true } + if ensureContainerNetworkAssignments() { + changed = true + } if ensureContainerSnapshotScheduleDefaults() { changed = true } @@ -608,13 +742,16 @@ func ensureContainerUUIDs() bool { func ensureContainerPortMappingLimits() bool { changed := false for i := range AppConfig.Containers { - if AppConfig.Containers[i].PortMappingLimit <= 0 { + if AppConfig.Containers[i].PortMappingLimit < 0 { limit := len(AppConfig.Containers[i].PortMappings) if limit < 2 { limit = 2 } AppConfig.Containers[i].PortMappingLimit = limit changed = true + } else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 { + AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings) + changed = true } } return changed @@ -631,6 +768,16 @@ func ensureContainerSnapshotLimits() bool { return changed } +func ensureContainerNetworkAssignments() bool { + changed := false + for i := range AppConfig.Containers { + if AppConfig.Containers[i].NormalizeNetworkAssignments() { + changed = true + } + } + return changed +} + func migrateSubUsers() bool { changed := false for i := range AppConfig.SubUsers { diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index d48fe42..8423007 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -20,24 +20,30 @@ var ( ) type savedTaskConfig struct { - Name string `json:"name"` - Virtualization string `json:"virtualization,omitempty"` - TemplateID string `json:"template_id"` - VCPU float64 `json:"vcpu"` - CPUPercent int `json:"cpu_percent"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` - TrafficInGB int `json:"traffic_in_gb"` - TrafficOutGB int `json:"traffic_out_gb"` - IOSpeedMBps int `json:"io_speed_mbps"` - ExtraPorts []int `json:"extra_ports"` - PortMappingCount int `json:"port_mapping_count"` - SnapshotLimit int `json:"snapshot_limit"` - AssignIPv6 bool `json:"assign_ipv6"` - ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` + TrafficInGB int `json:"traffic_in_gb"` + TrafficOutGB int `json:"traffic_out_gb"` + IOSpeedMBps int `json:"io_speed_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignNAT *bool `json:"assign_nat,omitempty"` + SnapshotLimit int `json:"snapshot_limit"` + AssignIPv4 bool `json:"assign_ipv4"` + IPv4Count int `json:"ipv4_count,omitempty"` + PublicIPv4s []string `json:"public_ipv4s,omitempty"` + AssignIPv6 bool `json:"assign_ipv6"` + IPv6Count int `json:"ipv6_count,omitempty"` + IPv6Addresses []string `json:"ipv6_addresses,omitempty"` + ExpiresAt string `json:"expires_at"` } func parseSavedTaskConfig(raw string) savedTaskConfig { @@ -175,10 +181,28 @@ func ensureSchema() error { position INTEGER NOT NULL, container_port INTEGER NOT NULL, host_port INTEGER NOT NULL, + host_ip TEXT, protocol TEXT, description TEXT, PRIMARY KEY (container_id, position) )`, + `CREATE TABLE IF NOT EXISTS container_public_ipv4s ( + container_id INTEGER NOT NULL, + position INTEGER NOT NULL, + address TEXT NOT NULL, + interface TEXT, + prefix_len INTEGER, + gateway TEXT, + PRIMARY KEY (container_id, position) + )`, + `CREATE TABLE IF NOT EXISTS container_ipv6_addresses ( + container_id INTEGER NOT NULL, + position INTEGER NOT NULL, + address TEXT NOT NULL, + prefix_len INTEGER, + interface TEXT, + PRIMARY KEY (container_id, position) + )`, `CREATE TABLE IF NOT EXISTS sub_users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -253,8 +277,14 @@ func ensureSchema() error { cfg_traffic_out_gb INTEGER, cfg_io_speed_mbps INTEGER, cfg_port_mapping_count INTEGER, + cfg_assign_nat INTEGER, cfg_snapshot_limit INTEGER, + cfg_assign_ipv4 INTEGER, + cfg_ipv4_count INTEGER, + cfg_public_ipv4s TEXT, cfg_assign_ipv6 INTEGER, + cfg_ipv6_count INTEGER, + cfg_ipv6_addresses TEXT, cfg_expires_at TEXT )`, `CREATE TABLE IF NOT EXISTS task_extra_ports ( @@ -308,6 +338,15 @@ func ensureSchemaMigrations() error { {"api_keys", "last_used_ip", "TEXT"}, {"tasks", "ip", "TEXT"}, {"tasks", "user_agent", "TEXT"}, + {"tasks", "cfg_assign_ipv4", "INTEGER"}, + {"tasks", "cfg_ipv4_count", "INTEGER"}, + {"tasks", "cfg_public_ipv4s", "TEXT"}, + {"tasks", "cfg_assign_nat", "INTEGER"}, + {"tasks", "cfg_ipv6_count", "INTEGER"}, + {"tasks", "cfg_ipv6_addresses", "TEXT"}, + {"port_mappings", "host_ip", "TEXT"}, + {"container_public_ipv4s", "prefix_len", "INTEGER"}, + {"container_public_ipv4s", "gateway", "TEXT"}, } { if err := ensureColumn(column.table, column.name, column.def); err != nil { return err @@ -381,6 +420,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" { _ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates) } + if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool) + } + if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes) + } if cfg.Containers, err = loadContainers(); err != nil { return nil, false, err @@ -424,6 +469,8 @@ func saveConfigToDB() error { for _, table := range []string{ "port_mappings", + "container_public_ipv4s", + "container_ipv6_addresses", "sub_user_container_names", "sub_user_container_uuids", "containers", @@ -475,6 +522,8 @@ func saveConfigToDB() error { func saveMeta(tx *sql.Tx) error { sslJSON, _ := json.Marshal(AppConfig.SSL) sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates) + publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool) + publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes) values := map[string]string{ "admin_user": AppConfig.AdminUser, "admin_pass_hash": AppConfig.AdminPassHash, @@ -489,6 +538,8 @@ func saveMeta(tx *sql.Tx) error { "language": NormalizeLanguage(AppConfig.Language), "ssl": string(sslJSON), "ssl_certificates": string(sslCertificatesJSON), + "public_ipv4_pool": string(publicIPv4PoolJSON), + "public_ipv6_prefixes": string(publicIPv6PrefixesJSON), "schema_version": "1", "updated_at": time.Now().Format("2006-01-02 15:04:05"), } @@ -524,8 +575,20 @@ func saveContainers(tx *sql.Tx) error { return err } for i, pm := range c.PortMappings { - if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description) - VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil { + if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description) + VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil { + return err + } + } + for i, ip := range c.PublicIPv4s { + if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway) + VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil { + return err + } + } + for i, ip := range c.IPv6Addresses { + if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface) + VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil { return err } } @@ -590,14 +653,16 @@ func saveTasksDB(tx *sql.Tx) error { id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, - cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, - cfg_assign_ipv6, cfg_expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, + cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, + cfg_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, - cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit, - boolInt(cfg.AssignIPv6), cfg.ExpiresAt, + cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit, + boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s), + boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), cfg.ExpiresAt, ); err != nil { return err } @@ -686,12 +751,21 @@ func loadContainers() ([]Container, error) { if err != nil { return nil, err } + result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID) + if err != nil { + return nil, err + } + result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID) + if err != nil { + return nil, err + } + result[i].NormalizeNetworkAssignments() } return result, nil } func loadPortMappings(containerID int) ([]PortMapping, error) { - rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID) + rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID) if err != nil { return nil, err } @@ -699,14 +773,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) { result := []PortMapping{} for rows.Next() { var pm PortMapping - if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil { + var hostIP sql.NullString + if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil { return nil, err } + pm.HostIP = hostIP.String result = append(result, pm) } return result, rows.Err() } +func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) { + rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []PublicIPv4Assignment{} + for rows.Next() { + var item PublicIPv4Assignment + var iface sql.NullString + var prefixLen sql.NullInt64 + var gateway sql.NullString + if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil { + return nil, err + } + item.Interface = iface.String + if prefixLen.Valid { + item.PrefixLen = int(prefixLen.Int64) + } + item.Gateway = gateway.String + result = append(result, item) + } + return result, rows.Err() +} + +func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) { + rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []IPv6Assignment{} + for rows.Next() { + var item IPv6Assignment + var prefixLen sql.NullInt64 + var iface sql.NullString + if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil { + return nil, err + } + if prefixLen.Valid { + item.PrefixLen = int(prefixLen.Int64) + } + item.Interface = iface.String + result = append(result, item) + } + return result, rows.Err() +} + func loadSubUsers() ([]SubUser, error) { rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`) if err != nil { @@ -808,8 +932,9 @@ func loadTasks() ([]SavedTask, error) { id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, - cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, - cfg_assign_ipv6, cfg_expires_at + cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, + cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, + cfg_expires_at FROM tasks ORDER BY created_at, id`) if err != nil { return nil, err @@ -820,20 +945,34 @@ func loadTasks() ([]SavedTask, error) { for rows.Next() { var t SavedTask var cfg savedTaskConfig - var assignIPv6 int - var ip, userAgent sql.NullString + var assignIPv4, assignIPv6 int + var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString + var assignNAT, ipv4Count, ipv6Count sql.NullInt64 if err := rows.Scan( &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, &cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, - &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit, - &assignIPv6, &cfg.ExpiresAt, + &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit, + &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &cfg.ExpiresAt, ); err != nil { return nil, err } t.IP = ip.String t.UserAgent = userAgent.String + if assignNAT.Valid { + value := assignNAT.Int64 != 0 + cfg.AssignNAT = &value + } + cfg.AssignIPv4 = assignIPv4 != 0 + if ipv4Count.Valid { + cfg.IPv4Count = int(ipv4Count.Int64) + } + cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String) cfg.AssignIPv6 = assignIPv6 != 0 + if ipv6Count.Valid { + cfg.IPv6Count = int(ipv6Count.Int64) + } + cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String) result = append(result, t) configs = append(configs, cfg) } @@ -947,6 +1086,13 @@ func boolInt(value bool) int { return 0 } +func boolPtrInt(value *bool) interface{} { + if value == nil { + return nil + } + return boolInt(*value) +} + func btoa(value bool) string { if value { return "1" diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 250ba56..785bf6e 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -364,8 +364,11 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) { return fmt.Errorf("KVM vCPU must be a whole number and at least 1") } - if cfg.PortMappingCount < 2 { + if cfg.WantsNAT() && cfg.PortMappingCount < 2 { cfg.PortMappingCount = 2 + } else if !cfg.WantsNAT() { + cfg.PortMappingCount = 0 + cfg.ExtraPorts = nil } if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit @@ -403,18 +406,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso") mac := randomMAC() sshPassword := generateRandomString(16) - ipv6 := "" - ipv6PrefixLen := 0 - ipv6Interface := "" - if cfg.AssignIPv6 { - assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) + if err != nil { + return nil, err + } + + ipv6Assignments := []config.IPv6Assignment{} + if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 { + assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true) if err != nil { return nil, err } - ipv6 = assigned - ipv6PrefixLen = prefixLen - ipv6Interface = iface + ipv6Assignments = assigned } + ipv6List := configIPv6AssignmentAddresses(ipv6Assignments) + defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s) var xml string winAdminPassword := "" @@ -433,7 +439,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig } winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") - if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6); err != nil { + if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil { return nil, err } xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) @@ -449,7 +455,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { return nil, err } - if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil { + if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6List, *image); err != nil { return nil, err } xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "") @@ -465,20 +471,26 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig sshPort := 0 portMappings := []config.PortMapping{} - if allocatePorts { + if allocatePorts && cfg.WantsNAT() { sshPort = config.AllocateSSHPort() if IsWindowsImage(image.ID) { // Windows: RDP (3389) instead of SSH (22) portMappings = []config.PortMapping{{ ContainerPort: 3389, HostPort: sshPort, + HostIP: defaultHostIP, Protocol: "tcp", Description: "RDP", }} } else { portMappings = lxc.SetupDefaultPortMappings(sshPort) + if defaultHostIP != "" { + for i := range portMappings { + portMappings[i].HostIP = defaultHostIP + } + } } - tempC := &config.Container{PortMappings: portMappings} + tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings} extraPorts := cfg.ExtraPorts if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) @@ -490,6 +502,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{ ContainerPort: port, HostPort: port, + HostIP: defaultHostIP, Protocol: "tcp", Description: fmt.Sprintf("Port-%d", port), }) @@ -502,7 +515,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig if trafficMode == "" { trafficMode = "total" } - return &config.Container{ + container := &config.Container{ ID: id, UUID: config.NewContainerUUID(), Name: cfg.Name, @@ -521,9 +534,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig TrafficOutGB: cfg.TrafficOutGB, TrafficResetDate: now[:7], IOSpeedMBps: cfg.IOSpeedMBps, - IPv6: ipv6, - IPv6PrefixLen: ipv6PrefixLen, - IPv6Interface: ipv6Interface, + PublicIPv4s: publicIPv4s, + IPv6Addresses: ipv6Assignments, Status: "stopped", SSHPort: sshPort, SSHPassword: func() string { @@ -537,7 +549,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), CreatedAt: now, ExpiresAt: cfg.ExpiresAt, - }, nil + } + container.NormalizeNetworkAssignments() + return container, nil } func (m *Manager) StartContainer(id int) error { @@ -548,6 +562,7 @@ func (m *Manager) StartContainer(id int) error { if err := m.validateHost(IsWindowsImage(c.Template)); err != nil { return err } + lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s) name := c.VirshName() if err := m.ensureDomainDefinition(c); err != nil { fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err) @@ -598,7 +613,7 @@ func (m *Manager) StartContainer(id int) error { return err } } - if c.IPv6 != "" { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.applyIPv6Runtime(c); err != nil { return err } @@ -1559,7 +1574,7 @@ func createEmptyDisk(target string, diskGB int) error { return nil } -func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error { +func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error { tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") if tool == "" { return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") @@ -1585,13 +1600,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) erro if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil { return err } _ = os.Remove(target) @@ -1701,7 +1716,7 @@ exit /b 0 ` } -func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string { +func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string { commands := []string{ "$ErrorActionPreference='Continue'", "$ProgressPreference='SilentlyContinue'", @@ -1731,9 +1746,10 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string { "Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic", "Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue", } - if strings.TrimSpace(ipv6) != "" { + ipv6s = normalizeKVMIPv6List(ipv6s) + if len(ipv6s) > 0 { commands = append(commands, - windowsIPv6PowerShell(strings.TrimSpace(ipv6)), + windowsIPv6PowerShell(ipv6s), ) } commands = append(commands, @@ -1743,17 +1759,24 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string { return strings.Join(commands, "\r\n") + "\r\n" } -func windowsIPv6PowerShell(ipv6 string) string { - ipv6 = strings.TrimSpace(ipv6) - if ipv6 == "" { +func windowsIPv6PowerShell(ipv6s []string) string { + ipv6s = normalizeKVMIPv6List(ipv6s) + if len(ipv6s) == 0 { return "" } + quoted := make([]string, 0, len(ipv6s)) + for _, ipv6 := range ipv6s { + quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'") + } return strings.Join([]string{ + "$clicdIPv6=@(" + strings.Join(quoted, ",") + ")", "$iface=$null", "for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }", "if ($iface) {", - " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", - " New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null", + " foreach ($ip in $clicdIPv6) {", + " Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue", + " New-NetIPAddress -IPAddress $ip -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null", + " }", " Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue", " New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null", " Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue", @@ -1765,13 +1788,14 @@ func shellQuoteWindows(value string) string { return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` } -func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error { +func createSeedISO(seedPath, instanceID, hostname, password, mac string, ipv6s []string, image Image) error { guestSetup := kvmSSHSetupScript(password) if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" { guestSetup += "\n" + desktopSetup } - if strings.TrimSpace(ipv6) != "" { - guestSetup += "\n" + kvmIPv6SetupScript(ipv6) + ipv6s = normalizeKVMIPv6List(ipv6s) + if len(ipv6s) > 0 { + guestSetup += "\n" + kvmIPv6SetupScript(ipv6s) } setupScript := indentScript(guestSetup, 4) userData := fmt.Sprintf(`#cloud-config @@ -1795,15 +1819,19 @@ runcmd: `, hostname, password, setupScript) metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname) ipv6Block := "" - if strings.TrimSpace(ipv6) != "" { + if len(ipv6s) > 0 { + addressLines := make([]string, 0, len(ipv6s)) + for _, ipv6 := range ipv6s { + addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6)) + } ipv6Block = fmt.Sprintf(` addresses: - - %s/128 +%s routes: - to: default via: %s on-link: true - metric: 100`, ipv6, ipv6GatewayLinkLocal) + metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal) } networkConfig := fmt.Sprintf(`version: 2 ethernets: @@ -1834,6 +1862,42 @@ ethernets: return nil } +func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string { + values := make([]string, 0, len(assignments)) + for _, item := range assignments { + if strings.TrimSpace(item.Address) != "" { + values = append(values, strings.TrimSpace(item.Address)) + } + } + return values +} + +func normalizeKVMIPv6List(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func shellQuotedKVMIPv6List(values []string) string { + values = normalizeKVMIPv6List(values) + if len(values) == 0 { + return "''" + } + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, shellQuote(value)) + } + return strings.Join(quoted, " ") +} + func indentScript(script string, spaces int) string { prefix := strings.Repeat(" ", spaces) lines := strings.Split(strings.TrimRight(script, "\n"), "\n") @@ -2575,7 +2639,7 @@ func (m *Manager) syncRunningNetworks() { } else if err != nil { fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err) } - if c.IPv6 != "" { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.applyIPv6Runtime(c); err != nil { fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err) } @@ -2589,7 +2653,7 @@ func (m *Manager) applyIPv6Guards() { if !c.IsKVM() || c.MACAddress == "" { continue } - if c.IPv6 == "" { + if c.IPv6 == "" && len(c.IPv6Addresses) == 0 { ensureKVMIPv6DenyRule("virbr0", c.MACAddress) continue } @@ -2935,13 +2999,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { return nil, fmt.Errorf("container is not a KVM VM: %d", id) } if c.IPv6 == "" { - addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true) if err != nil { return nil, err } - c.IPv6 = addr - c.IPv6PrefixLen = prefixLen - c.IPv6Interface = iface + c.IPv6Addresses = append(c.IPv6Addresses, assignments...) + c.NormalizeNetworkAssignments() config.SaveConfig() } if err := m.applyIPv6Runtime(c); err != nil { @@ -2951,9 +3014,10 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { } func (m *Manager) applyIPv6Runtime(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } + c.NormalizeNetworkAssignments() if err := m.applyIPv6HostRuntime(c); err != nil { return err } @@ -2964,7 +3028,13 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error { } } } - ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface) + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + ensureKVMIPv6NAT66(assignment.Address, uplink) + } return nil } @@ -2980,9 +3050,10 @@ func shouldLogIPv6GuestWarning(id int) bool { } func (m *Manager) applyIPv6HostRuntime(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } + c.NormalizeNetworkAssignments() if c.IPv6Interface == "" { prefixes := lxc.DetectPublicIPv6Prefixes() if len(prefixes) == 0 { @@ -2990,6 +3061,14 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error { } c.IPv6Interface = prefixes[0].Interface c.IPv6PrefixLen = prefixes[0].PrefixLen + for i := range c.IPv6Addresses { + if c.IPv6Addresses[i].Interface == "" { + c.IPv6Addresses[i].Interface = c.IPv6Interface + } + if c.IPv6Addresses[i].PrefixLen == 0 { + c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen + } + } config.SaveConfig() } runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1") @@ -3001,14 +3080,20 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error { runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1") runQuiet("ip", "link", "set", bridge, "up") runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge) - if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil { - return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out)) + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + if out, err := exec.Command("ip", "-6", "route", "replace", assignment.Address+"/128", "dev", bridge).CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out)) + } + if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", assignment.Address, "dev", uplink).CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out)) + } + ensureKVMIPv6ForwardRules(assignment.Address, bridge) + ensureKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress) } - if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil { - return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out)) - } - ensureKVMIPv6ForwardRules(c.IPv6, bridge) - ensureKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress) return nil } @@ -3074,16 +3159,23 @@ func removeKVMIPv6Runtime(c *config.Container) { } bridge := "virbr0" removeKVMIPv6DenyRule(bridge, c.MACAddress) - if c.IPv6 == "" { + if c.IPv6 == "" && len(c.IPv6Addresses) == 0 { return } - removeKVMIPv6NAT66(c.IPv6, c.IPv6Interface) - removeKVMIPv6ForwardRules(c.IPv6, bridge) - removeKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress) - if c.IPv6Interface != "" { - _ = exec.Command("ip", "-6", "neigh", "del", "proxy", c.IPv6, "dev", c.IPv6Interface).Run() + c.NormalizeNetworkAssignments() + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + removeKVMIPv6NAT66(assignment.Address, uplink) + removeKVMIPv6ForwardRules(assignment.Address, bridge) + removeKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress) + if uplink != "" { + _ = exec.Command("ip", "-6", "neigh", "del", "proxy", assignment.Address, "dev", uplink).Run() + } + _ = exec.Command("ip", "-6", "route", "del", assignment.Address+"/128", "dev", bridge).Run() } - _ = exec.Command("ip", "-6", "route", "del", c.IPv6+"/128", "dev", bridge).Run() } func removeKVMIPv6ForwardRules(ipv6 string, bridge string) { @@ -3147,13 +3239,14 @@ func deleteIP6Rule(rule []string) { } func (m *Manager) applyGuestIPv6(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } + c.NormalizeNetworkAssignments() if IsWindowsImage(c.Template) { return m.applyWindowsGuestIPv6(c) } - script := kvmIPv6SetupScript(c.IPv6) + script := kvmIPv6SetupScript(c.IPv6AddressStrings()) if err := qemuGuestPing(c.VirshName()); err != nil { return err } @@ -3167,14 +3260,15 @@ func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error { if err := qemuGuestPing(c.VirshName()); err != nil { return err } - script := windowsIPv6PowerShell(c.IPv6) + script := windowsIPv6PowerShell(c.IPv6AddressStrings()) return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second) } func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } + c.NormalizeNetworkAssignments() qgaErr := m.applyGuestIPv6(c) if qgaErr == nil { return nil @@ -3187,7 +3281,7 @@ func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error { } func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error { - if c == nil || c.IPv6 == "" { + if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { return nil } if IsWindowsImage(c.Template) { @@ -3206,12 +3300,13 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error { return err } defer client.Close() - return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6), "KVM IPv6", 60*time.Second) + return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6AddressStrings()), "KVM IPv6", 60*time.Second) } -func kvmIPv6SetupScript(ipv6 string) string { +func kvmIPv6SetupScript(ipv6s []string) string { + ipv6s = normalizeKVMIPv6List(ipv6s) return `set -eu -IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `" IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" if [ -z "$IFACE" ]; then @@ -3224,13 +3319,15 @@ fi sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true -ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +for IPV6_ADDR in $IPV6_ADDRS; do + ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +done ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100 mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF' #!/bin/sh set -eu -IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `" IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" if [ -z "$IFACE" ]; then @@ -3240,7 +3337,9 @@ fi sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true -ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +for IPV6_ADDR in $IPV6_ADDRS; do + ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +done ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100 EOF chmod +x /usr/local/sbin/clicd-kvm-ipv6-init @@ -3279,15 +3378,44 @@ chmod +x /etc/network/if-up.d/clicd-kvm-ipv6 } func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) { - prefixes := lxc.DetectPublicIPv6Prefixes() - if len(prefixes) == 0 { - return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found") - } - prefixInfo := prefixes[0] - prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true) if err != nil { return "", 0, "", err } + if len(assignments) == 0 { + return "", 0, "", fmt.Errorf("no free IPv6 address") + } + return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil +} + +func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) { + if count <= 0 { + count = 1 + } + if len(requested) > count { + count = len(requested) + } + prefixes := lxc.DetectPublicIPv6Prefixes() + if len(prefixes) == 0 { + return nil, fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found") + } + parsedPrefixes := make([]struct { + info lxc.IPv6PrefixInfo + prefix netip.Prefix + }, 0, len(prefixes)) + for _, prefixInfo := range prefixes { + prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + if err != nil { + continue + } + parsedPrefixes = append(parsedPrefixes, struct { + info lxc.IPv6PrefixInfo + prefix netip.Prefix + }{info: prefixInfo, prefix: prefix}) + } + if len(parsedPrefixes) == 0 { + return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix found") + } used := map[string]bool{} hostAddrs := map[string]bool{} @@ -3295,21 +3423,69 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) hostAddrs[p.Address] = true } for _, c := range config.AppConfig.Containers { + if c.ID == id { + continue + } if c.IPv6 != "" { used[c.IPv6] = true } - } - for offset := uint64(0x2000 + id); offset < 0x100000; offset++ { - addr, err := ipv6Add(prefix.Masked().Addr(), offset) - if err != nil || !prefix.Contains(addr) { - break - } - candidate := addr.String() - if !used[candidate] && !hostAddrs[candidate] { - return candidate, prefix.Bits(), prefixInfo.Interface, nil + for _, ip := range c.IPv6Addresses { + if ip.Address != "" { + used[ip.Address] = true + } } } - return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String()) + result := make([]config.IPv6Assignment, 0, count) + selected := map[string]bool{} + for _, raw := range requested { + raw = strings.TrimSpace(raw) + if raw == "" || selected[raw] { + continue + } + addr, err := netip.ParseAddr(raw) + if err != nil || !addr.Is6() { + return nil, fmt.Errorf("requested IPv6 %s is not valid", raw) + } + matchIndex := -1 + for i, item := range parsedPrefixes { + if item.prefix.Contains(addr) { + matchIndex = i + break + } + } + if matchIndex < 0 { + return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw) + } + if hostAddrs[raw] { + return nil, fmt.Errorf("requested IPv6 %s is used by host", raw) + } + if used[raw] { + return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw) + } + selected[raw] = true + used[raw] = true + result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: parsedPrefixes[matchIndex].prefix.Bits(), Interface: parsedPrefixes[matchIndex].info.Interface}) + } + if len(result) >= count || !auto { + return result, nil + } + for _, item := range parsedPrefixes { + for offset := uint64(0x2000 + id); offset < 0x100000; offset++ { + addr, err := ipv6Add(item.prefix.Masked().Addr(), offset) + if err != nil || !item.prefix.Contains(addr) { + break + } + candidate := addr.String() + if !used[candidate] && !hostAddrs[candidate] { + used[candidate] = true + result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface}) + if len(result) >= count { + return result, nil + } + } + } + } + return nil, fmt.Errorf("no free IPv6 address in configured prefixes") } func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { diff --git a/backend/internal/lxc/ipv6.go b/backend/internal/lxc/ipv6.go index a617b10..3a63a79 100644 --- a/backend/internal/lxc/ipv6.go +++ b/backend/internal/lxc/ipv6.go @@ -1,6 +1,7 @@ package lxc import ( + "context" "encoding/binary" "fmt" "math/big" @@ -11,6 +12,7 @@ import ( "sort" "strconv" "strings" + "time" "clicd/internal/config" ) @@ -28,11 +30,21 @@ type IPv6PrefixInfo struct { } type PublicIPInfo struct { - Address string `json:"address"` - Interface string `json:"interface"` - Prefix string `json:"prefix"` - IsTunnel bool `json:"is_tunnel"` - Source string `json:"source"` + Address string `json:"address"` + Interface string `json:"interface"` + Prefix string `json:"prefix"` + PrefixLen int `json:"prefix_len,omitempty"` + SubnetMask string `json:"subnet_mask,omitempty"` + Gateway string `json:"gateway,omitempty"` + IsTunnel bool `json:"is_tunnel"` + Source string `json:"source"` +} + +type PublicIPv4ScanResult struct { + PublicIPInfo + Status string `json:"status"` + Usable bool `json:"usable"` + Reason string `json:"reason"` } type IPv6Status struct { @@ -64,15 +76,707 @@ func DetectPublicIPv6Prefixes() []IPv6PrefixInfo { return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) } +func DetectHostPublicIPv6Prefixes() []IPv6PrefixInfo { + return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) +} + +func ConfiguredPublicIPv6Prefixes() []IPv6PrefixInfo { + if config.AppConfig == nil || len(config.AppConfig.PublicIPv6Prefixes) == 0 { + return nil + } + detected := detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) + defaultIface := "" + defaultGateway := "" + for _, item := range detected { + if defaultIface == "" { + defaultIface = item.Interface + } + if defaultGateway == "" { + defaultGateway = item.Gateway + } + } + result := make([]IPv6PrefixInfo, 0, len(config.AppConfig.PublicIPv6Prefixes)) + for _, item := range config.AppConfig.PublicIPv6Prefixes { + address := strings.TrimSpace(item.Address) + prefixText := strings.TrimSpace(item.Prefix) + if prefixText == "" && address != "" && item.PrefixLen > 0 { + prefixText = address + "/" + strconv.Itoa(item.PrefixLen) + } + prefix, err := netip.ParsePrefix(prefixText) + if err != nil || !prefix.Addr().Is6() { + continue + } + iface := strings.TrimSpace(item.Interface) + if iface == "" { + iface = defaultIface + } + gateway := strings.TrimSpace(item.Gateway) + if gateway == "" { + gateway = defaultGateway + } + addr := strings.TrimSpace(item.Address) + if addr == "" { + addr = prefix.Addr().String() + } + result = append(result, IPv6PrefixInfo{ + Interface: iface, + Address: addr, + Prefix: prefix.Masked().String(), + PrefixLen: prefix.Bits(), + Gateway: gateway, + IsTunnel: isTunnelLikeInterface(iface), + Source: "manual", + }) + } + sort.SliceStable(result, func(i, j int) bool { + return result[i].Prefix < result[j].Prefix + }) + return result +} + func DetectPublicIPv4() PublicIPInfo { - candidates := DetectPublicIPv4Candidates() - if len(candidates) == 0 { + addresses := detectPublicIPv4LocalAddresses() + if len(addresses) == 0 { return PublicIPInfo{} } - return candidates[0] + return addresses[0] } func DetectPublicIPv4Candidates() []PublicIPInfo { + return ConfiguredPublicIPv4Pool() +} + +func DetectFreePublicIPv4Candidates(skipContainerID int) []PublicIPInfo { + used := assignedPublicIPv4Map(skipContainerID) + candidates := DetectPublicIPv4Candidates() + result := make([]PublicIPInfo, 0, len(candidates)) + for _, item := range candidates { + if item.Address == "" || used[item.Address] { + continue + } + result = append(result, item) + } + return result +} + +func ConfiguredPublicIPv4Pool() []PublicIPInfo { + if config.AppConfig == nil { + return nil + } + localAddrs := detectPublicIPv4LocalAddresses() + primaryHostIP := "" + defaultIface := "" + defaultPrefixLen := 32 + defaultRoutes := detectIPv4DefaultRoutes() + gateways := ipv4GatewaysByInterface(defaultRoutes) + if len(localAddrs) > 0 { + primaryHostIP = localAddrs[0].Address + defaultIface = localAddrs[0].Interface + if localAddrs[0].PrefixLen > 0 { + defaultPrefixLen = localAddrs[0].PrefixLen + } + } + + type candidate struct { + info PublicIPInfo + score int + } + candidatesByAddr := map[string]candidate{} + addCandidate := func(info PublicIPInfo, score int) { + if info.Address == "" || info.Address == primaryHostIP || (info.Gateway != "" && info.Address == info.Gateway) { + return + } + if existing, ok := candidatesByAddr[info.Address]; ok && existing.score >= score { + return + } + candidatesByAddr[info.Address] = candidate{info: info, score: score} + } + + defaultIfaces := map[string]bool{} + for _, route := range defaultRoutes { + defaultIfaces[route.Interface] = true + } + for _, item := range config.AppConfig.PublicIPv4Pool { + address := strings.TrimSpace(item.Address) + if address == "" { + continue + } + addr, err := netip.ParseAddr(address) + if err != nil || !addr.Is4() { + continue + } + address = addr.String() + iface := strings.TrimSpace(item.Interface) + gateway := strings.TrimSpace(item.Gateway) + if iface == "" && gateway != "" { + iface = ipv4InterfaceForGateway(gateway, defaultRoutes) + } + if iface == "" { + iface = defaultIface + } + if gateway == "" && iface != "" { + gateway = gateways[iface] + } + prefixLen := item.PrefixLen + if prefixLen <= 0 || prefixLen > 32 { + if gatewayAddr, err := netip.ParseAddr(gateway); err == nil && gatewayAddr.Is4() { + prefixLen = inferPublicIPv4PrefixLen(addr, gatewayAddr, iface, localAddrs) + } + if prefixLen <= 0 || prefixLen > 32 { + prefixLen = defaultPrefixLen + } + } + score := publicInterfaceScore(iface, defaultIfaces) + addCandidate(PublicIPInfo{ + Address: address, + Interface: iface, + Prefix: ipv4PrefixString(addr, prefixLen), + PrefixLen: prefixLen, + SubnetMask: ipv4SubnetMask(prefixLen), + Gateway: gateway, + IsTunnel: isTunnelLikeInterface(iface), + Source: "manual", + }, score+50) + } + + candidates := make([]candidate, 0, len(candidatesByAddr)) + for _, item := range candidatesByAddr { + candidates = append(candidates, item) + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + return compareIPv4Strings(candidates[i].info.Address, candidates[j].info.Address) < 0 + } + return candidates[i].score > candidates[j].score + }) + result := make([]PublicIPInfo, 0, len(candidates)) + for _, c := range candidates { + result = append(result, c.info) + } + return result +} + +func NormalizePublicIPv4Pool(items []config.PublicIPv4Assignment) ([]config.PublicIPv4Assignment, error) { + localAddrs := detectPublicIPv4LocalAddresses() + primaryHostIP := "" + defaultIface := "" + defaultRoutes := detectIPv4DefaultRoutes() + gateways := ipv4GatewaysByInterface(defaultRoutes) + localMap := map[string]bool{} + if len(localAddrs) > 0 { + primaryHostIP = localAddrs[0].Address + defaultIface = localAddrs[0].Interface + } + for _, local := range localAddrs { + localMap[local.Address] = true + } + assigned := assignedPublicIPv4Map(0) + + seen := map[string]bool{} + result := make([]config.PublicIPv4Assignment, 0, len(items)) + for _, item := range items { + raw := strings.TrimSpace(item.Address) + if raw == "" { + continue + } + gateway := strings.TrimSpace(item.Gateway) + if gateway == "" && item.Interface != "" { + gateway = gateways[strings.TrimSpace(item.Interface)] + } + if gateway == "" { + return nil, fmt.Errorf("gateway is required for IPv4 %s", raw) + } + gatewayAddr, err := netip.ParseAddr(gateway) + if err != nil || !gatewayAddr.Is4() { + return nil, fmt.Errorf("gateway %s is not a valid IPv4 address", gateway) + } + gateway = gatewayAddr.String() + + iface := strings.TrimSpace(item.Interface) + if iface == "" { + iface = ipv4InterfaceForGateway(gateway, defaultRoutes) + } + if iface == "" { + iface = defaultIface + } + if iface == "" { + return nil, fmt.Errorf("interface is required for IPv4 %s", raw) + } + + addresses, prefixLen, singleInput, err := expandPublicIPv4Input(raw, item.PrefixLen, gatewayAddr, iface, localAddrs) + if err != nil { + return nil, err + } + addedFromInput := 0 + skippedReasons := []string{} + for _, addr := range addresses { + if !addr.Is4() || !isPublicIPv4(addr) { + if singleInput { + return nil, fmt.Errorf("IPv4 %s is not a valid public IPv4 address", addr.String()) + } + continue + } + address := addr.String() + switch { + case address == primaryHostIP: + if singleInput { + return nil, fmt.Errorf("IPv4 %s is the host primary IPv4 and cannot be allocated", address) + } + skippedReasons = append(skippedReasons, address+" is the host primary IPv4") + continue + case address == gateway: + if singleInput { + return nil, fmt.Errorf("IPv4 %s is the gateway and cannot be allocated", address) + } + skippedReasons = append(skippedReasons, address+" is the gateway") + continue + case seen[address]: + continue + case ipv4AddressResponds(iface, address) && !localMap[address] && !assigned[address]: + if singleInput { + return nil, fmt.Errorf("IPv4 %s responds on the network and cannot be allocated", address) + } + skippedReasons = append(skippedReasons, address+" responds on the network") + continue + } + seen[address] = true + result = append(result, config.PublicIPv4Assignment{ + Address: address, + Interface: iface, + PrefixLen: prefixLen, + Gateway: gateway, + }) + addedFromInput++ + } + if singleInput && addedFromInput == 0 { + if len(skippedReasons) > 0 { + return nil, fmt.Errorf("IPv4 %s is not allocatable: %s", raw, strings.Join(skippedReasons, "; ")) + } + return nil, fmt.Errorf("IPv4 %s is not allocatable", raw) + } + } + if len(result) == 0 && len(items) > 0 { + return nil, fmt.Errorf("no allocatable public IPv4 address found in the submitted pool") + } + sort.SliceStable(result, func(i, j int) bool { + return compareIPv4Strings(result[i].Address, result[j].Address) < 0 + }) + return result, nil +} + +func expandPublicIPv4Input(raw string, requestedPrefixLen int, gateway netip.Addr, iface string, localAddrs []PublicIPInfo) ([]netip.Addr, int, bool, error) { + if strings.Contains(raw, "/") { + prefix, err := netip.ParsePrefix(raw) + if err != nil || !prefix.Addr().Is4() { + return nil, 0, false, fmt.Errorf("invalid IPv4 segment: %s", raw) + } + prefix = prefix.Masked() + if prefix.Bits() < 24 { + return nil, 0, false, fmt.Errorf("IPv4 segment %s is too large; use /24 or smaller", prefix.String()) + } + return ipv4HostsInPrefix(prefix), prefix.Bits(), prefix.Bits() == 32, nil + } + + addr, err := netip.ParseAddr(raw) + if err != nil || !addr.Is4() { + return nil, 0, true, fmt.Errorf("IPv4 %s is not a valid public IPv4 address", raw) + } + prefixLen := requestedPrefixLen + if prefixLen <= 0 || prefixLen > 32 { + prefixLen = inferPublicIPv4PrefixLen(addr, gateway, iface, localAddrs) + } + if prefixLen <= 0 || prefixLen > 32 { + prefixLen = 32 + } + return []netip.Addr{addr}, prefixLen, true, nil +} + +func ipv4GatewaysByInterface(routes []routeInfo) map[string]string { + gateways := map[string]string{} + for _, route := range routes { + if route.Interface != "" && route.Gateway != "" && gateways[route.Interface] == "" { + gateways[route.Interface] = route.Gateway + } + } + return gateways +} + +func ipv4InterfaceForGateway(gateway string, defaultRoutes []routeInfo) string { + gateway = strings.TrimSpace(gateway) + if gateway == "" { + return "" + } + for _, route := range defaultRoutes { + if route.Gateway == gateway && route.Interface != "" { + return route.Interface + } + } + out, err := exec.Command("ip", "-4", "route", "get", gateway).Output() + if err != nil { + return "" + } + fields := strings.Fields(string(out)) + for i := 0; i < len(fields)-1; i++ { + if fields[i] == "dev" { + return normalizeIface(fields[i+1]) + } + } + return "" +} + +func assignedPublicIPv4Map(skipContainerID int) map[string]bool { + assigned := map[string]bool{} + if config.AppConfig == nil { + return assigned + } + for _, c := range config.AppConfig.Containers { + if skipContainerID > 0 && c.ID == skipContainerID { + continue + } + for _, item := range c.PublicIPv4s { + if address := strings.TrimSpace(item.Address); address != "" { + assigned[address] = true + } + } + } + return assigned +} + +func inferPublicIPv4PrefixLen(addr netip.Addr, gateway netip.Addr, iface string, localAddrs []PublicIPInfo) int { + best := 0 + consider := func(prefixText string, prefixIface string) { + if iface != "" && prefixIface != "" && iface != prefixIface { + return + } + prefix, err := netip.ParsePrefix(prefixText) + if err != nil || !prefix.Addr().Is4() || !prefix.Contains(addr) { + return + } + if gateway.IsValid() && gateway.Is4() && !prefix.Contains(gateway) { + return + } + if bits := prefix.Bits(); bits > best { + best = bits + } + } + for _, local := range localAddrs { + if local.Prefix != "" { + consider(local.Prefix, local.Interface) + continue + } + if local.Address != "" && local.PrefixLen > 0 { + consider(local.Address+"/"+strconv.Itoa(local.PrefixLen), local.Interface) + } + } + + out, err := exec.Command("ip", "-4", "route", "show").Output() + if err != nil { + return best + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] == "default" { + continue + } + if !strings.Contains(fields[0], "/") { + continue + } + routeIface := "" + for i := 0; i < len(fields)-1; i++ { + if fields[i] == "dev" { + routeIface = normalizeIface(fields[i+1]) + break + } + } + consider(fields[0], routeIface) + } + return best +} + +func ipv4PrefixString(addr netip.Addr, prefixLen int) string { + if prefixLen <= 0 || prefixLen > 32 { + prefixLen = 32 + } + prefix, err := netip.ParsePrefix(addr.String() + "/" + strconv.Itoa(prefixLen)) + if err != nil { + return addr.String() + "/" + strconv.Itoa(prefixLen) + } + return prefix.Masked().String() +} + +func ipv4SubnetMask(prefixLen int) string { + if prefixLen < 0 || prefixLen > 32 { + return "" + } + var mask uint32 + if prefixLen > 0 { + mask = ^uint32(0) << uint(32-prefixLen) + } + return uint32ToIPv4(mask).String() +} + +func NormalizePublicIPv6Prefixes(items []config.PublicIPv6Prefix) ([]config.PublicIPv6Prefix, error) { + detected := detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) + defaultIface := "" + defaultGateway := "" + for _, item := range detected { + if defaultIface == "" { + defaultIface = item.Interface + } + if defaultGateway == "" { + defaultGateway = item.Gateway + } + } + seen := map[string]bool{} + result := make([]config.PublicIPv6Prefix, 0, len(items)) + for _, item := range items { + raw := strings.TrimSpace(item.Prefix) + if raw == "" { + raw = strings.TrimSpace(item.Address) + if raw != "" && !strings.Contains(raw, "/") && item.PrefixLen > 0 { + raw += "/" + strconv.Itoa(item.PrefixLen) + } + } + if raw == "" { + continue + } + prefix, err := netip.ParsePrefix(raw) + if err != nil || !prefix.Addr().Is6() { + return nil, fmt.Errorf("invalid IPv6 prefix: %s", raw) + } + if prefix.Bits() > 120 { + return nil, fmt.Errorf("IPv6 prefix %s is too small for allocation", prefix.String()) + } + if !isPublicIPv6(prefix.Addr()) { + return nil, fmt.Errorf("IPv6 prefix %s is not public", prefix.String()) + } + key := prefix.Masked().String() + if seen[key] { + continue + } + iface := strings.TrimSpace(item.Interface) + if iface == "" { + iface = defaultIface + } + if iface == "" { + return nil, fmt.Errorf("interface is required for IPv6 prefix %s", key) + } + gateway := strings.TrimSpace(item.Gateway) + if gateway == "" { + gateway = defaultGateway + } + seen[key] = true + result = append(result, config.PublicIPv6Prefix{ + Address: prefix.Addr().String(), + Prefix: key, + PrefixLen: prefix.Bits(), + Interface: iface, + Gateway: gateway, + }) + } + sort.SliceStable(result, func(i, j int) bool { + return result[i].Prefix < result[j].Prefix + }) + return result, nil +} + +func ScanPublicIPv4Segment(cidr string, iface string, gateway string, verify bool, limit int) ([]PublicIPv4ScanResult, error) { + if limit <= 0 || limit > 256 { + limit = 256 + } + cidr = strings.TrimSpace(cidr) + if cidr == "" { + if host := DetectPublicIPv4(); host.Prefix != "" { + cidr = host.Prefix + } + } + prefix, err := netip.ParsePrefix(cidr) + if err != nil || !prefix.Addr().Is4() { + return nil, fmt.Errorf("invalid IPv4 segment: %s", cidr) + } + prefix = prefix.Masked() + bits := prefix.Bits() + if bits < 24 { + return nil, fmt.Errorf("IPv4 segment %s is too large; use /24 or smaller", prefix.String()) + } + + localAddrs := detectPublicIPv4LocalAddresses() + primaryHostIP := "" + defaultIface := strings.TrimSpace(iface) + defaultPrefixLen := bits + defaultRoutes := detectIPv4DefaultRoutes() + gateway = strings.TrimSpace(gateway) + if gateway == "" && defaultIface != "" { + gateway = ipv4GatewaysByInterface(defaultRoutes)[defaultIface] + } + if gateway != "" { + gatewayAddr, err := netip.ParseAddr(gateway) + if err != nil || !gatewayAddr.Is4() { + return nil, fmt.Errorf("gateway %s is not a valid IPv4 address", gateway) + } + gateway = gatewayAddr.String() + } + if defaultIface == "" && gateway != "" { + defaultIface = ipv4InterfaceForGateway(gateway, defaultRoutes) + } + localMap := map[string]bool{} + for _, local := range localAddrs { + if primaryHostIP == "" { + primaryHostIP = local.Address + } + if defaultIface == "" { + defaultIface = local.Interface + } + localMap[local.Address] = true + } + if defaultIface == "" { + return nil, fmt.Errorf("interface is required") + } + if gateway == "" { + return nil, fmt.Errorf("gateway is required") + } + assigned := map[string]bool{} + for _, c := range config.AppConfig.Containers { + for _, item := range c.PublicIPv4s { + if item.Address != "" { + assigned[item.Address] = true + } + } + } + inPool := map[string]bool{} + for _, item := range config.AppConfig.PublicIPv4Pool { + if item.Address != "" { + inPool[item.Address] = true + } + } + + addresses := ipv4HostsInPrefix(prefix) + if len(addresses) > limit { + return nil, fmt.Errorf("IPv4 segment %s has %d hosts; limit is %d", prefix.String(), len(addresses), limit) + } + + results := make([]PublicIPv4ScanResult, 0, len(addresses)) + for _, addr := range addresses { + address := addr.String() + info := PublicIPInfo{ + Address: address, + Interface: defaultIface, + Prefix: prefix.String(), + PrefixLen: defaultPrefixLen, + SubnetMask: ipv4SubnetMask(defaultPrefixLen), + Gateway: gateway, + IsTunnel: isTunnelLikeInterface(defaultIface), + Source: "scan", + } + result := PublicIPv4ScanResult{PublicIPInfo: info, Status: "unknown", Reason: "not checked"} + switch { + case address == primaryHostIP: + result.Status = "host" + result.Reason = "host primary IPv4" + case address == gateway: + result.Status = "gateway" + result.Reason = "default gateway" + case assigned[address]: + result.Status = "assigned" + result.Usable = true + result.Reason = "already assigned to a container" + case inPool[address]: + result.Status = "pool" + result.Usable = true + result.Reason = "already in allocation pool" + case localMap[address]: + result.Status = "configured" + result.Usable = true + result.Reason = "already configured on host" + case ipv4AddressResponds(defaultIface, address): + result.Status = "in_use" + result.Reason = "address responds on the network" + case verify: + if ok, reason := verifyIPv4SourceUsable(defaultIface, address); ok { + result.Status = "available" + result.Usable = true + result.Reason = reason + } else { + result.Status = "unknown" + result.Reason = reason + } + default: + result.Status = "available" + result.Usable = true + result.Reason = "no duplicate response detected; source routing not verified" + } + results = append(results, result) + } + return results, nil +} + +func ipv4HostsInPrefix(prefix netip.Prefix) []netip.Addr { + bits := prefix.Bits() + base := ipv4ToUint32(prefix.Masked().Addr()) + count := uint64(1) << uint(32-bits) + if bits == 32 { + return []netip.Addr{uint32ToIPv4(base)} + } + start := uint64(base) + end := start + count - 1 + if bits <= 30 { + start++ + end-- + } + result := make([]netip.Addr, 0, count) + for value := start; value <= end; value++ { + result = append(result, uint32ToIPv4(uint32(value))) + } + return result +} + +func ipv4AddressResponds(iface, address string) bool { + if commandExists("arping") { + if exec.Command("arping", "-D", "-I", iface, "-c", "2", "-w", "3", address).Run() != nil { + return true + } + return false + } + return exec.Command("ping", "-4", "-I", iface, "-c", "1", "-W", "1", address).Run() == nil +} + +func verifyIPv4SourceUsable(iface, address string) (bool, string) { + cidr := address + "/32" + added := false + if exec.Command("ip", "-4", "addr", "show", "dev", iface, "to", cidr).Run() != nil { + output, err := exec.Command("ip", "-4", "addr", "add", cidr, "dev", iface, "label", iface+":clicdscan").CombinedOutput() + if err != nil { + return false, "failed to temporarily bind address: " + strings.TrimSpace(string(output)) + } + added = true + } + if added { + defer exec.Command("ip", "-4", "addr", "del", cidr, "dev", iface).Run() + } + if commandExists("curl") { + for _, target := range []string{"https://api.ipify.org", "https://ifconfig.me/ip", "http://ifconfig.me/ip"} { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + out, err := exec.CommandContext(ctx, "curl", "-4", "-sS", "--interface", address, "--max-time", "4", target).Output() + cancel() + if err == nil && strings.TrimSpace(string(out)) == address { + return true, "source IPv4 verified by external check" + } + } + } + for _, target := range []string{"1.1.1.1", "8.8.8.8"} { + if exec.Command("ping", "-4", "-I", address, "-c", "1", "-W", "2", target).Run() == nil { + return true, "source IPv4 can reach external network" + } + } + return false, "no duplicate response, but source IPv4 verification failed" +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func detectPublicIPv4LocalAddresses() []PublicIPInfo { out, err := exec.Command("ip", "-4", "-o", "addr", "show", "scope", "global").Output() if err != nil { return nil @@ -109,11 +813,14 @@ func DetectPublicIPv4Candidates() []PublicIPInfo { score := publicInterfaceScore(iface, defaultIfaces) candidates = append(candidates, candidate{ info: PublicIPInfo{ - Address: prefix.Addr().String(), - Interface: iface, - Prefix: prefix.Masked().String(), - IsTunnel: isTunnelLikeInterface(iface), - Source: "local", + Address: prefix.Addr().String(), + Interface: iface, + Prefix: prefix.Masked().String(), + PrefixLen: prefix.Bits(), + SubnetMask: ipv4SubnetMask(prefix.Bits()), + Gateway: ipv4GatewayForInterface(defaultRoutes, iface), + IsTunnel: isTunnelLikeInterface(iface), + Source: "local", }, score: score, }) @@ -128,6 +835,169 @@ func DetectPublicIPv4Candidates() []PublicIPInfo { return result } +func AllocatePublicIPv4Assignments(id int, requested []string, count int, auto bool) ([]config.PublicIPv4Assignment, error) { + if count <= 0 { + count = 1 + } + if len(requested) > count { + count = len(requested) + } + candidates := DetectPublicIPv4Candidates() + if len(candidates) == 0 { + if len(requested) > 0 || auto { + return nil, fmt.Errorf("public IPv4 allocation is unavailable: no usable public IPv4 address found") + } + return nil, nil + } + byAddress := map[string]PublicIPInfo{} + for _, item := range candidates { + byAddress[item.Address] = item + } + + used := map[string]bool{} + for _, c := range config.AppConfig.Containers { + if c.ID == id { + continue + } + for _, item := range c.PublicIPv4s { + if item.Address != "" { + used[item.Address] = true + } + } + } + + result := make([]config.PublicIPv4Assignment, 0, count) + selected := map[string]bool{} + for _, raw := range requested { + raw = strings.TrimSpace(raw) + if raw == "" || selected[raw] { + continue + } + addr, err := netip.ParseAddr(raw) + if err != nil || !addr.Is4() { + return nil, fmt.Errorf("requested IPv4 %s is not valid", raw) + } + raw = addr.String() + if selected[raw] { + continue + } + info, ok := byAddress[raw] + if !ok { + return nil, fmt.Errorf("requested IPv4 %s is not an allocatable public IPv4", raw) + } + if used[raw] { + return nil, fmt.Errorf("requested IPv4 %s is already assigned", raw) + } + selected[raw] = true + used[raw] = true + result = append(result, config.PublicIPv4Assignment{Address: info.Address, Interface: info.Interface, PrefixLen: info.PrefixLen, Gateway: info.Gateway}) + } + + if len(result) >= count || !auto { + return result, nil + } + for _, info := range candidates { + if used[info.Address] || selected[info.Address] { + continue + } + used[info.Address] = true + selected[info.Address] = true + result = append(result, config.PublicIPv4Assignment{Address: info.Address, Interface: info.Interface, PrefixLen: info.PrefixLen, Gateway: info.Gateway}) + if len(result) >= count { + return result, nil + } + } + if len(result) == 0 { + return nil, fmt.Errorf("no free public IPv4 address is available") + } + if len(result) < count { + return nil, fmt.Errorf("only %d free public IPv4 address(es) are available; %d requested", len(result), count) + } + return result, nil +} + +func EnsureAssignedPublicIPv4s(assignments []config.PublicIPv4Assignment) { + for _, assignment := range assignments { + addr := strings.TrimSpace(assignment.Address) + iface := strings.TrimSpace(assignment.Interface) + if addr == "" || iface == "" { + continue + } + prefixLen := assignment.PrefixLen + if prefixLen <= 0 || prefixLen > 32 { + if info, ok := publicIPv4InfoByAddress(addr); ok && info.PrefixLen > 0 { + prefixLen = info.PrefixLen + if iface == "" { + iface = info.Interface + } + } + } + if prefixLen <= 0 || prefixLen > 32 { + prefixLen = 32 + } + prefixLen = publicIPv4BindingPrefixLen(assignment, prefixLen) + cidr := fmt.Sprintf("%s/%d", addr, prefixLen) + if publicIPv4AddressBound(addr, iface) { + continue + } + if output, err := exec.Command("ip", "-4", "addr", "add", cidr, "dev", iface, "label", iface+":clicd").CombinedOutput(); err != nil { + fmt.Printf("Warning: failed to add assigned public IPv4 %s to %s: %v, output: %s\n", cidr, iface, err, string(output)) + } + } +} + +func publicIPv4BindingPrefixLen(assignment config.PublicIPv4Assignment, prefixLen int) int { + if prefixLen <= 0 || prefixLen > 32 { + return 32 + } + addr, addrErr := netip.ParseAddr(strings.TrimSpace(assignment.Address)) + gateway, gatewayErr := netip.ParseAddr(strings.TrimSpace(assignment.Gateway)) + if addrErr != nil || gatewayErr != nil || !addr.Is4() || !gateway.Is4() { + return prefixLen + } + prefix, err := netip.ParsePrefix(addr.String() + "/" + strconv.Itoa(prefixLen)) + if err != nil { + return prefixLen + } + if !prefix.Masked().Contains(gateway) { + return 32 + } + return prefixLen +} + +func publicIPv4AddressBound(address string, iface string) bool { + out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet" { + continue + } + prefix, err := netip.ParsePrefix(fields[3]) + if err == nil && prefix.Addr().String() == address { + return true + } + } + return false +} + +func EnsureAllAssignedPublicIPv4s() { + for i := range config.AppConfig.Containers { + EnsureAssignedPublicIPv4s(config.AppConfig.Containers[i].PublicIPv4s) + } +} + +func publicIPv4InfoByAddress(address string) (PublicIPInfo, bool) { + for _, info := range DetectPublicIPv4Candidates() { + if info.Address == address { + return info, true + } + } + return PublicIPInfo{}, false +} + func detectPublicIPv6Prefixes(defaultRoutes []routeInfo) []IPv6PrefixInfo { out, err := exec.Command("ip", "-6", "-o", "addr", "show", "scope", "global").Output() if err != nil { @@ -213,6 +1083,15 @@ func detectIPv4DefaultRoutes() []routeInfo { return parseDefaultRoutes(string(out)) } +func ipv4GatewayForInterface(routes []routeInfo, iface string) string { + for _, route := range routes { + if route.Interface == iface && route.Gateway != "" { + return route.Gateway + } + } + return "" +} + func detectIPv6DefaultRoutes() []routeInfo { out, err := exec.Command("ip", "-6", "route", "show", "default").Output() if err != nil { @@ -349,9 +1228,48 @@ func isPublicIPv4(addr netip.Addr) bool { if raw[0] == 192 && raw[1] == 0 && raw[2] == 0 { return false } + if raw[0] == 192 && raw[1] == 0 && raw[2] == 2 { + return false + } + if raw[0] == 198 && (raw[1] == 18 || raw[1] == 19 || raw[1] == 51 && raw[2] == 100) { + return false + } + if raw[0] == 203 && raw[1] == 0 && raw[2] == 113 { + return false + } return true } +func ipv4ToUint32(addr netip.Addr) uint32 { + raw := addr.As4() + return binary.BigEndian.Uint32(raw[:]) +} + +func uint32ToIPv4(value uint32) netip.Addr { + var raw [4]byte + binary.BigEndian.PutUint32(raw[:], value) + return netip.AddrFrom4(raw) +} + +func compareIPv4Strings(a, b string) int { + addrA, errA := netip.ParseAddr(a) + addrB, errB := netip.ParseAddr(b) + if errA == nil && errB == nil && addrA.Is4() && addrB.Is4() { + rawA := addrA.As4() + rawB := addrB.As4() + for i := range rawA { + if rawA[i] < rawB[i] { + return -1 + } + if rawA[i] > rawB[i] { + return 1 + } + } + return 0 + } + return strings.Compare(a, b) +} + func isPublicIPv6(addr netip.Addr) bool { if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { return false @@ -360,15 +1278,44 @@ func isPublicIPv6(addr netip.Addr) bool { } func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) { - status := m.DetectIPv6Status() - if !status.Available { - return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason) - } - prefixInfo := status.Prefixes[0] - prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true) if err != nil { return "", 0, "", err } + if len(assignments) == 0 { + return "", 0, "", fmt.Errorf("no free IPv6 address") + } + return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil +} + +func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) { + if count <= 0 { + count = 1 + } + if len(requested) > count { + count = len(requested) + } + status := m.DetectIPv6Status() + if !status.Available { + return nil, fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason) + } + prefixes := make([]struct { + info IPv6PrefixInfo + prefix netip.Prefix + }, 0, len(status.Prefixes)) + for _, prefixInfo := range status.Prefixes { + prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + if err != nil { + continue + } + prefixes = append(prefixes, struct { + info IPv6PrefixInfo + prefix netip.Prefix + }{info: prefixInfo, prefix: prefix}) + } + if len(prefixes) == 0 { + return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix") + } used := map[string]bool{} hostAddrs := map[string]bool{} @@ -376,21 +1323,72 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) hostAddrs[p.Address] = true } for _, c := range config.AppConfig.Containers { + if c.ID == id { + continue + } if c.IPv6 != "" { used[c.IPv6] = true } - } - for offset := uint64(0x1000 + id); offset < 0x100000; offset++ { - addr, err := ipv6Add(prefix.Masked().Addr(), offset) - if err != nil || !prefix.Contains(addr) { - break - } - candidate := addr.String() - if !used[candidate] && !hostAddrs[candidate] { - return candidate, prefix.Bits(), prefixInfo.Interface, nil + for _, ip := range c.IPv6Addresses { + if ip.Address != "" { + used[ip.Address] = true + } } } - return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String()) + + result := make([]config.IPv6Assignment, 0, count) + selected := map[string]bool{} + for _, raw := range requested { + raw = strings.TrimSpace(raw) + if raw == "" || selected[raw] { + continue + } + addr, err := netip.ParseAddr(raw) + if err != nil || !addr.Is6() { + return nil, fmt.Errorf("requested IPv6 %s is not valid", raw) + } + matchIndex := -1 + for i, item := range prefixes { + if item.prefix.Contains(addr) { + matchIndex = i + break + } + } + if matchIndex < 0 { + return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw) + } + if hostAddrs[raw] { + return nil, fmt.Errorf("requested IPv6 %s is used by host", raw) + } + if used[raw] { + return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw) + } + selected[raw] = true + used[raw] = true + result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: prefixes[matchIndex].prefix.Bits(), Interface: prefixes[matchIndex].info.Interface}) + } + + if len(result) >= count || !auto { + return result, nil + } + + for _, item := range prefixes { + for offset := uint64(0x1000 + id); offset < 0x100000; offset++ { + addr, err := ipv6Add(item.prefix.Masked().Addr(), offset) + if err != nil || !item.prefix.Contains(addr) { + break + } + candidate := addr.String() + if !used[candidate] && !hostAddrs[candidate] { + used[candidate] = true + result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface}) + if len(result) >= count { + return result, nil + } + } + } + } + return nil, fmt.Errorf("no free IPv6 address in configured prefixes") } func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { @@ -410,27 +1408,62 @@ func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { return netip.AddrFrom16(out), nil } +func ipv6AssignmentAddresses(assignments []config.IPv6Assignment) []string { + values := make([]string, 0, len(assignments)) + for _, item := range assignments { + if strings.TrimSpace(item.Address) != "" { + values = append(values, strings.TrimSpace(item.Address)) + } + } + return values +} + +func normalizeIPv6List(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func shellQuotedIPv6List(values []string) string { + values = normalizeIPv6List(values) + if len(values) == 0 { + return "''" + } + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, shellQuote(value)) + } + return strings.Join(quoted, " ") +} + func (m *Manager) AssignIPv6(id int) (*config.Container, error) { c := config.FindContainer(id) if c == nil { return nil, fmt.Errorf("container not found: %d", id) } if c.IPv6 == "" { - addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true) if err != nil { return nil, err } - c.IPv6 = addr - c.IPv6PrefixLen = prefixLen - c.IPv6Interface = iface + c.IPv6Addresses = append(c.IPv6Addresses, assignments...) + c.NormalizeNetworkAssignments() config.SaveConfig() } - if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil { + if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil { return nil, err } rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs") if _, err := os.Stat(rootfsPath); err == nil { - if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err) } } @@ -440,7 +1473,7 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) { return c, nil } -func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error { +func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error { configFile := filepath.Join(m.LxcPath, lxcName, "config") data, err := os.ReadFile(configFile) if err != nil { @@ -457,9 +1490,12 @@ func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error { } next = append(next, line) } - if ipv6 != "" { + ipv6s = normalizeIPv6List(ipv6s) + if len(ipv6s) > 0 { next = append(next, "", "# clicd managed: public IPv6 routed /128") - next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6)) + for _, ipv6 := range ipv6s { + next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6)) + } next = append(next, "lxc.net.0.ipv6.gateway = auto") } return os.WriteFile(configFile, []byte(strings.Join(next, "\n")), 0644) @@ -470,9 +1506,10 @@ func (m *Manager) ApplyIPv6(id int) error { if c == nil { return fmt.Errorf("container not found: %d", id) } - if c.IPv6 == "" { + if c.IPv6 == "" && len(c.IPv6Addresses) == 0 { return nil } + c.NormalizeNetworkAssignments() if c.IPv6Interface == "" { status := m.DetectIPv6Status() if len(status.Prefixes) == 0 { @@ -480,32 +1517,59 @@ func (m *Manager) ApplyIPv6(id int) error { } c.IPv6Interface = status.Prefixes[0].Interface c.IPv6PrefixLen = status.Prefixes[0].PrefixLen + for i := range c.IPv6Addresses { + if c.IPv6Addresses[i].Interface == "" { + c.IPv6Addresses[i].Interface = c.IPv6Interface + } + if c.IPv6Addresses[i].PrefixLen == 0 { + c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen + } + } config.SaveConfig() } rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs") if _, err := os.Stat(rootfsPath); err == nil { - if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err) } } - if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil { - return err + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + if err := ensureHostIPv6Routing(assignment.Address, uplink); err != nil { + return err + } } status, _ := m.GetContainerStatus(c.LxcName()) if status != "running" { return nil } + addrs := shellQuotedIPv6List(c.IPv6AddressStrings()) cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", - fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0 metric 100", - shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal))) + fmt.Sprintf("for ip in %s; do ip -6 addr replace \"$ip/128\" dev eth0; done && ip -6 route replace default via %s dev eth0 metric 100", + addrs, shellQuote(ipv6GatewayLinkLocal))) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) } - removeIPv6NAT66(c.IPv6, c.IPv6Interface) + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + removeIPv6NAT66(assignment.Address, uplink) + } if !containerIPv6ConnectivityOK(c.LxcName()) { - ensureIPv6NAT66(c.IPv6, c.IPv6Interface) + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + ensureIPv6NAT66(assignment.Address, uplink) + } } return nil } @@ -529,12 +1593,15 @@ func ensureHostIPv6Routing(ipv6, uplink string) error { return nil } -func installContainerIPv6Init(rootfsPath, ipv6 string) error { - if strings.TrimSpace(ipv6) == "" { +func installContainerIPv6Init(rootfsPath string, ipv6s ...string) error { + ipv6s = normalizeIPv6List(ipv6s) + if len(ipv6s) == 0 { return nil } - if _, err := netip.ParseAddr(ipv6); err != nil { - return fmt.Errorf("invalid IPv6 address %q: %w", ipv6, err) + for _, ipv6 := range ipv6s { + if _, err := netip.ParseAddr(ipv6); err != nil { + return fmt.Errorf("invalid IPv6 address %q: %w", ipv6, err) + } } scriptPath := filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init") @@ -542,7 +1609,7 @@ func installContainerIPv6Init(rootfsPath, ipv6 string) error { return err } script := `#!/bin/sh -IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `" IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` IFACE="${CLICD_IPV6_IFACE:-eth0}" @@ -558,7 +1625,9 @@ while [ "$i" -lt 30 ]; do done ip link set dev "$IFACE" up >/dev/null 2>&1 || true -ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" >/dev/null 2>&1 || true +for IPV6_ADDR in $IPV6_ADDRS; do + ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" >/dev/null 2>&1 || true +done ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 >/dev/null 2>&1 || true exit 0 ` diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 4af2949..1229559 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -218,24 +218,34 @@ func NewManager() *Manager { // ContainerConfig defines container creation parameters type ContainerConfig struct { - Name string `json:"name"` - Virtualization string `json:"virtualization,omitempty"` - TemplateID string `json:"template_id"` - VCPU float64 `json:"vcpu"` - CPUPercent int `json:"cpu_percent"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` // "total" or "in_out" - TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited - TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited - IOSpeedMBps int `json:"io_speed_mbps"` - ExtraPorts []int `json:"extra_ports"` - PortMappingCount int `json:"port_mapping_count"` - SnapshotLimit int `json:"snapshot_limit"` - AssignIPv6 bool `json:"assign_ipv6"` - ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` + TemplateID string `json:"template_id"` + VCPU float64 `json:"vcpu"` + CPUPercent int `json:"cpu_percent"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited + IOSpeedMBps int `json:"io_speed_mbps"` + ExtraPorts []int `json:"extra_ports"` + PortMappingCount int `json:"port_mapping_count"` + AssignNAT *bool `json:"assign_nat,omitempty"` + SnapshotLimit int `json:"snapshot_limit"` + AssignIPv4 bool `json:"assign_ipv4"` + IPv4Count int `json:"ipv4_count,omitempty"` + PublicIPv4s []string `json:"public_ipv4s,omitempty"` + AssignIPv6 bool `json:"assign_ipv6"` + IPv6Count int `json:"ipv6_count,omitempty"` + IPv6Addresses []string `json:"ipv6_addresses,omitempty"` + ExpiresAt string `json:"expires_at"` +} + +func (cfg ContainerConfig) WantsNAT() bool { + return cfg.AssignNAT == nil || *cfg.AssignNAT } // CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally. @@ -244,8 +254,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { if tmpl == nil { return fmt.Errorf("template not found: %s", cfg.TemplateID) } - if cfg.PortMappingCount < 2 { + if cfg.WantsNAT() && cfg.PortMappingCount < 2 { cfg.PortMappingCount = 2 + } else if !cfg.WantsNAT() { + cfg.PortMappingCount = 0 + cfg.ExtraPorts = nil } if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit @@ -296,50 +309,64 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { return err } - ipv6 := "" - ipv6PrefixLen := 0 - ipv6Interface := "" - if cfg.AssignIPv6 { - assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4) + if err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + + ipv6Assignments := []config.IPv6Assignment{} + if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 { + assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true) if err != nil { _ = m.cleanupContainerStorage(lxcName) return err } - ipv6 = assigned - ipv6PrefixLen = prefixLen - ipv6Interface = iface - if err := m.applyIPv6Config(lxcName, ipv6); err != nil { + ipv6Assignments = assigned + if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { _ = m.cleanupContainerStorage(lxcName) return err } } - sshPort := config.AllocateSSHPort() sshPassword := generateRandomString(16) - // Setup default port mappings (SSH only) - portMappings := SetupDefaultPortMappings(sshPort) - tempC := &config.Container{PortMappings: portMappings} + sshPort := 0 + portMappings := []config.PortMapping{} + if cfg.WantsNAT() { + sshPort = config.AllocateSSHPort() - extraPorts := cfg.ExtraPorts - if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { - extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) - } - for _, containerPort := range extraPorts { - if containerPort <= 0 { - continue + // Setup default port mappings (SSH only) + portMappings = SetupDefaultPortMappings(sshPort) + defaultHostIP := defaultPortMappingHostIP(publicIPv4s) + if defaultHostIP != "" { + for i := range portMappings { + portMappings[i].HostIP = defaultHostIP + } } - pm, err := normalizePortMapping(tempC, -1, config.PortMapping{ - ContainerPort: containerPort, - HostPort: containerPort, - Protocol: "tcp", - Description: fmt.Sprintf("Port-%d", containerPort), - }) - if err != nil { - continue + tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings} + + extraPorts := cfg.ExtraPorts + if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { + extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) + } + for _, containerPort := range extraPorts { + if containerPort <= 0 { + continue + } + pm, err := normalizePortMapping(tempC, -1, config.PortMapping{ + ContainerPort: containerPort, + HostPort: containerPort, + HostIP: defaultHostIP, + Protocol: "tcp", + Description: fmt.Sprintf("Port-%d", containerPort), + }) + if err != nil { + continue + } + tempC.PortMappings = append(tempC.PortMappings, pm) + portMappings = tempC.PortMappings } - tempC.PortMappings = append(tempC.PortMappings, pm) - portMappings = tempC.PortMappings } now := time.Now().Format("2006-01-02 15:04:05") @@ -368,9 +395,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { IOSpeedMBps: cfg.IOSpeedMBps, Status: "stopped", IP: "", - IPv6: ipv6, - IPv6PrefixLen: ipv6PrefixLen, - IPv6Interface: ipv6Interface, + PublicIPv4s: publicIPv4s, + IPv6Addresses: ipv6Assignments, VNCPort: 0, SSHPort: sshPort, SSHPassword: sshPassword, @@ -380,13 +406,14 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { CreatedAt: now, ExpiresAt: cfg.ExpiresAt, } + container.NormalizeNetworkAssignments() config.AddContainer(container) // Pre-configure network and SSH in the rootfs before first boot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, cfg.TemplateID) - if ipv6 != "" { - if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil { + if len(ipv6Assignments) > 0 { + if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) } } @@ -525,7 +552,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error if err != nil { return err } - apparmorProfile, err := findAppArmorProfile() + apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID) if err != nil { return err } @@ -940,6 +967,26 @@ func findAppArmorProfile() (string, error) { return "", errors.New("required LXC AppArmor profile not loaded") } +func appArmorProfileForTemplate(templateID string) (string, error) { + if systemdTemplateNeedsUnconfinedAppArmor(templateID) { + return "unconfined", nil + } + return findAppArmorProfile() +} + +func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool { + id := strings.ToLower(strings.TrimSpace(templateID)) + if id == "" || strings.Contains(id, "alpine") { + return false + } + for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} { + if strings.Contains(id, token) { + return true + } + } + return false +} + func unprivilegedIDMap() (int, int, error) { if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil { return 0, 0, err @@ -1197,27 +1244,31 @@ func (m *Manager) StartContainer(id int) error { NetworkBWMbps: c.NetworkBWMbps, MonthlyTrafficGB: c.MonthlyTrafficGB, IOSpeedMBps: c.IOSpeedMBps, - AssignIPv6: c.IPv6 != "", + AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, ExpiresAt: c.ExpiresAt, }); err != nil { return err } } - if c.IPv6 != "" { - if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { + c.NormalizeNetworkAssignments() + if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil { return err } if err := m.ApplyIPv6(id); err != nil { return err } } + EnsureAssignedPublicIPv4s(c.PublicIPv4s) - logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") - os.Remove(logFile) - cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG") - output, err := cmd.CombinedOutput() + logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName) if err != nil { - return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80)) + config.UpdateContainerStatus(id, "stopped") + return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80)) + } + if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil { + config.UpdateContainerStatus(id, "stopped") + return err } config.UpdateContainerStatus(id, "running") @@ -1262,7 +1313,7 @@ func (m *Manager) StartContainer(id int) error { if err := m.ApplyPortMappings(id); err != nil { fmt.Printf("Warning: failed to apply port mappings: %v\n", err) } - if c.IPv6 != "" { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.ApplyIPv6(id); err != nil { fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err) } @@ -1272,6 +1323,41 @@ func (m *Manager) StartContainer(id int) error { return nil } +func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) { + logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") + consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log") + os.Remove(logFile) + os.Remove(consoleLog) + cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog) + output, err := cmd.CombinedOutput() + return logFile, consoleLog, output, err +} + +func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error { + runningChecks := 0 + lastStatus := "unknown" + for retry := 0; retry < 10; retry++ { + time.Sleep(1 * time.Second) + status, err := m.GetContainerStatus(lxcName) + if err != nil { + lastStatus = "unknown" + continue + } + lastStatus = status + if status == "running" { + runningChecks++ + if runningChecks >= 3 { + return nil + } + continue + } + if runningChecks > 0 || retry >= 1 { + break + } + } + return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80)) +} + // applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface // ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container. func (m *Manager) ApplyContainerLimits(c *config.Container) error { @@ -1553,8 +1639,17 @@ func (m *Manager) DestroyContainer(id int) error { return fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() - if c.IPv6 != "" && c.IPv6Interface != "" { - removeHostIPv6Routing(c.IPv6, c.IPv6Interface) + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { + c.NormalizeNetworkAssignments() + for _, assignment := range c.IPv6Addresses { + uplink := assignment.Interface + if uplink == "" { + uplink = c.IPv6Interface + } + if uplink != "" { + removeHostIPv6Routing(assignment.Address, uplink) + } + } } if err := m.StopContainer(id); err != nil { @@ -1799,6 +1894,11 @@ install_sshd() { return 1 } +ensure_sshd_runtime_dir() { + mkdir -p /run/sshd /var/run/sshd + chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true +} + set_sshd_option() { key="$1" value="$2" @@ -1825,7 +1925,8 @@ set_sshd_option() { install_sshd || exit 30 -mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d +mkdir -p /etc/ssh /etc/ssh/sshd_config.d +ensure_sshd_runtime_dir ssh-keygen -A >/dev/null 2>&1 || true cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF' @@ -1858,6 +1959,7 @@ if command -v chkconfig >/dev/null 2>&1; then fi SSHD_BIN="$(sshd_path)" || exit 32 +ensure_sshd_runtime_dir "$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || { cat /tmp/clicd-sshd-test.log exit 32 @@ -1872,6 +1974,7 @@ if command -v systemctl >/dev/null 2>&1; then systemctl stop ssh.socket 2>/dev/null || true systemctl disable ssh.socket 2>/dev/null || true fi +ensure_sshd_runtime_dir if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true fi @@ -1882,9 +1985,22 @@ service ssh restart >/dev/null 2>&1 || /etc/init.d/sshd restart >/dev/null 2>&1 || true +ensure_sshd_runtime_dir + +for i in 1 2 3 4 5; do + if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then + exit 0 + fi + if pgrep -x sshd >/dev/null 2>&1; then + exit 0 + fi + sleep 1 +done + if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true rm -f /run/sshd.pid /var/run/sshd.pid + ensure_sshd_runtime_dir "$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32 fi @@ -2425,14 +2541,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { NetworkBWMbps: c.NetworkBWMbps, MonthlyTrafficGB: c.MonthlyTrafficGB, IOSpeedMBps: c.IOSpeedMBps, - AssignIPv6: c.IPv6 != "", + AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, ExpiresAt: c.ExpiresAt, } if err := m.applyResourceLimits(lxcName, cfg); err != nil { return err } - if c.IPv6 != "" { - if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { + c.NormalizeNetworkAssignments() + if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil { return err } } @@ -2440,8 +2557,8 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { // Set root password and pre-configure network/SSH via chroot. rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") m.preconfigureNetwork(rootfsPath, templateID) - if c.IPv6 != "" { - if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { + if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err) } } @@ -2470,14 +2587,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { config.SaveConfig() return err } - logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log") - os.Remove(logFile) - startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG") - if output, err := startCmd.CombinedOutput(); err != nil { + logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName) + if err != nil { fmt.Printf("Warning: failed to start container after reinstall: %v\n", err) c.Status = "stopped" config.SaveConfig() - return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80)) + return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80)) + } + if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil { + c.Status = "stopped" + config.SaveConfig() + return fmt.Errorf("reinstalled but container did not stay running: %v", err) } // Wait for network and install SSH @@ -2503,7 +2623,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { if c.NetworkBWMbps > 0 { m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps) } - if c.IPv6 != "" { + if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if err := m.ApplyIPv6(id); err != nil { fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err) } diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index d41c473..ddc577e 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -2,8 +2,10 @@ package lxc import ( "fmt" + "net/netip" "os/exec" "strconv" + "strings" "clicd/internal/config" ) @@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error { if c.IP == "" { return fmt.Errorf("container has no IP") } + EnsureAssignedPublicIPv4s(c.PublicIPv4s) tag := clicdTag(id) bridge := "lxcbr0" subnet := "10.0.3.0/24" @@ -27,35 +30,180 @@ func (m *Manager) ApplyPortMappings(id int) error { EnsureForwardRules(bridge) m.CleanPortMappings(id) + deleteBridgeMasquerade(subnet) for _, pm := range c.PortMappings { - cmd := exec.Command("iptables", - "-t", "nat", - "-I", "PREROUTING", "1", - "-p", pm.Protocol, - "--dport", fmt.Sprintf("%d", pm.HostPort), - "-j", "DNAT", - "--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort), - "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort), - ) - output, err := cmd.CombinedOutput() - if err != nil { - fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n", - pm.HostPort, c.IP, pm.ContainerPort, err, string(output)) - continue + for _, hostIP := range expandPortMappingHostIPs(c, pm) { + args := []string{ + "-t", "nat", + "-I", "PREROUTING", "1", + "-p", pm.Protocol, + } + if hostIP != "" { + args = append(args, "-d", hostIP) + } + args = append(args, + "--dport", fmt.Sprintf("%d", pm.HostPort), + "-j", "DNAT", + "--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort), + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort), + ) + cmd := exec.Command("iptables", args...) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n", + displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output)) + continue + } + fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort) } - fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort) } - if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil { - exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() - } + applyIPv4EgressPolicy(c, bridge, subnet, tag) return nil } +func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) { + if c == nil || strings.TrimSpace(c.IP) == "" { + return + } + if containerAllowsPublicIPv4Egress(c) { + if _, ok := primaryPublicIPv4Assignment(c); ok { + applyPublicIPv4SNAT(c, tag) + return + } + ensureContainerMasquerade(c, tag) + return + } + ensureIPv4EgressBlocked(c, bridge, subnet, tag) +} + +func containerAllowsPublicIPv4Egress(c *config.Container) bool { + if c == nil { + return false + } + if len(c.PublicIPv4s) > 0 { + return true + } + return c.PortMappingLimit > 0 || len(c.PortMappings) > 0 +} + +func ensureContainerMasquerade(c *config.Container, tag string) { + args := []string{ + "-s", c.IP + "/32", + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag), + "-j", "MASQUERADE", + } + if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" { + args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...) + } else { + args = append([]string{"-o", "eth+"}, args...) + } + ensureNATRule("POSTROUTING", args) +} + +func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) { + args := []string{ + "-i", bridge, + "-s", c.IP + "/32", + "!", "-d", subnet, + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag), + "-j", "REJECT", + } + ensureFilterRule("FORWARD", args) +} + +func ensureNATRule(chain string, args []string) { + check := append([]string{"-t", "nat", "-C", chain}, args...) + if exec.Command("iptables", check...).Run() == nil { + return + } + add := append([]string{"-t", "nat", "-I", chain, "1"}, args...) + exec.Command("iptables", add...).Run() +} + +func ensureFilterRule(chain string, args []string) { + check := append([]string{"-C", chain}, args...) + if exec.Command("iptables", check...).Run() == nil { + return + } + add := append([]string{"-I", chain, "1"}, args...) + exec.Command("iptables", add...).Run() +} + +func deleteBridgeMasquerade(subnet string) { + for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil { + } +} + +func applyPublicIPv4SNAT(c *config.Container, tag string) { + if c == nil || strings.TrimSpace(c.IP) == "" { + return + } + assignment, ok := primaryPublicIPv4Assignment(c) + if !ok { + return + } + hostIP := strings.TrimSpace(assignment.Address) + if hostIP == "" { + return + } + iface := strings.TrimSpace(assignment.Interface) + if iface == "" { + if info, ok := publicIPv4InfoByAddress(hostIP); ok { + iface = strings.TrimSpace(info.Interface) + } + } + if iface == "" { + if host := DetectPublicIPv4(); host.Interface != "" { + iface = host.Interface + } + } + args := []string{ + "-t", "nat", + "-I", "POSTROUTING", "1", + "-s", c.IP + "/32", + } + if iface != "" { + args = append(args, "-o", iface) + } + args = append(args, + "-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)), + "-j", "SNAT", "--to-source", hostIP, + ) + if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil { + fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output)) + } +} + +func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) { + if c == nil { + return config.PublicIPv4Assignment{}, false + } + for _, item := range c.PublicIPv4s { + if strings.TrimSpace(item.Address) != "" { + return item, true + } + } + return config.PublicIPv4Assignment{}, false +} + func clicdTag(id int) string { return "c" + strconv.Itoa(id) } +func EnsureAllRunningPortMappings() { + m := NewManager() + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if c.Status != "running" || strings.TrimSpace(c.IP) == "" { + continue + } + if err := m.ApplyPortMappings(c.ID); err != nil { + fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err) + } + } +} + // EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic. func EnsureForwardRules(bridge string) { if bridge == "" { @@ -81,8 +229,13 @@ func EnsureForwardRules(bridge string) { // CleanPortMappings removes all iptables rules for a container func (m *Manager) CleanPortMappings(id int) error { tag := clicdTag(id) + for _, chain := range []string{"PREROUTING", "POSTROUTING"} { + cmd := exec.Command("sh", "-c", + fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain)) + cmd.Run() + } cmd := exec.Command("sh", "-c", - fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag)) + fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag)) cmd.Run() return nil } @@ -94,12 +247,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping { } } +func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string { + if len(assignments) == 1 { + return strings.TrimSpace(assignments[0].Address) + } + return "" +} + +func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string { + return DefaultPortMappingHostIP(assignments) +} + // AddPortMapping adds a NAT rule to a container func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) { c := config.FindContainer(id) if c == nil { return nil, fmt.Errorf("container not found: %d", id) } + if c.PortMappingLimit <= 0 { + return nil, fmt.Errorf("container has no IPv4 NAT port quota") + } if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit { return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit) } @@ -168,6 +335,17 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp if pm.Protocol == "" { pm.Protocol = "tcp" } + pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol)) + pm.HostIP = strings.TrimSpace(pm.HostIP) + if pm.HostIP != "" { + addr, err := netip.ParseAddr(pm.HostIP) + if err != nil || !addr.Is4() { + return pm, fmt.Errorf("host_ip must be a valid IPv4 address") + } + if !containerHasPublicIPv4(c, pm.HostIP) { + return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP) + } + } if pm.Description == "" { pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort) } @@ -179,8 +357,8 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp if i == skipIndex { continue } - if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol { - return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol) + if portMappingsConflict(c, pm, c, existing) { + return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol) } } // Check all other containers (LXC + KVM) for port conflicts @@ -189,8 +367,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp continue } for _, existing := range oc.PortMappings { - if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol { - return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID) + oc := oc + if portMappingsConflict(c, pm, &oc, existing) { + return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID) } } } @@ -204,7 +383,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int { used := map[int]bool{} // Mark current container's ports for _, pm := range c.PortMappings { - used[pm.HostPort] = true + for _, hostIP := range expandPortMappingHostIPs(c, pm) { + used[hostPortKey(hostIP, pm.HostPort)] = true + } used[pm.ContainerPort] = true } // Also mark all other containers' host ports (LXC + KVM) @@ -213,13 +394,17 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int { continue } for _, pm := range oc.PortMappings { - used[pm.HostPort] = true + oc := oc + for _, hostIP := range expandPortMappingHostIPs(&oc, pm) { + used[hostPortKey(hostIP, pm.HostPort)] = true + } } } ports := make([]int, 0, count) next := 20000 for len(ports) < count { - if !used[next] { + hostIP := c.PrimaryPublicIPv4() + if !used[hostPortKey(hostIP, next)] && !used[next] { ports = append(ports, next) } next++ @@ -229,3 +414,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int { } return ports } + +func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool { + if c == nil || hostPort <= 0 { + return false + } + pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol} + for _, existing := range c.PortMappings { + if portMappingsConflict(c, pm, c, existing) { + return false + } + } + for _, oc := range config.AppConfig.Containers { + if oc.ID == c.ID { + continue + } + oc := oc + for _, existing := range oc.PortMappings { + if portMappingsConflict(c, pm, &oc, existing) { + return false + } + } + } + return true +} + +func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string { + if strings.TrimSpace(pm.HostIP) != "" { + return []string{strings.TrimSpace(pm.HostIP)} + } + if c != nil && len(c.PublicIPv4s) > 0 { + values := make([]string, 0, len(c.PublicIPv4s)) + for _, item := range c.PublicIPv4s { + if strings.TrimSpace(item.Address) != "" { + values = append(values, strings.TrimSpace(item.Address)) + } + } + if len(values) > 0 { + return values + } + } + return []string{""} +} + +func containerHasPublicIPv4(c *config.Container, hostIP string) bool { + if c == nil { + return false + } + for _, item := range c.PublicIPv4s { + if item.Address == hostIP { + return true + } + } + return false +} + +func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool { + if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) { + return false + } + aIPs := expandPortMappingHostIPs(aContainer, a) + bIPs := expandPortMappingHostIPs(bContainer, b) + for _, aIP := range aIPs { + for _, bIP := range bIPs { + if aIP == "" || bIP == "" || aIP == bIP { + return true + } + } + } + return false +} + +func protocolsOverlap(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == "" { + a = "tcp" + } + if b == "" { + b = "tcp" + } + if a == b || a == "all" || b == "all" { + return true + } + return (a == "tcp+udp" && (b == "tcp" || b == "udp")) || + (b == "tcp+udp" && (a == "tcp" || a == "udp")) +} + +func natRuleIPTag(ip string) string { + ip = strings.TrimSpace(ip) + if ip == "" { + return "any" + } + return strings.ReplaceAll(ip, ".", "_") +} + +func displayHostIP(ip string) string { + if strings.TrimSpace(ip) == "" { + return "host" + } + return ip +} + +func hostPortKey(hostIP string, port int) int { + if hostIP == "" { + return port + } + sum := 0 + for _, r := range hostIP { + sum = sum*31 + int(r) + } + if sum < 0 { + sum = -sum + } + return port + (sum % 1000000 * 100000) +} diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go index bfe8a92..c608a95 100644 --- a/backend/internal/lxc/templates.go +++ b/backend/internal/lxc/templates.go @@ -46,17 +46,17 @@ func GetTemplates() []Template { }, { ID: "archlinux-current", Name: "Arch Linux", - Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud", + Distro: "archlinux", Release: "current", Arch: "amd64", Description: "Arch Linux (Rolling)", }, { ID: "fedora-44", Name: "Fedora 44", - Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud", + Distro: "fedora", Release: "44", Arch: "amd64", Description: "Fedora 44", }, { ID: "rockylinux-10", Name: "Rocky Linux 10", - Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud", + Distro: "rockylinux", Release: "10", Arch: "amd64", Description: "Rocky Linux 10", }, } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d1b0a06..4513693 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -92,6 +92,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) + mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) @@ -134,6 +135,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots)))) + mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan))) mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting))) mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index 30259b2..e69de29 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -1 +0,0 @@ - diff --git a/backend/main.go b/backend/main.go index 317ea59..a2f7e44 100644 --- a/backend/main.go +++ b/backend/main.go @@ -55,6 +55,7 @@ func main() { // Ensure iptables FORWARD rules allow managed bridge traffic. lxc.EnsureForwardRules("lxcbr0") lxc.EnsureForwardRules("virbr0") + lxc.EnsureAllAssignedPublicIPv4s() // Start expiry scanners (stops expired/over-traffic workloads every 30s) manager := lxc.NewManager() @@ -74,6 +75,7 @@ func main() { // Clean up stale container configs (LXC dir was deleted but config remains) config.CleanStaleContainers() + lxc.EnsureAllRunningPortMappings() // Pre-warm SSH for containers already running after host boot or service restart. manager.StartSSHWarmupScanner() diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 4c8283c..62a01a8 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import { CalendarClock, X } from 'lucide-react' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' import { useDialog } from './Dialog' +import { useLanguage, type Language } from '../contexts/LanguageContext' interface CreateContainerModalProps { isOpen: boolean @@ -26,13 +27,21 @@ const defaultForm: CreateContainerRequest = { io_speed_mbps: 0, extra_ports: [], port_mapping_count: 2, + assign_nat: true, snapshot_limit: 1, + assign_ipv4: false, + ipv4_count: 1, + public_ipv4s: [], assign_ipv6: false, + ipv6_count: 1, + ipv6_addresses: [], expires_at: '', } export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { const dialog = useDialog() + const { language } = useLanguage() + const networkText = createNetworkText[language] const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(false) const [batchCount, setBatchCount] = useState(1) @@ -74,16 +83,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist }, [isOpen, form.virtualization]) const ipv6Available = !!ipv6Status?.available - const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' + const ipv6Prefixes = ipv6Status?.prefixes || [] + const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '') + const publicIPv4s = hostInfo?.network.public_ipv4_addresses || [] + const ipv4Available = publicIPv4s.length > 0 + const manualIPv4s = form.public_ipv4s || [] const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) + const natEnabled = form.assign_nat !== false + const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 const autoPorts = useMemo(() => { - const count = Math.max(2, form.port_mapping_count) + if (!natEnabled) return [] + const count = natPortCount return Array.from({ length: count - 1 }, (_, index) => 22002 + index) - }, [form.port_mapping_count]) + }, [natEnabled, natPortCount]) // SSH port preview (will be allocated sequentially, starting around 22000+) const sshPortPreview = 22000 @@ -127,7 +143,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist return } + if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) { + dialog.alert('提示', '请勾选任意一个可用网络') + return + } + const boundedForm = normalizeCreateForm(form) + const wantsNAT = boundedForm.assign_nat !== false // Build batch of containers const containers: CreateContainerRequest[] = [] @@ -137,8 +159,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist containers.push({ ...boundedForm, name, - port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), + assign_nat: wantsNAT, + port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0, snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), + ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0, + ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0, extra_ports: [], }) } @@ -229,21 +254,157 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist -