From 38debab1aaa6d556315cf9ca8658aebd7b174e03 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:04:45 +0800 Subject: [PATCH] Add custom image handling and access policy management - Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected. - Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies. - Add NAT network configuration with validation for RFC1918 compliance and subnet parsing. - Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers. - Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests. - Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives. - Include comprehensive tests for all new functionalities to ensure reliability and correctness. --- .gitignore | 1 + backend/internal/api/access_policy.go | 94 ++++ backend/internal/api/images.go | 333 +++++++++++++- backend/internal/api/images_custom_test.go | 83 ++++ backend/internal/api/routing.go | 13 + backend/internal/api/routing_test.go | 42 ++ backend/internal/api/taskqueue.go | 15 +- backend/internal/cli/access_policy.go | 86 ++++ backend/internal/cli/cli.go | 104 ++++- backend/internal/config/config.go | 196 +++++++- backend/internal/config/nat_network.go | 144 ++++++ backend/internal/config/nat_network_test.go | 56 +++ backend/internal/config/nat_test.go | 24 + backend/internal/config/panel_access.go | 198 ++++++++ backend/internal/config/panel_access_test.go | 146 ++++++ backend/internal/config/store_sqlite.go | 19 + backend/internal/config/store_sqlite_test.go | 46 ++ backend/internal/kvm/kvm.go | 78 +++- backend/internal/kvm/kvm_test.go | 122 +++++ backend/internal/kvm/templates.go | 54 ++- backend/internal/lxc/custom_images.go | 310 +++++++++++++ backend/internal/lxc/custom_images_test.go | 61 +++ backend/internal/lxc/lxc.go | 115 +++-- backend/internal/lxc/lxc_test.go | 177 ++++++++ backend/internal/lxc/portmap.go | 416 +++++++++++++++-- backend/internal/lxc/templates.go | 28 +- backend/internal/server/access_policy.go | 39 ++ backend/internal/server/access_policy_test.go | 46 ++ backend/internal/server/server.go | 6 +- backend/main.go | 9 + docs/en/features/api.md | 22 + docs/en/guide/configuration.md | 17 + docs/en/guide/installation.md | 2 + docs/features/api.md | 22 + docs/guide/configuration.md | 17 + docs/guide/installation.md | 2 + .../src/components/CreateContainerModal.tsx | 414 ++++++++++++++--- frontend/src/components/Layout.tsx | 2 +- frontend/src/index.css | 3 + frontend/src/pages/ApiIntegration.tsx | 46 +- frontend/src/pages/Containers.tsx | 1 + frontend/src/pages/ImageManagement.tsx | 294 +++++++++++- frontend/src/pages/Login.tsx | 18 +- frontend/src/pages/Routing.tsx | 6 +- frontend/src/pages/Settings.tsx | 182 +++++++- frontend/src/pages/Storage.tsx | 82 ++-- frontend/src/services/api.ts | 65 ++- frontend/src/utils/i18n.ts | 73 +++ install.sh | 421 +++++++++++++++++- 49 files changed, 4504 insertions(+), 246 deletions(-) create mode 100644 backend/internal/api/access_policy.go create mode 100644 backend/internal/api/images_custom_test.go create mode 100644 backend/internal/cli/access_policy.go create mode 100644 backend/internal/config/nat_network.go create mode 100644 backend/internal/config/nat_network_test.go create mode 100644 backend/internal/config/panel_access.go create mode 100644 backend/internal/config/panel_access_test.go create mode 100644 backend/internal/lxc/custom_images.go create mode 100644 backend/internal/lxc/custom_images_test.go create mode 100644 backend/internal/server/access_policy.go create mode 100644 backend/internal/server/access_policy_test.go diff --git a/.gitignore b/.gitignore index 202d44c..12cfaac 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ backend/clicd api.md deploy-arm.ps1 deploy-dhcp.ps1 +deploy-pve-windows.ps1 diff --git a/backend/internal/api/access_policy.go b/backend/internal/api/access_policy.go new file mode 100644 index 0000000..eccd3fb --- /dev/null +++ b/backend/internal/api/access_policy.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "clicd/internal/config" +) + +type panelAccessPolicyResponse struct { + Enabled bool `json:"enabled"` + AllowedSources []string `json:"allowed_sources"` + TrustedProxies []string `json:"trusted_proxies"` + CurrentSource string `json:"current_source"` + DirectSource string `json:"direct_source"` + UsingForwarded bool `json:"using_forwarded"` +} + +func HandlePanelAccessPolicy(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: panelAccessPolicyStatus(r, config.AppConfig.PanelAccessPolicy)}) + case http.MethodPut: + updatePanelAccessPolicy(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func updatePanelAccessPolicy(w http.ResponseWriter, r *http.Request) { + var requested config.PanelAccessPolicy + if err := json.NewDecoder(r.Body).Decode(&requested); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + normalized, err := config.NormalizePanelAccessPolicy(requested) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + decision := evaluatePanelRequest(r, normalized) + if normalized.Enabled && !decision.Allowed { + jsonResponse(w, http.StatusBadRequest, APIResponse{ + Success: false, + Message: "The new access policy does not allow your current source address " + decision.CurrentSource, + }) + return + } + + previous := config.AppConfig.PanelAccessPolicy + config.AppConfig.PanelAccessPolicy = normalized + if err := config.SaveConfig(); err != nil { + config.AppConfig.PanelAccessPolicy = previous + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save panel access policy"}) + return + } + detail := "enabled=" + strings.ToLower(strings.TrimSpace(boolText(normalized.Enabled))) + + ",allowed=" + strings.Join(normalized.AllowedSources, ",") + + ",trusted_proxies=" + strings.Join(normalized.TrustedProxies, ",") + auditRequest(r, "settings.panel_access", "Panel access policy", detail, true, "") + jsonResponse(w, http.StatusOK, APIResponse{ + Success: true, + Message: "Panel access policy saved", + Data: panelAccessPolicyStatus(r, normalized), + }) +} + +func panelAccessPolicyStatus(r *http.Request, policy config.PanelAccessPolicy) panelAccessPolicyResponse { + decision := evaluatePanelRequest(r, policy) + return panelAccessPolicyResponse{ + Enabled: policy.Enabled, + AllowedSources: append([]string(nil), policy.AllowedSources...), + TrustedProxies: append([]string(nil), policy.TrustedProxies...), + CurrentSource: decision.CurrentSource, + DirectSource: decision.DirectSource, + UsingForwarded: decision.UsedForwarded, + } +} + +func evaluatePanelRequest(r *http.Request, policy config.PanelAccessPolicy) config.PanelAccessDecision { + return config.EvaluatePanelAccess(policy, r.RemoteAddr, config.ForwardedClientHeaders{ + ForwardedFor: r.Header.Get("X-Forwarded-For"), + RealIP: r.Header.Get("X-Real-IP"), + CFConnectingIP: r.Header.Get("CF-Connecting-IP"), + }) +} + +func boolText(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 522f73e..2c5e39d 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -2,12 +2,16 @@ package api import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "net/http" + "net/url" "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "sync" @@ -38,8 +42,14 @@ type ImageInfo struct { SizeBytes int64 `json:"size_bytes"` ManualPath string `json:"manual_path,omitempty"` Desktop string `json:"desktop,omitempty"` + Provisioner string `json:"provisioner,omitempty"` + Custom bool `json:"custom,omitempty"` + SHA256 string `json:"sha256,omitempty"` } +var customImageFieldPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) +var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) + var imageDownloadsMu sync.Mutex var imageDownloads = map[string]*imageDownloadStatus{} var lxcImageCacheMu sync.Mutex @@ -217,6 +227,13 @@ func imageDownloadedInfo(distro, release, arch string) (bool, int64) { return false, 0 } +func lxcTemplateDownloadedInfo(template lxc.Template) (bool, int64) { + if template.Custom { + return lxc.CustomImageDownloadedInfo(template.ID) + } + return imageDownloadedInfo(template.Distro, template.Release, template.Arch) +} + // getEnabledImageSet returns the set of enabled image IDs. // If none have been explicitly set, all templates are enabled by default. func getEnabledImageSet() map[string]bool { @@ -258,7 +275,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { images := make([]ImageInfo, 0, len(templates)+len(kvmImages)) for _, t := range templates { dl := imageDownloadInfo(t.ID) - downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) + downloaded, size := lxcTemplateDownloadedInfo(t) images = append(images, ImageInfo{ ID: t.ID, Name: t.Name, @@ -276,13 +293,15 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { Stage: dl.Stage, Error: dl.Error, SizeBytes: size, + Custom: t.Custom, + SHA256: t.SHA256, }) } for _, t := range kvmImages { dl := imageDownloadInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID) manualPath := "" - if t.Distro == "windows" { + if t.IsWindows() { manualPath = kvm.ImagePath(t.ID) } images = append(images, ImageInfo{ @@ -304,12 +323,261 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { SizeBytes: size, ManualPath: manualPath, Desktop: t.Desktop, + Provisioner: t.Provisioner, + Custom: t.Custom, + SHA256: t.SHA256, }) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) } +// HandleCustomKVMImages creates or removes administrator-defined LXC/KVM image sources. +func HandleCustomKVMImages(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if !requireScope(w, r, "image:download") { + return + } + handleCustomKVMImageCreate(w, r) + case http.MethodDelete: + if !requireScope(w, r, "image:delete") { + return + } + handleCustomKVMImageDelete(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func handleCustomKVMImageCreate(w http.ResponseWriter, r *http.Request) { + var req struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + URL string `json:"url"` + Provisioner string `json:"provisioner"` + SHA256 string `json:"sha256"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + req.Name = strings.TrimSpace(req.Name) + req.Type = strings.ToLower(strings.TrimSpace(req.Type)) + if req.Type == "" { + req.Type = config.VirtualizationKVM + } + req.Description = strings.TrimSpace(req.Description) + req.Distro = strings.ToLower(strings.TrimSpace(req.Distro)) + req.Release = strings.ToLower(strings.TrimSpace(req.Release)) + req.Arch = strings.ToLower(strings.TrimSpace(req.Arch)) + req.URL = strings.TrimSpace(req.URL) + req.Provisioner = strings.ToLower(strings.TrimSpace(req.Provisioner)) + req.SHA256 = strings.ToLower(strings.TrimSpace(req.SHA256)) + + if req.Name == "" || len(req.Name) > 100 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "name must be between 1 and 100 characters"}) + return + } + if len(req.Description) > 500 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "description must not exceed 500 characters"}) + return + } + if req.Arch != runtime.GOARCH || (req.Arch != "amd64" && req.Arch != "arm64") { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "image architecture must match the host architecture"}) + return + } + if req.Type == config.VirtualizationLXC { + if !customImageFieldPattern.MatchString(req.Distro) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"}) + return + } + if !customImageFieldPattern.MatchString(req.Release) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"}) + return + } + } else if req.Type == config.VirtualizationKVM { + switch req.Provisioner { + case config.KVMProvisionerLinuxCloudInit: + if !customImageFieldPattern.MatchString(req.Distro) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"}) + return + } + if !customImageFieldPattern.MatchString(req.Release) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"}) + return + } + case config.KVMProvisionerWindows10: + if req.Arch != "amd64" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"}) + return + } + req.Distro = "windows" + req.Release = "10" + case config.KVMProvisionerWindows11: + if req.Arch != "amd64" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"}) + return + } + req.Distro = "windows" + req.Release = "11" + default: + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "unsupported unattended installation template"}) + return + } + } else { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "type must be lxc or kvm"}) + return + } + if len(req.URL) > 4096 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must not exceed 4096 characters"}) + return + } + parsedURL, err := url.ParseRequestURI(req.URL) + if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") || parsedURL.User != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must be a valid HTTP or HTTPS download URL without credentials"}) + return + } + if req.SHA256 != "" && !sha256Pattern.MatchString(req.SHA256) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "sha256 must contain exactly 64 hexadecimal characters"}) + return + } + if req.Type == config.VirtualizationLXC { + for _, existing := range lxc.GetTemplates() { + if strings.EqualFold(existing.Name, req.Name) { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"}) + return + } + if existing.Custom && existing.URL == req.URL { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"}) + return + } + } + } else { + for _, existing := range kvm.GetImages() { + if strings.EqualFold(existing.Name, req.Name) { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"}) + return + } + if existing.Custom && existing.URL == req.URL { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"}) + return + } + } + } + + random := make([]byte, 5) + if _, err := rand.Read(random); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to generate image ID"}) + return + } + createdAt := time.Now().Format("2006-01-02 15:04:05") + if req.Type == config.VirtualizationLXC { + image := config.CustomLXCImage{ + ID: "custom-lxc-" + hex.EncodeToString(random), + Name: req.Name, + Description: req.Description, + Distro: req.Distro, + Release: req.Release, + Arch: req.Arch, + URL: req.URL, + SHA256: req.SHA256, + CreatedAt: createdAt, + } + if err := config.AddCustomLXCImage(image); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()}) + return + } + jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image}) + return + } + image := config.CustomKVMImage{ + ID: "custom-kvm-" + hex.EncodeToString(random), Name: req.Name, Description: req.Description, + Distro: req.Distro, Release: req.Release, Arch: req.Arch, URL: req.URL, + Provisioner: req.Provisioner, SHA256: req.SHA256, CreatedAt: createdAt, + } + if err := config.AddCustomKVMImage(image); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()}) + return + } + jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image}) +} + +func handleCustomKVMImageDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ID) == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "id required"}) + return + } + req.ID = strings.TrimSpace(req.ID) + kvmImage := kvm.FindImage(req.ID) + lxcImage := lxc.FindTemplate(req.ID) + isCustomKVM := kvmImage != nil && kvmImage.Custom + isCustomLXC := lxcImage != nil && lxcImage.Custom + if !isCustomKVM && !isCustomLXC { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"}) + return + } + if isImageDownloadActive(req.ID) { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before removing the source"}) + return + } + for i := range config.AppConfig.Containers { + if config.AppConfig.Containers[i].Template == req.ID { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still used by a container"}) + return + } + } + for i := range config.AppConfig.Tasks { + task := &config.AppConfig.Tasks[i] + if task.Status != "pending" && task.Status != "running" { + continue + } + var taskConfig struct { + TemplateID string `json:"template_id"` + } + _ = json.Unmarshal([]byte(task.Config), &taskConfig) + if task.TemplateID == req.ID || taskConfig.TemplateID == req.ID { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still referenced by an active task"}) + return + } + } + var deleteErr error + if isCustomLXC { + deleteErr = lxc.DeleteCustomImage(req.ID) + } else { + deleteErr = kvm.DeleteImage(req.ID) + } + if deleteErr != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + deleteErr.Error()}) + return + } + removeImageEnabled(req.ID) + var removed bool + var err error + if isCustomLXC { + removed, err = config.RemoveCustomLXCImage(req.ID) + } else { + removed, err = config.RemoveCustomKVMImage(req.ID) + } + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to remove custom image: " + err.Error()}) + return + } + if !removed { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"}) + return + } + clearImageDownload(req.ID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Custom image removed"}) +} + // HandleImageDownload starts a template image download in the background. func HandleImageDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -407,19 +675,52 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { } // Already downloaded? Just enable if needed. - if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { + if downloaded, _ := lxcTemplateDownloadedInfo(*tmpl); downloaded { ensureImageEnabled(tmpl.ID) clearImageDownload(tmpl.ID) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) return } - ctx, ok := startImageDownload(tmpl.ID, "lxc-create") + startStage := "lxc-create" + if tmpl.Custom { + startStage = "downloading" + } + ctx, ok := startImageDownload(tmpl.ID, startStage) if !ok { jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) return } + if tmpl.Custom { + go func(tmpl lxc.Template) { + defer endLXCImageDownload() + err := lxc.DownloadCustomImageWithProgress(ctx, tmpl, func(progress lxc.CustomImageDownloadProgress) { + updateImageDownload(tmpl.ID, func(status *imageDownloadStatus) { + status.Stage = progress.Stage + status.DownloadedBytes = progress.DownloadedBytes + status.TotalBytes = progress.TotalBytes + status.Progress = progress.Percent + }) + }) + if err != nil { + if ctx.Err() != nil { + _ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp") + _ = os.Remove(lxc.CustomImagePath(tmpl.ID)) + finishImageDownload(tmpl.ID, nil) + return + } + finishImageDownload(tmpl.ID, err) + return + } + ensureImageEnabled(tmpl.ID) + finishImageDownload(tmpl.ID, nil) + }(*tmpl) + lxcDownloadHandedOff = true + jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"}) + return + } + go func(tmpl lxc.Template) { defer endLXCImageDownload() // Download via lxc-create with a temp container, then destroy it. @@ -633,7 +934,12 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) { os.Remove(kvm.ImagePath(image.ID)) } if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil { - go cleanupLXCImageDownloadTemp(tmpl.ID) + if tmpl.Custom { + _ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp") + _ = os.Remove(lxc.CustomImagePath(tmpl.ID)) + } else { + go cleanupLXCImageDownloadTemp(tmpl.ID) + } } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"}) } @@ -674,6 +980,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) return } + if tmpl.Custom { + if err := lxc.DeleteCustomImage(tmpl.ID); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()}) + return + } + removeImageEnabled(tmpl.ID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"}) + return + } // Remove cache directory cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch) @@ -773,7 +1088,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) { continue } - if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) { + if downloaded, _ := lxcTemplateDownloadedInfo(t); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) { result = append(result, map[string]string{ "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC, @@ -810,7 +1125,8 @@ func isImageDownloadedForRuntime(templateID string, runtime string) bool { if tmpl == nil { return false } - return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) + downloaded, _ := lxcTemplateDownloadedInfo(*tmpl) + return downloaded } func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool { @@ -844,7 +1160,8 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool { return false } enabledSet := getEnabledImageSet() - return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) + downloaded, _ := lxcTemplateDownloadedInfo(*tmpl) + return enabledSet[tmpl.ID] && downloaded } func hostKVMAvailable() bool { diff --git a/backend/internal/api/images_custom_test.go b/backend/internal/api/images_custom_test.go new file mode 100644 index 0000000..41fadeb --- /dev/null +++ b/backend/internal/api/images_custom_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" +) + +func TestCustomKVMImageCreateRejectsInvalidSource(t *testing.T) { + payload := map[string]string{ + "name": "Invalid Source", + "distro": "ubuntu", + "release": "noble", + "arch": runtime.GOARCH, + "url": "file:///etc/passwd", + "provisioner": "linux-cloud-init", + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body)) + response := httptest.NewRecorder() + + HandleCustomKVMImages(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String()) + } +} + +func TestCustomKVMImageCreateRejectsArchitectureMismatch(t *testing.T) { + otherArch := "arm64" + if runtime.GOARCH == otherArch { + otherArch = "amd64" + } + payload := map[string]string{ + "name": "Wrong Architecture", + "distro": "ubuntu", + "release": "noble", + "arch": otherArch, + "url": "https://example.test/image.qcow2", + "provisioner": "linux-cloud-init", + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body)) + response := httptest.NewRecorder() + + HandleCustomKVMImages(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String()) + } +} + +func TestCustomLXCImageCreateRejectsInvalidSource(t *testing.T) { + payload := map[string]string{ + "type": "lxc", + "name": "Invalid LXC Source", + "distro": "alpine", + "release": "3.21", + "arch": runtime.GOARCH, + "url": "file:///tmp/rootfs.tar.xz", + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body)) + response := httptest.NewRecorder() + + HandleCustomKVMImages(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String()) + } +} diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go index 6405211..ed80e02 100644 --- a/backend/internal/api/routing.go +++ b/backend/internal/api/routing.go @@ -22,6 +22,11 @@ type nat4PortRange struct { End int `json:"end"` } +type nat4Networks struct { + LXC config.NATNetwork `json:"lxc"` + KVM config.NATNetwork `json:"kvm"` +} + type nat4Route struct { ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` @@ -72,6 +77,8 @@ type ipv6Route struct { type routingResponse struct { NAT4 routeCapacity `json:"nat4"` NAT4PortRange nat4PortRange `json:"nat4_port_range"` + NAT4NextPort int `json:"nat4_next_port"` + NAT4Networks nat4Networks `json:"nat4_networks"` IPv4 routeCapacity `json:"ipv4"` LANDHCP routeCapacity `json:"lan_dhcp"` IPv6 routeCapacity `json:"ipv6"` @@ -237,6 +244,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) { if nat4Remaining < 0 { nat4Remaining = 0 } + nat4NextPort, _ := config.PreviewSSHPortExcluding(nil) prefixes := lxc.DetectPublicIPv6Prefixes() hostPublicIPv4 := lxc.DetectPublicIPv4() @@ -262,6 +270,11 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) { Start: nat4StartPort, End: nat4EndPort, }, + NAT4NextPort: nat4NextPort, + NAT4Networks: nat4Networks{ + LXC: config.LXCNATNetwork(), + KVM: config.KVMNATNetwork(), + }, IPv4: routeCapacity{ Used: ipv4Used, Remaining: strconv.Itoa(ipv4Remaining), diff --git a/backend/internal/api/routing_test.go b/backend/internal/api/routing_test.go index aa0cf75..259f671 100644 --- a/backend/internal/api/routing_test.go +++ b/backend/internal/api/routing_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -24,3 +25,44 @@ func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) { t.Fatal("routing:write scope should be able to receive the routing response after updates") } } + +func TestHandleRoutingGetReturnsConfiguredNextNATPort(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 35000, + NextSSHPort: 30000, + Containers: []config.Container{{ + PortMappings: []config.PortMapping{{HostPort: 30000}}, + }}, + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil) + req = withAuthContext(req, AuthContext{ + Type: authTypeAPIKey, + Scopes: []string{"routing:read"}, + }) + rec := httptest.NewRecorder() + handleRoutingGet(rec, req) + + var response struct { + Success bool `json:"success"` + Data struct { + NAT4PortRange nat4PortRange `json:"nat4_port_range"` + NAT4NextPort int `json:"nat4_next_port"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !response.Success { + t.Fatalf("routing response was unsuccessful: %s", rec.Body.String()) + } + if response.Data.NAT4PortRange.Start != 30000 || response.Data.NAT4PortRange.End != 35000 { + t.Fatalf("NAT range = %+v", response.Data.NAT4PortRange) + } + if response.Data.NAT4NextPort != 30001 { + t.Fatalf("next NAT port = %d, want 30001", response.Data.NAT4NextPort) + } +} diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 4e17ee6..e545e07 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -462,6 +462,8 @@ func (q *TaskQueue) runCreateTask(task *Task) { q.finishTask(task, "failed", err) return } + } else { + lxc.ReleaseQueuedCreateNATPorts(cfg.Name) } q.mu.Lock() @@ -866,7 +868,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { } requestNames[name] = true } - ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent()) + planned, err := lxc.ReserveBatchCreateNATPorts(req.Containers) + if err != nil { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()}) + return + } + ids := globalQueue.EnqueueBatchCreateWithAudit(planned, requestActor(r), clientIP(r), r.UserAgent()) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) } @@ -988,7 +995,8 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) { return } globalQueue.mu.Lock() - if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) { + task := globalQueue.tasks[taskID] + if task != nil && !isTaskAllowedForRequest(r, task) { globalQueue.mu.Unlock() jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"}) return @@ -1011,6 +1019,9 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) { globalQueue.opQueue = newOp globalQueue.persistTasks() globalQueue.mu.Unlock() + if task != nil && task.Type == TaskCreate { + lxc.ReleaseQueuedCreateNATPorts(task.Config.Name) + } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"}) } diff --git a/backend/internal/cli/access_policy.go b/backend/internal/cli/access_policy.go new file mode 100644 index 0000000..31eba50 --- /dev/null +++ b/backend/internal/cli/access_policy.go @@ -0,0 +1,86 @@ +package cli + +import ( + "flag" + "fmt" + "strings" + + "clicd/internal/config" +) + +// RunAccessPolicyCommand manages the panel source policy without requiring the +// interactive menu. It is intended to remain usable over SSH as a recovery path. +func RunAccessPolicyCommand(args []string) error { + action := "show" + if len(args) > 0 { + action = strings.ToLower(strings.TrimSpace(args[0])) + args = args[1:] + } + + switch action { + case "show": + printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy) + return nil + case "disable", "off": + next := config.AppConfig.PanelAccessPolicy + next.Enabled = false + if err := savePanelAccessPolicy(next); err != nil { + return err + } + fmt.Println("Panel access allowlist disabled.") + return reloadPanelAfterAccessPolicyCommand() + case "set", "enable": + flags := flag.NewFlagSet("clicd access-policy set", flag.ContinueOnError) + flags.SetOutput(new(strings.Builder)) + var allowed string + var trusted string + flags.StringVar(&allowed, "allow", "", "comma-separated allowed IP/CIDR values") + flags.StringVar(&trusted, "trusted-proxy", "", "comma-separated trusted proxy IP/CIDR values") + if err := flags.Parse(args); err != nil { + return fmt.Errorf("invalid access-policy arguments: %w", err) + } + next := config.PanelAccessPolicy{ + Enabled: true, + AllowedSources: splitPanelAccessEntries(allowed), + TrustedProxies: splitPanelAccessEntries(trusted), + } + if err := savePanelAccessPolicy(next); err != nil { + return err + } + fmt.Println("Panel access allowlist saved.") + printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy) + return reloadPanelAfterAccessPolicyCommand() + default: + return fmt.Errorf("unknown access-policy action %q; use show, set, or disable", action) + } +} + +func savePanelAccessPolicy(policy config.PanelAccessPolicy) error { + normalized, err := config.NormalizePanelAccessPolicy(policy) + if err != nil { + return err + } + previous := config.AppConfig.PanelAccessPolicy + config.AppConfig.PanelAccessPolicy = normalized + if err := config.SaveConfig(); err != nil { + config.AppConfig.PanelAccessPolicy = previous + return fmt.Errorf("save panel access policy: %w", err) + } + return nil +} + +func reloadPanelAfterAccessPolicyCommand() error { + if !isWebPanelRunning() { + return nil + } + if err := restartService("clicd"); err != nil { + return fmt.Errorf("policy was saved but clicd service restart failed: %w", err) + } + return nil +} + +func printPanelAccessPolicy(policy config.PanelAccessPolicy) { + fmt.Printf("Enabled: %t\n", policy.Enabled) + fmt.Printf("Allowed sources: %s\n", strings.Join(policy.AllowedSources, ", ")) + fmt.Printf("Trusted proxies: %s\n", strings.Join(policy.TrustedProxies, ", ")) +} diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index f583455..7f8610d 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -54,6 +54,7 @@ var cliTranslations = map[string]string{ "导入现有 LXC 容器": "Import existing LXC containers", "检查并升级 CLICD": "Check and upgrade CLICD", "卸载 CLICD": "Uninstall CLICD", + "面板访问白名单": "Panel access allowlist", "系统信息": "System info", "退出": "Exit", "获取容器列表失败": "Failed to get container list", @@ -175,10 +176,26 @@ var cliTranslations = map[string]string{ "LXC 版本": "LXC version", "暂无可用容器": "No available containers", "忽略无效端口": "Ignoring invalid port", - "?": "? ", - "。": ". ", - ",": ", ", - ":": ": ", + "面板访问来源策略": "Panel access source policy", + "当前状态": "Current status", + "已启用": "enabled", + "已关闭": "disabled", + "允许来源": "Allowed sources", + "可信代理": "Trusted proxies", + "启用或修改白名单": "Enable or update allowlist", + "关闭白名单限制": "Disable allowlist", + "取消": "Cancel", + "允许的 IP/CIDR,多个用逗号分隔": "Allowed IP/CIDR values, comma-separated", + "可信代理 IP/CIDR,多个用逗号分隔,可留空": "Trusted proxy IP/CIDR values, comma-separated; optional", + "白名单配置无效": "Invalid allowlist configuration", + "保存访问来源策略失败": "Failed to save access source policy", + "面板访问白名单已保存。": "Panel access allowlist saved.", + "面板访问白名单已关闭。": "Panel access allowlist disabled.", + "至少填写一个允许的 IP 或网段。": "Enter at least one allowed IP address or network.", + "?": "? ", + "。": ". ", + ",": ", ", + ":": ": ", } // Run starts the CLI interface. @@ -193,7 +210,7 @@ func Run() { refreshCLILanguage() clearScreen() printMenu() - cliPrint("\n请选择操作 [1-12,l,0/q]: ") + cliPrint("\n请选择操作 [1-13,l,0/q]: ") input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) @@ -246,6 +263,10 @@ func Run() { clearScreen() cliUninstall(reader) return + case "13": + clearScreen() + cliConfigurePanelAccess(reader) + waitEnter(reader) case "0": clearScreen() cliShowInfo() @@ -293,11 +314,80 @@ func printMenu() { cliPrintln(" 10. 导入现有 LXC 容器") cliPrintln(" 11. 检查并升级 CLICD") cliPrintln(" 12. 卸载 CLICD") + cliPrintln(" 13. 面板访问白名单") cliPrintln(" 0. 系统信息") cliPrintln(" l. 切换语言") cliPrintln(" q. 退出") } +func cliConfigurePanelAccess(reader *bufio.Reader) { + cliPrintf("\n--- %s ---\n", cliT("面板访问来源策略")) + policy := config.AppConfig.PanelAccessPolicy + status := cliT("已关闭") + if policy.Enabled { + status = cliT("已启用") + } + cliPrintf("%s: %s\n", cliT("当前状态"), status) + cliPrintf("%s: %s\n", cliT("允许来源"), strings.Join(policy.AllowedSources, ", ")) + cliPrintf("%s: %s\n", cliT("可信代理"), strings.Join(policy.TrustedProxies, ", ")) + cliPrintf("\n 1. %s\n", cliT("启用或修改白名单")) + cliPrintf(" 2. %s\n", cliT("关闭白名单限制")) + cliPrintf(" 0. %s\n", cliT("取消")) + + choice := promptString(reader, "请选择操作", "0") + next := policy + switch strings.TrimSpace(choice) { + case "1": + allowed := promptString(reader, "允许的 IP/CIDR,多个用逗号分隔", strings.Join(policy.AllowedSources, ",")) + allowedSources := splitPanelAccessEntries(allowed) + if len(allowedSources) == 0 { + cliPrintln("至少填写一个允许的 IP 或网段。") + return + } + trusted := promptString(reader, "可信代理 IP/CIDR,多个用逗号分隔,可留空", strings.Join(policy.TrustedProxies, ",")) + next = config.PanelAccessPolicy{ + Enabled: true, + AllowedSources: allowedSources, + TrustedProxies: splitPanelAccessEntries(trusted), + } + case "2": + next.Enabled = false + case "0", "": + cliPrintln("已取消") + return + default: + cliPrintln("无效选择") + return + } + + normalized, err := config.NormalizePanelAccessPolicy(next) + if err != nil { + cliPrintf("%s: %v\n", cliT("白名单配置无效"), err) + return + } + previous := config.AppConfig.PanelAccessPolicy + config.AppConfig.PanelAccessPolicy = normalized + if err := config.SaveConfig(); err != nil { + config.AppConfig.PanelAccessPolicy = previous + cliPrintf("%s: %v\n", cliT("保存访问来源策略失败"), err) + return + } + if normalized.Enabled { + cliPrintln("面板访问白名单已保存。") + } else { + cliPrintln("面板访问白名单已关闭。") + } + if isWebPanelRunning() { + restartWebPanelForConfigChange() + } +} + +func splitPanelAccessEntries(value string) []string { + return strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' ' + }) +} + func cliSwitchLanguage(reader *bufio.Reader) { cliPrintf("\n--- %s ---\n", cliT("切换语言")) cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language))) @@ -1244,8 +1334,8 @@ func removeCLICDNATRules() { break } } - deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE") - deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE") + deleteNATRule("POSTROUTING", "-s", config.LXCNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE") + deleteNATRule("POSTROUTING", "-s", config.KVMNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE") } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6c98fe9..923a413 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -794,6 +794,8 @@ type ClicdConfig struct { NextSSHPort int `json:"next_ssh_port"` NATPortStart int `json:"nat_port_start"` NATPortEnd int `json:"nat_port_end"` + LXCNATSubnet string `json:"lxc_nat_subnet"` + KVMNATSubnet string `json:"kvm_nat_subnet"` SetupComplete bool `json:"setup_complete"` SubUsers []SubUser `json:"sub_users"` ApiKeys []ApiKeyConfig `json:"api_keys"` @@ -801,10 +803,13 @@ type ClicdConfig struct { Tasks []SavedTask `json:"tasks"` LoginLogs []SavedLoginLog `json:"login_logs"` EnabledImages []string `json:"enabled_images"` + CustomKVMImages []CustomKVMImage `json:"custom_kvm_images"` + CustomLXCImages []CustomLXCImage `json:"custom_lxc_images"` Snapshots []Snapshot `json:"snapshots"` PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"` PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"` WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"` + PanelAccessPolicy PanelAccessPolicy `json:"panel_access_policy"` SecurityAutoShutdown bool `json:"security_auto_shutdown"` TaskConcurrency int `json:"task_concurrency"` Language string `json:"language"` @@ -813,6 +818,39 @@ type ClicdConfig struct { StoragePools []StoragePool `json:"storage_pools"` } +const ( + KVMProvisionerLinuxCloudInit = "linux-cloud-init" + KVMProvisionerWindows10 = "windows-10" + KVMProvisionerWindows11 = "windows-11" +) + +// CustomKVMImage is an administrator-defined KVM image source. +type CustomKVMImage struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + URL string `json:"url"` + Provisioner string `json:"provisioner"` + SHA256 string `json:"sha256,omitempty"` + CreatedAt string `json:"created_at"` +} + +// CustomLXCImage is an administrator-defined LXC rootfs archive source. +type CustomLXCImage struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + URL string `json:"url"` + SHA256 string `json:"sha256,omitempty"` + CreatedAt string `json:"created_at"` +} + var configPath string var AppConfig *ClicdConfig var allocationMu sync.Mutex @@ -946,6 +984,8 @@ func InitConfig() (*ClicdConfig, error) { NextSSHPort: 22000, NATPortStart: DefaultNATPortStart, NATPortEnd: DefaultNATPortEnd, + LXCNATSubnet: configuredSubnetValue("", "CLICD_LXC_SUBNET", DefaultLXCNATSubnet), + KVMNATSubnet: configuredSubnetValue("", "CLICD_KVM_SUBNET", DefaultKVMNATSubnet), SetupComplete: false, SubUsers: []SubUser{}, AuditLogs: []AuditLog{}, @@ -955,8 +995,12 @@ func InitConfig() (*ClicdConfig, error) { PublicIPv4Pool: []PublicIPv4Assignment{}, PublicIPv6Prefixes: []PublicIPv6Prefix{}, WebSSHAllowedOrigins: []string{}, - TaskConcurrency: DefaultTaskConcurrency, - StoragePools: []StoragePool{defaultPrimaryStoragePool()}, + PanelAccessPolicy: PanelAccessPolicy{ + AllowedSources: []string{}, + TrustedProxies: []string{}, + }, + TaskConcurrency: DefaultTaskConcurrency, + StoragePools: []StoragePool{defaultPrimaryStoragePool()}, } if err := SaveConfig(); err != nil { @@ -994,6 +1038,9 @@ func normalizeConfigDefaults(dataDir string) bool { if normalizeNATPortRangeDefaults() { changed = true } + if normalizeNATNetworkDefaults() { + changed = true + } if AppConfig.NextContainerID == 0 { AppConfig.NextContainerID = 1 changed = true @@ -1029,6 +1076,18 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.WebSSHAllowedOrigins = normalized changed = true } + if normalized, err := NormalizePanelAccessPolicy(AppConfig.PanelAccessPolicy); err == nil { + if !panelAccessPoliciesEqual(AppConfig.PanelAccessPolicy, normalized) { + AppConfig.PanelAccessPolicy = normalized + changed = true + } + } else { + AppConfig.PanelAccessPolicy = PanelAccessPolicy{ + AllowedSources: []string{}, + TrustedProxies: []string{}, + } + changed = true + } if len(AppConfig.StoragePools) == 0 { AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()} changed = true @@ -1067,6 +1126,14 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.EnabledImages = make([]string, 0) changed = true } + if AppConfig.CustomKVMImages == nil { + AppConfig.CustomKVMImages = make([]CustomKVMImage, 0) + changed = true + } + if AppConfig.CustomLXCImages == nil { + AppConfig.CustomLXCImages = make([]CustomLXCImage, 0) + changed = true + } if AppConfig.Language == "" { AppConfig.Language = "zh" changed = true @@ -1435,6 +1502,104 @@ func SaveConfig() error { return saveConfigToDB() } +func ListCustomKVMImages() []CustomKVMImage { + allocationMu.Lock() + defer allocationMu.Unlock() + if AppConfig == nil { + return nil + } + return append([]CustomKVMImage(nil), AppConfig.CustomKVMImages...) +} + +func AddCustomKVMImage(image CustomKVMImage) error { + allocationMu.Lock() + defer allocationMu.Unlock() + for _, existing := range AppConfig.CustomKVMImages { + if existing.ID == image.ID { + return fmt.Errorf("custom KVM image %q already exists", image.ID) + } + } + AppConfig.CustomKVMImages = append(AppConfig.CustomKVMImages, image) + if err := SaveConfig(); err != nil { + AppConfig.CustomKVMImages = AppConfig.CustomKVMImages[:len(AppConfig.CustomKVMImages)-1] + return err + } + return nil +} + +func RemoveCustomKVMImage(id string) (bool, error) { + allocationMu.Lock() + defer allocationMu.Unlock() + filtered := make([]CustomKVMImage, 0, len(AppConfig.CustomKVMImages)) + found := false + for _, image := range AppConfig.CustomKVMImages { + if image.ID == id { + found = true + continue + } + filtered = append(filtered, image) + } + if !found { + return false, nil + } + previous := AppConfig.CustomKVMImages + AppConfig.CustomKVMImages = filtered + if err := SaveConfig(); err != nil { + AppConfig.CustomKVMImages = previous + return false, err + } + return true, nil +} + +func ListCustomLXCImages() []CustomLXCImage { + allocationMu.Lock() + defer allocationMu.Unlock() + if AppConfig == nil { + return nil + } + return append([]CustomLXCImage(nil), AppConfig.CustomLXCImages...) +} + +func AddCustomLXCImage(image CustomLXCImage) error { + allocationMu.Lock() + defer allocationMu.Unlock() + for _, existing := range AppConfig.CustomLXCImages { + if existing.ID == image.ID { + return fmt.Errorf("custom LXC image %q already exists", image.ID) + } + } + AppConfig.CustomLXCImages = append(AppConfig.CustomLXCImages, image) + if err := SaveConfig(); err != nil { + AppConfig.CustomLXCImages = AppConfig.CustomLXCImages[:len(AppConfig.CustomLXCImages)-1] + return err + } + return nil +} + +func RemoveCustomLXCImage(id string) (bool, error) { + allocationMu.Lock() + defer allocationMu.Unlock() + filtered := make([]CustomLXCImage, 0, len(AppConfig.CustomLXCImages)) + found := false + for _, image := range AppConfig.CustomLXCImages { + if image.ID == id { + found = true + continue + } + filtered = append(filtered, image) + } + if !found { + return false, nil + } + previous := AppConfig.CustomLXCImages + AppConfig.CustomLXCImages = filtered + if err := SaveConfig(); err != nil { + AppConfig.CustomLXCImages = previous + return false, err + } + return true, nil +} + // AddContainer adds a container to the config func AddContainer(c Container) { allocationMu.Lock() @@ -1736,6 +1901,28 @@ func AllocateSSHPort() (int, error) { func AllocateSSHPortExcluding(excluded []int) (int, error) { allocationMu.Lock() defer allocationMu.Unlock() + candidate, err := previewSSHPortExcluding(excluded) + if err != nil { + return 0, err + } + start, end := NATPortRange() + AppConfig.NextSSHPort = candidate + 1 + if AppConfig.NextSSHPort > end { + AppConfig.NextSSHPort = start + } + SaveConfig() + return candidate, nil +} + +// PreviewSSHPortExcluding returns the management port that the allocator would +// choose without advancing or persisting the allocation cursor. +func PreviewSSHPortExcluding(excluded []int) (int, error) { + allocationMu.Lock() + defer allocationMu.Unlock() + return previewSSHPortExcluding(excluded) +} + +func previewSSHPortExcluding(excluded []int) (int, error) { used := collectAllHostPorts() for _, port := range excluded { if port > 0 { @@ -1753,11 +1940,6 @@ func AllocateSSHPortExcluding(excluded []int) (int, error) { if used[candidate] { continue } - AppConfig.NextSSHPort = candidate + 1 - if AppConfig.NextSSHPort > end { - AppConfig.NextSSHPort = start - } - SaveConfig() return candidate, nil } return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end) diff --git a/backend/internal/config/nat_network.go b/backend/internal/config/nat_network.go new file mode 100644 index 0000000..65a9a75 --- /dev/null +++ b/backend/internal/config/nat_network.go @@ -0,0 +1,144 @@ +package config + +import ( + "encoding/binary" + "fmt" + "net/netip" + "os" + "strings" +) + +const ( + DefaultLXCNATSubnet = "10.0.3.0/24" + DefaultKVMNATSubnet = "192.168.122.0/24" +) + +type NATNetwork struct { + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` + Netmask string `json:"netmask"` + DHCPStart string `json:"dhcp_start"` + DHCPEnd string `json:"dhcp_end"` + DHCPMax int `json:"dhcp_max"` + PrefixBits int `json:"prefix_bits"` +} + +func ParseNATNetwork(raw string) (NATNetwork, error) { + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err != nil || !prefix.Addr().Is4() { + return NATNetwork{}, fmt.Errorf("NAT subnet must be a valid IPv4 CIDR") + } + prefix = prefix.Masked() + if prefix.Bits() < 16 || prefix.Bits() > 28 { + return NATNetwork{}, fmt.Errorf("NAT subnet prefix must be between /16 and /28") + } + if !isRFC1918Prefix(prefix) { + return NATNetwork{}, fmt.Errorf("NAT subnet must use an RFC1918 private IPv4 range") + } + + network := ipv4Uint32(prefix.Addr()) + hostBits := 32 - prefix.Bits() + broadcast := network | uint32((uint64(1)< 0 { + mask = ^uint32(0) << (32 - bits) + } + return uint32IPv4(mask).String() +} diff --git a/backend/internal/config/nat_network_test.go b/backend/internal/config/nat_network_test.go new file mode 100644 index 0000000..c6985f8 --- /dev/null +++ b/backend/internal/config/nat_network_test.go @@ -0,0 +1,56 @@ +package config + +import "testing" + +func TestParseNATNetwork(t *testing.T) { + network, err := ParseNATNetwork("172.28.40.0/24") + if err != nil { + t.Fatalf("ParseNATNetwork returned error: %v", err) + } + if network.Subnet != "172.28.40.0/24" || + network.Gateway != "172.28.40.1" || + network.Netmask != "255.255.255.0" || + network.DHCPStart != "172.28.40.2" || + network.DHCPEnd != "172.28.40.254" || + network.DHCPMax != 253 { + t.Fatalf("unexpected network values: %+v", network) + } +} + +func TestParseNATNetworkMasksHostBits(t *testing.T) { + network, err := ParseNATNetwork("10.44.8.99/20") + if err != nil { + t.Fatalf("ParseNATNetwork returned error: %v", err) + } + if network.Subnet != "10.44.0.0/20" || network.Gateway != "10.44.0.1" || network.DHCPEnd != "10.44.15.254" { + t.Fatalf("unexpected masked network values: %+v", network) + } +} + +func TestParseNATNetworkRejectsUnsafeRanges(t *testing.T) { + for _, raw := range []string{ + "203.0.113.0/24", + "10.0.0.0/15", + "10.0.0.0/29", + "not-a-subnet", + } { + if _, err := ParseNATNetwork(raw); err == nil { + t.Fatalf("ParseNATNetwork(%q) unexpectedly succeeded", raw) + } + } +} + +func TestNormalizeNATNetworkDefaultsUsesEnvironment(t *testing.T) { + t.Setenv("CLICD_LXC_SUBNET", "172.30.8.0/24") + t.Setenv("CLICD_KVM_SUBNET", "10.230.0.0/20") + previous := AppConfig + AppConfig = &ClicdConfig{} + t.Cleanup(func() { AppConfig = previous }) + + if !normalizeNATNetworkDefaults() { + t.Fatal("expected defaults to change") + } + if AppConfig.LXCNATSubnet != "172.30.8.0/24" || AppConfig.KVMNATSubnet != "10.230.0.0/20" { + t.Fatalf("unexpected configured subnets: LXC=%s KVM=%s", AppConfig.LXCNATSubnet, AppConfig.KVMNATSubnet) + } +} diff --git a/backend/internal/config/nat_test.go b/backend/internal/config/nat_test.go index 1f01700..fdb41b7 100644 --- a/backend/internal/config/nat_test.go +++ b/backend/internal/config/nat_test.go @@ -62,3 +62,27 @@ func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) { t.Fatalf("allocated port = %d, want 32002", port) } } + +func TestPreviewSSHPortUsesRangeWithoutAdvancingCursor(t *testing.T) { + previous := AppConfig + t.Cleanup(func() { AppConfig = previous }) + AppConfig = &ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 35000, + NextSSHPort: 30000, + Containers: []Container{{ + PortMappings: []PortMapping{{HostPort: 30000}}, + }}, + } + + port, err := PreviewSSHPortExcluding([]int{30001}) + if err != nil { + t.Fatal(err) + } + if port != 30002 { + t.Fatalf("preview port = %d, want 30002", port) + } + if AppConfig.NextSSHPort != 30000 { + t.Fatalf("preview advanced cursor to %d", AppConfig.NextSSHPort) + } +} diff --git a/backend/internal/config/panel_access.go b/backend/internal/config/panel_access.go new file mode 100644 index 0000000..05ec120 --- /dev/null +++ b/backend/internal/config/panel_access.go @@ -0,0 +1,198 @@ +package config + +import ( + "fmt" + "net" + "net/netip" + "strings" +) + +// PanelAccessPolicy limits access to the complete web panel and API surface. +type PanelAccessPolicy struct { + Enabled bool `json:"enabled"` + AllowedSources []string `json:"allowed_sources"` + TrustedProxies []string `json:"trusted_proxies"` +} + +// ForwardedClientHeaders contains proxy-provided client address headers. +type ForwardedClientHeaders struct { + ForwardedFor string + RealIP string + CFConnectingIP string +} + +// PanelAccessDecision describes the address used by the access policy. +type PanelAccessDecision struct { + Allowed bool + DirectSource string + CurrentSource string + UsedForwarded bool +} + +func NormalizePanelAccessPolicy(policy PanelAccessPolicy) (PanelAccessPolicy, error) { + allowed, err := normalizeIPRanges(policy.AllowedSources, "allowed source") + if err != nil { + return PanelAccessPolicy{}, err + } + trusted, err := normalizeIPRanges(policy.TrustedProxies, "trusted proxy") + if err != nil { + return PanelAccessPolicy{}, err + } + if policy.Enabled && len(allowed) == 0 { + return PanelAccessPolicy{}, fmt.Errorf("at least one allowed IP address or CIDR is required") + } + return PanelAccessPolicy{ + Enabled: policy.Enabled, + AllowedSources: allowed, + TrustedProxies: trusted, + }, nil +} + +func normalizeIPRanges(values []string, label string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + normalized, err := normalizeIPRange(value) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", label, value, err) + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result, nil +} + +func normalizeIPRange(value string) (string, error) { + if strings.Contains(value, "/") { + prefix, err := netip.ParsePrefix(value) + if err != nil { + return "", err + } + if prefix.Addr().Zone() != "" { + return "", fmt.Errorf("IPv6 zones are not supported") + } + return prefix.Masked().String(), nil + } + addr, err := netip.ParseAddr(value) + if err != nil { + return "", err + } + if addr.Zone() != "" { + return "", fmt.Errorf("IPv6 zones are not supported") + } + return addr.Unmap().String(), nil +} + +func panelAccessPoliciesEqual(a, b PanelAccessPolicy) bool { + return a.Enabled == b.Enabled && + stringSlicesEqual(a.AllowedSources, b.AllowedSources) && + stringSlicesEqual(a.TrustedProxies, b.TrustedProxies) +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// EvaluatePanelAccess resolves the effective client address and applies policy. +// Forwarded headers are only considered when the TCP peer is trusted. +func EvaluatePanelAccess(policy PanelAccessPolicy, remoteAddr string, headers ForwardedClientHeaders) PanelAccessDecision { + direct, ok := parseRemoteIP(remoteAddr) + decision := PanelAccessDecision{} + if ok { + decision.DirectSource = direct.String() + decision.CurrentSource = direct.String() + } + if !policy.Enabled { + decision.Allowed = true + return decision + } + if !ok { + return decision + } + + current := direct + if ipInRanges(direct, policy.TrustedProxies) { + if forwarded, forwardedOK := resolveForwardedIP(direct, policy.TrustedProxies, headers); forwardedOK { + current = forwarded + decision.CurrentSource = forwarded.String() + decision.UsedForwarded = true + } + } + + // A direct local connection remains an emergency recovery path. When a + // trusted local reverse proxy forwards a client address, that client is + // still checked normally. + if current.IsLoopback() && !decision.UsedForwarded { + decision.Allowed = true + return decision + } + decision.Allowed = ipInRanges(current, policy.AllowedSources) + return decision +} + +func parseRemoteIP(value string) (netip.Addr, bool) { + value = strings.TrimSpace(value) + if host, _, err := net.SplitHostPort(value); err == nil { + value = host + } + value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[") + addr, err := netip.ParseAddr(value) + if err != nil { + return netip.Addr{}, false + } + return addr.Unmap(), true +} + +func resolveForwardedIP(direct netip.Addr, trusted []string, headers ForwardedClientHeaders) (netip.Addr, bool) { + for _, raw := range []string{headers.CFConnectingIP, headers.RealIP} { + if addr, ok := parseRemoteIP(strings.TrimSpace(strings.Split(raw, ",")[0])); ok { + return addr, true + } + } + + parts := strings.Split(headers.ForwardedFor, ",") + current := direct + found := false + for i := len(parts) - 1; i >= 0 && ipInRanges(current, trusted); i-- { + addr, ok := parseRemoteIP(strings.TrimSpace(parts[i])) + if !ok { + continue + } + current = addr + found = true + } + return current, found +} + +func ipInRanges(addr netip.Addr, ranges []string) bool { + addr = addr.Unmap() + for _, raw := range ranges { + if strings.Contains(raw, "/") { + prefix, err := netip.ParsePrefix(raw) + if err == nil && prefix.Contains(addr) { + return true + } + continue + } + candidate, err := netip.ParseAddr(raw) + if err == nil && candidate.Unmap() == addr { + return true + } + } + return false +} diff --git a/backend/internal/config/panel_access_test.go b/backend/internal/config/panel_access_test.go new file mode 100644 index 0000000..57095d7 --- /dev/null +++ b/backend/internal/config/panel_access_test.go @@ -0,0 +1,146 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestNormalizePanelAccessPolicy(t *testing.T) { + policy, err := NormalizePanelAccessPolicy(PanelAccessPolicy{ + Enabled: true, + AllowedSources: []string{" 192.0.2.8 ", "10.20.30.44/24", "192.0.2.8", "2001:db8::1"}, + TrustedProxies: []string{"127.0.0.1", "2001:db8:1::/64"}, + }) + if err != nil { + t.Fatalf("NormalizePanelAccessPolicy() error = %v", err) + } + if want := []string{"192.0.2.8", "10.20.30.0/24", "2001:db8::1"}; !reflect.DeepEqual(policy.AllowedSources, want) { + t.Fatalf("AllowedSources = %#v, want %#v", policy.AllowedSources, want) + } + if want := []string{"127.0.0.1", "2001:db8:1::/64"}; !reflect.DeepEqual(policy.TrustedProxies, want) { + t.Fatalf("TrustedProxies = %#v, want %#v", policy.TrustedProxies, want) + } +} + +func TestNormalizePanelAccessPolicyRejectsEmptyEnabledPolicy(t *testing.T) { + if _, err := NormalizePanelAccessPolicy(PanelAccessPolicy{Enabled: true}); err == nil { + t.Fatal("expected enabled empty policy to fail") + } +} + +func TestEvaluatePanelAccess(t *testing.T) { + base := PanelAccessPolicy{ + Enabled: true, + AllowedSources: []string{"192.0.2.0/24", "2001:db8::/32"}, + TrustedProxies: []string{"10.0.0.1", "127.0.0.1"}, + } + tests := []struct { + name string + policy PanelAccessPolicy + remote string + headers ForwardedClientHeaders + allowed bool + current string + usedForwarded bool + }{ + { + name: "disabled", + policy: PanelAccessPolicy{}, + remote: "198.51.100.9:44321", + allowed: true, + current: "198.51.100.9", + }, + { + name: "direct CIDR match", + policy: base, + remote: "192.0.2.25:44321", + allowed: true, + current: "192.0.2.25", + }, + { + name: "direct denied", + policy: base, + remote: "198.51.100.9:44321", + allowed: false, + current: "198.51.100.9", + }, + { + name: "spoofed forwarding header ignored", + policy: base, + remote: "198.51.100.9:44321", + headers: ForwardedClientHeaders{ + ForwardedFor: "192.0.2.10", + }, + allowed: false, + current: "198.51.100.9", + }, + { + name: "trusted proxy forwards allowed source", + policy: base, + remote: "10.0.0.1:44321", + headers: ForwardedClientHeaders{ + ForwardedFor: "192.0.2.10", + }, + allowed: true, + current: "192.0.2.10", + usedForwarded: true, + }, + { + name: "trusted proxy forwards denied source", + policy: base, + remote: "10.0.0.1:44321", + headers: ForwardedClientHeaders{ + RealIP: "198.51.100.20", + }, + allowed: false, + current: "198.51.100.20", + usedForwarded: true, + }, + { + name: "direct loopback recovery", + policy: base, + remote: "127.0.0.1:44321", + allowed: true, + current: "127.0.0.1", + usedForwarded: false, + }, + { + name: "trusted loopback proxy is enforced", + policy: base, + remote: "127.0.0.1:44321", + headers: ForwardedClientHeaders{ + ForwardedFor: "198.51.100.20", + }, + allowed: false, + current: "198.51.100.20", + usedForwarded: true, + }, + { + name: "IPv6 source", + policy: base, + remote: "[2001:db8::88]:44321", + allowed: true, + current: "2001:db8::88", + }, + { + name: "trusted proxy chain", + policy: base, + remote: "10.0.0.1:44321", + headers: ForwardedClientHeaders{ + ForwardedFor: "192.0.2.70, 10.0.0.1", + }, + allowed: true, + current: "192.0.2.70", + usedForwarded: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EvaluatePanelAccess(tt.policy, tt.remote, tt.headers) + if got.Allowed != tt.allowed || got.CurrentSource != tt.current || got.UsedForwarded != tt.usedForwarded { + t.Fatalf("EvaluatePanelAccess() = %#v", got) + } + }) + } +} diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 0975ba0..96a9827 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -606,6 +606,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { NextSSHPort: atoi(meta["next_ssh_port"]), NATPortStart: atoi(meta["nat_port_start"]), NATPortEnd: atoi(meta["nat_port_end"]), + LXCNATSubnet: meta["lxc_nat_subnet"], + KVMNATSubnet: meta["kvm_nat_subnet"], SetupComplete: atob(meta["setup_complete"]), SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), TaskConcurrency: atoi(meta["task_concurrency"]), @@ -626,9 +628,18 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" { _ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins) } + if raw := strings.TrimSpace(meta["panel_access_policy"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.PanelAccessPolicy) + } if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" { _ = json.Unmarshal([]byte(raw), &cfg.StoragePools) } + if raw := strings.TrimSpace(meta["custom_kvm_images"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.CustomKVMImages) + } + if raw := strings.TrimSpace(meta["custom_lxc_images"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.CustomLXCImages) + } if cfg.Containers, err = loadContainers(); err != nil { return nil, false, err @@ -729,7 +740,10 @@ func saveMeta(tx *sql.Tx) error { publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool) publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes) webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins) + panelAccessPolicyJSON, _ := json.Marshal(AppConfig.PanelAccessPolicy) storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools) + customKVMImagesJSON, _ := json.Marshal(AppConfig.CustomKVMImages) + customLXCImagesJSON, _ := json.Marshal(AppConfig.CustomLXCImages) values := map[string]string{ "admin_user": AppConfig.AdminUser, "admin_pass_hash": AppConfig.AdminPassHash, @@ -741,6 +755,8 @@ func saveMeta(tx *sql.Tx) error { "next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort), "nat_port_start": strconv.Itoa(AppConfig.NATPortStart), "nat_port_end": strconv.Itoa(AppConfig.NATPortEnd), + "lxc_nat_subnet": AppConfig.LXCNATSubnet, + "kvm_nat_subnet": AppConfig.KVMNATSubnet, "setup_complete": btoa(AppConfig.SetupComplete), "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), "task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency), @@ -750,7 +766,10 @@ func saveMeta(tx *sql.Tx) error { "public_ipv4_pool": string(publicIPv4PoolJSON), "public_ipv6_prefixes": string(publicIPv6PrefixesJSON), "webssh_allowed_origins": string(webSSHAllowedOriginsJSON), + "panel_access_policy": string(panelAccessPolicyJSON), "storage_pools": string(storagePoolsJSON), + "custom_kvm_images": string(customKVMImagesJSON), + "custom_lxc_images": string(customLXCImagesJSON), "schema_version": "1", "updated_at": time.Now().Format("2006-01-02 15:04:05"), } diff --git a/backend/internal/config/store_sqlite_test.go b/backend/internal/config/store_sqlite_test.go index 18341b9..a32fad4 100644 --- a/backend/internal/config/store_sqlite_test.go +++ b/backend/internal/config/store_sqlite_test.go @@ -66,6 +66,34 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`, }}, EnabledImages: []string{"debian-12"}, + CustomKVMImages: []CustomKVMImage{{ + ID: "custom-kvm-test", + Name: "Test Cloud Image", + Description: "third-party image", + Distro: "ubuntu", + Release: "noble", + Arch: "amd64", + URL: "https://images.example.test/ubuntu.qcow2", + Provisioner: KVMProvisionerLinuxCloudInit, + SHA256: strings.Repeat("a", 64), + CreatedAt: "2026-07-26 10:00:00", + }}, + CustomLXCImages: []CustomLXCImage{{ + ID: "custom-lxc-test", + Name: "Test Rootfs", + Description: "third-party LXC image", + Distro: "alpine", + Release: "3.21", + Arch: "amd64", + URL: "https://images.example.test/alpine-rootfs.tar.xz", + SHA256: strings.Repeat("b", 64), + CreatedAt: "2026-07-26 10:00:00", + }}, + PanelAccessPolicy: PanelAccessPolicy{ + Enabled: true, + AllowedSources: []string{"192.0.2.0/24"}, + TrustedProxies: []string{"127.0.0.1"}, + }, Snapshots: []Snapshot{{ ID: "snap-1", ContainerID: 1, @@ -102,6 +130,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { if cfg.TaskConcurrency != DefaultTaskConcurrency { t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency) } + if !cfg.PanelAccessPolicy.Enabled || len(cfg.PanelAccessPolicy.AllowedSources) != 1 { + t.Fatalf("legacy panel access policy was not migrated: %+v", cfg.PanelAccessPolicy) + } + if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].ID != "custom-kvm-test" { + t.Fatalf("legacy custom KVM images were not migrated: %+v", cfg.CustomKVMImages) + } + if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].ID != "custom-lxc-test" { + t.Fatalf("legacy custom LXC images were not migrated: %+v", cfg.CustomLXCImages) + } if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil { t.Fatalf("sqlite database was not created: %v", err) } @@ -124,6 +161,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) { if got := cfg.TaskConcurrency; got != 6 { t.Fatalf("persisted task concurrency = %d, want 6", got) } + if !cfg.PanelAccessPolicy.Enabled || cfg.PanelAccessPolicy.AllowedSources[0] != "192.0.2.0/24" { + t.Fatalf("persisted panel access policy = %+v", cfg.PanelAccessPolicy) + } + if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].SHA256 != strings.Repeat("a", 64) { + t.Fatalf("persisted custom KVM images = %+v", cfg.CustomKVMImages) + } + if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].SHA256 != strings.Repeat("b", 64) { + t.Fatalf("persisted custom LXC images = %+v", cfg.CustomLXCImages) + } } func resetConfigStoreForTest(t *testing.T) { diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index b76a6a3..a4fca0e 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -166,7 +166,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo return err } ext := ".qcow2" - if image.Distro == "windows" { + if image.IsWindows() { ext = ".iso" } target := filepath.Join(cacheDir, image.ID+ext) @@ -184,7 +184,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo } tmp := target + ".tmp" _ = os.Remove(tmp) - if image.Distro == "windows" { + if image.IsWindows() { if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil { _ = os.Remove(tmp) return err @@ -197,7 +197,13 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo _ = os.Remove(tmp) return err } - if image.Distro == "windows" { + if image.SHA256 != "" { + if err := verifyFileSHA256(tmp, image.SHA256); err != nil { + _ = os.Remove(tmp) + return err + } + } + if image.IsWindows() { if err := validateWindowsISO(tmp, target); err != nil { _ = os.Remove(tmp) return err @@ -221,6 +227,23 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo return nil } +func verifyFileSHA256(path, expected string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, strings.TrimSpace(expected)) { + return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual) + } + return nil +} + func DeleteImage(id string) error { return os.RemoveAll(ImagePath(id)) } @@ -500,11 +523,15 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig var xml string winAdminPassword := "" if IsWindowsImage(image.ID) { - if cfg.RAMMB < 2048 { - cfg.RAMMB = 2048 + minVCPU, minRAMMB, minDiskGB := windowsMinimumResources(image.ID) + if cfg.VCPU < minVCPU { + cfg.VCPU = minVCPU } - if cfg.DiskGB < 30 { - cfg.DiskGB = 30 + if cfg.RAMMB < minRAMMB { + cfg.RAMMB = minRAMMB + } + if cfg.DiskGB < minDiskGB { + cfg.DiskGB = minDiskGB } cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘") if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil { @@ -516,7 +543,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig winAdminPassword = generateWindowsPassword() unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso") cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置") - if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil { + if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List, IsWindows11Image(image.ID)); err != nil { return nil, err } xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps) @@ -1724,16 +1751,17 @@ func ensureDefaultNetwork() error { // Ensure default network is defined if virshCLocaleCommand("net-info", "default").Run() != nil { // Default network may not be defined; try to define it - netXML := ` + network := config.KVMNATNetwork() + netXML := fmt.Sprintf(` default - + - + -` +`, network.Gateway, network.Netmask, network.DHCPStart, network.DHCPEnd) tmpFile := filepath.Join(os.TempDir(), "clicd-default-net.xml") if err := os.WriteFile(tmpFile, []byte(netXML), 0644); err != nil { return fmt.Errorf("failed to write default network XML: %v", err) @@ -1832,7 +1860,7 @@ func createEmptyDisk(target string, diskGB int) error { return nil } -func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error { +func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string, windows11 bool) error { tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso") if tool == "" { return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup") @@ -1852,7 +1880,7 @@ func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s return err } } - if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword)), 0600); err != nil { + if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword, windows11)), 0600); err != nil { return err } if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil { @@ -1890,12 +1918,21 @@ func firstAvailableCommand(names ...string) string { return "" } -func windowsAutounattendXML(hostname, adminPassword string) string { +func windowsAutounattendXML(hostname, adminPassword string, windows11 bool) string { if strings.TrimSpace(hostname) == "" { hostname = "clicd-win" } hostname = sanitizeWindowsComputerName(hostname) setupCommand := `cmd.exe /c if exist C:\CLICD\FirstLogon.ps1 (powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1) else (for %%d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do @if exist %%d:\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %%d:\FirstLogon.ps1)` + compatibilityCommands := "" + if windows11 { + compatibilityCommands = ` + + 1Allow virtual TPM compatibilityreg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f + 2Allow virtual Secure Boot compatibilityreg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f + 3Allow virtual CPU compatibilityreg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f + ` + } return fmt.Sprintf(` @@ -1924,7 +1961,7 @@ func windowsAutounattendXML(hostname, adminPassword string) string { true CLICD CLICD - + %s @@ -1945,7 +1982,14 @@ func windowsAutounattendXML(hostname, adminPassword string) string { -`, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand)) +`, compatibilityCommands, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand)) +} + +func windowsMinimumResources(imageID string) (float64, int, int) { + if IsWindows11Image(imageID) { + return 2, 4096, 64 + } + return 1, 2048, 30 } func sanitizeWindowsComputerName(name string) string { diff --git a/backend/internal/kvm/kvm_test.go b/backend/internal/kvm/kvm_test.go index 6aa230a..decd774 100644 --- a/backend/internal/kvm/kvm_test.go +++ b/backend/internal/kvm/kvm_test.go @@ -3,8 +3,14 @@ package kvm import ( "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "os" "path/filepath" "reflect" + "runtime" + "strings" "testing" "clicd/internal/config" @@ -24,6 +30,51 @@ func TestImagePathUsesAllowlistedImageID(t *testing.T) { } } +func TestWindows11ImageDefinition(t *testing.T) { + image := FindImage("kvm-windows-11") + if image == nil { + t.Fatal("Windows 11 image is missing from the amd64 image list") + } + if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" { + t.Fatalf("Windows 11 image metadata = %+v", image) + } + if !strings.Contains(image.URL, "microsoft.com/fwlink/") { + t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL) + } + if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" { + t.Fatalf("Windows 11 image basename = %q", got) + } +} + +func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) { + windows11 := windowsAutounattendXML("win11-test", "Password123!", true) + windows10 := windowsAutounattendXML("win10-test", "Password123!", false) + + for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} { + if !strings.Contains(windows11, key) { + t.Fatalf("Windows 11 unattend is missing %s", key) + } + if strings.Contains(windows10, key) { + t.Fatalf("Windows 10 unattend unexpectedly contains %s", key) + } + } + var document struct { + XMLName xml.Name + } + if err := xml.Unmarshal([]byte(windows11), &document); err != nil { + t.Fatalf("Windows 11 unattend XML is invalid: %v", err) + } +} + +func TestWindowsMinimumResources(t *testing.T) { + if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 { + t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk) + } + if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 { + t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk) + } +} + func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) { tests := []struct { name string @@ -115,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) { } } +func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{ + CustomKVMImages: []config.CustomKVMImage{ + { + ID: "custom-kvm-linux", + Name: "Custom Linux", + Distro: "ubuntu", + Release: "noble", + Arch: runtime.GOARCH, + URL: "https://example.test/linux.qcow2", + Provisioner: config.KVMProvisionerLinuxCloudInit, + }, + { + ID: "custom-kvm-other-arch", + Name: "Other Architecture", + Distro: "ubuntu", + Release: "noble", + Arch: "not-" + runtime.GOARCH, + URL: "https://example.test/other.qcow2", + Provisioner: config.KVMProvisionerLinuxCloudInit, + }, + }, + } + + image := FindImage("custom-kvm-linux") + if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit { + t.Fatalf("custom image was not exposed correctly: %+v", image) + } + if FindImage("custom-kvm-other-arch") != nil { + t.Fatal("custom image for another architecture was exposed") + } +} + +func TestCustomWindowsProvisionerControlsImageType(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{ + ID: "custom-kvm-windows", + Name: "Custom Windows", + Distro: "windows", + Release: "11", + Arch: runtime.GOARCH, + URL: "https://example.test/windows.iso", + Provisioner: config.KVMProvisionerWindows11, + }}} + + if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") { + t.Fatal("custom Windows 11 provisioner was not recognized") + } + if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" { + t.Fatalf("custom Windows image extension = %q, want .iso", ext) + } +} + +func TestVerifyFileSHA256(t *testing.T) { + path := filepath.Join(t.TempDir(), "image") + content := []byte("clicd custom image") + if err := os.WriteFile(path, content, 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil { + t.Fatalf("valid checksum failed: %v", err) + } + if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil { + t.Fatal("invalid checksum unexpectedly passed") + } +} + func testSSHPublicKey(t *testing.T) ssh.PublicKey { t.Helper() _, privateKey, err := ed25519.GenerateKey(rand.Reader) diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 5ca1e9e..bfac024 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -17,15 +17,37 @@ type Image struct { Description string `json:"description"` URL string `json:"url"` Desktop string `json:"desktop,omitempty"` + Provisioner string `json:"provisioner,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Custom bool `json:"custom,omitempty"` } func GetImages() []Image { + var images []Image switch runtime.GOARCH { case "arm64": - return arm64Images() + images = arm64Images() default: - return amd64Images() + images = amd64Images() } + for _, custom := range config.ListCustomKVMImages() { + if custom.Arch != runtime.GOARCH { + continue + } + images = append(images, Image{ + ID: custom.ID, + Name: custom.Name, + Distro: custom.Distro, + Release: custom.Release, + Arch: custom.Arch, + Description: custom.Description, + URL: custom.URL, + Provisioner: custom.Provisioner, + SHA256: custom.SHA256, + Custom: true, + }) + } + return images } func amd64Images() []Image { @@ -111,6 +133,12 @@ func amd64Images() []Image { Description: "Rocky Linux 9 GenericCloud image for KVM", URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2", }, + { + ID: "kvm-windows-11", Name: "Windows 11 KVM", + Distro: "windows", Release: "11", Arch: "amd64", + Description: "Windows 11 Enterprise LTSC 2024 Evaluation", + URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029", + }, { ID: "kvm-windows-10", Name: "Windows 10 KVM", Distro: "windows", Release: "10", Arch: "amd64", @@ -196,7 +224,7 @@ func ImagePath(id string) string { if img != nil { safeID = img.ID } - if img != nil && img.Distro == "windows" { + if img != nil && img.IsWindows() { ext = ".iso" } fileName := safeID + ext @@ -213,10 +241,26 @@ func ImagePath(id string) string { return filepath.Join(CacheDir(), fileName) } -// IsWindowsImage returns true if the image distro is "windows". +func (image Image) IsWindows() bool { + return image.Provisioner == config.KVMProvisionerWindows10 || + image.Provisioner == config.KVMProvisionerWindows11 || + (image.Provisioner == "" && image.Distro == "windows") +} + +func (image Image) IsWindows11() bool { + return image.Provisioner == config.KVMProvisionerWindows11 || + (image.Provisioner == "" && image.Distro == "windows" && image.Release == "11") +} + +// IsWindowsImage returns true if the image uses Windows unattended installation. func IsWindowsImage(id string) bool { img := FindImage(id) - return img != nil && img.Distro == "windows" + return img != nil && img.IsWindows() +} + +func IsWindows11Image(id string) bool { + img := FindImage(id) + return img != nil && img.IsWindows11() } func virtioWinISOPath() string { diff --git a/backend/internal/lxc/custom_images.go b/backend/internal/lxc/custom_images.go new file mode 100644 index 0000000..73a85a7 --- /dev/null +++ b/backend/internal/lxc/custom_images.go @@ -0,0 +1,310 @@ +package lxc + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" +) + +type CustomImageDownloadProgress struct { + Stage string + DownloadedBytes int64 + TotalBytes int64 + Percent int +} + +type CustomImageDownloadProgressFunc func(CustomImageDownloadProgress) + +func CustomImagePath(id string) string { + template := FindTemplate(id) + if template == nil || !template.Custom { + return filepath.Join("/var/cache/lxc/download/custom", "__invalid_image_id__", "rootfs.tar") + } + return filepath.Join("/var/cache/lxc/download/custom", template.ID, "rootfs.tar") +} + +func CustomImageDownloadedInfo(id string) (bool, int64) { + info, err := os.Stat(CustomImagePath(id)) + if err != nil || info.IsDir() { + return false, 0 + } + return true, info.Size() +} + +func DeleteCustomImage(id string) error { + template := FindTemplate(id) + if template == nil || !template.Custom { + return fmt.Errorf("custom LXC image not found") + } + return os.RemoveAll(filepath.Dir(CustomImagePath(id))) +} + +func DownloadCustomImageWithProgress(ctx context.Context, template Template, progress CustomImageDownloadProgressFunc) error { + if !template.Custom { + return fmt.Errorf("template is not a custom LXC image") + } + target := CustomImagePath(template.ID) + if ok, _ := CustomImageDownloadedInfo(template.ID); ok { + return nil + } + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + tmp := target + ".tmp" + _ = os.Remove(tmp) + if err := downloadCustomRootfs(ctx, template.URL, tmp, progress); err != nil { + _ = os.Remove(tmp) + return err + } + if err := ctx.Err(); err != nil { + _ = os.Remove(tmp) + return err + } + if template.SHA256 != "" { + if err := verifyCustomRootfsSHA256(tmp, template.SHA256); err != nil { + _ = os.Remove(tmp) + return err + } + } + if progress != nil { + progress(CustomImageDownloadProgress{Stage: "validating", Percent: 100}) + } + if err := ValidateCustomRootfsArchive(tmp); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Chmod(target, 0644) +} + +func downloadCustomRootfs(ctx context.Context, sourceURL, target string, progress CustomImageDownloadProgressFunc) error { + client := http.Client{ + Timeout: 30 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "CLICD/1.0 LXC image downloader") + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("download failed: %s", response.Status) + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer file.Close() + + total := response.ContentLength + buffer := make([]byte, 128*1024) + var downloaded int64 + for { + count, readErr := response.Body.Read(buffer) + if count > 0 { + if _, err := file.Write(buffer[:count]); err != nil { + return err + } + downloaded += int64(count) + if progress != nil { + percent := 0 + if total > 0 { + percent = int(downloaded * 100 / total) + if percent > 100 { + percent = 100 + } + } + progress(CustomImageDownloadProgress{ + Stage: "downloading", + DownloadedBytes: downloaded, + TotalBytes: total, + Percent: percent, + }) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + return file.Sync() +} + +func verifyCustomRootfsSHA256(filePath, expected string) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, strings.TrimSpace(expected)) { + return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +func ValidateCustomRootfsArchive(archivePath string) error { + command := exec.Command("tar", "-tf", archivePath) + stdout, err := command.StdoutPipe() + if err != nil { + return err + } + var stderr strings.Builder + command.Stderr = &stderr + if err := command.Start(); err != nil { + return fmt.Errorf("failed to inspect rootfs archive: %v", err) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + entries := make([]string, 0, 4096) + for scanner.Scan() { + if len(entries) >= 2_000_000 { + _ = command.Process.Kill() + return fmt.Errorf("rootfs archive contains too many entries") + } + entries = append(entries, scanner.Text()) + } + scanErr := scanner.Err() + waitErr := command.Wait() + if scanErr != nil { + return fmt.Errorf("failed to read rootfs archive: %v", scanErr) + } + if waitErr != nil { + return fmt.Errorf("invalid rootfs archive: %v, output: %s", waitErr, strings.TrimSpace(stderr.String())) + } + return validateCustomRootfsEntries(entries) +} + +func validateCustomRootfsEntries(entries []string) error { + hasInit := false + for _, entry := range entries { + entry = strings.TrimSpace(strings.ReplaceAll(entry, "\\", "/")) + entry = strings.TrimPrefix(entry, "./") + if entry == "" || entry == "." { + continue + } + if strings.HasPrefix(entry, "/") { + return fmt.Errorf("rootfs archive contains an absolute path: %s", entry) + } + clean := path.Clean(entry) + if clean == ".." || strings.HasPrefix(clean, "../") { + return fmt.Errorf("rootfs archive contains path traversal: %s", entry) + } + switch strings.TrimSuffix(clean, "/") { + case "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh": + hasInit = true + } + } + if len(entries) == 0 { + return fmt.Errorf("rootfs archive is empty") + } + if !hasInit { + return fmt.Errorf("rootfs archive does not contain a supported init") + } + return nil +} + +func ExtractCustomRootfs(templateID, destination string) error { + template := FindTemplate(templateID) + if template == nil || !template.Custom { + return fmt.Errorf("custom LXC image not found: %s", templateID) + } + archive := CustomImagePath(template.ID) + if ok, _ := CustomImageDownloadedInfo(template.ID); !ok { + return fmt.Errorf("custom LXC image is not downloaded: %s", templateID) + } + if err := ValidateCustomRootfsArchive(archive); err != nil { + return err + } + if err := os.MkdirAll(destination, 0755); err != nil { + return err + } + output, err := exec.Command("tar", "-xpf", archive, "-C", destination).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to extract custom LXC rootfs: %v, output: %s", err, strings.TrimSpace(string(output))) + } + if err := secureExtractedRootfs(destination); err != nil { + return err + } + if !rootfsHasInit(destination) { + return fmt.Errorf("extracted custom LXC rootfs is invalid: init not found") + } + return nil +} + +func secureExtractedRootfs(root string) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink == 0 { + return nil + } + target, err := os.Readlink(filePath) + if err != nil { + return err + } + var resolved string + if filepath.IsAbs(target) { + resolved = filepath.Join(root, strings.TrimLeft(filepath.ToSlash(target), "/")) + relative, err := filepath.Rel(filepath.Dir(filePath), resolved) + if err != nil { + return err + } + if err := os.Remove(filePath); err != nil { + return err + } + if err := os.Symlink(relative, filePath); err != nil { + return err + } + } else { + resolved = filepath.Join(filepath.Dir(filePath), target) + } + relativeToRoot, err := filepath.Rel(root, filepath.Clean(resolved)) + if err != nil { + return err + } + if relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(os.PathSeparator)) { + return fmt.Errorf("rootfs symlink escapes the archive root: %s -> %s", filePath, target) + } + return nil + }) +} diff --git a/backend/internal/lxc/custom_images_test.go b/backend/internal/lxc/custom_images_test.go new file mode 100644 index 0000000..8ac20d1 --- /dev/null +++ b/backend/internal/lxc/custom_images_test.go @@ -0,0 +1,61 @@ +package lxc + +import ( + "path/filepath" + "runtime" + "testing" + + "clicd/internal/config" +) + +func TestGetTemplatesIncludesHostArchitectureCustomLXCImage(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{CustomLXCImages: []config.CustomLXCImage{ + { + ID: "custom-lxc-host", Name: "Host Rootfs", Distro: "alpine", + Release: "3.21", Arch: runtime.GOARCH, URL: "https://example.test/rootfs.tar.xz", + }, + { + ID: "custom-lxc-other", Name: "Other Rootfs", Distro: "alpine", + Release: "3.21", Arch: "not-" + runtime.GOARCH, URL: "https://example.test/other.tar.xz", + }, + }} + + template := FindTemplate("custom-lxc-host") + if template == nil || !template.Custom || template.URL == "" { + t.Fatalf("custom LXC template was not exposed correctly: %+v", template) + } + if FindTemplate("custom-lxc-other") != nil { + t.Fatal("custom LXC template for another architecture was exposed") + } +} + +func TestCustomImagePathUsesAllowlistedID(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{} + + for _, id := range []string{"", ".", "..", "../../etc/passwd", "/absolute", "unknown"} { + got := filepath.ToSlash(CustomImagePath(id)) + if filepath.Base(filepath.Dir(got)) != "__invalid_image_id__" { + t.Fatalf("CustomImagePath(%q) = %q", id, got) + } + } +} + +func TestValidateCustomRootfsEntries(t *testing.T) { + if err := validateCustomRootfsEntries([]string{"./etc/", "./bin/", "./bin/sh"}); err != nil { + t.Fatalf("valid rootfs entries failed: %v", err) + } + for _, entries := range [][]string{ + {}, + {"etc/passwd"}, + {"/etc/passwd", "bin/sh"}, + {"../../etc/passwd", "bin/sh"}, + } { + if err := validateCustomRootfsEntries(entries); err == nil { + t.Fatalf("unsafe rootfs entries unexpectedly passed: %#v", entries) + } + } +} diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index afd1d43..e00da2b 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -503,15 +503,30 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch) cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统") - args := []string{"-n", lxcName, "-t", "download", "--", - "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} - if tmpl.Variant != "" { - args = append(args, "--variant", tmpl.Variant) - } - cmd := exec.Command("lxc-create", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) + if tmpl.Custom { + output, err := exec.Command("lxc-create", "-n", lxcName, "-t", "none").CombinedOutput() + if err != nil { + return fmt.Errorf("lxc-create failed for custom rootfs: %v, output: %s", err, string(output)) + } + if err := m.configureCustomLXCBase(lxcName, tmpl); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + if err := ExtractCustomRootfs(tmpl.ID, filepath.Join(containerDir, "rootfs")); err != nil { + _ = m.cleanupContainerStorage(lxcName) + return err + } + } else { + args := []string{"-n", lxcName, "-t", "download", "--", + "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} + if tmpl.Variant != "" { + args = append(args, "--variant", tmpl.Variant) + } + cmd := exec.Command("lxc-create", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output)) + } } cfg.ReportProgress("storage", "复制容器数据到存储磁盘") @@ -676,6 +691,41 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { return nil } +func (m *Manager) configureCustomLXCBase(lxcName string, tmpl *Template) error { + rootfsPath, err := m.safeRootfsPath(filepath.Join(m.LxcPath, lxcName, "rootfs")) + if err != nil { + return fmt.Errorf("invalid custom LXC rootfs path: %v", err) + } + configFile := filepath.Join(filepath.Dir(rootfsPath), "config") + data, err := os.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read custom LXC base config: %v", err) + } + if _, err := os.Stat("/usr/share/lxc/config/common.conf"); err != nil { + return fmt.Errorf("LXC common configuration is unavailable: %v", err) + } + + arch := "linux64" + switch strings.ToLower(strings.TrimSpace(tmpl.Arch)) { + case "amd64", "x86_64", "arm64", "aarch64": + default: + return fmt.Errorf("unsupported custom LXC architecture: %s", tmpl.Arch) + } + + base := []string{ + "# CLICD custom rootfs base configuration", + "lxc.include = /usr/share/lxc/config/common.conf", + "lxc.arch = " + arch, + "lxc.rootfs.path = dir:" + rootfsPath, + "lxc.uts.name = " + lxcName, + "", + } + if err := os.WriteFile(configFile, []byte(strings.Join(base, "\n")+string(data)), 0644); err != nil { + return fmt.Errorf("failed to write custom LXC base config: %v", err) + } + return nil +} + func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) { templateID := cfg.TemplateID osRelease := "" @@ -1667,6 +1717,9 @@ func appArmorProfileForTemplate(templateID string) (string, error) { func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool { id := strings.ToLower(strings.TrimSpace(templateID)) + if template := FindTemplate(templateID); template != nil { + id += " " + strings.ToLower(template.Distro+" "+template.Release) + } if id == "" || strings.Contains(id, "alpine") { return false } @@ -2564,7 +2617,7 @@ if [ -L /etc/resolv.conf ] 2>/dev/null; then fi # Also try resolvectl for systemd-resolved setups if command -v resolvectl >/dev/null 2>&1; then - resolvectl dns eth0 10.0.3.1 2>/dev/null || true + resolvectl dns eth0 __CLICD_LXC_GATEWAY__ 2>/dev/null || true resolvectl dns eth0 8.8.8.8 2>/dev/null || true resolvectl domain eth0 '~.' 2>/dev/null || true fi @@ -2572,7 +2625,7 @@ fi # Avoid the trap where systemd stub resolver puts "nameserver 127.0.0.53" # but doesn't actually resolve anything. if ! grep -q '^nameserver [1-9]' /etc/resolv.conf 2>/dev/null; then - echo "nameserver 10.0.3.1" > /etc/resolv.conf + echo "nameserver __CLICD_LXC_GATEWAY__" > /etc/resolv.conf echo "nameserver 8.8.8.8" >> /etc/resolv.conf fi export DEBIAN_FRONTEND=noninteractive @@ -2709,6 +2762,7 @@ ensure_sshd_runtime_dir } ` script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue) + script = strings.ReplaceAll(script, "__CLICD_LXC_GATEWAY__", config.LXCNATNetwork().Gateway) if !startService { return script } @@ -3308,23 +3362,32 @@ func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) erro } defer m.cleanupTemporaryContainer(tmpName) - args := []string{ - "-n", tmpName, - "-t", "download", - "--", - "-d", tmpl.Distro, - "-r", tmpl.Release, - "-a", tmpl.Arch, - } - if tmpl.Variant != "" { - args = append(args, "--variant", tmpl.Variant) - } - output, err := exec.Command("lxc-create", args...).CombinedOutput() - if err != nil { - return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output)) + tmpRootfs := filepath.Join(tmpDir, "rootfs") + if tmpl.Custom { + if err := os.MkdirAll(tmpRootfs, 0755); err != nil { + return err + } + if err := ExtractCustomRootfs(tmpl.ID, tmpRootfs); err != nil { + return err + } + } else { + args := []string{ + "-n", tmpName, + "-t", "download", + "--", + "-d", tmpl.Distro, + "-r", tmpl.Release, + "-a", tmpl.Arch, + } + if tmpl.Variant != "" { + args = append(args, "--variant", tmpl.Variant) + } + output, err := exec.Command("lxc-create", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output)) + } } - tmpRootfs := filepath.Join(tmpDir, "rootfs") if !rootfsHasInit(tmpRootfs) { return fmt.Errorf("downloaded replacement rootfs is invalid: init not found") } diff --git a/backend/internal/lxc/lxc_test.go b/backend/internal/lxc/lxc_test.go index 8995667..a4375be 100644 --- a/backend/internal/lxc/lxc_test.go +++ b/backend/internal/lxc/lxc_test.go @@ -122,6 +122,78 @@ func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) { } } +func TestTaggedRuleLineNumbersReturnsMatchingRulesDescending(t *testing.T) { + output := []byte(`Chain PREROUTING (policy ACCEPT) +num target prot opt source destination +2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30080 /* clicd-c12-any-30080 */ +7 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30081 /* clicd-c13-any-30081 */ +11 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30082 /* clicd-c12-any-30082 */ +`) + got := taggedRuleLineNumbers(output, "clicd-c12-") + want := []int{11, 2} + if !reflect.DeepEqual(got, want) { + t.Fatalf("taggedRuleLineNumbers() = %v, want %v", got, want) + } +} + +func TestPortMappingConntrackDeleteArgs(t *testing.T) { + got := portMappingConntrackDeleteArgs(config.PortMapping{ + HostIP: "203.0.113.10", + HostPort: 32022, + Protocol: "TCP", + }) + want := []string{"-D", "-p", "tcp", "--dport", "32022", "--dst", "203.0.113.10"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("portMappingConntrackDeleteArgs() = %v, want %v", got, want) + } + + if got := portMappingConntrackDeleteArgs(config.PortMapping{HostPort: 32022, Protocol: "icmp"}); got != nil { + t.Fatalf("unsupported protocol returned args: %v", got) + } +} + +func TestUpdateSSHPortMappingKeepsIdentityAndSynchronizesSSHPort(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 65535, + Containers: []config.Container{{ + ID: 12, + Name: "ct-test", + Status: "stopped", + SSHPort: 30022, + PortMappings: []config.PortMapping{{ + HostPort: 30022, + ContainerPort: 22, + Protocol: "tcp", + Description: "SSH", + }}, + }}, + } + + manager := NewManager() + mappings, err := manager.UpdatePortMapping(12, 0, config.PortMapping{ + HostPort: 31022, + ContainerPort: 22, + Protocol: "tcp", + Description: "renamed", + }) + if err != nil { + t.Fatal(err) + } + if len(mappings) != 1 || mappings[0].Description != "SSH" { + t.Fatalf("updated mappings = %+v", mappings) + } + container := config.FindContainer(12) + if container == nil || container.SSHPort != 31022 { + t.Fatalf("container after SSH update = %+v", container) + } + if _, err := manager.DeletePortMapping(12, 0); err == nil { + t.Fatal("updated SSH mapping became deletable") + } +} + func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) { previous := config.AppConfig t.Cleanup(func() { config.AppConfig = previous }) @@ -133,10 +205,12 @@ func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) { createNATReservationMu.Lock() createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} createNATReservationMu.Unlock() t.Cleanup(func() { createNATReservationMu.Lock() createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} createNATReservationMu.Unlock() }) @@ -181,6 +255,109 @@ func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) { } } +func TestReserveBatchCreateNATPortsPlansAllAutomaticPorts(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 30010, + NextSSHPort: 30001, + } + + createNATReservationMu.Lock() + createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} + createNATReservationMu.Unlock() + t.Cleanup(func() { + createNATReservationMu.Lock() + createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} + createNATReservationMu.Unlock() + }) + + configs := []ContainerConfig{ + {Name: "batch-1", PortMappingCount: 2}, + {Name: "batch-2", PortMappingCount: 2}, + } + for i := range configs { + if err := configs[i].NormalizeCreateNATMappings(); err != nil { + t.Fatal(err) + } + } + + planned, err := ReserveBatchCreateNATPorts(configs) + if err != nil { + t.Fatal(err) + } + used := map[int]string{} + for _, cfg := range planned { + if cfg.ManagementPort == 0 { + t.Fatalf("%s has no planned management port", cfg.Name) + } + if len(cfg.NATPortMappings) != 1 { + t.Fatalf("%s automatic mappings = %d, want 1", cfg.Name, len(cfg.NATPortMappings)) + } + for _, port := range []int{cfg.ManagementPort, cfg.NATPortMappings[0].HostPort} { + if owner := used[port]; owner != "" { + t.Fatalf("planned port %d is shared by %s and %s", port, owner, cfg.Name) + } + used[port] = cfg.Name + } + } + + for _, cfg := range planned { + port, release, err := ReserveCreateNATPorts(cfg) + if err != nil { + t.Fatalf("%s could not claim its queued reservation: %v", cfg.Name, err) + } + if port != cfg.ManagementPort { + t.Fatalf("%s claimed management port %d, want %d", cfg.Name, port, cfg.ManagementPort) + } + release() + } + if len(queuedCreateNATReservations) != 0 { + t.Fatalf("queued reservations remain after claim: %v", queuedCreateNATReservations) + } +} + +func TestReserveBatchCreateNATPortsRejectsWholeConflictingBatch(t *testing.T) { + previous := config.AppConfig + t.Cleanup(func() { config.AppConfig = previous }) + config.AppConfig = &config.ClicdConfig{ + NATPortStart: 30000, + NATPortEnd: 30010, + NextSSHPort: 30001, + } + + createNATReservationMu.Lock() + createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} + createNATReservationMu.Unlock() + t.Cleanup(func() { + createNATReservationMu.Lock() + createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} + createNATReservationMu.Unlock() + }) + + configs := []ContainerConfig{ + {Name: "batch-1", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 80, Protocol: "tcp"}}}, + {Name: "batch-2", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 8080, Protocol: "tcp"}}}, + } + for i := range configs { + if err := configs[i].NormalizeCreateNATMappings(); err != nil { + t.Fatal(err) + } + } + + if _, err := ReserveBatchCreateNATPorts(configs); err == nil { + t.Fatal("conflicting batch was accepted") + } + if len(queuedCreateNATReservations) != 0 { + t.Fatalf("conflicting batch left partial reservations: %v", queuedCreateNATReservations) + } +} + func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) { base := t.TempDir() rootfs := filepath.Join(base, "ct-1", "rootfs") diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index d08bb96..a2b5b46 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -1,9 +1,12 @@ package lxc import ( + "errors" "fmt" "net/netip" "os/exec" + "regexp" + "sort" "strconv" "strings" "sync" @@ -12,9 +15,10 @@ import ( ) var ( - createNATReservationMu sync.Mutex - createNATReservationNextID uint64 - createNATReservations = map[uint64][]config.PortMapping{} + createNATReservationMu sync.Mutex + createNATReservationNextID uint64 + createNATReservations = map[uint64][]config.PortMapping{} + queuedCreateNATReservations = map[string][]config.PortMapping{} ) // ApplyPortMappings applies iptables DNAT rules for a container's port mappings @@ -29,14 +33,16 @@ func (m *Manager) ApplyPortMappings(id int) error { EnsureAssignedPublicIPv4s(c.PublicIPv4s) tag := clicdTag(id) bridge := "lxcbr0" - subnet := "10.0.3.0/24" + subnet := config.LXCNATNetwork().Subnet if c.IsKVM() { bridge = "virbr0" - subnet = "192.168.122.0/24" + subnet = config.KVMNATNetwork().Subnet } EnsureForwardRules(bridge) - m.CleanPortMappings(id) + if err := m.CleanPortMappings(id); err != nil { + return fmt.Errorf("clean existing port mappings for container %d: %w", id, err) + } deleteBridgeMasquerade(subnet) for _, pm := range c.PortMappings { @@ -243,9 +249,13 @@ func clicdTag(id int) string { return "c" + strconv.Itoa(id) } func EnsureAllRunningPortMappings() { m := NewManager() + m.cleanOrphanedPortMappings() for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] if c.Status != "running" || strings.TrimSpace(c.IP) == "" { + if err := m.CleanPortMappings(c.ID); err != nil { + fmt.Printf("Warning: failed to clean inactive port mappings for %s: %v\n", c.Name, err) + } continue } if err := m.ApplyPortMappings(c.ID); err != nil { @@ -254,6 +264,33 @@ func EnsureAllRunningPortMappings() { } } +var taggedContainerIDPattern = regexp.MustCompile(`clicd-c([0-9]+)-`) + +func (m *Manager) cleanOrphanedPortMappings() { + output, err := exec.Command("iptables-save").Output() + if err != nil { + return + } + configured := make(map[int]bool, len(config.AppConfig.Containers)) + for i := range config.AppConfig.Containers { + configured[config.AppConfig.Containers[i].ID] = true + } + seen := map[int]bool{} + for _, match := range taggedContainerIDPattern.FindAllSubmatch(output, -1) { + if len(match) < 2 { + continue + } + id, err := strconv.Atoi(string(match[1])) + if err != nil || configured[id] || seen[id] { + continue + } + seen[id] = true + if err := m.CleanPortMappings(id); err != nil { + fmt.Printf("Warning: failed to clean orphaned port mappings for container %d: %v\n", id, err) + } + } +} + // EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic. func EnsureForwardRules(bridge string) { if bridge == "" { @@ -306,16 +343,108 @@ func ensureLibvirtForwardRules(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() + marker := "clicd-" + clicdTag(id) + "-" + var cleanupErrors []error + for _, target := range []struct { + table string + chain string + }{ + {table: "nat", chain: "PREROUTING"}, + {table: "nat", chain: "POSTROUTING"}, + {chain: "FORWARD"}, + } { + if err := deleteTaggedIPTablesRules(target.table, target.chain, marker); err != nil { + cleanupErrors = append(cleanupErrors, err) + } } - cmd := exec.Command("sh", "-c", - 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 + if c := config.FindContainer(id); c != nil { + for _, mapping := range c.PortMappings { + clearPortMappingConntrack(mapping) + } + } + return errors.Join(cleanupErrors...) +} + +func deleteTaggedIPTablesRules(table, chain, marker string) error { + listArgs := []string{"-w", "5"} + if table != "" { + listArgs = append(listArgs, "-t", table) + } + listArgs = append(listArgs, "-L", chain, "-n", "--line-numbers") + output, err := exec.Command("iptables", listArgs...).CombinedOutput() + if err != nil { + return fmt.Errorf("list iptables %s/%s: %w: %s", tableName(table), chain, err, strings.TrimSpace(string(output))) + } + + var deleteErrors []error + for _, lineNumber := range taggedRuleLineNumbers(output, marker) { + deleteArgs := []string{"-w", "5"} + if table != "" { + deleteArgs = append(deleteArgs, "-t", table) + } + deleteArgs = append(deleteArgs, "-D", chain, strconv.Itoa(lineNumber)) + if output, err := exec.Command("iptables", deleteArgs...).CombinedOutput(); err != nil { + deleteErrors = append(deleteErrors, fmt.Errorf( + "delete iptables %s/%s rule %d: %w: %s", + tableName(table), chain, lineNumber, err, strings.TrimSpace(string(output)), + )) + } + } + return errors.Join(deleteErrors...) +} + +func taggedRuleLineNumbers(output []byte, marker string) []int { + lineNumbers := make([]int, 0) + for _, line := range strings.Split(string(output), "\n") { + if !strings.Contains(line, marker) { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + lineNumber, err := strconv.Atoi(fields[0]) + if err == nil && lineNumber > 0 { + lineNumbers = append(lineNumbers, lineNumber) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(lineNumbers))) + return lineNumbers +} + +func tableName(table string) string { + if table == "" { + return "filter" + } + return table +} + +func clearPortMappingConntrack(mapping config.PortMapping) { + args := portMappingConntrackDeleteArgs(mapping) + if len(args) == 0 { + return + } + // conntrack exits non-zero when no matching flow exists; that is already clean. + _ = exec.Command("conntrack", args...).Run() +} + +func portMappingConntrackDeleteArgs(mapping config.PortMapping) []string { + protocol := strings.ToLower(strings.TrimSpace(mapping.Protocol)) + if protocol != "tcp" && protocol != "udp" { + return nil + } + if mapping.HostPort < 1 || mapping.HostPort > 65535 { + return nil + } + args := []string{ + "-D", + "-p", protocol, + "--dport", strconv.Itoa(mapping.HostPort), + } + if hostIP := strings.TrimSpace(mapping.HostIP); hostIP != "" { + args = append(args, "--dst", hostIP) + } + return args } // SetupDefaultPortMappings creates default port mappings @@ -368,14 +497,19 @@ func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([ if index < 0 || index >= len(c.PortMappings) { return nil, fmt.Errorf("invalid port mapping index: %d", index) } + existing := c.PortMappings[index] normalized, err := normalizePortMapping(c, index, pm) if err != nil { return nil, err } + if strings.EqualFold(existing.Description, "SSH") { + normalized.Description = "SSH" + } c.PortMappings[index] = normalized if err := persistAndReloadMappings(m, c); err != nil { return nil, err } + clearPortMappingConntrack(existing) return c.PortMappings, nil } @@ -388,17 +522,20 @@ func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, er if index < 0 || index >= len(c.PortMappings) { return nil, fmt.Errorf("invalid port mapping index: %d", index) } - if c.PortMappings[index].Description == "SSH" { + removed := c.PortMappings[index] + if strings.EqualFold(removed.Description, "SSH") { return nil, fmt.Errorf("SSH default mapping cannot be deleted") } c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...) if err := persistAndReloadMappings(m, c); err != nil { return nil, err } + clearPortMappingConntrack(removed) return c.PortMappings, nil } func persistAndReloadMappings(m *Manager, c *config.Container) error { + syncContainerSSHPort(c) config.SaveConfig() if c.Status == "running" && c.IP != "" { return m.ApplyPortMappings(c.ID) @@ -406,6 +543,18 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error { return nil } +func syncContainerSSHPort(c *config.Container) { + if c == nil { + return + } + for _, mapping := range c.PortMappings { + if strings.EqualFold(mapping.Description, "SSH") { + c.SSHPort = mapping.HostPort + return + } + } +} + func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) { c := config.FindContainer(id) if c == nil { @@ -553,32 +702,25 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) { createNATReservationMu.Lock() defer createNATReservationMu.Unlock() + owner := createNATReservationOwner(cfg.Name) + requestedReservations := createNATReservationMappings(cfg, cfg.ManagementPort) + if queued, ok := queuedCreateNATReservations[owner]; ok { + if !sameCreateNATReservations(queued, requestedReservations) { + return 0, nil, fmt.Errorf("queued NAT port plan for %s no longer matches the create task", cfg.Name) + } + delete(queuedCreateNATReservations, owner) + return activateCreateNATReservationLocked(cfg.ManagementPort, queued) + } + if err := ValidateCreateNATPortAvailability(cfg); err != nil { return 0, nil, err } - requestedReservations := append([]config.PortMapping(nil), cfg.NATPortMappings...) - if cfg.ManagementPort > 0 { - requestedReservations = append(requestedReservations, config.PortMapping{ - HostPort: cfg.ManagementPort, - Protocol: "tcp", - }) - } - for _, requested := range requestedReservations { - for _, reservations := range createNATReservations { - for _, reserved := range reservations { - if requested.HostPort == reserved.HostPort && protocolsOverlap(requested.Protocol, reserved.Protocol) { - return 0, nil, fmt.Errorf("NAT host port %d/%s is reserved by another create task", requested.HostPort, requested.Protocol) - } - } - } + if err := validateCreateNATReservationsAvailableLocked(requestedReservations, owner); err != nil { + return 0, nil, err } excluded := cfg.RequestedNATHostPorts() - for _, reservations := range createNATReservations { - for _, reserved := range reservations { - excluded = append(excluded, reserved.HostPort) - } - } + excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...) managementPort := cfg.ManagementPort if managementPort == 0 { var err error @@ -588,12 +730,96 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) { } } + reservations := createNATReservationMappings(cfg, managementPort) + return activateCreateNATReservationLocked(managementPort, reservations) +} + +// ReserveBatchCreateNATPorts resolves every automatic NAT port and reserves +// the complete batch before any create task is enqueued. +func ReserveBatchCreateNATPorts(configs []ContainerConfig) ([]ContainerConfig, error) { + createNATReservationMu.Lock() + defer createNATReservationMu.Unlock() + + planned := append([]ContainerConfig(nil), configs...) + addedOwners := make([]string, 0, len(planned)) + rollback := func() { + for _, owner := range addedOwners { + delete(queuedCreateNATReservations, owner) + } + } + + for i := range planned { + cfg := &planned[i] + cfg.NATPortMappings = append([]config.PortMapping(nil), cfg.NATPortMappings...) + if !cfg.WantsNAT() { + continue + } + owner := createNATReservationOwner(cfg.Name) + if owner == "" { + rollback() + return nil, fmt.Errorf("container name is required for NAT port reservation") + } + if _, exists := queuedCreateNATReservations[owner]; exists { + rollback() + return nil, fmt.Errorf("container creation already has reserved NAT ports: %s", cfg.Name) + } + if err := ValidateCreateNATPortAvailability(*cfg); err != nil { + rollback() + return nil, fmt.Errorf("%s: %w", cfg.Name, err) + } + + explicit := createNATReservationMappings(*cfg, cfg.ManagementPort) + if err := validateCreateNATReservationsAvailableLocked(explicit, owner); err != nil { + rollback() + return nil, fmt.Errorf("%s: %w", cfg.Name, err) + } + + excluded := cfg.RequestedNATHostPorts() + excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...) + if cfg.ManagementPort == 0 { + port, err := config.AllocateSSHPortExcluding(excluded) + if err != nil { + rollback() + return nil, fmt.Errorf("%s: %w", cfg.Name, err) + } + cfg.ManagementPort = port + } + + if len(cfg.NATPortMappings) == 0 && cfg.PortMappingCount > 1 { + generated, err := planDefaultCreateNATMappingsLocked(*cfg, cfg.PortMappingCount-1, owner) + if err != nil { + rollback() + return nil, fmt.Errorf("%s: %w", cfg.Name, err) + } + cfg.NATPortMappings = generated + cfg.PortMappingCount = len(generated) + 1 + } + + reservations := createNATReservationMappings(*cfg, cfg.ManagementPort) + if err := validateCreateNATReservationsAvailableLocked(reservations, owner); err != nil { + rollback() + return nil, fmt.Errorf("%s: %w", cfg.Name, err) + } + queuedCreateNATReservations[owner] = reservations + addedOwners = append(addedOwners, owner) + } + return planned, nil +} + +func ReleaseQueuedCreateNATPorts(name string) { + owner := createNATReservationOwner(name) + if owner == "" { + return + } + createNATReservationMu.Lock() + delete(queuedCreateNATReservations, owner) + createNATReservationMu.Unlock() +} + +func activateCreateNATReservationLocked(managementPort int, reservations []config.PortMapping) (int, func(), error) { createNATReservationNextID++ reservationID := createNATReservationNextID - reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings)+1) - reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"}) - reservations = append(reservations, cfg.NATPortMappings...) - createNATReservations[reservationID] = reservations + createNATReservations[reservationID] = append([]config.PortMapping(nil), reservations...) var once sync.Once release := func() { @@ -606,6 +832,116 @@ func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) { return managementPort, release, nil } +func createNATReservationOwner(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func createNATReservationMappings(cfg ContainerConfig, managementPort int) []config.PortMapping { + reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings)+1) + if managementPort > 0 { + reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"}) + } + reservations = append(reservations, cfg.NATPortMappings...) + return reservations +} + +func validateCreateNATReservationsAvailableLocked(requested []config.PortMapping, exceptOwner string) error { + for _, candidate := range requested { + for _, reservations := range createNATReservations { + if conflictingCreateNATReservation(candidate, reservations) { + return fmt.Errorf("NAT host port %d/%s is reserved by another create task", candidate.HostPort, candidate.Protocol) + } + } + for owner, reservations := range queuedCreateNATReservations { + if owner == exceptOwner { + continue + } + if conflictingCreateNATReservation(candidate, reservations) { + return fmt.Errorf("NAT host port %d/%s is reserved by queued create task %s", candidate.HostPort, candidate.Protocol, owner) + } + } + } + return nil +} + +func conflictingCreateNATReservation(candidate config.PortMapping, reservations []config.PortMapping) bool { + for _, reserved := range reservations { + if candidate.HostPort == reserved.HostPort && protocolsOverlap(candidate.Protocol, reserved.Protocol) { + return true + } + } + return false +} + +func allReservedCreateNATHostPortsLocked(exceptOwner string) []int { + ports := make([]int, 0) + for _, reservations := range createNATReservations { + for _, reserved := range reservations { + ports = append(ports, reserved.HostPort) + } + } + for owner, reservations := range queuedCreateNATReservations { + if owner == exceptOwner { + continue + } + for _, reserved := range reservations { + ports = append(ports, reserved.HostPort) + } + } + return ports +} + +func planDefaultCreateNATMappingsLocked(cfg ContainerConfig, count int, owner string) ([]config.PortMapping, error) { + if count <= 0 { + return nil, nil + } + unavailable := map[int]bool{cfg.ManagementPort: true} + for _, port := range allReservedCreateNATHostPortsLocked(owner) { + unavailable[port] = true + } + for _, mapping := range cfg.NATPortMappings { + unavailable[mapping.HostPort] = true + } + + candidate := &config.Container{ID: -1} + start, end := config.NATPortRange() + mappings := make([]config.PortMapping, 0, count) + for port := start; port <= end && len(mappings) < count; port++ { + if unavailable[port] || !HostPortAvailable(candidate, "", port, "tcp") { + continue + } + unavailable[port] = true + mappings = append(mappings, config.PortMapping{ + HostPort: port, + ContainerPort: port, + Protocol: "tcp", + Description: fmt.Sprintf("Port-%d", port), + }) + } + if len(mappings) != count { + return nil, fmt.Errorf("not enough free NAT4 host ports for %d automatic mappings", count) + } + return mappings, nil +} + +func sameCreateNATReservations(left, right []config.PortMapping) bool { + if len(left) != len(right) { + return false + } + counts := make(map[string]int, len(left)) + for _, mapping := range left { + counts[fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))]++ + } + for _, mapping := range right { + key := fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol)) + if counts[key] == 0 { + return false + } + counts[key]-- + } + return true +} + func allocateDefaultEqualPorts(c *config.Container, count int) []int { if count <= 0 { return nil diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go index d43e6c1..0a5ee6c 100644 --- a/backend/internal/lxc/templates.go +++ b/backend/internal/lxc/templates.go @@ -1,6 +1,10 @@ package lxc -import "runtime" +import ( + "runtime" + + "clicd/internal/config" +) // Template represents an LXC image template type Template struct { @@ -11,12 +15,15 @@ type Template struct { Arch string `json:"arch"` Variant string `json:"variant"` Description string `json:"description"` + URL string `json:"url,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Custom bool `json:"custom,omitempty"` } // GetTemplates returns available LXC image templates (only verified working ones) func GetTemplates() []Template { arch := defaultTemplateArch() - return []Template{ + templates := []Template{ { ID: "ubuntu-noble", Name: "Ubuntu 24.04", Distro: "ubuntu", Release: "noble", Arch: arch, @@ -68,6 +75,23 @@ func GetTemplates() []Template { Description: "Rocky Linux 10", }, } + for _, custom := range config.ListCustomLXCImages() { + if custom.Arch != arch { + continue + } + templates = append(templates, Template{ + ID: custom.ID, + Name: custom.Name, + Distro: custom.Distro, + Release: custom.Release, + Arch: custom.Arch, + Description: custom.Description, + URL: custom.URL, + SHA256: custom.SHA256, + Custom: true, + }) + } + return templates } func defaultTemplateArch() string { diff --git a/backend/internal/server/access_policy.go b/backend/internal/server/access_policy.go new file mode 100644 index 0000000..0eefa8f --- /dev/null +++ b/backend/internal/server/access_policy.go @@ -0,0 +1,39 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + + "clicd/internal/config" +) + +func panelAccessMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decision := config.EvaluatePanelAccess( + config.AppConfig.PanelAccessPolicy, + r.RemoteAddr, + config.ForwardedClientHeaders{ + ForwardedFor: r.Header.Get("X-Forwarded-For"), + RealIP: r.Header.Get("X-Real-IP"), + CFConnectingIP: r.Header.Get("CF-Connecting-IP"), + }, + ) + if decision.Allowed { + next.ServeHTTP(w, r) + return + } + + w.Header().Set("Cache-Control", "no-store") + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "message": "Access denied by panel source policy", + }) + return + } + http.Error(w, "Access denied by panel source policy", http.StatusForbidden) + }) +} diff --git a/backend/internal/server/access_policy_test.go b/backend/internal/server/access_policy_test.go new file mode 100644 index 0000000..8772d4c --- /dev/null +++ b/backend/internal/server/access_policy_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "clicd/internal/config" +) + +func TestPanelAccessMiddleware(t *testing.T) { + previous := config.AppConfig + config.AppConfig = &config.ClicdConfig{ + PanelAccessPolicy: config.PanelAccessPolicy{ + Enabled: true, + AllowedSources: []string{"192.0.2.0/24"}, + TrustedProxies: []string{"10.0.0.1"}, + }, + } + t.Cleanup(func() { + config.AppConfig = previous + }) + + handler := panelAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + allowed := httptest.NewRequest(http.MethodGet, "/api/version", nil) + allowed.RemoteAddr = "192.0.2.8:50000" + allowedRecorder := httptest.NewRecorder() + handler.ServeHTTP(allowedRecorder, allowed) + if allowedRecorder.Code != http.StatusNoContent { + t.Fatalf("allowed status = %d", allowedRecorder.Code) + } + + denied := httptest.NewRequest(http.MethodGet, "/api/version", nil) + denied.RemoteAddr = "198.51.100.8:50000" + deniedRecorder := httptest.NewRecorder() + handler.ServeHTTP(deniedRecorder, denied) + if deniedRecorder.Code != http.StatusForbidden { + t.Fatalf("denied status = %d", deniedRecorder.Code) + } + if got := deniedRecorder.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("denied content type = %q", got) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 0ca8ca1..90ef7d0 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -49,11 +49,13 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings))) mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings))) + mux.HandleFunc("/api/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy))) mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias)))) mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) + mux.HandleFunc("/api/images/custom", corsMiddleware(api.AdminMiddleware(api.HandleCustomKVMImages))) mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel))) mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) @@ -101,6 +103,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages))) + mux.HandleFunc("/api/v1/images/custom", corsMiddleware(api.AuthMiddleware(api.HandleCustomKVMImages))) mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload))) mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel))) mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete))) @@ -126,6 +129,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs))) mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings))) mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings))) + mux.HandleFunc("/api/v1/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy))) mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts)))) mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck)))) mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs)))) @@ -192,7 +196,7 @@ func Run() error { server := &http.Server{ Addr: addr, - Handler: mux, + Handler: panelAccessMiddleware(mux), } if sslEnabled() { diff --git a/backend/main.go b/backend/main.go index d40e512..8d5ea13 100644 --- a/backend/main.go +++ b/backend/main.go @@ -27,6 +27,7 @@ func main() { isServerMode := false isCliMode := false noWebAutostart := false + isAccessPolicyCommand := len(os.Args) > 1 && os.Args[1] == "access-policy" for _, arg := range os.Args[1:] { if arg == "server" || arg == "-s" || arg == "--server" { isServerMode = true @@ -48,6 +49,14 @@ func main() { } _ = cfg + if isAccessPolicyCommand { + if err := cli.RunAccessPolicyCommand(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Access policy error: %v\n", err) + os.Exit(1) + } + return + } + if isServerMode || (!isTerminal && !isCliMode) { installShutdownStateCapture() diff --git a/docs/en/features/api.md b/docs/en/features/api.md index 137f789..e4671a0 100644 --- a/docs/en/features/api.md +++ b/docs/en/features/api.md @@ -191,6 +191,26 @@ Update example: | `disabled` | Whether this key is disabled. | | `container_uuids` | Optional container allowlist that limits the key to specific containers. | +## Panel Access Source Policy + +Use `GET /api/v1/access-policy` to read the panel source allowlist and `PUT /api/v1/access-policy` to update it. Both endpoints require `admin:access`. The policy covers panel pages, login endpoints, and every API. + +```json +{ + "enabled": true, + "allowed_sources": [ + "203.0.113.10", + "192.168.1.0/24", + "2001:db8::/32" + ], + "trusted_proxies": [ + "127.0.0.1" + ] +} +``` + +Both lists accept IPv4, IPv6, and CIDR values. The backend only uses `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` when the direct peer matches `trusted_proxies`, so untrusted clients cannot bypass the policy by spoofing those headers. An enabled policy requires at least one allowed source, and the API rejects changes that exclude the current administrator source. Direct loopback access remains available as a CLI/SSH recovery path. + ## Python Example Fetch containers: @@ -302,6 +322,8 @@ print(resp.json()) | GET | `/api/v1/templates` | Template list | | GET | `/api/v1/images` | Image management list | | GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` | +| POST | `/api/v1/images/custom` | Add a third-party LXC/KVM image source | +| DELETE | `/api/v1/images/custom` | Remove a third-party LXC/KVM image source and cache | | POST | `/api/v1/images/download` | Download image | | POST | `/api/v1/images/cancel` | Cancel image download | | DELETE | `/api/v1/images/delete` | Delete image cache | diff --git a/docs/en/guide/configuration.md b/docs/en/guide/configuration.md index e4736e5..e119443 100644 --- a/docs/en/guide/configuration.md +++ b/docs/en/guide/configuration.md @@ -21,6 +21,23 @@ systemctl restart clicd journalctl -u clicd -n 100 --no-pager ``` +## Panel Access Allowlist CLI + +```bash +# Show the current policy +clicd access-policy show + +# Allow selected addresses and networks; add reverse proxies when needed +clicd access-policy set \ + --allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \ + --trusted-proxy "127.0.0.1" + +# Disable source restrictions +clicd access-policy disable +``` + +The same controls are available from the "Panel access allowlist" item in `clicd cli`. Both paths persist the setting and restart the running panel service automatically. + ## Security Recommendations - Do not expose the web panel directly to untrusted networks. diff --git a/docs/en/guide/installation.md b/docs/en/guide/installation.md index 5c8371a..4b91f4e 100644 --- a/docs/en/guide/installation.md +++ b/docs/en/guide/installation.md @@ -17,6 +17,8 @@ CLICD provides a one-line installer. By default, it installs the latest version curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh ``` +The installer asks for separate LXC and KVM NAT private subnets. Press Enter to scan host routes, interfaces, bridges, and libvirt networks and select non-overlapping RFC1918 `/24` networks, or enter a CIDR such as `172.28.40.0/24`. For unattended installation, set `CLICD_LXC_SUBNET` and `CLICD_KVM_SUBNET`. + The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture. ## Install a Specific Version diff --git a/docs/features/api.md b/docs/features/api.md index 86ef1cd..029aa8c 100644 --- a/docs/features/api.md +++ b/docs/features/api.md @@ -191,6 +191,26 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da | `disabled` | 是否禁用该 Key。 | | `container_uuids` | 可选;限制该 Key 只能访问指定容器。 | +## 面板访问来源策略 + +`GET /api/v1/access-policy` 读取面板访问白名单,`PUT /api/v1/access-policy` 更新策略。两者均需要 `admin:access` 权限。策略覆盖面板页面、登录入口和全部 API。 + +```json +{ + "enabled": true, + "allowed_sources": [ + "203.0.113.10", + "192.168.1.0/24", + "2001:db8::/32" + ], + "trusted_proxies": [ + "127.0.0.1" + ] +} +``` + +`allowed_sources` 和 `trusted_proxies` 均支持 IPv4、IPv6 及 CIDR。只有直接连接来源命中 `trusted_proxies` 时,后端才会使用 `X-Forwarded-For`、`X-Real-IP` 或 `CF-Connecting-IP`;其他客户端伪造这些请求头不会绕过白名单。启用策略时至少要配置一个允许来源,且接口会拒绝排除当前管理来源的配置。本机回环直连保留为 CLI/SSH 故障恢复通道。 + ## Python 示例 获取容器列表: @@ -302,6 +322,8 @@ print(resp.json()) | GET | `/api/v1/templates` | 模板列表 | | GET | `/api/v1/images` | 镜像管理列表 | | GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` | +| POST | `/api/v1/images/custom` | 添加第三方 LXC/KVM 镜像源 | +| DELETE | `/api/v1/images/custom` | 移除第三方 LXC/KVM 镜像源及缓存 | | POST | `/api/v1/images/download` | 下载镜像 | | POST | `/api/v1/images/cancel` | 取消镜像下载 | | DELETE | `/api/v1/images/delete` | 删除镜像缓存 | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1e75f90..9a60c74 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -21,6 +21,23 @@ systemctl restart clicd journalctl -u clicd -n 100 --no-pager ``` +## 面板访问白名单 CLI + +```bash +# 查看当前策略 +clicd access-policy show + +# 仅允许指定 IP/网段;反向代理地址按需填写 +clicd access-policy set \ + --allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \ + --trusted-proxy "127.0.0.1" + +# 关闭白名单限制 +clicd access-policy disable +``` + +也可以运行 `clicd cli`,在交互菜单中选择“面板访问白名单”。直接命令和交互菜单都会保存配置,并在服务运行时自动重启面板。 + ## 安全建议 - 不要把 Web 面板直接暴露给不可信来源。 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index dd446d5..7c56ed8 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -17,6 +17,8 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版 curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh ``` +安装器会分别询问 LXC 与 KVM 的 NAT 私网网段。直接回车时,脚本会扫描宿主机路由、网卡、网桥和 libvirt 网络,自动选择未冲突的 RFC1918 `/24` 网段;也可以输入 `172.28.40.0/24` 这类 CIDR。非交互安装可设置 `CLICD_LXC_SUBNET` 和 `CLICD_KVM_SUBNET`。 + 脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。 ## 安装指定版本 diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 61ae7b9..ad5ef3b 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' -import { ArrowRight, CalendarClock, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { ArrowLeft, ArrowRight, CalendarClock, Check, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useNavigate } from 'react-router-dom' -import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, StorageInfo, Template } from '../services/api' +import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getRoutingInfo, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, RoutingInfo, StorageInfo, Template } from '../services/api' import { useDialog } from './Dialog' import { useLanguage, type Language } from '../contexts/LanguageContext' import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth' @@ -60,8 +60,10 @@ const defaultForm: CreateContainerRequest = { export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { const navigate = useNavigate() const dialog = useDialog() - const { language } = useLanguage() + const { language, t } = useLanguage() const networkText = createNetworkText[language] + const wizardSteps = [t('基础信息'), t('镜像选择'), t('网络配置'), t('预览清单')] + const [currentStep, setCurrentStep] = useState(0) const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(false) const [batchCount, setBatchCount] = useState(1) @@ -70,9 +72,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const [hostReport, setHostReport] = useState(null) const [storageInfo, setStorageInfo] = useState(null) const [storageLoading, setStorageLoading] = useState(true) + const [routingInfo, setRoutingInfo] = useState(null) const [ipv6Status, setIPv6Status] = useState(null) const [nameError, setNameError] = useState('') + useEffect(() => { + if (isOpen) setCurrentStep(0) + }, [isOpen]) + useEffect(() => { if (!isOpen) return @@ -134,6 +141,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist return () => { active = false } }, [isOpen]) + useEffect(() => { + if (!isOpen) return + let active = true + getRoutingInfo() + .then((res) => { + if (active) setRoutingInfo(res.data.data || null) + }) + .catch(() => { + if (active) setRoutingInfo(null) + }) + return () => { active = false } + }, [isOpen]) + const ipv6Available = !!ipv6Status?.available const ipv6Prefixes = ipv6Status?.prefixes || [] const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '') @@ -167,22 +187,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist : 0 const linuxTemplate = !isWindowsTemplate(form.template_id) const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode - - const autoPortMappings = useMemo(() => { - if (!natEnabled) return [] - const count = natPortCount - return Array.from({ length: count - 1 }, (_, index) => ({ - host_port: 22002 + index, - container_port: 22002 + index, - protocol: 'tcp', - description: `Port-${22002 + index}`, - })) - }, [natEnabled, natPortCount]) - const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings - const managementPort = Math.round(Number(form.management_port) || 0) - // Automatic allocation starts around 22000; an explicit value is exact. - const sshPortPreview = managementPort || 22000 + const natAllocationPreview = useMemo( + () => previewNATAllocation( + routingInfo, + customNATMappings, + managementPort, + natEnabled ? natPortCount - 1 : 0, + isWindowsTemplate(form.template_id) ? 3389 : 22 + ), + [routingInfo, customNATMappings, managementPort, natEnabled, natPortCount, form.template_id] + ) + const autoPortMappings = natAllocationPreview.autoMappings + const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings + const sshPortPreview = managementPort || natAllocationPreview.managementPort + const selectedTemplate = templates.find((template) => template.id === form.template_id) + const selectedStoragePool = storagePools.find((pool) => pool.id === form.storage_pool_id) + const selectedAllowedImages = templates.filter((template) => (form.allowed_image_ids || []).includes(template.id)) + const networkSummary = form.assign_ipv4 + ? (manualIPv4s.length > 0 + ? `${networkText.publicIPv4}: ${manualIPv4s.join(', ')}` + : `${networkText.publicIPv4}: ${t('自动分配')} × ${form.ipv4_count || 1}`) + : lanIPv4Enabled + ? `${t('局域网')}: ${lanStaticEnabled ? `${form.lan_ipv4_address}/${form.lan_ipv4_prefix_len}` : 'DHCP'}` + : natEnabled + ? `${networkText.publicNAT}: ${natPortCount} ${t('个端口')}` + : form.assign_ipv6 + ? `${networkText.publicIPv6}: ${form.ipv6_count || 1}` + : t('未配置网络') // Find next available batch index to avoid name conflicts const batchStartIndex = useMemo(() => { @@ -212,6 +244,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist } } + const validateStep = (step: number) => { + if (step === 0) { + if (!form.name.trim() || nameError) { + dialog.alert(t('基础信息有误'), t('请填写有效且未被占用的容器名称')) + return false + } + if (Object.keys(resourceErrors).length > 0) { + dialog.alert(t('资源配置有误'), t('请按红色提示修改 vCPU、内存或磁盘配置')) + return false + } + if (!storageReady) { + dialog.alert(t('未配置存储'), `${t('请先在存储管理中为')} ${form.virtualization === 'kvm' ? t('KVM 磁盘') : t('LXC 容器')} ${t('开启至少一块存储磁盘')}`) + return false + } + const authError = validateSSHAuthInputs(form) + if (authError) { + dialog.alert(t('登录方式有误'), authError) + return false + } + } + + if (step === 1 && !form.template_id) { + dialog.alert(t('请选择镜像'), t('请选择用于创建容器的系统镜像')) + return false + } + + if (step === 2) { + if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') { + dialog.alert(t('网络配置有误'), t('请至少启用一种网络连接方式')) + return false + } + if (form.lan_ipv4_mode === 'static' && (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len)) { + dialog.alert(t('局域网 IPv4 配置有误'), t('请填写有效的 IPv4 地址、子网掩码和网关')) + return false + } + const natMappingError = natEnabled + ? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount) + : '' + if (natMappingError) { + dialog.alert(t('NAT 端口配置有误'), natMappingError) + return false + } + } + return true + } + + const handleNextStep = () => { + if (!validateStep(currentStep)) return + setCurrentStep((step) => Math.min(wizardSteps.length - 1, step + 1)) + } + const handleSubmit = async () => { if (!form.name || !form.template_id) { dialog.alert('提示', '请填写容器名称并选择系统模板') @@ -263,7 +346,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist for (let i = 0; i < batchCount; i++) { const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name const expandedNAT = wantsNAT - ? expandBatchNATConfig(boundedForm.nat_port_mappings || [], boundedForm.management_port || 0, i, batchCount) + ? expandBatchNATConfig(boundedForm.nat_port_mappings || [], boundedForm.management_port || 0, i) : { mappings: [], managementPort: 0 } const natPortMappings = expandedNAT.mappings containers.push({ @@ -301,7 +384,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist return (
-
+

创建新容器

-
+ + +
+
+ {currentStep === 0 && ( + <>
+ + )} + {currentStep === 1 && ( + <> {templates.length === 0 ? (
@@ -378,7 +497,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist )} + + )} + {currentStep === 0 && ( {storageLoading ? (
@@ -411,8 +533,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)}
+ )} - {templates.length > 0 && ( + {currentStep === 1 && templates.length > 0 && (
默认勾选当前系统;取消后,子用户也不能重装该系统。
@@ -444,7 +567,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist )} - {linuxTemplate && ( + {currentStep === 0 && linuxTemplate && (
登录方式
@@ -493,6 +616,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)} + {currentStep === 2 && (
@@ -817,10 +951,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist 0} + disabled={!routingInfo} onChange={() => { + const suggestedPort = autoPortMappings[0]?.host_port || natAllocationPreview.managementPort + if (!suggestedPort) return const next = customNATMappings.length > 0 ? customNATMappings - : [{ host_port: 22002, container_port: 22002, protocol: 'tcp', description: 'Port-22002' }] + : [{ host_port: suggestedPort, container_port: suggestedPort, protocol: 'tcp', description: `Port-${suggestedPort}` }] setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1, assign_nat: true }) }} /> @@ -918,15 +1055,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist {batchCount > 1 && (

{language === 'en' - ? 'Batch mode shifts the public source-port group for each container; target ports stay unchanged.' - : '批量创建时,每台容器使用不重叠的公网源端口组,容器目标端口保持不变。'} + ? 'Each later container starts after the previous highest public port; target ports stay unchanged.' + : '后续容器从上一台的最高公网端口之后开始,容器内部端口保持不变。'}

)}
)}
- {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22} + {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview || '--'} -> {isWindowsTemplate(form.template_id) ? 3389 : 22} {managementPort === 0 ? (language === 'en' ? ' (auto)' : '(自动)') : ''} {natPreviewMappings.map((mapping, index) => ( @@ -939,7 +1076,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist )}
+ )} + {currentStep === 0 && (
不选则长期有效

+ )} + + {currentStep === 3 && ( +
+
+

{t('基础信息')}

+
+ 1 ? `${form.name}-${batchStartIndex} … ${form.name}-${batchStartIndex + batchCount - 1}` : form.name} /> + + + + + + + +
+
+ +
+

{t('镜像与登录')}

+
+ + + +
+ {selectedAllowedImages.length > 0 && ( +
+ {selectedAllowedImages.map((template) => ( + + {template.name} + + ))} +
+ )} +
+ +
+

{t('网络配置')}

+
+ + +
+ {natEnabled && ( +
+
{t('端口映射')}
+
+ + {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview || t('自动')} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}/TCP + + {natPreviewMappings.map((mapping, index) => ( + + {mapping.host_port || t('自动')} -> {mapping.container_port}/{mapping.protocol.toUpperCase()} + + ))} +
+
+ )} +
+
+ )} +
-
+
- +
+ {currentStep > 0 && ( + + )} + {currentStep < wizardSteps.length - 1 ? ( + + ) : ( + + )} +
) } +function ReviewItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ) +} + function Field({ label, children }: { label: string; children: ReactNode }) { return (
@@ -1115,9 +1360,10 @@ function NumberInput({ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) { const errors: Partial> = {} const windows = isWindowsTemplate(form.template_id) + const windows11 = form.template_id.toLowerCase().includes('windows-11') const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25) - const minRAMMB = windows ? 2048 : 128 - const minDiskGB = windows ? 30 : 1 + const minRAMMB = windows11 ? 4096 : windows ? 2048 : 128 + const minDiskGB = windows11 ? 64 : windows ? 30 : 1 if (!Number.isFinite(form.vcpu)) { errors.vcpu = '请输入 vCPU' @@ -1272,8 +1518,68 @@ function normalizeNATPortMappings(mappings: PortMapping[]) { }) } -function expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number, batchCount: number) { - const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount) +function previewNATAllocation( + routing: RoutingInfo | null, + customMappings: PortMapping[], + explicitManagementPort: number, + autoMappingCount: number, + managementTargetPort: number +) { + if (!routing) { + return { managementPort: explicitManagementPort, autoMappings: [] as PortMapping[] } + } + + const { start, end } = routing.nat4_port_range + const used = new Set( + (routing.nat4_mappings || []) + .map((mapping) => Math.round(Number(mapping.host_port) || 0)) + .filter((port) => port >= start && port <= end) + ) + const excluded = new Set( + customMappings + .map((mapping) => Math.round(Number(mapping.host_port) || 0)) + .filter((port) => port >= start && port <= end) + ) + + let managementPort = explicitManagementPort + if (managementPort === 0) { + const cursor = routing.nat4_next_port >= start && routing.nat4_next_port <= end + ? routing.nat4_next_port + : start + managementPort = findAvailableNATPort(start, end, cursor, new Set([...used, ...excluded])) + } + + const autoMappings: PortMapping[] = [] + if (customMappings.length === 0 && autoMappingCount > 0) { + const unavailable = new Set(used) + if (managementPort > 0) unavailable.add(managementPort) + if (managementTargetPort >= start && managementTargetPort <= end) unavailable.add(managementTargetPort) + for (let port = start; port <= end && autoMappings.length < autoMappingCount; port++) { + if (unavailable.has(port)) continue + unavailable.add(port) + autoMappings.push({ + host_port: port, + container_port: port, + protocol: 'tcp', + description: `Port-${port}`, + }) + } + } + + return { managementPort, autoMappings } +} + +function findAvailableNATPort(start: number, end: number, cursor: number, unavailable: Set) { + const capacity = end - start + 1 + for (let offset = 0; offset < capacity; offset++) { + const candidate = start + ((cursor - start + offset) % capacity) + if (!unavailable.has(candidate)) return candidate + } + return 0 +} + +function expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number) { + const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort)) const offset = batchIndex * stride return { mappings: mappings.map((mapping) => ({ @@ -1297,26 +1603,12 @@ function batchNATSourceMappings(mappings: PortMapping[], managementPort: number) ] } -function batchNATPortStride(mappings: PortMapping[], batchCount: number) { - if (mappings.length === 0 || batchCount <= 1) return 1 - const invalid = new Set() - for (let left = 0; left < mappings.length; left++) { - for (let right = left + 1; right < mappings.length; right++) { - const leftProtocol = (mappings[left].protocol || 'tcp').toLowerCase() - const rightProtocol = (mappings[right].protocol || 'tcp').toLowerCase() - if (leftProtocol !== rightProtocol) continue - const difference = Math.abs( - Math.round(Number(mappings[left].host_port) || 0) - - Math.round(Number(mappings[right].host_port) || 0) - ) - for (let distance = 1; difference > 0 && distance < batchCount; distance++) { - if (difference % distance === 0) invalid.add(difference / distance) - } - } - } - let stride = 1 - while (invalid.has(stride)) stride++ - return stride +function batchNATPortStride(mappings: PortMapping[]) { + const sourcePorts = mappings + .map((mapping) => Math.round(Number(mapping.host_port) || 0)) + .filter((port) => port > 0) + if (sourcePorts.length === 0) return 1 + return Math.max(...sourcePorts) - Math.min(...sourcePorts) + 1 } function validateBatchNATPortMappings(mappings: PortMapping[], managementPort: number, batchCount: number) { @@ -1327,7 +1619,7 @@ function validateBatchNATPortMappings(mappings: PortMapping[], managementPort: n if (mappings.length > 63) return '每个容器最多可配置 63 条自定义 NAT 映射' const used = new Map() - const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount) + const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort)) for (let batchIndex = 0; batchIndex < batchCount; batchIndex++) { if (managementPort > 0) { const expandedManagementPort = managementPort + batchIndex * stride diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4e903de..a3f9efe 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -12,7 +12,7 @@ export default function Layout() { setSidebarCollapsed(!sidebarCollapsed)} /> -
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index e8c6da5..b8cda9a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -17,6 +17,7 @@ body { /* Scrollbar */ ::-webkit-scrollbar { width: 6px; + height: 6px; } ::-webkit-scrollbar-track { background: #f1f1f1; @@ -199,4 +200,6 @@ body { .dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; } .dark .peer-checked\:bg-black:checked + *, .dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; } +.dark .access-policy-switch .access-policy-switch-thumb { background-color: #e5e7eb !important; } +.dark .access-policy-switch[aria-checked="true"] .access-policy-switch-thumb { background-color: #111827 !important; } diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index c3a7af1..e1a8ced 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -199,6 +199,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [ ['GET', '/api/v1/templates', '模板列表'], ['GET', '/api/v1/images', '镜像管理列表'], ['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'], + ['POST', '/api/v1/images/custom', '添加第三方 LXC/KVM 镜像源'], + ['DELETE', '/api/v1/images/custom', '移除第三方 LXC/KVM 镜像源'], ['POST', '/api/v1/images/download', '下载镜像'], ['POST', '/api/v1/images/cancel', '取消镜像下载'], ['DELETE', '/api/v1/images/delete', '删除镜像缓存'], @@ -228,6 +230,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [ ['PUT', '/api/v1/ssl', '更新 SSL 配置'], ['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'], ['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'], + ['GET', '/api/v1/access-policy', '面板访问来源策略'], + ['PUT', '/api/v1/access-policy', '更新面板访问来源策略'], ['GET', '/api/v1/language', '面板语言'], ['PUT', '/api/v1/language', '更新面板语言'], ], @@ -845,6 +849,18 @@ const requestBodySamples: Record> = { time: '03:00', }, 'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 }, + 'POST /api/v1/images/custom': { + type: 'kvm', + name: 'Custom Ubuntu Cloud', + description: 'Private mirror image', + distro: 'ubuntu', + release: 'noble', + arch: 'amd64', + url: 'https://images.example.com/ubuntu-noble.qcow2', + provisioner: 'linux-cloud-init', + sha256: '', + }, + 'DELETE /api/v1/images/custom': { id: 'custom-kvm-a1b2c3d4e5' }, 'POST /api/v1/images/download': { template_id: 'debian-bookworm' }, 'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' }, 'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' }, @@ -873,6 +889,11 @@ const requestBodySamples: Record> = { 'PUT /api/v1/webssh-origins': { origins: ['https://panel.example.com'], }, + 'PUT /api/v1/access-policy': { + enabled: true, + allowed_sources: ['203.0.113.10', '192.168.1.0/24', '2001:db8::/32'], + trusted_proxies: ['127.0.0.1'], + }, 'PUT /api/v1/language': { language: 'zh' }, 'PUT /api/v1/routing': { items: [ @@ -1027,6 +1048,11 @@ const responseSamples: Record = { data: { nat4: { used: 62, remaining: '45474', total: '45536' }, nat4_port_range: { start: 20000, end: 65535 }, + nat4_next_port: 22005, + nat4_networks: { + lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1', netmask: '255.255.255.0', dhcp_start: '10.0.3.2', dhcp_end: '10.0.3.254', dhcp_max: 253, prefix_bits: 24 }, + kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1', netmask: '255.255.255.0', dhcp_start: '192.168.122.2', dhcp_end: '192.168.122.254', dhcp_max: 253, prefix_bits: 24 }, + }, ipv4: { used: 1, remaining: '3', total: '4' }, ipv6: { used: 31, remaining: 'large', total: 'large' }, public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }], @@ -1042,6 +1068,11 @@ const responseSamples: Record = { data: { nat4: { used: 62, remaining: '45474', total: '45536' }, nat4_port_range: { start: 20000, end: 65535 }, + nat4_next_port: 22005, + nat4_networks: { + lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1' }, + kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1' }, + }, ipv4: { used: 1, remaining: '3', total: '4' }, public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }], ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }], @@ -1234,6 +1265,12 @@ const responseSamples: Record = { { id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true }, ], }, + 'POST /api/v1/images/custom': { + success: true, + message: 'Custom image added', + data: { id: 'custom-kvm-a1b2c3d4e5', name: 'Custom Ubuntu Cloud' }, + }, + 'DELETE /api/v1/images/custom': { success: true, message: 'Custom image removed' }, 'POST /api/v1/images/download': { success: true, message: 'Already downloaded' }, 'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' }, 'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' }, @@ -1277,6 +1314,8 @@ const responseSamples: Record = { 'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } }, 'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } }, 'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } }, + 'GET /api/v1/access-policy': { success: true, data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } }, + 'PUT /api/v1/access-policy': { success: true, message: 'Panel access policy saved', data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } }, 'GET /api/v1/language': { success: true, data: { language: 'zh' } }, 'PUT /api/v1/language': { success: true, data: { language: 'zh' } }, 'GET /api/v1/security/alerts': { success: true, data: [] }, @@ -1367,7 +1406,7 @@ function endpointNoteFor(key: string) { } if (key === 'POST /api/v1/batch-create') { notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.') - notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel shifts each source-port group for later containers while keeping target ports unchanged; direct API clients should submit the expanded values explicitly.') + notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel places each later source-port group after the previous container\'s highest public port while keeping every target container_port unchanged; direct API clients should submit the expanded values explicitly.') } if (key === 'PUT /api/v1/containers/{id}/resource-limit') { notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.') @@ -1399,9 +1438,12 @@ function endpointNoteFor(key: string) { if (key === 'PUT /api/v1/storage') { notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.') } - if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) { + if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins') || key.includes('/access-policy')) { notes.push('This endpoint requires an API key with admin:access.') } + if (key === 'PUT /api/v1/access-policy') { + notes.push('allowed_sources and trusted_proxies accept IPv4, IPv6, or CIDR values. Forwarded client headers are ignored unless the direct peer matches trusted_proxies. The server rejects an enabled policy that excludes the current source.') + } if (key === 'PUT /api/v1/task-queue/settings') { notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.') } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 29aae19..1bde97d 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -991,6 +991,7 @@ function getTemplateName(id: string) { 'kvm-debian-bookworm': 'Debian 12', 'kvm-debian-bullseye': 'Debian 11', 'kvm-rockylinux-9': 'Rocky 9', + 'kvm-windows-11': 'Windows 11', 'kvm-windows-10': 'Windows 10', } return map[id] || id diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index 50aabb5..10bad26 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -11,12 +11,29 @@ import { Loader2, AlertCircle, X, + Plus, + Unlink, + CloudDownload, } from 'lucide-react' -import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api' +import { + getImages, + getStorageInfo, + downloadImage, + cancelImageDownload, + deleteImage, + toggleImage, + createCustomKVMImage, + removeCustomKVMImage, + ImageInfo, + StorageInfo, + CustomKVMImageInput, +} from '../services/api' import { useDialog } from '../components/Dialog' +import { useLanguage } from '../contexts/LanguageContext' export default function ImageManagement() { const dialog = useDialog() + const { t } = useLanguage() const navigate = useNavigate() const [images, setImages] = useState([]) const [loading, setLoading] = useState(true) @@ -24,6 +41,7 @@ export default function ImageManagement() { const [error, setError] = useState('') const [storageInfo, setStorageInfo] = useState(null) const [storageLoading, setStorageLoading] = useState(true) + const [customModalOpen, setCustomModalOpen] = useState<'lxc' | 'kvm' | null>(null) const fetchImages = useCallback(async () => { try { @@ -115,6 +133,34 @@ export default function ImageManagement() { } } + const handleRemoveCustom = async (templateId: string) => { + if (!(await dialog.confirm('移除第三方镜像', '确定移除该镜像源和已下载的缓存吗?正在使用该镜像的虚拟机不会允许移除。'))) return + setActionLoading(templateId) + setError('') + try { + await removeCustomKVMImage(templateId) + await fetchImages() + dialog.alert('完成', '第三方镜像已移除') + } catch (err: unknown) { + dialog.alert('失败', apiErrorMessage(err, '移除第三方镜像失败')) + } finally { + setActionLoading(null) + } + } + + const handleCustomCreated = async (payload: CustomKVMImageInput) => { + const response = await createCustomKVMImage(payload) + const image = response.data.data + if (!image) throw new Error('镜像源保存成功,但服务器没有返回镜像 ID') + try { + await downloadImage(image.id) + dialog.alert('完成', '第三方镜像已添加,下载任务已启动') + } catch (err: unknown) { + dialog.alert('提示', `镜像源已保存,但下载未启动:${apiErrorMessage(err, '请在列表中重试')}`) + } + await fetchImages() + } + const downloadedCount = images.filter((img) => img.downloaded).length const lxcImages = images.filter((img) => img.type === 'lxc') const kvmImages = images.filter((img) => img.type === 'kvm') @@ -185,8 +231,21 @@ export default function ImageManagement() { onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} + onRemoveCustom={handleRemoveCustom} storageReady={imageStorageReady} storageLoading={storageLoading} + headerAction={( + + )} /> {kvmImages.length > 0 && ( @@ -200,10 +259,206 @@ export default function ImageManagement() { onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} + onRemoveCustom={handleRemoveCustom} storageReady={imageStorageReady} storageLoading={storageLoading} + headerAction={( + + )} /> )} + {customModalOpen !== null && ( + setCustomModalOpen(null)} + onSubmit={handleCustomCreated} + /> + )} +
+ ) +} + +const emptyCustomImage = (arch: string, virtualization: 'lxc' | 'kvm'): CustomKVMImageInput => ({ + type: virtualization, + name: '', + description: '', + distro: '', + release: '', + arch, + url: '', + provisioner: virtualization === 'lxc' ? 'lxc-rootfs' : 'linux-cloud-init', + sha256: '', +}) + +function CustomKVMImageModal({ + virtualization, + arch, + onClose, + onSubmit, +}: { + virtualization: 'lxc' | 'kvm' + arch: string + onClose: () => void + onSubmit: (payload: CustomKVMImageInput) => Promise +}) { + const { t } = useLanguage() + const [form, setForm] = useState(() => emptyCustomImage(arch, virtualization)) + const [submitting, setSubmitting] = useState(false) + const [formError, setFormError] = useState('') + const windows = virtualization === 'kvm' && form.provisioner !== 'linux-cloud-init' + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !submitting) onClose() + } + window.addEventListener('keydown', closeOnEscape) + return () => window.removeEventListener('keydown', closeOnEscape) + }, [onClose, submitting]) + + const updateProvisioner = (provisioner: 'linux-cloud-init' | 'windows-10' | 'windows-11') => { + setForm((current) => ({ + ...current, + provisioner, + distro: provisioner === 'linux-cloud-init' ? (current.distro === 'windows' ? '' : current.distro) : 'windows', + release: provisioner === 'windows-10' ? '10' : provisioner === 'windows-11' ? '11' : (current.distro === 'windows' ? '' : current.release), + })) + } + + const submit = async () => { + if (!form.name.trim() || !form.distro.trim() || !form.release.trim() || !form.url.trim()) { + setFormError(t('请填写名称、发行版、版本和下载地址')) + return + } + if (form.sha256 && !/^[a-fA-F0-9]{64}$/.test(form.sha256.trim())) { + setFormError(t('SHA-256 必须是 64 位十六进制字符串')) + return + } + setSubmitting(true) + setFormError('') + try { + await onSubmit({ + ...form, + name: form.name.trim(), + description: form.description.trim(), + distro: form.distro.trim().toLowerCase(), + release: form.release.trim().toLowerCase(), + url: form.url.trim(), + sha256: form.sha256?.trim().toLowerCase(), + }) + onClose() + } catch (err: unknown) { + setFormError(apiErrorMessage(err, t('添加第三方镜像失败'))) + } finally { + setSubmitting(false) + } + } + + const inputClass = 'mt-1.5 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black outline-none focus:border-black focus:ring-2 focus:ring-black/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white/10' + + return ( +
+
+
+
+

{t(virtualization === 'lxc' ? '下载第三方 LXC 镜像' : '下载第三方 KVM 镜像')}

+

+ {t(virtualization === 'lxc' ? '支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs' : '镜像格式必须与所选无人值守安装模板匹配')} +

+
+ +
+ +
+ {virtualization === 'kvm' &&
+ +
+ {([ + ['linux-cloud-init', 'Linux cloud-init', 'QCOW2 / IMG'], + ['windows-10', 'Windows 10', '安装 ISO'], + ['windows-11', 'Windows 11', '安装 ISO'], + ] as const).map(([value, label, hint]) => ( + + ))} +
+
} + +
+ + + + +
+ +