From 6dd7079e2338384e08cddd0d38001d07751b3a40 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:24:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E6=94=AF=E6=8C=81KVM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/worktrees/agent-ae3871aebda20eb86 | 1 + backend/internal/api/handlers.go | 27 +- backend/internal/api/images.go | 112 +- backend/internal/api/ipv6.go | 2 +- backend/internal/api/runtime.go | 172 ++ backend/internal/api/snapshots.go | 8 +- backend/internal/api/ssh.go | 83 +- backend/internal/api/taskqueue.go | 27 +- backend/internal/config/config.go | 63 + backend/internal/kvm/kvm.go | 2135 +++++++++++++++++ backend/internal/kvm/templates.go | 79 + backend/internal/lxc/expiry.go | 4 +- backend/internal/lxc/lxc.go | 17 + backend/internal/lxc/portmap.go | 35 +- backend/internal/lxc/snapshot.go | 2 +- backend/internal/server/web/.gitkeep | 1 + backend/main.go | 16 +- .../src/components/CreateContainerModal.tsx | 147 +- frontend/src/pages/ContainerDetail.tsx | 7 +- frontend/src/pages/Containers.tsx | 192 +- frontend/src/pages/ImageManagement.tsx | 38 +- frontend/src/services/api.ts | 10 +- 22 files changed, 2986 insertions(+), 192 deletions(-) create mode 160000 .claude/worktrees/agent-ae3871aebda20eb86 create mode 100644 backend/internal/api/runtime.go create mode 100644 backend/internal/kvm/kvm.go create mode 100644 backend/internal/kvm/templates.go diff --git a/.claude/worktrees/agent-ae3871aebda20eb86 b/.claude/worktrees/agent-ae3871aebda20eb86 new file mode 160000 index 0000000..422e48b --- /dev/null +++ b/.claude/worktrees/agent-ae3871aebda20eb86 @@ -0,0 +1 @@ +Subproject commit 422e48b524e3c18e24a82e1e93287c7e30e3b395 diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 46b9707..dd48f44 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -105,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { } func listContainers(w http.ResponseWriter, r *http.Request) { - containers, err := lxcManager.ListContainers() - if err != nil { - containers = config.AppConfig.Containers - } + containers, _ := listByRuntime() containers = filterContainersForRequest(r, containers) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers}) } @@ -123,11 +120,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"}) return } + cfg.Virtualization = runtimeFromRequest(cfg.Virtualization) if cfg.TemplateID == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) return } - if !isTemplateEnabledAndDownloaded(cfg.TemplateID) { + if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) return } @@ -150,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) { if cfg.SnapshotLimit <= 0 { cfg.SnapshotLimit = config.DefaultSnapshotLimit } - if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { + if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return } @@ -166,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) { } } - if err := lxcManager.CreateContainer(cfg); err != nil { + if err := createByRuntime(cfg); err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } @@ -183,7 +181,7 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) { } func getUsage(w http.ResponseWriter, r *http.Request, id int) { - usage, err := lxcManager.GetResourceUsage(id) + usage, err := usageByRuntime(id) if err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return @@ -192,7 +190,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) { } func getTraffic(w http.ResponseWriter, r *http.Request, id int) { - info := lxcManager.GetTrafficInfo(id) + info := trafficByRuntime(id) if info == nil { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return @@ -281,7 +279,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) { if req.RAMMB > 0 { nextRAMMB = req.RAMMB } - if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil { + if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return } @@ -294,7 +292,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) { // Re-apply resource limits to running container if c.Status == "running" { - if err := lxcManager.ApplyContainerLimits(c); err != nil { + if err := applyLimitsByRuntime(c); err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } @@ -354,10 +352,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } - containers, err := lxcManager.ListContainers() - if err != nil { - containers = config.AppConfig.Containers - } + containers, _ := listByRuntime() running := 0 stopped := 0 for _, c := range containers { @@ -391,7 +386,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) return } - newPassword, err := lxcManager.ResetSSHPassword(id) + newPassword, err := resetPasswordByRuntime(id) if err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 6369ae7..05f53a4 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -10,6 +10,7 @@ import ( "sync" "clicd/internal/config" + "clicd/internal/kvm" "clicd/internal/lxc" ) @@ -17,6 +18,7 @@ import ( type ImageInfo struct { ID string `json:"id"` Name string `json:"name"` + Type string `json:"type"` Distro string `json:"distro"` Release string `json:"release"` Arch string `json:"arch"` @@ -78,6 +80,9 @@ func getEnabledImageSet() map[string]bool { for _, t := range lxc.GetTemplates() { set[t.ID] = true } + for _, t := range kvm.GetImages() { + set[t.ID] = true + } } else { for _, id := range config.AppConfig.EnabledImages { set[id] = true @@ -93,16 +98,34 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { return } - templates := lxc.GetTemplates() enabledSet := getEnabledImageSet() - images := make([]ImageInfo, 0, len(templates)) + templates := lxc.GetTemplates() + images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) for _, t := range templates { _, downloading := imageDownloads[t.ID] downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) images = append(images, ImageInfo{ ID: t.ID, Name: t.Name, + Type: config.VirtualizationLXC, + Distro: t.Distro, + Release: t.Release, + Arch: t.Arch, + Description: t.Description, + Downloaded: downloaded, + Enabled: enabledSet[t.ID], + Downloading: downloading, + SizeBytes: size, + }) + } + for _, t := range kvm.GetImages() { + _, downloading := imageDownloads[t.ID] + downloaded, size := kvm.ImageDownloadedInfo(t.ID) + images = append(images, ImageInfo{ + ID: t.ID, + Name: t.Name, + Type: config.VirtualizationKVM, Distro: t.Distro, Release: t.Release, Arch: t.Arch, @@ -134,7 +157,35 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { tmpl := lxc.FindTemplate(req.TemplateID) if tmpl == nil { - jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) + image := kvm.FindImage(req.TemplateID) + if image == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) + return + } + if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { + ensureImageEnabled(image.ID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) + return + } + imageDownloadsMu.Lock() + if imageDownloads[req.TemplateID] { + imageDownloadsMu.Unlock() + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) + return + } + imageDownloads[req.TemplateID] = true + imageDownloadsMu.Unlock() + defer func() { + imageDownloadsMu.Lock() + delete(imageDownloads, req.TemplateID) + imageDownloadsMu.Unlock() + }() + ensureImageEnabled(image.ID) + if err := kvm.DownloadImage(*image); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) return } @@ -206,6 +257,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) { tmpl := lxc.FindTemplate(req.TemplateID) if tmpl == nil { + if image := kvm.FindImage(req.TemplateID); image != nil { + if err := kvm.DeleteImage(image.ID); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()}) + return + } + removeImageEnabled(image.ID) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"}) + return + } jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) return } @@ -259,13 +319,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { return } - templates := lxc.GetTemplates() + runtime := runtimeFromRequest(r.URL.Query().Get("type")) enabledSet := getEnabledImageSet() - result := make([]lxc.Template, 0) - for _, t := range templates { - if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) { - result = append(result, t) + result := make([]map[string]string, 0) + if runtime == config.VirtualizationKVM { + for _, t := range kvm.GetImages() { + if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { + result = append(result, map[string]string{ + "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, + "description": t.Description, "type": config.VirtualizationKVM, + }) + } + } + } else { + for _, t := range lxc.GetTemplates() { + if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) { + 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, + }) + } } } @@ -273,6 +347,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { } func isTemplateEnabledAndDownloaded(templateID string) bool { + return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID)) +} + +func isImageEnabledAndDownloaded(templateID string, runtime string) bool { + runtime = runtimeFromRequest(runtime) + if runtime == config.VirtualizationKVM { + image := kvm.FindImage(templateID) + if image == nil { + return false + } + enabledSet := getEnabledImageSet() + downloaded, _ := kvm.ImageDownloadedInfo(image.ID) + return enabledSet[image.ID] && downloaded + } tmpl := lxc.FindTemplate(templateID) if tmpl == nil { return false @@ -288,6 +376,9 @@ func ensureImageEnabled(id string) { for _, t := range lxc.GetTemplates() { config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) } + for _, t := range kvm.GetImages() { + config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) + } config.SaveConfig() return // Already contains all IDs including this one } @@ -313,6 +404,11 @@ func removeImageEnabled(id string) { config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) } } + for _, t := range kvm.GetImages() { + if t.ID != id { + config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID) + } + } config.SaveConfig() return } diff --git a/backend/internal/api/ipv6.go b/backend/internal/api/ipv6.go index 594d408..9087299 100644 --- a/backend/internal/api/ipv6.go +++ b/backend/internal/api/ipv6.go @@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) { } func assignIPv6(w http.ResponseWriter, r *http.Request, id int) { - c, err := lxcManager.AssignIPv6(id) + c, err := assignIPv6ByRuntime(id) if err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go new file mode 100644 index 0000000..b662a4a --- /dev/null +++ b/backend/internal/api/runtime.go @@ -0,0 +1,172 @@ +package api + +import ( + "fmt" + "math" + "os" + "strings" + + "clicd/internal/config" + "clicd/internal/kvm" + "clicd/internal/lxc" +) + +var kvmManager = kvm.NewManager() + +func runtimeFromRequest(value string) string { + return config.NormalizeVirtualization(value) +} + +func runtimeFromTemplateID(templateID string) string { + if kvm.FindImage(templateID) != nil { + return config.VirtualizationKVM + } + return config.VirtualizationLXC +} + +func createByRuntime(cfg lxc.ContainerConfig) error { + cfg.Virtualization = runtimeFromRequest(cfg.Virtualization) + if cfg.Virtualization == config.VirtualizationKVM { + return kvmManager.CreateContainer(cfg) + } + return lxcManager.CreateContainer(cfg) +} + +func startByRuntime(id int) error { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.StartContainer(id) + } + return lxcManager.StartContainer(id) +} + +func stopByRuntime(id int) error { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.StopContainer(id) + } + return lxcManager.StopContainer(id) +} + +func restartByRuntime(id int) error { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.RestartContainer(id) + } + return lxcManager.RestartContainer(id) +} + +func destroyByRuntime(id int) error { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.DestroyContainer(id) + } + return lxcManager.DestroyContainer(id) +} + +func reinstallByRuntime(id int, templateID string) error { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.ReinstallContainer(id, templateID) + } + return lxcManager.ReinstallContainer(id, templateID) +} + +func resetPasswordByRuntime(id int) (string, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.ResetSSHPassword(id) + } + return lxcManager.ResetSSHPassword(id) +} + +func assignIPv6ByRuntime(id int) (*config.Container, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.AssignIPv6(id) + } + return lxcManager.AssignIPv6(id) +} + +func usageByRuntime(id int) (map[string]interface{}, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.GetResourceUsage(id) + } + return lxcManager.GetResourceUsage(id) +} + +func trafficByRuntime(id int) map[string]interface{} { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.GetTrafficInfo(id) + } + return lxcManager.GetTrafficInfo(id) +} + +func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit) + } + return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit) +} + +func deleteSnapshotByRuntime(snapshotID string) error { + snapshot := config.FindSnapshot(snapshotID) + if snapshot != nil { + if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() { + return kvmManager.DeleteSnapshot(snapshotID) + } + if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) { + return kvmManager.DeleteSnapshot(snapshotID) + } + } + return lxcManager.DeleteSnapshot(snapshotID) +} + +func restoreSnapshotByRuntime(snapshotID string) error { + snapshot := config.FindSnapshot(snapshotID) + if snapshot != nil { + if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() { + return kvmManager.RestoreSnapshot(snapshotID) + } + if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) { + return kvmManager.RestoreSnapshot(snapshotID) + } + } + return lxcManager.RestoreSnapshot(snapshotID) +} + +func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) { + c := config.FindContainer(id) + if c != nil && c.IsKVM() { + return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy) + } + return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy) +} + +func applyLimitsByRuntime(c *config.Container) error { + if c != nil && c.IsKVM() { + return kvmManager.ApplyContainerLimits(c) + } + return lxcManager.ApplyContainerLimits(c) +} + +func listByRuntime() ([]config.Container, error) { + containers, err := lxcManager.ListContainers() + if err != nil { + containers = config.AppConfig.Containers + } + containers = kvmManager.ListContainers(containers) + return containers, err +} + +func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error { + if runtime == config.VirtualizationKVM { + if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 { + return fmt.Errorf("KVM vCPU must be a whole number and at least 1") + } + } + return validateContainerResourceRequest(vcpu, ramMB, diskGB) +} diff --git a/backend/internal/api/snapshots.go b/backend/internal/api/snapshots.go index e12fa70..4b6d69d 100644 --- a/backend/internal/api/snapshots.go +++ b/backend/internal/api/snapshots.go @@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID return } } - snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0) + snapshot, err := createSnapshotByRuntime(containerID, user, false, 0) if err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return @@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID req.Time = "03:00" } user := requestUser(r) - c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user) + c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user) if err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return @@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID return } user := requestUser(r) - if err := lxcManager.DeleteSnapshot(snapshotID); err != nil { + if err := deleteSnapshotByRuntime(snapshotID); err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } @@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI return } user := requestUser(r) - if err := lxcManager.RestoreSnapshot(snapshotID); err != nil { + if err := restoreSnapshotByRuntime(snapshotID); err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go index 33f4591..d439369 100644 --- a/backend/internal/api/ssh.go +++ b/backend/internal/api/ssh.go @@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { return } if c.IP == "" { - if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil { + var ip string + var err error + if c.IsKVM() { + ip, err = kvmManager.GetContainerIP(c.VirshName()) + } else { + ip, err = lxcManager.GetContainerIP(c.LxcName()) + } + if err == nil { c.IP = ip config.SaveConfig() } } - if c.IP == "" { + if c.IP == "" && !c.IsKVM() { if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" { c.IP = ip } } + if c.IP == "" && c.IsKVM() { + if err := kvmManager.EnsureSSH(c.ID); err == nil { + if refreshed := config.FindContainer(c.ID); refreshed != nil { + c = refreshed + } + } + } if c.IP == "" { http.Error(w, "container ip is not available", http.StatusBadRequest) return @@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { defer ws.Close() if c.SSHPassword == "" { + if c.IsKVM() { + writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n") + return + } writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n") if err := lxcManager.EnsureSSH(c.ID); err != nil { writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err)) @@ -154,25 +172,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) { writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr)) client, err := ssh.Dial("tcp", addr, sshConfig) if err != nil { - writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n") - if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil { - writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) - return - } - if refreshed := config.FindContainer(c.ID); refreshed != nil { - c = refreshed - } - if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" { - c.IP = ip - config.SaveConfig() - addr = net.JoinHostPort(c.IP, "22") - } - sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} - sshConfig.Timeout = 10 * time.Second - client, err = ssh.Dial("tcp", addr, sshConfig) - if err != nil { - writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) - return + if c.IsKVM() { + writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n") + if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr)) + return + } + if refreshed := config.FindContainer(c.ID); refreshed != nil { + c = refreshed + } + if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" { + c.IP = ip + config.SaveConfig() + addr = net.JoinHostPort(c.IP, "22") + } + sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} + sshConfig.Timeout = 10 * time.Second + client, err = ssh.Dial("tcp", addr, sshConfig) + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) + return + } + } else { + writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n") + if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr)) + return + } + if refreshed := config.FindContainer(c.ID); refreshed != nil { + c = refreshed + } + if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" { + c.IP = ip + config.SaveConfig() + addr = net.JoinHostPort(c.IP, "22") + } + sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)} + sshConfig.Timeout = 10 * time.Second + client, err = ssh.Dial("tcp", addr, sshConfig) + if err != nil { + writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err)) + return + } } } defer client.Close() diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index d460cc8..87e3751 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -226,7 +226,7 @@ func (q *TaskQueue) createWorker() { c := config.FindContainerByName(task.Config.Name) if c == nil { // 1) Download image + apply limits (lxc-create) - err := lxcManager.CreateContainer(task.Config) + err := createByRuntime(task.Config) if err != nil { task.Status = "failed" task.Error = err.Error() @@ -256,10 +256,10 @@ func (q *TaskQueue) createWorker() { // 3) Start + initialize SSH/network in the same worker. // If init fails, destroy the container so no dead entry remains. - startErr := lxcManager.StartContainer(c.ID) + startErr := startByRuntime(c.ID) if startErr != nil { if createdByTask { - lxcManager.DestroyContainer(c.ID) + _ = destroyByRuntime(c.ID) } task.Status = "failed" task.Error = startErr.Error() @@ -304,13 +304,13 @@ func (q *TaskQueue) opWorker() { if err == nil { switch task.Type { case TaskStart: - err = lxcManager.StartContainer(task.ContainerID) + err = startByRuntime(task.ContainerID) case TaskStop: - err = lxcManager.StopContainer(task.ContainerID) + err = stopByRuntime(task.ContainerID) case TaskRestart: - err = lxcManager.RestartContainer(task.ContainerID) + err = restartByRuntime(task.ContainerID) case TaskDelete: - err = lxcManager.DestroyContainer(task.ContainerID) + err = destroyByRuntime(task.ContainerID) if err == nil { time.Sleep(1 * time.Second) if config.FindContainer(task.ContainerID) != nil { @@ -318,7 +318,7 @@ func (q *TaskQueue) opWorker() { } } case TaskReinstall: - err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID) + err = reinstallByRuntime(task.ContainerID, task.TemplateID) } } @@ -456,7 +456,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti templateID = c.Template } } - if !isTemplateEnabledAndDownloaded(templateID) { + runtime := runtimeFromTemplateID(templateID) + if c := config.FindContainer(id); c != nil { + runtime = c.Runtime() + } + if !isImageEnabledAndDownloaded(templateID, runtime) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) return } @@ -516,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { if req.Containers[i].VCPU <= 0 { req.Containers[i].VCPU = 1 } + req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization) if req.Containers[i].RAMMB < 128 { req.Containers[i].RAMMB = 512 } if req.Containers[i].DiskGB < 1 { req.Containers[i].DiskGB = 5 } - if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) { + if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) return } @@ -532,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { if req.Containers[i].SnapshotLimit <= 0 { req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit } - if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil { + if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) return } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8753389..34f6d8d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -73,7 +73,11 @@ type Container struct { ID int `json:"id"` UUID string `json:"uuid"` Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` LXCName string `json:"lxc_name,omitempty"` + KVMName string `json:"kvm_name,omitempty"` + DiskImage string `json:"disk_image,omitempty"` + MACAddress string `json:"mac_address,omitempty"` Template string `json:"template"` VCPU float64 `json:"vcpu"` RAMMB int `json:"ram_mb"` @@ -109,6 +113,28 @@ type Container struct { SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` } +const ( + VirtualizationLXC = "lxc" + VirtualizationKVM = "kvm" +) + +func NormalizeVirtualization(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case VirtualizationKVM: + return VirtualizationKVM + default: + return VirtualizationLXC + } +} + +func (c *Container) Runtime() string { + return NormalizeVirtualization(c.Virtualization) +} + +func (c *Container) IsKVM() bool { + return c.Runtime() == VirtualizationKVM +} + // LxcName returns the internal LXC container name (ct-{id}) func (c *Container) LxcName() string { if c.LXCName != "" { @@ -117,6 +143,14 @@ func (c *Container) LxcName() string { return fmt.Sprintf("ct-%d", c.ID) } +// VirshName returns the internal libvirt domain name for KVM instances. +func (c *Container) VirshName() string { + if c.KVMName != "" { + return c.KVMName + } + return fmt.Sprintf("vm-%d", c.ID) +} + // SubUser represents a sub-user with access to specific containers type ApiKeyConfig struct { ID string `json:"id"` @@ -343,6 +377,9 @@ func InitConfig() (*ClicdConfig, error) { AppConfig.Oversell.SubUserSnapshotLimit = 3 } changed := ensureContainerUUIDs() + if ensureContainerVirtualization() { + changed = true + } if ensureContainerPortMappingLimits() { changed = true } @@ -367,6 +404,18 @@ func InitConfig() (*ClicdConfig, error) { return AppConfig, nil } +func ensureContainerVirtualization() bool { + changed := false + for i := range AppConfig.Containers { + next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization) + if AppConfig.Containers[i].Virtualization != next { + AppConfig.Containers[i].Virtualization = next + changed = true + } + } + return changed +} + func ensureContainerSnapshotScheduleDefaults() bool { changed := false for i := range AppConfig.Containers { @@ -519,6 +568,7 @@ func AddContainer(c Container) { if c.UUID == "" { c.UUID = NewContainerUUID() } + c.Virtualization = NormalizeVirtualization(c.Virtualization) AppConfig.Containers = append(AppConfig.Containers, c) SaveConfig() } @@ -792,6 +842,19 @@ func CleanStaleContainers() { valid := make([]Container, 0) changed := false for _, c := range AppConfig.Containers { + if c.IsKVM() { + if c.DiskImage == "" { + valid = append(valid, c) + continue + } + if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) { + fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName()) + changed = true + continue + } + valid = append(valid, c) + continue + } lxcDir := "/var/lib/lxc/" + c.LxcName() if _, err := os.Stat(lxcDir); os.IsNotExist(err) { fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName()) diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go new file mode 100644 index 0000000..c2f9f63 --- /dev/null +++ b/backend/internal/kvm/kvm.go @@ -0,0 +1,2135 @@ +package kvm + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "io" + "math/big" + "net" + "net/http" + "net/netip" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "clicd/internal/config" + "clicd/internal/lxc" + + "golang.org/x/crypto/ssh" +) + +type Manager struct { + BasePath string +} + +const ipv6GatewayLinkLocal = "fe80::1" + +type usageSample struct { + CPUUsec uint64 + RXBytes uint64 + TXBytes uint64 + ReadBytes uint64 + WriteBytes uint64 + At time.Time +} + +type rateSnapshot struct { + CPUPct float64 + RXBps float64 + TXBps float64 + ReadBps float64 + WriteBps float64 + UpdatedAt time.Time +} + +type trafficSample struct { + RXBytes uint64 + TXBytes uint64 +} + +var ( + usageMu sync.RWMutex + lastUsage = map[string]usageSample{} + rateCache = map[string]rateSnapshot{} + trafficMu sync.Mutex + lastTrafficSnapshot = map[string]trafficSample{} + kvmSnapshotMu sync.Mutex + kvmSSHEnsureLocks sync.Map +) + +func BaseDir() string { + return "/var/lib/clicd/kvm" +} + +func NewManager() *Manager { + return &Manager{BasePath: BaseDir()} +} + +func (m *Manager) instancesDir() string { + return filepath.Join(m.BasePath, "instances") +} + +func (m *Manager) instanceDir(name string) string { + return filepath.Join(m.instancesDir(), name) +} + +func ImageDownloadedInfo(id string) (bool, int64) { + path := ImagePath(id) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false, 0 + } + return true, info.Size() +} + +func DownloadImage(image Image) error { + if err := os.MkdirAll(CacheDir(), 0755); err != nil { + return err + } + target := ImagePath(image.ID) + if ok, _ := ImageDownloadedInfo(image.ID); ok { + return nil + } + tmp := target + ".tmp" + _ = os.Remove(tmp) + if err := downloadFile(image.URL, tmp); err != nil { + _ = os.Remove(tmp) + return err + } + if err := normalizeQCOW2(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + _ = os.Chmod(target, 0644) + return nil +} + +func DeleteImage(id string) error { + return os.RemoveAll(ImagePath(id)) +} + +func downloadFile(url, target string) error { + client := http.Client{Timeout: 30 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download failed: %s", resp.Status) + } + out, err := os.Create(target) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, resp.Body); err != nil { + return err + } + return out.Sync() +} + +func normalizeQCOW2(src, target string) error { + if err := requireCommand("qemu-img"); err != nil { + return err + } + cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output)) + } + return os.Remove(src) +} + +func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { + image := FindImage(cfg.TemplateID) + if image == nil { + return fmt.Errorf("KVM image not found: %s", cfg.TemplateID) + } + if ok, _ := ImageDownloadedInfo(image.ID); !ok { + return fmt.Errorf("KVM image is not downloaded: %s", cfg.TemplateID) + } + if err := m.validateHost(); err != nil { + return err + } + if !config.IsValidContainerName(cfg.Name) { + return fmt.Errorf("invalid VM name: %s", cfg.Name) + } + if config.FindContainerByName(cfg.Name) != nil { + return fmt.Errorf("container name already exists: %s", cfg.Name) + } + if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) { + return fmt.Errorf("KVM vCPU must be a whole number and at least 1") + } + if cfg.PortMappingCount < 2 { + cfg.PortMappingCount = 2 + } + if cfg.SnapshotLimit <= 0 { + cfg.SnapshotLimit = config.DefaultSnapshotLimit + } + + id := config.AllocateContainerID() + vmName := fmt.Sprintf("vm-%d", id) + c, err := m.defineContainer(id, vmName, cfg, true) + if err != nil { + _ = m.cleanupVM(vmName) + return err + } + config.AddContainer(*c) + return nil +} + +func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool) (*config.Container, error) { + image := FindImage(cfg.TemplateID) + if image == nil { + return nil, fmt.Errorf("KVM image not found: %s", cfg.TemplateID) + } + if err := os.MkdirAll(m.instanceDir(vmName), 0700); err != nil { + return nil, err + } + if err := os.Chmod(m.BasePath, 0755); err != nil && !os.IsNotExist(err) { + return nil, err + } + if err := os.Chmod(m.instancesDir(), 0755); err != nil && !os.IsNotExist(err) { + return nil, err + } + if err := os.Chmod(m.instanceDir(vmName), 0755); err != nil { + return nil, err + } + diskPath := filepath.Join(m.instanceDir(vmName), "disk.qcow2") + seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso") + mac := randomMAC() + sshPassword := generateRandomString(16) + ipv6 := "" + ipv6PrefixLen := 0 + ipv6Interface := "" + if cfg.AssignIPv6 { + assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + if err != nil { + return nil, err + } + ipv6 = assigned + ipv6PrefixLen = prefixLen + ipv6Interface = iface + } + + if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { + return nil, err + } + if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil { + return nil, err + } + + xml := domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) + xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml") + if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { + return nil, err + } + cmd := exec.Command("virsh", "define", xmlPath) + if output, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output)) + } + + sshPort := 0 + portMappings := []config.PortMapping{} + if allocatePorts { + sshPort = config.AllocateSSHPort() + portMappings = lxc.SetupDefaultPortMappings(sshPort) + tempC := &config.Container{PortMappings: portMappings} + extraPorts := cfg.ExtraPorts + if len(extraPorts) == 0 && cfg.PortMappingCount > 1 { + extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1) + } + for _, port := range extraPorts { + if port <= 0 { + continue + } + tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{ + ContainerPort: port, + HostPort: port, + Protocol: "tcp", + Description: fmt.Sprintf("Port-%d", port), + }) + } + portMappings = tempC.PortMappings + } + + now := time.Now().Format("2006-01-02 15:04:05") + trafficMode := cfg.TrafficMode + if trafficMode == "" { + trafficMode = "total" + } + return &config.Container{ + ID: id, + UUID: config.NewContainerUUID(), + Name: cfg.Name, + Virtualization: config.VirtualizationKVM, + KVMName: vmName, + DiskImage: diskPath, + MACAddress: mac, + Template: cfg.TemplateID, + VCPU: cfg.VCPU, + RAMMB: cfg.RAMMB, + DiskGB: cfg.DiskGB, + NetworkBWMbps: cfg.NetworkBWMbps, + MonthlyTrafficGB: cfg.MonthlyTrafficGB, + TrafficMode: trafficMode, + TrafficInGB: cfg.TrafficInGB, + TrafficOutGB: cfg.TrafficOutGB, + TrafficResetDate: now[:7], + IOSpeedMBps: cfg.IOSpeedMBps, + IPv6: ipv6, + IPv6PrefixLen: ipv6PrefixLen, + IPv6Interface: ipv6Interface, + Status: "stopped", + SSHPort: sshPort, + SSHPassword: sshPassword, + PortMappings: portMappings, + PortMappingLimit: cfg.PortMappingCount, + SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), + CreatedAt: now, + ExpiresAt: cfg.ExpiresAt, + }, nil +} + +func (m *Manager) StartContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + if err := m.validateHost(); err != nil { + return err + } + name := c.VirshName() + if err := m.ensureDomainDefinition(c); err != nil { + fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err) + } + status, _ := m.GetContainerStatus(name) + if status != "running" { + cmd := exec.Command("virsh", "start", name) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("virsh start failed: %v, output: %s", err, string(output)) + } + } + config.UpdateContainerStatus(id, "running") + _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--live").Run() + _ = exec.Command("virsh", "dommemstat", name, "--period", "10", "--config").Run() + for i := 0; i < 90; i++ { + if ip, err := m.GetContainerIP(name); err == nil && ip != "" { + c.IP = ip + config.SaveConfig() + break + } + time.Sleep(2 * time.Second) + } + if c.IP == "" { + return fmt.Errorf("KVM VM %s started but no IPv4 address was detected", c.Name) + } + if err := lxc.NewManager().ApplyPortMappings(id); err != nil { + return err + } + return m.EnsureSSH(id) +} + +func (m *Manager) StopContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + _ = lxc.NewManager().CleanPortMappings(id) + name := c.VirshName() + status, _ := m.GetContainerStatus(name) + if status != "running" { + config.UpdateContainerStatus(id, "stopped") + return nil + } + exec.Command("virsh", "shutdown", name).Run() + for i := 0; i < 20; i++ { + if status, _ := m.GetContainerStatus(name); status != "running" { + config.UpdateContainerStatus(id, "stopped") + return nil + } + time.Sleep(1 * time.Second) + } + cmd := exec.Command("virsh", "destroy", name) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("virsh destroy failed: %v, output: %s", err, string(output)) + } + config.UpdateContainerStatus(id, "stopped") + return nil +} + +func (m *Manager) RestartContainer(id int) error { + if err := m.StopContainer(id); err != nil { + return err + } + time.Sleep(1 * time.Second) + return m.StartContainer(id) +} + +func (m *Manager) DestroyContainer(id int) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + name := c.VirshName() + _ = m.StopContainer(id) + _ = undefineDomain(name) + if err := os.RemoveAll(m.instanceDir(name)); err != nil { + return err + } + if !config.RemoveContainer(id) { + return fmt.Errorf("VM destroyed but config entry was not removed: %d", id) + } + return nil +} + +func (m *Manager) ReinstallContainer(id int, templateID string) error { + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + image := FindImage(templateID) + if image == nil { + return fmt.Errorf("KVM image not found: %s", templateID) + } + if ok, _ := ImageDownloadedInfo(image.ID); !ok { + return fmt.Errorf("KVM image is not downloaded: %s", templateID) + } + name := c.VirshName() + _ = m.StopContainer(id) + _ = undefineDomain(name) + _ = os.RemoveAll(m.instanceDir(name)) + cfg := lxc.ContainerConfig{ + Name: c.Name, + TemplateID: templateID, + VCPU: c.VCPU, + RAMMB: c.RAMMB, + DiskGB: c.DiskGB, + NetworkBWMbps: c.NetworkBWMbps, + MonthlyTrafficGB: c.MonthlyTrafficGB, + TrafficMode: c.TrafficMode, + TrafficInGB: c.TrafficInGB, + TrafficOutGB: c.TrafficOutGB, + IOSpeedMBps: c.IOSpeedMBps, + PortMappingCount: c.PortMappingLimit, + SnapshotLimit: c.SnapshotLimit, + ExpiresAt: c.ExpiresAt, + } + next, err := m.defineContainer(id, name, cfg, false) + if err != nil { + return err + } + c.Template = templateID + c.DiskImage = next.DiskImage + c.MACAddress = next.MACAddress + c.SSHPassword = next.SSHPassword + c.SSHHostKey = "" + c.IP = "" + c.Status = "stopped" + config.SaveConfig() + return m.StartContainer(id) +} + +func (m *Manager) ResetSSHPassword(id int) (string, error) { + c := config.FindContainer(id) + if c == nil { + return "", fmt.Errorf("container not found: %d", id) + } + if c.Status != "running" || c.IP == "" || c.SSHPassword == "" { + return "", fmt.Errorf("KVM VM must be running with SSH ready before password reset") + } + if err := m.EnsureSSH(id); err != nil { + return "", err + } + password := generateRandomString(16) + client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 8 * time.Second, + }) + if err != nil { + return "", err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password)) + if output, err := session.CombinedOutput(cmd); err != nil { + return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output)) + } + c.SSHPassword = password + c.SSHHostKey = "" + config.SaveConfig() + return password, nil +} + +func (m *Manager) ApplyContainerLimits(c *config.Container) error { + if c == nil || !c.IsKVM() { + return nil + } + if c.Status == "running" { + return fmt.Errorf("KVM resource changes require shutdown and start") + } + if c.DiskImage == "" || c.MACAddress == "" { + return nil + } + seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") + xml := domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") + if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { + return err + } + cmd := exec.Command("virsh", "define", xmlPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("virsh define failed: %v, output: %s", err, string(output)) + } + return nil +} + +func (m *Manager) ensureDomainDefinition(c *config.Container) error { + if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" { + return nil + } + seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") + xml := domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) + xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") + if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { + return err + } + cmd := exec.Command("virsh", "define", xmlPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("virsh define failed: %v, output: %s", err, string(output)) + } + return nil +} + +func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { + kvmSnapshotMu.Lock() + defer kvmSnapshotMu.Unlock() + + c := config.FindContainer(id) + if c == nil { + return config.Snapshot{}, fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return config.Snapshot{}, fmt.Errorf("container is not a KVM VM: %d", id) + } + if scheduled && rotateLimit > 0 { + for { + existing := config.ContainerSnapshots(id) + if len(existing) < rotateLimit { + break + } + sortSnapshotsOldestFirst(existing) + if err := m.deleteSnapshotLocked(existing[0]); err != nil { + return config.Snapshot{}, err + } + } + } + + name := c.VirshName() + instanceDir := m.instanceDir(name) + if err := safePathUnder(instanceDir, m.instancesDir()); err != nil { + return config.Snapshot{}, err + } + if _, err := os.Stat(instanceDir); err != nil { + return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err) + } + + now := time.Now() + snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) + snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID) + if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { + return config.Snapshot{}, err + } + if err := os.MkdirAll(snapshotDir, 0700); err != nil { + return config.Snapshot{}, err + } + + wasRunning, err := m.prepareVMForColdCopy(id, name) + if err != nil { + _ = os.RemoveAll(snapshotDir) + return config.Snapshot{}, err + } + if wasRunning { + defer func() { + if err := m.StartContainer(id); err != nil { + fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", name, err) + } + }() + } + + if err := copyTree(instanceDir, snapshotDir); err != nil { + _ = os.RemoveAll(snapshotDir) + return config.Snapshot{}, err + } + + snapshot := config.Snapshot{ + ID: snapshotID, + ContainerID: c.ID, + ContainerName: c.Name, + LXCName: name, + CreatedAt: now.Format("2006-01-02 15:04:05"), + CreatedBy: createdBy, + Scheduled: scheduled, + Path: snapshotDir, + SizeBytes: dirSizeBytes(snapshotDir), + } + config.AddSnapshot(snapshot) + return snapshot, nil +} + +func (m *Manager) DeleteSnapshot(id string) error { + kvmSnapshotMu.Lock() + defer kvmSnapshotMu.Unlock() + + snapshot := config.FindSnapshot(id) + if snapshot == nil { + return fmt.Errorf("snapshot not found: %s", id) + } + return m.deleteSnapshotLocked(*snapshot) +} + +func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error { + if snapshot.Path != "" { + if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + return err + } + if err := os.RemoveAll(snapshot.Path); err != nil { + return fmt.Errorf("failed to delete snapshot files: %v", err) + } + } + config.RemoveSnapshot(snapshot.ID) + return nil +} + +func (m *Manager) RestoreSnapshot(id string) error { + kvmSnapshotMu.Lock() + defer kvmSnapshotMu.Unlock() + + snapshot := config.FindSnapshot(id) + if snapshot == nil { + return fmt.Errorf("snapshot not found: %s", id) + } + if snapshot.Path == "" { + return fmt.Errorf("snapshot path is empty") + } + if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + return err + } + if _, err := os.Stat(snapshot.Path); err != nil { + return fmt.Errorf("snapshot files not found: %v", err) + } + + c := config.FindContainer(snapshot.ContainerID) + if c == nil { + return fmt.Errorf("container not found: %d", snapshot.ContainerID) + } + if !c.IsKVM() { + return fmt.Errorf("container is not a KVM VM: %d", c.ID) + } + name := c.VirshName() + instanceDir := m.instanceDir(name) + if err := safePathUnder(instanceDir, m.instancesDir()); err != nil { + return err + } + + wasRunning, err := m.prepareVMForColdCopy(c.ID, name) + if err != nil { + return err + } + backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano())) + if err := safePathUnder(backupDir, m.instancesDir()); err != nil { + return err + } + if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to move current VM aside: %v", err) + } + if err := copyTree(snapshot.Path, instanceDir); err != nil { + _ = os.RemoveAll(instanceDir) + _ = os.Rename(backupDir, instanceDir) + return fmt.Errorf("failed to restore snapshot: %v", err) + } + _ = os.RemoveAll(backupDir) + if err := undefineDomain(name); err != nil { + fmt.Printf("Warning: failed to undefine %s before restore redefine: %v\n", name, err) + } + xmlPath := filepath.Join(instanceDir, "domain.xml") + if output, err := exec.Command("virsh", "define", xmlPath).CombinedOutput(); err != nil { + return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output)) + } + c.DiskImage = filepath.Join(instanceDir, "disk.qcow2") + c.Status = "stopped" + c.IP = "" + config.SaveConfig() + if wasRunning { + return m.StartContainer(c.ID) + } + return nil +} + +func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if intervalHours < 24 { + return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours") + } + if _, err := parseScheduleClock(scheduleTime); err != nil { + return nil, err + } + c.SnapshotScheduleEnabled = enabled + c.SnapshotScheduleIntervalHours = intervalHours + c.SnapshotScheduleTime = scheduleTime + c.SnapshotScheduleCreatedBy = createdBy + if enabled { + c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339) + } else { + c.SnapshotScheduleNextRun = "" + } + if err := config.SaveConfig(); err != nil { + return nil, err + } + return c, nil +} + +func (m *Manager) StartSnapshotScheduler() { + go func() { + m.runDueSnapshotSchedules() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + m.runDueSnapshotSchedules() + } + }() +} + +func (m *Manager) runDueSnapshotSchedules() { + now := time.Now() + containers := append([]config.Container(nil), config.AppConfig.Containers...) + for _, c := range containers { + if !c.IsKVM() || !c.SnapshotScheduleEnabled { + continue + } + nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) + if err != nil || c.SnapshotScheduleNextRun == "" { + nextRun = now + } + if now.Before(nextRun) { + continue + } + createdBy := c.SnapshotScheduleCreatedBy + if createdBy == "" { + createdBy = "admin" + } + rotateLimit := 0 + if strings.HasPrefix(createdBy, "user:") { + rotateLimit = config.ContainerSnapshotLimit(&c) + } + if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil { + fmt.Printf("Warning: scheduled KVM snapshot failed for %s: %v\n", c.Name, err) + continue + } + if current := config.FindContainer(c.ID); current != nil { + interval := current.SnapshotScheduleIntervalHours + if interval < 24 { + interval = 24 + } + next := nextRun.Add(time.Duration(interval) * time.Hour) + for !next.After(now) { + next = next.Add(time.Duration(interval) * time.Hour) + } + current.SnapshotScheduleLastRun = now.Format(time.RFC3339) + current.SnapshotScheduleNextRun = next.Format(time.RFC3339) + config.SaveConfig() + } + } +} + +func (m *Manager) prepareVMForColdCopy(id int, name string) (bool, error) { + status, _ := m.GetContainerStatus(name) + wasRunning := status == "running" + if wasRunning { + if err := m.StopContainer(id); err != nil { + return false, err + } + time.Sleep(time.Second) + } else { + _ = lxc.NewManager().CleanPortMappings(id) + } + return wasRunning, nil +} + +func parseScheduleClock(value string) (time.Duration, error) { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return 0, fmt.Errorf("snapshot schedule time must be HH:MM") + } + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, fmt.Errorf("snapshot schedule hour must be 00-23") + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return 0, fmt.Errorf("snapshot schedule minute must be 00-59") + } + return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil +} + +func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time { + clock, err := parseScheduleClock(scheduleTime) + if err != nil { + clock = 3 * time.Hour + } + midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location()) + next := midnight.Add(clock) + interval := time.Duration(intervalHours) * time.Hour + for !next.After(from) { + next = next.Add(interval) + } + return next +} + +func snapshotBaseDir() string { + return filepath.Join(config.AppConfig.DataDir, "snapshots") +} + +func copyTree(src string, dst string) error { + if err := os.MkdirAll(dst, 0700); err != nil { + return err + } + output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() + if err != nil { + output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() + if err != nil { + return fmt.Errorf("cp failed: %v, output: %s", err, string(output)) + } + } + return nil +} + +func dirSizeBytes(path string) int64 { + out, err := exec.Command("du", "-s", "-B1", path).Output() + if err != nil { + return 0 + } + parts := strings.Fields(string(out)) + if len(parts) == 0 { + return 0 + } + var size int64 + fmt.Sscanf(parts[0], "%d", &size) + return size +} + +func safePathUnder(path string, base string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + absBase, err := filepath.Abs(base) + if err != nil { + return err + } + if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) { + return nil + } + return fmt.Errorf("refusing unsafe path: %s", absPath) +} + +func sortSnapshotsOldestFirst(snapshots []config.Snapshot) { + sort.SliceStable(snapshots, func(i, j int) bool { + ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt) + tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt) + return ti.Before(tj) + }) +} + +func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + name := c.VirshName() + cpuUsec, rxBytes, txBytes, readBytes, writeBytes := m.getUsageCounters(c) + usage := map[string]interface{}{ + "memory_usage_bytes": int64(0), + "cpu_usage_usec": cpuUsec, + "cpu_usage_pct": 0.0, + "disk_usage_bytes": int64(0), + "network_rx_bytes": rxBytes, + "network_tx_bytes": txBytes, + "network_rx_bps": 0.0, + "network_tx_bps": 0.0, + "disk_read_bytes": readBytes, + "disk_write_bytes": writeBytes, + "disk_read_bps": 0.0, + "disk_write_bps": 0.0, + } + if c.DiskImage != "" { + if info, err := os.Stat(c.DiskImage); err == nil { + usage["disk_usage_bytes"] = info.Size() + } + } + if c.Status == "running" { + if mem := virshMemBytes(name); mem > 0 { + usage["memory_usage_bytes"] = mem + } + } + usageMu.RLock() + rate, hasRate := rateCache[name] + usageMu.RUnlock() + if hasRate && time.Since(rate.UpdatedAt) < 15*time.Second { + usage["cpu_usage_pct"] = rate.CPUPct + usage["network_rx_bps"] = rate.RXBps + usage["network_tx_bps"] = rate.TXBps + usage["disk_read_bps"] = rate.ReadBps + usage["disk_write_bps"] = rate.WriteBps + } + return usage, nil +} + +func (m *Manager) ListContainers(containers []config.Container) []config.Container { + for i := range containers { + if !containers[i].IsKVM() { + continue + } + status, err := m.GetContainerStatus(containers[i].VirshName()) + if err == nil && status != "" { + containers[i].Status = status + } + if status == "running" { + if ip, err := m.GetContainerIP(containers[i].VirshName()); err == nil && ip != "" { + containers[i].IP = ip + } + } + } + return containers +} + +func (m *Manager) GetContainerStatus(name string) (string, error) { + cmd := exec.Command("virsh", "domstate", name) + out, err := cmd.Output() + if err != nil { + return "", err + } + state := strings.ToLower(strings.TrimSpace(string(out))) + if strings.Contains(state, "running") { + return "running", nil + } + if strings.Contains(state, "shut") || strings.Contains(state, "off") { + return "stopped", nil + } + return state, nil +} + +func (m *Manager) GetContainerIP(name string) (string, error) { + for _, source := range []string{"lease", "arp", "agent"} { + cmd := exec.Command("virsh", "domifaddr", name, "--source", source) + out, err := cmd.Output() + if err != nil { + continue + } + if ip := firstIPv4(string(out)); ip != "" { + return ip, nil + } + } + if mac := domainMACAddress(name); mac != "" { + if ip := dhcpLeaseIP("default", mac); ip != "" { + return ip, nil + } + } + return "", fmt.Errorf("no IPv4 address found for %s", name) +} + +func (m *Manager) validateHost() error { + for _, name := range []string{"virsh", "qemu-img", "cloud-localds"} { + if err := requireCommand(name); err != nil { + return err + } + } + if _, err := os.Stat("/dev/kvm"); err != nil { + return fmt.Errorf("KVM is not available: /dev/kvm not found") + } + if err := ensureDefaultNetwork(); err != nil { + return err + } + return nil +} + +func requireCommand(name string) error { + if _, err := exec.LookPath(name); err != nil { + return fmt.Errorf("%s is required for KVM support", name) + } + return nil +} + +func ensureDefaultNetwork() error { + if exec.Command("virsh", "net-info", "default").Run() != nil { + return fmt.Errorf("libvirt default network is required for KVM support") + } + if exec.Command("virsh", "net-info", "default").Run() == nil { + out, _ := exec.Command("virsh", "net-info", "default").Output() + if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") { + _ = exec.Command("virsh", "net-start", "default").Run() + } + _ = exec.Command("virsh", "net-autostart", "default").Run() + } + return nil +} + +func createOverlayDisk(base, target string, diskGB int) error { + if diskGB < 1 { + diskGB = 5 + } + cmd := exec.Command("qemu-img", "create", "-f", "qcow2", "-F", "qcow2", "-b", base, target) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img create failed: %v, output: %s", err, string(output)) + } + cmd = exec.Command("qemu-img", "resize", target, fmt.Sprintf("%dG", diskGB)) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img resize failed: %v, output: %s", err, string(output)) + } + _ = os.Chmod(target, 0644) + return nil +} + +func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error { + setupScript := indentScript(kvmSSHSetupScript(password), 4) + userData := fmt.Sprintf(`#cloud-config +preserve_hostname: false +hostname: %s +ssh_pwauth: true +disable_root: false +package_update: true +chpasswd: + expire: false + users: + - name: root + password: %s + type: text +users: + - name: root + lock_passwd: false +runcmd: + - | +%s +`, hostname, password, setupScript) + metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname) + ipv6Block := "" + if strings.TrimSpace(ipv6) != "" { + ipv6Block = fmt.Sprintf(` + addresses: + - %s/128 + routes: + - to: default + via: %s + metric: 100`, ipv6, ipv6GatewayLinkLocal) + } + networkConfig := fmt.Sprintf(`version: 2 +ethernets: + nic0: + match: + macaddress: "%s" + dhcp4: true + dhcp6: false%s +`, strings.ToLower(mac), ipv6Block) + dir := filepath.Dir(seedPath) + userPath := filepath.Join(dir, "user-data") + metaPath := filepath.Join(dir, "meta-data") + networkPath := filepath.Join(dir, "network-config") + if err := os.WriteFile(userPath, []byte(userData), 0600); err != nil { + return err + } + if err := os.WriteFile(metaPath, []byte(metaData), 0600); err != nil { + return err + } + if err := os.WriteFile(networkPath, []byte(networkConfig), 0600); err != nil { + return err + } + cmd := exec.Command("cloud-localds", "--network-config="+networkPath, seedPath, userPath, metaPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("cloud-localds failed: %v, output: %s", err, string(output)) + } + _ = os.Chmod(seedPath, 0644) + return nil +} + +func indentScript(script string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + lines := strings.Split(strings.TrimRight(script, "\n"), "\n") + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string { + if vcpu < 1 { + vcpu = 1 + } + if ramMB < 512 { + ramMB = 512 + } + iotune := "" + if ioSpeedMBps > 0 { + bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024 + iotune = fmt.Sprintf(` + + %d + `, bytesPerSecond) + } + bandwidth := "" + if networkBWMbps > 0 { + averageKiB := networkBWMbps * 128 + bandwidth = fmt.Sprintf(` + + + + `, averageKiB, averageKiB) + } + return fmt.Sprintf(` + %s + %s + %d + %d + %d + 2048 + + hvm + + + + + + destroy + restart + restart + + /usr/bin/qemu-system-x86_64 + + + + %s + + + + + + + + + + + %s + + + + + + + + + + + + +`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth) +} + +func xmlEscape(value string) string { + return html.EscapeString(value) +} + +func domainUUIDXML(name string) string { + out, err := exec.Command("virsh", "domuuid", name).Output() + if err != nil { + return "" + } + uuid := strings.TrimSpace(string(out)) + if uuid == "" { + return "" + } + return fmt.Sprintf("%s", xmlEscape(uuid)) +} + +func (m *Manager) cleanupVM(name string) error { + _ = exec.Command("virsh", "destroy", name).Run() + _ = undefineDomain(name) + return os.RemoveAll(m.instanceDir(name)) +} + +func undefineDomain(name string) error { + if err := exec.Command("virsh", "undefine", name, "--nvram").Run(); err == nil { + return nil + } + return exec.Command("virsh", "undefine", name).Run() +} + +func firstIPv4(output string) string { + re := regexp.MustCompile(`\b((?:\d{1,3}\.){3}\d{1,3})(?:/\d+)?\b`) + for _, match := range re.FindAllStringSubmatch(output, -1) { + if len(match) > 1 && net.ParseIP(match[1]) != nil && !strings.HasPrefix(match[1], "127.") { + return match[1] + } + } + return "" +} + +func domainMACAddress(name string) string { + out, err := exec.Command("virsh", "domiflist", name).Output() + if err != nil { + return "" + } + macRE := regexp.MustCompile(`(?i)\b[0-9a-f]{2}(?::[0-9a-f]{2}){5}\b`) + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(strings.ToLower(line), "network") || strings.Contains(strings.ToLower(line), "default") { + if mac := macRE.FindString(line); mac != "" { + return strings.ToLower(mac) + } + } + } + if mac := macRE.FindString(string(out)); mac != "" { + return strings.ToLower(mac) + } + return "" +} + +func dhcpLeaseIP(networkName string, mac string) string { + if mac == "" { + return "" + } + out, err := exec.Command("virsh", "net-dhcp-leases", networkName, "--mac", mac).Output() + if err != nil { + return "" + } + return firstIPv4(string(out)) +} + +func kvmSSHEnsureLock(id int) *sync.Mutex { + lock, _ := kvmSSHEnsureLocks.LoadOrStore(id, &sync.Mutex{}) + return lock.(*sync.Mutex) +} + +// EnsureSSH verifies root password SSH, installs missing SSH/agent packages, and +// refreshes the SSH config for cloud images whose first boot is still settling. +func (m *Manager) EnsureSSH(id int) error { + lock := kvmSSHEnsureLock(id) + lock.Lock() + defer lock.Unlock() + + c := config.FindContainer(id) + if c == nil { + return fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return fmt.Errorf("container is not a KVM VM: %d", id) + } + status, _ := m.GetContainerStatus(c.VirshName()) + if status != "running" { + return fmt.Errorf("KVM VM %s is not running; cannot configure SSH", c.Name) + } + if err := m.ensureDomainDefinition(c); err != nil { + fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", c.VirshName(), err) + } + if c.SSHPassword == "" { + return fmt.Errorf("KVM SSH password is empty; reinstall or reset password after boot") + } + + deadline := time.Now().Add(4 * time.Minute) + var lastErr error + qgaAttempted := false + for time.Now().Before(deadline) { + if c.IP == "" { + if ip, err := m.GetContainerIP(c.VirshName()); err == nil && ip != "" { + c.IP = ip + config.SaveConfig() + } + } + if c.IP == "" { + lastErr = fmt.Errorf("waiting for VM IPv4 address") + time.Sleep(3 * time.Second) + continue + } + + client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 8 * time.Second, + }) + if err != nil { + lastErr = err + if !qgaAttempted { + qgaAttempted = true + if setupErr := runKVMGuestAgentSSHSetup(c.VirshName(), c.SSHPassword); setupErr != nil { + lastErr = fmt.Errorf("%v; guest-agent fallback failed: %w", err, setupErr) + if strings.Contains(setupErr.Error(), "QEMU guest agent is not active") { + return fmt.Errorf("KVM SSH is not reachable for %s, and QEMU guest agent is not active. Restart this VM once to attach the guest-agent channel, then try WebSSH again. If it was created before KVM SSH initialization support and still fails after restart, reinstall it", c.Name) + } + } + } + time.Sleep(5 * time.Second) + continue + } + err = runKVMSSHSetup(client, c.SSHPassword) + _ = client.Close() + if err != nil { + lastErr = err + time.Sleep(5 * time.Second) + continue + } + if mapErr := lxc.NewManager().ApplyPortMappings(id); mapErr != nil { + return mapErr + } + return nil + } + if lastErr == nil { + lastErr = fmt.Errorf("timed out waiting for SSH") + } + return fmt.Errorf("KVM SSH initialization failed for %s: %v", c.Name, lastErr) +} + +func runKVMGuestAgentSSHSetup(name string, password string) error { + if err := qemuGuestPing(name); err != nil { + return err + } + return qemuGuestExec(name, kvmSSHSetupScript(password), 180*time.Second) +} + +func runKVMSSHSetup(client *ssh.Client, password string) error { + script := kvmSSHSetupScript(password) + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + done := make(chan error, 1) + var output []byte + go func() { + var err error + output, err = session.CombinedOutput(script) + done <- err + }() + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to configure KVM SSH: %v, output: %s", err, string(output)) + } + return nil + case <-time.After(150 * time.Second): + _ = session.Close() + return fmt.Errorf("timed out configuring KVM SSH after 150s") + } +} + +func kvmSSHSetupScript(password string) string { + return `set -u +ROOT_PASSWORD=` + shellQuote(password) + ` +export DEBIAN_FRONTEND=noninteractive +if command -v apt-get >/dev/null 2>&1; then + if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then + apt-get update || true + apt-get install -y openssh-server qemu-guest-agent || true + fi +fi +if command -v dnf >/dev/null 2>&1; then + if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then + dnf install -y openssh-server qemu-guest-agent || true + fi +fi +if command -v yum >/dev/null 2>&1; then + if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then + yum install -y openssh-server qemu-guest-agent || true + fi +fi +if command -v apk >/dev/null 2>&1; then + if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then + apk update || true + apk add --no-cache openssh qemu-guest-agent shadow iproute2 || true + fi +fi +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF' +PermitRootLogin yes +PasswordAuthentication yes +KbdInteractiveAuthentication yes +ChallengeResponseAuthentication yes +EOF +if [ -f /etc/ssh/sshd_config ]; then + grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config + grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true + grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config + grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true + grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config + grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true +fi +if command -v chpasswd >/dev/null 2>&1; then + printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true +fi +ssh-keygen -A >/dev/null 2>&1 || true +if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now qemu-guest-agent >/dev/null 2>&1 || true + systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || systemctl enable --now ssh >/dev/null 2>&1 || systemctl enable --now sshd >/dev/null 2>&1 || true +fi +if command -v rc-update >/dev/null 2>&1; then + rc-update add sshd default >/dev/null 2>&1 || true + rc-update add qemu-guest-agent default >/dev/null 2>&1 || rc-update add qemu-ga default >/dev/null 2>&1 || true + rc-service qemu-guest-agent start >/dev/null 2>&1 || rc-service qemu-ga start >/dev/null 2>&1 || true + rc-service sshd restart >/dev/null 2>&1 || /etc/init.d/sshd restart >/dev/null 2>&1 || true +fi +service qemu-guest-agent start >/dev/null 2>&1 || service qemu-ga start >/dev/null 2>&1 || true +service ssh restart >/dev/null 2>&1 || service sshd restart >/dev/null 2>&1 || true +` +} + +func qemuGuestPing(name string) error { + out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if strings.Contains(msg, "guest agent is not configured") || strings.Contains(msg, "QEMU guest agent is not configured") || strings.Contains(msg, "argument unsupported") { + return fmt.Errorf("QEMU guest agent is not active for %s; restart the VM once to attach the agent channel, or reinstall if the image was created before KVM SSH initialization support", name) + } + return fmt.Errorf("QEMU guest agent is not ready for %s: %v, output: %s", name, err, msg) + } + return nil +} + +func qemuGuestExec(name string, script string, timeout time.Duration) error { + req := map[string]interface{}{ + "execute": "guest-exec", + "arguments": map[string]interface{}{ + "path": "/bin/sh", + "arg": []string{"-lc", script}, + "capture-output": true, + }, + } + payload, err := json.Marshal(req) + if err != nil { + return err + } + out, err := exec.Command("virsh", "qemu-agent-command", name, string(payload)).CombinedOutput() + if err != nil { + return fmt.Errorf("guest-exec failed: %v, output: %s", err, string(out)) + } + var started struct { + Return struct { + PID int `json:"pid"` + } `json:"return"` + } + if err := json.Unmarshal(out, &started); err != nil || started.Return.PID <= 0 { + return fmt.Errorf("guest-exec returned invalid response: %s", string(out)) + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + statusReq := fmt.Sprintf(`{"execute":"guest-exec-status","arguments":{"pid":%d}}`, started.Return.PID) + statusOut, err := exec.Command("virsh", "qemu-agent-command", name, statusReq).CombinedOutput() + if err != nil { + return fmt.Errorf("guest-exec-status failed: %v, output: %s", err, string(statusOut)) + } + var status struct { + Return struct { + Exited bool `json:"exited"` + Exitcode int `json:"exitcode"` + OutData string `json:"out-data"` + ErrData string `json:"err-data"` + } `json:"return"` + } + if err := json.Unmarshal(statusOut, &status); err != nil { + return fmt.Errorf("guest-exec-status returned invalid response: %s", string(statusOut)) + } + if !status.Return.Exited { + time.Sleep(3 * time.Second) + continue + } + if status.Return.Exitcode == 0 { + return nil + } + stdout, _ := base64.StdEncoding.DecodeString(status.Return.OutData) + stderr, _ := base64.StdEncoding.DecodeString(status.Return.ErrData) + return fmt.Errorf("guest SSH setup exited with %d, stdout: %s, stderr: %s", status.Return.Exitcode, string(stdout), string(stderr)) + } + return fmt.Errorf("timed out waiting for guest SSH setup after %s", timeout) +} + +func (m *Manager) getUsageCounters(c *config.Container) (uint64, uint64, uint64, uint64, uint64) { + if c == nil || c.Status != "running" { + return 0, 0, 0, 0, 0 + } + name := c.VirshName() + cpuUsec, readBytes, writeBytes := virshDomstatsCounters(name) + rxBytes, txBytes := virshInterfaceBytes(name, c.MACAddress) + return cpuUsec, rxBytes, txBytes, readBytes, writeBytes +} + +func (m *Manager) StartUsageMonitor() { + go func() { + for { + time.Sleep(5 * time.Second) + m.updateAllRates() + } + }() +} + +func (m *Manager) updateAllRates() { + usageMu.Lock() + defer usageMu.Unlock() + + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if !c.IsKVM() { + continue + } + name := c.VirshName() + if c.Status != "running" { + delete(lastUsage, name) + delete(rateCache, name) + continue + } + cpuUsec, rxBytes, txBytes, readBytes, writeBytes := m.getUsageCounters(c) + now := time.Now() + sample := usageSample{CPUUsec: cpuUsec, RXBytes: rxBytes, TXBytes: txBytes, ReadBytes: readBytes, WriteBytes: writeBytes, At: now} + prev, exists := lastUsage[name] + lastUsage[name] = sample + rate := rateSnapshot{UpdatedAt: now} + if exists { + elapsed := sample.At.Sub(prev.At).Seconds() + if elapsed > 0 { + if sample.CPUUsec >= prev.CPUUsec { + rate.CPUPct = float64(sample.CPUUsec-prev.CPUUsec) / (elapsed * 1e6) * 100 + } + if sample.RXBytes >= prev.RXBytes { + rate.RXBps = float64(sample.RXBytes-prev.RXBytes) / elapsed + } + if sample.TXBytes >= prev.TXBytes { + rate.TXBps = float64(sample.TXBytes-prev.TXBytes) / elapsed + } + if sample.ReadBytes >= prev.ReadBytes { + rate.ReadBps = float64(sample.ReadBytes-prev.ReadBytes) / elapsed + } + if sample.WriteBytes >= prev.WriteBytes { + rate.WriteBps = float64(sample.WriteBytes-prev.WriteBytes) / elapsed + } + } + } + rateCache[name] = rate + } +} + +func virshDomstatsCounters(name string) (uint64, uint64, uint64) { + out, err := exec.Command("virsh", "domstats", name, "--cpu-total", "--block").Output() + if err != nil { + return 0, 0, 0 + } + var cpuUsec, readBytes, writeBytes uint64 + for _, line := range strings.Split(string(out), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "=", 2) + if len(parts) != 2 { + continue + } + key, value := parts[0], parts[1] + parsed, _ := strconv.ParseUint(value, 10, 64) + switch { + case key == "cpu.time": + cpuUsec = parsed / 1000 + case strings.HasSuffix(key, ".rd.bytes"): + readBytes += parsed + case strings.HasSuffix(key, ".wr.bytes"): + writeBytes += parsed + } + } + return cpuUsec, readBytes, writeBytes +} + +func virshInterfaceBytes(name string, mac string) (uint64, uint64) { + iface := virshInterfaceName(name, mac) + if iface == "" { + return 0, 0 + } + out, err := exec.Command("virsh", "domifstat", name, iface).Output() + if err != nil { + return 0, 0 + } + var rx, tx uint64 + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + key := fields[0] + valueField := fields[len(fields)-1] + if len(fields) >= 3 && strings.HasPrefix(fields[0], iface) { + key = fields[1] + valueField = fields[2] + } + value, _ := strconv.ParseUint(valueField, 10, 64) + switch key { + case "rx_bytes": + rx = value + case "tx_bytes": + tx = value + } + } + return rx, tx +} + +func virshInterfaceName(name string, mac string) string { + out, err := exec.Command("virsh", "domiflist", name).Output() + if err != nil { + return "" + } + mac = strings.ToLower(mac) + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 5 || strings.HasPrefix(fields[0], "-") || strings.EqualFold(fields[0], "Interface") { + continue + } + if mac == "" || strings.EqualFold(fields[4], mac) { + return fields[0] + } + } + return "" +} + +func (m *Manager) GetTrafficInfo(id int) map[string]interface{} { + c := config.FindContainer(id) + if c == nil { + return nil + } + m.accumulateContainerTraffic(c) + return trafficInfoMap(c) +} + +func (m *Manager) AccumulateTraffic() { + currentMonth := time.Now().Format("2006-01") + changed := false + trafficMu.Lock() + defer trafficMu.Unlock() + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if !c.IsKVM() { + continue + } + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + delete(lastTrafficSnapshot, c.VirshName()) + changed = true + } + if c.Status != "running" { + delete(lastTrafficSnapshot, c.VirshName()) + continue + } + if accumulateTrafficLocked(c) { + changed = true + } + } + if changed { + config.SaveConfig() + } +} + +func (m *Manager) accumulateContainerTraffic(c *config.Container) { + currentMonth := time.Now().Format("2006-01") + trafficMu.Lock() + defer trafficMu.Unlock() + changed := false + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + delete(lastTrafficSnapshot, c.VirshName()) + changed = true + } + if c.Status == "running" && accumulateTrafficLocked(c) { + changed = true + } + if changed { + config.SaveConfig() + } +} + +func accumulateTrafficLocked(c *config.Container) bool { + rx, tx := virshInterfaceBytes(c.VirshName(), c.MACAddress) + key := c.VirshName() + prev, exists := lastTrafficSnapshot[key] + changed := false + if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { + deltaRX := int64(rx - prev.RXBytes) + deltaTX := int64(tx - prev.TXBytes) + if deltaRX > 0 || deltaTX > 0 { + c.TrafficUsedRX += deltaRX + c.TrafficUsedTX += deltaTX + changed = true + } + } + lastTrafficSnapshot[key] = trafficSample{RXBytes: rx, TXBytes: tx} + return changed +} + +func trafficInfoMap(c *config.Container) map[string]interface{} { + totalUsed := c.TrafficUsedRX + c.TrafficUsedTX + limitGB := 0 + usedPct := 0.0 + if c.TrafficMode == "in_out" { + limitGB = c.TrafficInGB + c.TrafficOutGB + inLimit := float64(c.TrafficInGB) * 1073741824 + outLimit := float64(c.TrafficOutGB) * 1073741824 + if c.TrafficInGB > 0 { + usedPct = float64(c.TrafficUsedRX) / inLimit * 100 + } + if c.TrafficOutGB > 0 { + outPct := float64(c.TrafficUsedTX) / outLimit * 100 + if outPct > usedPct { + usedPct = outPct + } + } + } else { + limitGB = c.MonthlyTrafficGB + if limitGB > 0 { + usedPct = float64(totalUsed) / float64(limitGB*1073741824) * 100 + } + } + return map[string]interface{}{ + "total_used_bytes": totalUsed, + "rx_used_bytes": c.TrafficUsedRX, + "tx_used_bytes": c.TrafficUsedTX, + "mode": c.TrafficMode, + "limit_gb": limitGB, + "in_limit_gb": c.TrafficInGB, + "out_limit_gb": c.TrafficOutGB, + "used_pct": usedPct, + "reset_date": c.TrafficResetDate, + } +} + +func (m *Manager) StartExpiryScanner() { + go func() { + for { + time.Sleep(30 * time.Second) + now := time.Now() + m.AccumulateTraffic() + m.StopExpiredContainers(now) + m.StopTrafficExceededContainers(now) + } + }() +} + +func (m *Manager) StopExpiredContainers(now time.Time) { + for _, container := range config.AppConfig.Containers { + if !container.IsKVM() || !lxc.IsExpired(container) { + continue + } + status, err := m.GetContainerStatus(container.VirshName()) + if err != nil { + status = container.Status + } + if status != "running" { + continue + } + fmt.Printf("KVM VM %s (ID=%d) expired at %s, stopping...\n", container.Name, container.ID, container.ExpiresAt) + if err := m.StopContainer(container.ID); err != nil { + fmt.Printf("Warning: failed to stop expired KVM VM %s: %v\n", container.Name, err) + } + } +} + +func (m *Manager) StopTrafficExceededContainers(now time.Time) { + currentMonth := now.Format("2006-01") + saved := false + for i := range config.AppConfig.Containers { + c := &config.AppConfig.Containers[i] + if !c.IsKVM() || c.Status != "running" { + continue + } + if c.TrafficResetDate != currentMonth { + c.TrafficUsedRX = 0 + c.TrafficUsedTX = 0 + c.TrafficResetDate = currentMonth + saved = true + continue + } + if lxc.IsTrafficExceeded(*c) { + fmt.Printf("KVM VM %s (ID=%d) exceeded traffic limit, stopping...\n", c.Name, c.ID) + if err := m.StopContainer(c.ID); err != nil { + fmt.Printf("Warning: failed to stop traffic-exceeded KVM VM %s: %v\n", c.Name, err) + } + } + } + if saved { + config.SaveConfig() + } +} + +func virshMemBytes(name string) int64 { + out, err := exec.Command("virsh", "dommemstat", name).Output() + if err != nil { + return 0 + } + stats := map[string]int64{} + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + kib, err := strconv.ParseInt(fields[1], 10, 64) + if err == nil { + stats[fields[0]] = kib + } + } + if available := stats["available"]; available > 0 { + if unused, ok := stats["unused"]; ok && unused >= 0 && available >= unused { + return (available - unused) * 1024 + } + } + if actual := stats["actual"]; actual > 0 { + if unused, ok := stats["unused"]; ok && unused > 0 && actual >= unused { + return (actual - unused) * 1024 + } + } + if rss := stats["rss"]; rss > 0 { + return rss * 1024 + } + return 0 +} + +func (m *Manager) AssignIPv6(id int) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if !c.IsKVM() { + return nil, fmt.Errorf("container is not a KVM VM: %d", id) + } + if c.IPv6 == "" { + addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id) + if err != nil { + return nil, err + } + c.IPv6 = addr + c.IPv6PrefixLen = prefixLen + c.IPv6Interface = iface + config.SaveConfig() + } + if err := m.applyIPv6Runtime(c); err != nil { + return nil, err + } + return c, nil +} + +func (m *Manager) applyIPv6Runtime(c *config.Container) error { + if c == nil || c.IPv6 == "" { + return nil + } + if c.IPv6Interface == "" { + prefixes := lxc.DetectPublicIPv6Prefixes() + if len(prefixes) == 0 { + return fmt.Errorf("failed to detect IPv6 uplink for %s", c.IPv6) + } + c.IPv6Interface = prefixes[0].Interface + c.IPv6PrefixLen = prefixes[0].PrefixLen + config.SaveConfig() + } + runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1") + runQuiet("sysctl", "-w", "net.ipv6.conf."+c.IPv6Interface+".accept_ra=2") + runQuiet("sysctl", "-w", "net.ipv6.conf."+c.IPv6Interface+".proxy_ndp=1") + bridge := "virbr0" + runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".disable_ipv6=0") + runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", bridge) + if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out)) + } + if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil { + return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out)) + } + ensureKVMIPv6ForwardRules(c.IPv6, bridge) + if c.Status == "running" { + if err := m.applyGuestIPv6(c); err != nil { + return err + } + if !m.guestIPv6ConnectivityOK(c) { + ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface) + if !m.guestIPv6ConnectivityOK(c) { + fmt.Printf("Warning: IPv6 assigned for KVM VM %s, but guest connectivity test did not pass immediately\n", c.Name) + } + } + } + return nil +} + +func runQuiet(name string, args ...string) { + _ = exec.Command(name, args...).Run() +} + +func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) { + if ipv6 == "" || bridge == "" { + return + } + rules := [][]string{ + {"FORWARD", "-i", bridge, "-s", ipv6 + "/128", "-j", "ACCEPT"}, + {"FORWARD", "-o", bridge, "-d", ipv6 + "/128", "-j", "ACCEPT"}, + } + for _, rule := range rules { + check := append([]string{"-C"}, rule...) + add := append([]string{"-I"}, append([]string{rule[0], "1"}, rule[1:]...)...) + if exec.Command("ip6tables", check...).Run() != nil { + exec.Command("ip6tables", add...).Run() + } + } +} + +func ensureKVMIPv6NAT66(ipv6 string, uplink string) { + if ipv6 == "" || uplink == "" { + return + } + rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"} + check := append([]string{"-t", "nat", "-C"}, rule...) + add := append([]string{"-t", "nat", "-I"}, append([]string{rule[0], "1"}, rule[1:]...)...) + if exec.Command("ip6tables", check...).Run() != nil { + exec.Command("ip6tables", add...).Run() + } +} + +func (m *Manager) applyGuestIPv6(c *config.Container) error { + if c == nil || c.IPv6 == "" { + return nil + } + script := kvmIPv6SetupScript(c.IPv6) + if c.IP != "" && c.SSHPassword != "" { + client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 8 * time.Second, + }) + if err == nil { + defer client.Close() + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + if output, err := session.CombinedOutput(script); err != nil { + return fmt.Errorf("failed to apply guest IPv6 over SSH: %v, output: %s", err, string(output)) + } + return nil + } + } + if err := qemuGuestPing(c.VirshName()); err != nil { + return fmt.Errorf("failed to apply guest IPv6: SSH unavailable and %w", err) + } + return qemuGuestExec(c.VirshName(), script, 60*time.Second) +} + +func kvmIPv6SetupScript(ipv6 string) string { + return `set -eu +IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` +IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" +if [ -z "$IFACE" ]; then + IFACE="$(ip -o link show up | awk -F': ' '$2 != "lo" {print $2; exit}' | cut -d@ -f1)" +fi +if [ -z "$IFACE" ]; then + echo "failed to detect guest network interface" >&2 + exit 1 +fi +sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true +sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true +sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true +ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 +mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d +cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF' +#!/bin/sh +set -eu +IPV6_ADDR=` + shellQuote(ipv6) + ` +IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + ` +IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')" +if [ -z "$IFACE" ]; then + IFACE="$(ip -o link show up | awk -F': ' '$2 != "lo" {print $2; exit}' | cut -d@ -f1)" +fi +[ -n "$IFACE" ] || exit 0 +sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true +sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true +sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true +ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" +ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 +EOF +chmod +x /usr/local/sbin/clicd-kvm-ipv6-init +cat > /etc/systemd/system/clicd-kvm-ipv6.service <<'EOF' +[Unit] +Description=CLICD KVM IPv6 setup +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/clicd-kvm-ipv6-init +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload >/dev/null 2>&1 || true +systemctl enable --now clicd-kvm-ipv6.service >/dev/null 2>&1 || true +cat > /etc/local.d/clicd-kvm-ipv6.start <<'EOF' +#!/bin/sh +/usr/local/sbin/clicd-kvm-ipv6-init || true +EOF +chmod +x /etc/local.d/clicd-kvm-ipv6.start +if command -v rc-update >/dev/null 2>&1; then + rc-update add local default >/dev/null 2>&1 || true + rc-service local restart >/dev/null 2>&1 || true +fi +cat > /etc/network/if-up.d/clicd-kvm-ipv6 <<'EOF' +#!/bin/sh +[ "$IFACE" = "lo" ] && exit 0 +/usr/local/sbin/clicd-kvm-ipv6-init || true +EOF +chmod +x /etc/network/if-up.d/clicd-kvm-ipv6 +` +} + +func (m *Manager) guestIPv6ConnectivityOK(c *config.Container) bool { + if c == nil || c.IP == "" || c.SSHPassword == "" { + return false + } + client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 8 * time.Second, + }) + if err != nil { + return false + } + defer client.Close() + for _, target := range []string{"2606:4700:4700::1111", "2001:4860:4860::8888"} { + session, err := client.NewSession() + if err != nil { + continue + } + err = session.Run("ping -6 -c 1 -W 2 " + shellQuote(target)) + _ = session.Close() + if err == nil { + return true + } + } + return false +} + +func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) { + prefixes := lxc.DetectPublicIPv6Prefixes() + if len(prefixes) == 0 { + return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found") + } + prefixInfo := prefixes[0] + prefix, err := netip.ParsePrefix(prefixInfo.Prefix) + if err != nil { + return "", 0, "", err + } + + used := map[string]bool{} + hostAddrs := map[string]bool{} + for _, p := range prefixes { + hostAddrs[p.Address] = true + } + for _, c := range config.AppConfig.Containers { + if c.IPv6 != "" { + used[c.IPv6] = true + } + } + for offset := uint64(0x2000 + id); offset < 0x100000; offset++ { + addr, err := ipv6Add(prefix.Masked().Addr(), offset) + if err != nil || !prefix.Contains(addr) { + break + } + candidate := addr.String() + if !used[candidate] && !hostAddrs[candidate] { + return candidate, prefix.Bits(), prefixInfo.Interface, nil + } + } + return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String()) +} + +func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { + raw := base.As16() + value := big.NewInt(0).SetBytes(raw[:]) + add := make([]byte, 8) + binary.BigEndian.PutUint64(add, offset) + value.Add(value, big.NewInt(0).SetBytes(add)) + bytes := value.Bytes() + if len(bytes) > 16 { + return netip.Addr{}, fmt.Errorf("IPv6 address overflow") + } + padded := make([]byte, 16) + copy(padded[16-len(bytes):], bytes) + var out [16]byte + copy(out[:], padded) + return netip.AddrFrom16(out), nil +} + +func randomMAC() string { + b := make([]byte, 3) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("52:54:00:%02x:%02x:%02x", time.Now().UnixNano()&0xff, (time.Now().UnixNano()>>8)&0xff, (time.Now().UnixNano()>>16)&0xff) + } + return fmt.Sprintf("52:54:00:%02x:%02x:%02x", b[0], b[1], b[2]) +} + +func generateRandomString(length int) string { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano())[:length] + } + return hex.EncodeToString(b)[:length] +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func allocateDefaultEqualPorts(c *config.Container, count int) []int { + if count <= 0 { + return nil + } + used := map[int]bool{} + for _, pm := range c.PortMappings { + used[pm.HostPort] = true + used[pm.ContainerPort] = true + } + ports := make([]int, 0, count) + for next := 20000; next <= 65535 && len(ports) < count; next++ { + if !used[next] { + ports = append(ports, next) + } + } + return ports +} + +func runStdin(command string, stdin []byte, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Stdin = bytes.NewReader(stdin) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s failed: %v, output: %s", command, err, string(output)) + } + return nil +} diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go new file mode 100644 index 0000000..0c2a187 --- /dev/null +++ b/backend/internal/kvm/templates.go @@ -0,0 +1,79 @@ +package kvm + +import ( + "path/filepath" +) + +type Image struct { + ID string `json:"id"` + Name string `json:"name"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + Description string `json:"description"` + URL string `json:"url"` +} + +func GetImages() []Image { + return []Image{ + { + ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM", + Distro: "ubuntu", Release: "noble", Arch: "amd64", + Description: "Ubuntu 24.04 LTS cloud image for KVM", + URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + }, + { + ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM", + Distro: "ubuntu", Release: "jammy", Arch: "amd64", + Description: "Ubuntu 22.04 LTS cloud image for KVM", + URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img", + }, + { + ID: "kvm-debian-bookworm", Name: "Debian 12 KVM", + Distro: "debian", Release: "bookworm", Arch: "amd64", + Description: "Debian 12 generic cloud image for KVM", + URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", + }, + { + ID: "kvm-debian-bullseye", Name: "Debian 11 KVM", + Distro: "debian", Release: "bullseye", Arch: "amd64", + Description: "Debian 11 generic cloud image for KVM", + URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2", + }, + { + ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM", + Distro: "rockylinux", Release: "9", Arch: "amd64", + 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-centos-9-stream", Name: "CentOS Stream 9 KVM", + Distro: "centos", Release: "9-stream", Arch: "amd64", + Description: "CentOS Stream 9 GenericCloud image for KVM", + URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2", + }, + { + ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM", + Distro: "alpine", Release: "3.23", Arch: "amd64", + Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM", + URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2", + }, + } +} + +func FindImage(id string) *Image { + for _, image := range GetImages() { + if image.ID == id { + return &image + } + } + return nil +} + +func CacheDir() string { + return filepath.Join(BaseDir(), "images") +} + +func ImagePath(id string) string { + return filepath.Join(CacheDir(), id+".qcow2") +} diff --git a/backend/internal/lxc/expiry.go b/backend/internal/lxc/expiry.go index d1ecf1d..c2d6b81 100644 --- a/backend/internal/lxc/expiry.go +++ b/backend/internal/lxc/expiry.go @@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool { // StopExpiredContainers stops running containers whose expiration date has passed. func (m *Manager) StopExpiredContainers(now time.Time) { for _, container := range config.AppConfig.Containers { - if !isContainerExpired(container, now) { + if container.IsKVM() || !isContainerExpired(container, now) { continue } @@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) { saved := false for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] - if c.Status != "running" { + if c.IsKVM() || c.Status != "running" { continue } diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 1aa3a4f..6aae523 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -73,6 +73,9 @@ func (m *Manager) WarmRunningContainersSSH() { containers := append([]config.Container(nil), config.AppConfig.Containers...) for _, container := range containers { c := container + if c.IsKVM() { + continue + } status, err := m.GetContainerStatus(c.LxcName()) if err != nil || status != "running" { continue @@ -102,6 +105,11 @@ func (m *Manager) updateAllRates() { for i := range config.AppConfig.Containers { c := &config.AppConfig.Containers[i] + if c.IsKVM() { + delete(lastUsage, c.VirshName()) + delete(rateCache, c.VirshName()) + continue + } if c.Status != "running" { delete(lastUsage, c.LxcName()) delete(rateCache, c.LxcName()) @@ -211,6 +219,7 @@ func NewManager() *Manager { // ContainerConfig defines container creation parameters type ContainerConfig struct { Name string `json:"name"` + Virtualization string `json:"virtualization,omitempty"` TemplateID string `json:"template_id"` VCPU float64 `json:"vcpu"` CPUPercent int `json:"cpu_percent"` @@ -345,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { ID: id, UUID: config.NewContainerUUID(), Name: cfg.Name, + Virtualization: config.VirtualizationLXC, Template: cfg.TemplateID, VCPU: cfg.VCPU, RAMMB: cfg.RAMMB, @@ -1986,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) { func (m *Manager) ListContainers() ([]config.Container, error) { containers := config.AppConfig.Containers for i := range containers { + if containers[i].IsKVM() { + continue + } status, err := m.GetContainerStatus(containers[i].LxcName()) if err == nil { containers[i].Status = status @@ -2061,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) { UUID: config.NewContainerUUID(), Name: name, LXCName: lxcName, + Virtualization: config.VirtualizationLXC, Template: "imported", VCPU: 1, RAMMB: 512, @@ -2579,6 +2593,9 @@ func (m *Manager) AccumulateTraffic() { delete(lastTrafficSnapshot, c.LxcName()) continue } + if c.IsKVM() { + continue + } // Reset if new month if c.TrafficResetDate != currentMonth { c.TrafficUsedRX = 0 diff --git a/backend/internal/lxc/portmap.go b/backend/internal/lxc/portmap.go index 93cb1d3..4ccd42f 100644 --- a/backend/internal/lxc/portmap.go +++ b/backend/internal/lxc/portmap.go @@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error { return fmt.Errorf("container has no IP") } tag := clicdTag(id) + bridge := "lxcbr0" + subnet := "10.0.3.0/24" + if c.IsKVM() { + bridge = "virbr0" + subnet = "192.168.122.0/24" + } - EnsureForwardRules() + EnsureForwardRules(bridge) m.CleanPortMappings(id) for _, pm := range c.PortMappings { @@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error { fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort) } - if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil { - exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() + if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil { + exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() } return nil @@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error { func clicdTag(id int) string { return "c" + strconv.Itoa(id) } -// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic -func EnsureForwardRules() { +// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic. +func EnsureForwardRules(bridge string) { + if bridge == "" { + bridge = "lxcbr0" + } rules := [][]string{ - {"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"}, - {"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"}, - {"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"}, + {"-i", bridge, "-j", "ACCEPT"}, + {"-o", bridge, "-j", "ACCEPT"}, + {"-i", bridge, "-o", bridge, "-j", "ACCEPT"}, } for _, args := range rules { - checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...) - if exec.Command("iptables", checkArgs...).Run() != nil { - exec.Command("iptables", args...).Run() + for { + deleteArgs := append([]string{"-D", "FORWARD"}, args...) + if exec.Command("iptables", deleteArgs...).Run() != nil { + break + } } + insertArgs := append([]string{"-I", "FORWARD", "1"}, args...) + exec.Command("iptables", insertArgs...).Run() } } diff --git a/backend/internal/lxc/snapshot.go b/backend/internal/lxc/snapshot.go index e1c40c8..3a7c8c4 100644 --- a/backend/internal/lxc/snapshot.go +++ b/backend/internal/lxc/snapshot.go @@ -207,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() { now := time.Now() containers := append([]config.Container(nil), config.AppConfig.Containers...) for _, c := range containers { - if !c.SnapshotScheduleEnabled { + if c.IsKVM() || !c.SnapshotScheduleEnabled { continue } nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index e69de29..8b13789 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/main.go b/backend/main.go index 366e0bd..b2da22b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -9,6 +9,7 @@ import ( "clicd/internal/api" "clicd/internal/cli" "clicd/internal/config" + "clicd/internal/kvm" "clicd/internal/lxc" "clicd/internal/server" @@ -50,18 +51,23 @@ func main() { // Start security scanner api.InitScanner() - // Ensure iptables FORWARD rules allow LXC traffic - lxc.EnsureForwardRules() + // Ensure iptables FORWARD rules allow managed bridge traffic. + lxc.EnsureForwardRules("lxcbr0") + lxc.EnsureForwardRules("virbr0") - // Start expiry scanner (stops expired containers every 30s) + // Start expiry scanners (stops expired/over-traffic workloads every 30s) manager := lxc.NewManager() + kvmManager := kvm.NewManager() manager.StartExpiryScanner() + kvmManager.StartExpiryScanner() - // Start usage monitor (computes CPU/network/disk rates every 5s) + // Start usage monitors (computes CPU/network/disk rates every 5s) manager.StartUsageMonitor() + kvmManager.StartUsageMonitor() - // Start scheduled snapshot scanner. + // Start scheduled snapshot scanners. manager.StartSnapshotScheduler() + kvmManager.StartSnapshotScheduler() // Clean up stale container configs (LXC dir was deleted but config remains) config.CleanStaleContainers() diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 7a236d3..cf84b13 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -12,6 +12,7 @@ interface CreateContainerModalProps { const defaultForm: CreateContainerRequest = { name: '', + virtualization: 'lxc', template_id: '', vcpu: 1, cpu_percent: 100, @@ -43,13 +44,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist useEffect(() => { if (!isOpen) return - getEnabledImages() + getEnabledImages(form.virtualization) .then((res) => { const data = res.data.data || [] setTemplates(data) - if (data.length > 0) { - setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id })) - } + setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') })) }) .catch(console.error) @@ -69,13 +68,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist getHostInfo() .then((res) => setHostInfo(res.data.data || null)) .catch(() => setHostInfo(null)) - }, [isOpen]) + }, [isOpen, form.virtualization]) const ipv6Available = !!ipv6Status?.available const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined + const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const autoPorts = useMemo(() => { const count = Math.max(2, form.port_mapping_count) @@ -119,7 +119,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist return } - const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB) + if (Object.keys(resourceErrors).length > 0) { + dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置') + return + } + + const boundedForm = normalizeCreateForm(form) // Build batch of containers const containers: CreateContainerRequest[] = [] @@ -181,10 +186,29 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist {batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}

} + +
+ + +
+
+ {templates.length === 0 ? (
- 暂无可用的系统镜像,请先在「镜像管理」中下载镜像模板。 + 暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
) : ( setFocused(true)} + onBlur={() => { + setFocused(false) + setDraft(Number.isFinite(value) ? String(value) : '') + }} onChange={(event) => { const raw = event.target.value - const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) - onChange(value) + setDraft(raw) + const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10) + onChange(next) }} - className={inputClass} + aria-invalid={invalid || undefined} + data-min={min} + data-max={max} + data-step={step} + className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`} /> ) } -function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest { +function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) { + const errors: Partial> = {} + const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25 + + if (!Number.isFinite(form.vcpu)) { + errors.vcpu = '请输入 vCPU' + } else if (form.vcpu < minVCPU) { + errors.vcpu = `不能小于 ${minVCPU} 核` + } else if (form.vcpu > maxVCPU) { + errors.vcpu = `不能大于 ${maxVCPU} 核` + } else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) { + errors.vcpu = 'KVM vCPU 必须是整数' + } + + if (!Number.isFinite(form.ram_mb)) { + errors.ram_mb = '请输入内存' + } else if (form.ram_mb < 128) { + errors.ram_mb = '不能小于 128 MB' + } else if (maxRAMMB && form.ram_mb > maxRAMMB) { + errors.ram_mb = `不能大于 ${maxRAMMB} MB` + } + + if (!Number.isFinite(form.disk_gb)) { + errors.disk_gb = '请输入磁盘' + } else if (form.disk_gb < 1) { + errors.disk_gb = '不能小于 1 GB' + } else if (maxDiskGB && form.disk_gb > maxDiskGB) { + errors.disk_gb = `不能大于 ${maxDiskGB} GB` + } + + return errors +} + +function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest { return { ...form, - vcpu: clampVCPU(form.vcpu, maxVCPU), - ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), - disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), + vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu), + ram_mb: Math.round(form.ram_mb), + disk_gb: Math.round(form.disk_gb), snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), } } -function clampVCPU(value: number, max: number) { +function normalizeLXCvCPU(value: number) { const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 - return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2)) + return Number(rounded.toFixed(2)) } function clampInt(value: number, min: number, max?: number, fallback = min) { diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index ad7c402..00984a0 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -359,7 +359,7 @@ export default function ContainerDetail() { const openReinstall = async () => { try { - const res = await getEnabledImages() + const res = await getEnabledImages(container?.virtualization || 'lxc') if (res.data.data) { setTemplates(res.data.data) setSelectedTemplate(res.data.data[0]?.id || '') @@ -728,6 +728,7 @@ export default function ContainerDetail() {
系统 {container.template} + 类型 {(container.virtualization || 'lxc').toUpperCase()} 内网 {container.ip || '-'} NAT {mappingCount} 条 {publicHost}:{container.ssh_port} @@ -1395,12 +1396,13 @@ function StatusBadge({ running }: { running: boolean }) { ) } -function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; children: ReactNode }) { +function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) { const classes = { blue: 'bg-blue-50 text-blue-700 border-blue-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', amber: 'bg-amber-50 text-amber-700 border-amber-100', violet: 'bg-violet-50 text-violet-700 border-violet-100', + slate: 'bg-slate-50 text-slate-700 border-slate-100', } return {children} } @@ -1804,6 +1806,7 @@ function TrafficBar({ container }: { container: Container }) { function getTemplateIcon(id: string): ReactNode { const size = 'w-6 h-6' + id = id.startsWith('kvm-') ? id.slice(4) : id if (id.startsWith('debian')) return if (id.startsWith('ubuntu')) return if (id.startsWith('alpine')) return diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index c0b6f9d..354227d 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -48,6 +48,7 @@ export default function Containers() { const [tasks, setTasks] = useState([]) const [queuedCreates, setQueuedCreates] = useState>({}) const [searchText, setSearchText] = useState('') + const [typeFilter, setTypeFilter] = useState('all') const [systemFilter, setSystemFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) @@ -168,12 +169,13 @@ export default function Containers() { const filteredContainers = useMemo(() => { return filterContainers(displayContainers, { search: searchText, + type: typeFilter, system: systemFilter, status: statusFilter, taskStatusMap, taskNameMap, }) - }, [displayContainers, searchText, systemFilter, statusFilter, tasks]) + }, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks]) const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize)) const currentPage = Math.min(page, totalPages) const pageStart = (currentPage - 1) * pageSize @@ -185,7 +187,7 @@ export default function Containers() { useEffect(() => { setPage(1) - }, [searchText, systemFilter, statusFilter, pageSize]) + }, [searchText, typeFilter, systemFilter, statusFilter, pageSize]) const toggleAll = () => { if (allFilteredSelected) { @@ -235,70 +237,6 @@ export default function Containers() {

- {selected.size > 0 && ( -
- {selected.size} 个 - - - - -
- )} - {displayContainers.length > 0 && ( - <> -
- - setSearchText(event.target.value)} - className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black" - placeholder="搜索名称、ID、UUID、IP" - /> -
- - - - - )}
+ {displayContainers.length > 0 && ( +
+
+
+ + setSearchText(event.target.value)} + className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black" + placeholder="搜索名称、ID、UUID、IP" + /> +
+ + + + +
+ + {selected.size > 0 && ( +
+ {selected.size} 个 + + + + +
+ )} +
+ )} + {displayContainers.length === 0 ? (
@@ -343,7 +359,7 @@ export default function Containers() { ) : (
- +
@@ -612,6 +632,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?: ) } +function RuntimeBadge({ runtime }: { runtime: string }) { + const normalized = runtime === 'kvm' ? 'kvm' : 'lxc' + return ( + + {normalized.toUpperCase()} + + ) +} + function buildDisplayContainers( containers: Container[], queuedCreates: Record, @@ -643,6 +672,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { id: 0, uuid: '', name: cfg.name, + virtualization: cfg.virtualization || 'lxc', template: cfg.template_id, vcpu: cfg.vcpu, ram_mb: cfg.ram_mb, @@ -715,6 +745,7 @@ function hasActiveTasks(tasks: Task[]) { type ContainerFilters = { search: string + type: string system: string status: string taskStatusMap: Record @@ -728,6 +759,9 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { return false } + if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) { + return false + } if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) { return false } @@ -740,6 +774,7 @@ function filterContainers(containers: DisplayContainer[], filters: ContainerFilt container.ip, container.ipv6, container.template, + container.virtualization || 'lxc', getTemplateName(container.template), getSystemFilterLabel(getSystemFilterValue(container.template)), String(container.ssh_port || ''), @@ -760,14 +795,15 @@ function buildSystemOptions(containers: DisplayContainer[]) { } function getSystemFilterValue(template: string) { - if (template.startsWith('ubuntu')) return 'ubuntu' - if (template.startsWith('debian')) return 'debian' - if (template.startsWith('alpine')) return 'alpine' - if (template.startsWith('centos')) return 'centos' - if (template.startsWith('archlinux')) return 'archlinux' - if (template.startsWith('fedora')) return 'fedora' - if (template.startsWith('rockylinux')) return 'rockylinux' - return template || 'unknown' + const normalized = template.startsWith('kvm-') ? template.slice(4) : template + if (normalized.startsWith('ubuntu')) return 'ubuntu' + if (normalized.startsWith('debian')) return 'debian' + if (normalized.startsWith('alpine')) return 'alpine' + if (normalized.startsWith('centos')) return 'centos' + if (normalized.startsWith('archlinux')) return 'archlinux' + if (normalized.startsWith('fedora')) return 'fedora' + if (normalized.startsWith('rockylinux')) return 'rockylinux' + return normalized || 'unknown' } function getSystemFilterLabel(system: string) { @@ -903,12 +939,18 @@ function getTemplateName(id: string) { 'archlinux-current': 'Arch Linux', 'fedora-44': 'Fedora 44', 'rockylinux-10': 'Rocky 10', + 'kvm-ubuntu-noble': 'Ubuntu 24.04', + 'kvm-ubuntu-jammy': 'Ubuntu 22.04', + 'kvm-debian-bookworm': 'Debian 12', + 'kvm-debian-bullseye': 'Debian 11', + 'kvm-rockylinux-9': 'Rocky 9', } return map[id] || id } function getTemplateIcon(id: string): ReactNode { const size = 'w-4 h-4' + id = id.startsWith('kvm-') ? id.slice(4) : id if (id.startsWith('debian')) return if (id.startsWith('ubuntu')) return if (id.startsWith('alpine')) return diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index 50244d6..a353e24 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -17,6 +17,7 @@ export default function ImageManagement() { const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState('') + const [typeFilter, setTypeFilter] = useState('all') const fetchImages = useCallback(async () => { try { @@ -80,6 +81,7 @@ export default function ImageManagement() { } const downloadedCount = images.filter((img) => img.downloaded).length + const visibleImages = images.filter((img) => typeFilter === 'all' || img.type === typeFilter) if (loading) { return ( @@ -99,13 +101,24 @@ export default function ImageManagement() { 已下载 {downloadedCount}/{images.length}

- +
+ + +
{error && ( @@ -126,6 +139,9 @@ export default function ImageManagement() {
+ @@ -141,7 +157,7 @@ export default function ImageManagement() { - {images.map((img) => { + {visibleImages.map((img) => { const isBusy = actionLoading === img.id return ( @@ -159,6 +175,11 @@ export default function ImageManagement() { + @@ -264,6 +285,7 @@ function StatusBadge({ img }: { img: ImageInfo }) { function getTemplateIcon(id: string): ReactNode { const size = 'w-5 h-5' + id = id.startsWith('kvm-') ? id.slice(4) : id if (id.startsWith('debian')) return if (id.startsWith('ubuntu')) return if (id.startsWith('alpine')) return diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 59e2a83..d21ed23 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -48,6 +48,7 @@ export interface Container { id: number uuid: string name: string + virtualization?: string template: string vcpu: number ram_mb: number @@ -85,6 +86,7 @@ export interface Container { export interface Template { id: string name: string + type?: string distro: string release: string arch: string @@ -94,6 +96,7 @@ export interface Template { export interface CreateContainerRequest { name: string + virtualization: string template_id: string vcpu: number cpu_percent: number @@ -338,6 +341,7 @@ export const getTemplates = () => export interface ImageInfo { id: string name: string + type: string distro: string release: string arch: string @@ -352,7 +356,7 @@ export const getImages = () => api.get>('/images') export const downloadImage = (templateId: string) => - api.post('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout + api.post('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout export const deleteImage = (templateId: string) => api.delete('/images/delete', { data: { template_id: templateId } }) @@ -360,8 +364,8 @@ export const deleteImage = (templateId: string) => export const toggleImage = (templateId: string, enabled: boolean) => api.put('/images/toggle', { template_id: templateId, enabled }) -export const getEnabledImages = () => - api.get>('/images/enabled') +export const getEnabledImages = (virtualization = 'lxc') => + api.get>('/images/enabled', { params: { type: virtualization } }) // Dashboard export const getDashboard = () =>
@@ -361,6 +377,7 @@ export default function Containers() { 名称 状态 系统 + 类型 CPU MEMORY DISK @@ -421,6 +438,9 @@ export default function Containers() { {getTemplateName(container.template)} + + + 发行版 + 类型 + 架构
{img.distro} {img.release} + + {(img.type || 'lxc').toUpperCase()} + + {img.arch}