From ade1c6c093b3948042fe698f9bf46158066ec54f Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:21:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8A=9F=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers.go | 44 ++- backend/internal/api/images.go | 368 ++++++++++++++++------ backend/internal/api/runtime.go | 6 +- backend/internal/kvm/kvm.go | 93 +++++- backend/internal/lxc/lxc.go | 65 +++- backend/internal/server/server.go | 1 + frontend/src/components/ContainerCard.tsx | 2 +- frontend/src/components/Sidebar.tsx | 4 +- frontend/src/pages/ContainerDetail.tsx | 127 +++++++- frontend/src/pages/ImageManagement.tsx | 91 +++++- frontend/src/pages/Login.tsx | 2 +- frontend/src/services/api.ts | 14 +- 12 files changed, 663 insertions(+), 154 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index ea2ed9c..9a1f29e 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -2,10 +2,12 @@ package api import ( "encoding/json" + "fmt" "net/http" "strconv" "strings" "time" + "unicode" "clicd/internal/config" "clicd/internal/lxc" @@ -394,7 +396,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"}) return } - newPassword, err := resetPasswordByRuntime(id) + var req struct { + Password string `json:"password"` + } + if r.Body != nil { + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + } + password := strings.TrimSpace(req.Password) + if password != "" { + if err := validateSSHPassword(password); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + } + newPassword, err := resetPasswordByRuntime(id, password) if err != nil { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return @@ -406,6 +425,29 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) { }) } +func validateSSHPassword(password string) error { + if len(password) < 8 || len(password) > 64 { + return fmt.Errorf("密码长度必须为 8-64 位") + } + hasLetter := false + hasDigit := false + for _, r := range password { + if unicode.IsSpace(r) { + return fmt.Errorf("密码不能包含空白字符") + } + if unicode.IsLetter(r) { + hasLetter = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + } + if !hasLetter || !hasDigit { + return fmt.Errorf("密码至少需要包含字母和数字") + } + return nil +} + func addPortMapping(w http.ResponseWriter, r *http.Request, id int) { var pm config.PortMapping if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 46b3120..ae6a83b 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -8,6 +9,7 @@ import ( "os/exec" "path/filepath" "sync" + "time" "clicd/internal/config" "clicd/internal/kvm" @@ -16,23 +18,143 @@ import ( // ImageInfo represents a template image with its download/enable status. 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"` - Description string `json:"description"` - Downloaded bool `json:"downloaded"` - Enabled bool `json:"enabled"` - Downloading bool `json:"downloading"` - SizeBytes int64 `json:"size_bytes"` - ManualPath string `json:"manual_path,omitempty"` - Desktop string `json:"desktop,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Distro string `json:"distro"` + Release string `json:"release"` + Arch string `json:"arch"` + Description string `json:"description"` + Downloaded bool `json:"downloaded"` + Enabled bool `json:"enabled"` + Downloading bool `json:"downloading"` + Progress int `json:"progress"` + DownloadedBytes int64 `json:"downloaded_bytes"` + TotalBytes int64 `json:"total_bytes"` + Stage string `json:"stage,omitempty"` + Error string `json:"error,omitempty"` + SizeBytes int64 `json:"size_bytes"` + ManualPath string `json:"manual_path,omitempty"` + Desktop string `json:"desktop,omitempty"` } var imageDownloadsMu sync.Mutex -var imageDownloads = map[string]bool{} +var imageDownloads = map[string]*imageDownloadStatus{} + +type imageDownloadStatus struct { + Downloading bool + Progress int + DownloadedBytes int64 + TotalBytes int64 + Stage string + Error string + Cancel context.CancelFunc + UpdatedAt time.Time +} + +type imageDownloadSnapshot struct { + Downloading bool + Progress int + DownloadedBytes int64 + TotalBytes int64 + Stage string + Error string +} + +func imageDownloadInfo(id string) imageDownloadSnapshot { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + st := imageDownloads[id] + if st == nil { + return imageDownloadSnapshot{} + } + return imageDownloadSnapshot{ + Downloading: st.Downloading, + Progress: st.Progress, + DownloadedBytes: st.DownloadedBytes, + TotalBytes: st.TotalBytes, + Stage: st.Stage, + Error: st.Error, + } +} + +func startImageDownload(id, stage string) (context.Context, bool) { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + if st := imageDownloads[id]; st != nil && st.Downloading { + return nil, false + } + ctx, cancel := context.WithCancel(context.Background()) + imageDownloads[id] = &imageDownloadStatus{ + Downloading: true, + Stage: stage, + Cancel: cancel, + UpdatedAt: time.Now(), + } + return ctx, true +} + +func updateImageDownload(id string, update func(*imageDownloadStatus)) { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + st := imageDownloads[id] + if st == nil { + return + } + update(st) + st.UpdatedAt = time.Now() +} + +func finishImageDownload(id string, err error) { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + st := imageDownloads[id] + if st == nil { + return + } + st.Downloading = false + st.Cancel = nil + st.UpdatedAt = time.Now() + if err != nil { + st.Error = err.Error() + return + } + delete(imageDownloads, id) +} + +func clearImageDownload(id string) { + imageDownloadsMu.Lock() + delete(imageDownloads, id) + imageDownloadsMu.Unlock() +} + +func isImageDownloadActive(id string) bool { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + st := imageDownloads[id] + return st != nil && st.Downloading +} + +func lxcImageDownloadTempName(id string) string { + return fmt.Sprintf("clicd-img-dl-%s", id) +} + +func cleanupLXCImageDownloadTemp(id string) { + tmpName := lxcImageDownloadTempName(id) + exec.Command("lxc-destroy", "-n", tmpName, "-f").Run() + os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName)) +} + +func cleanupOldImageDownloadErrors() { + imageDownloadsMu.Lock() + defer imageDownloadsMu.Unlock() + cutoff := time.Now().Add(-10 * time.Minute) + for id, st := range imageDownloads { + if !st.Downloading && st.UpdatedAt.Before(cutoff) { + delete(imageDownloads, id) + } + } +} // isImageDownloaded checks if the LXC download cache exists for a template. func isImageDownloaded(distro, release, arch string) bool { @@ -101,54 +223,65 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { } enabledSet := getEnabledImageSet() + cleanupOldImageDownloadErrors() templates := lxc.GetTemplates() images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) for _, t := range templates { - _, downloading := imageDownloads[t.ID] + dl := imageDownloadInfo(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, + 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: dl.Downloading, + Progress: dl.Progress, + DownloadedBytes: dl.DownloadedBytes, + TotalBytes: dl.TotalBytes, + Stage: dl.Stage, + Error: dl.Error, + SizeBytes: size, }) } for _, t := range kvm.GetImages() { - _, downloading := imageDownloads[t.ID] + dl := imageDownloadInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID) manualPath := "" if t.Distro == "windows" { manualPath = kvm.ImagePath(t.ID) } images = append(images, ImageInfo{ - ID: t.ID, - Name: t.Name, - Type: config.VirtualizationKVM, - Distro: t.Distro, - Release: t.Release, - Arch: t.Arch, - Description: t.Description, - Downloaded: downloaded, - Enabled: enabledSet[t.ID], - Downloading: downloading, - SizeBytes: size, - ManualPath: manualPath, - Desktop: t.Desktop, + ID: t.ID, + Name: t.Name, + Type: config.VirtualizationKVM, + Distro: t.Distro, + Release: t.Release, + Arch: t.Arch, + Description: t.Description, + Downloaded: downloaded, + Enabled: enabledSet[t.ID], + Downloading: dl.Downloading, + Progress: dl.Progress, + DownloadedBytes: dl.DownloadedBytes, + TotalBytes: dl.TotalBytes, + Stage: dl.Stage, + Error: dl.Error, + SizeBytes: size, + ManualPath: manualPath, + Desktop: t.Desktop, }) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) } -// HandleImageDownload downloads a template image from the LXC image server. +// HandleImageDownload starts a template image download in the background. func HandleImageDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) @@ -172,82 +305,127 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { } if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { ensureImageEnabled(image.ID) + clearImageDownload(image.ID) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) return } - imageDownloadsMu.Lock() - if imageDownloads[req.TemplateID] { - imageDownloadsMu.Unlock() + ctx, ok := startImageDownload(image.ID, "downloading") + if !ok { 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 { - message := "Download failed: " + err.Error() - - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message}) - return - } - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) + go func(image kvm.Image) { + err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) { + updateImageDownload(image.ID, func(st *imageDownloadStatus) { + if p.Stage != "" { + st.Stage = p.Stage + } + if p.DownloadedBytes > 0 || p.TotalBytes > 0 { + st.DownloadedBytes = p.DownloadedBytes + st.TotalBytes = p.TotalBytes + } + st.Progress = p.Percent + }) + }) + if err != nil { + if ctx.Err() != nil { + os.Remove(kvm.ImagePath(image.ID) + ".tmp") + os.Remove(kvm.ImagePath(image.ID)) + finishImageDownload(image.ID, nil) + return + } + finishImageDownload(image.ID, err) + return + } + ensureImageEnabled(image.ID) + finishImageDownload(image.ID, nil) + }(*image) + jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"}) return } // Already downloaded? Just enable if needed. if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { ensureImageEnabled(tmpl.ID) + clearImageDownload(tmpl.ID) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) return } - // Already downloading? - imageDownloadsMu.Lock() - if imageDownloads[req.TemplateID] { - imageDownloadsMu.Unlock() + ctx, ok := startImageDownload(tmpl.ID, "lxc-create") + if !ok { 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() - }() - - // Auto-enable on download - ensureImageEnabled(tmpl.ID) - - // Download via lxc-create with a temp container, then destroy it. - tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID) - args := []string{"-n", tmpName, "-t", "download", "--", - "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} - if tmpl.Variant != "" { - args = append(args, "--variant", tmpl.Variant) - } - cmd := exec.Command("lxc-create", args...) - output, err := cmd.CombinedOutput() - - // Clean up the temp container unconditionally. - exec.Command("lxc-destroy", "-n", tmpName, "-f").Run() - os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName)) - - if err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{ - Success: false, - Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)), + go func(tmpl lxc.Template) { + // Download via lxc-create with a temp container, then destroy it. + tmpName := lxcImageDownloadTempName(tmpl.ID) + args := []string{"-n", tmpName, "-t", "download", "--", + "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} + if tmpl.Variant != "" { + args = append(args, "--variant", tmpl.Variant) + } + updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) { + st.Stage = "lxc-create" }) + cmd := exec.CommandContext(ctx, "lxc-create", args...) + output, err := cmd.CombinedOutput() + + // Clean up the temp container unconditionally. + cleanupLXCImageDownloadTemp(tmpl.ID) + + if err != nil { + if ctx.Err() != nil { + finishImageDownload(tmpl.ID, nil) + return + } + err = fmt.Errorf("Download failed: %v, output: %s", err, string(output)) + finishImageDownload(tmpl.ID, err) + return + } + ensureImageEnabled(tmpl.ID) + finishImageDownload(tmpl.ID, nil) + }(*tmpl) + + jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"}) +} + +// HandleImageCancel cancels an in-progress image download. +func HandleImageCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + var req struct { + TemplateID string `json:"template_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) return } - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) + imageDownloadsMu.Lock() + st := imageDownloads[req.TemplateID] + if st == nil || !st.Downloading || st.Cancel == nil { + imageDownloadsMu.Unlock() + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "No active download"}) + return + } + cancel := st.Cancel + st.Stage = "canceling" + st.UpdatedAt = time.Now() + imageDownloadsMu.Unlock() + + cancel() + if image := kvm.FindImage(req.TemplateID); image != nil { + os.Remove(kvm.ImagePath(image.ID) + ".tmp") + os.Remove(kvm.ImagePath(image.ID)) + } + if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil { + go cleanupLXCImageDownloadTemp(tmpl.ID) + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"}) } // HandleImageDelete deletes a cached template image from disk. @@ -264,6 +442,10 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) return } + if isImageDownloadActive(req.TemplateID) { + jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"}) + return + } tmpl := lxc.FindTemplate(req.TemplateID) if tmpl == nil { diff --git a/backend/internal/api/runtime.go b/backend/internal/api/runtime.go index b662a4a..9b6a63f 100644 --- a/backend/internal/api/runtime.go +++ b/backend/internal/api/runtime.go @@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error { return lxcManager.ReinstallContainer(id, templateID) } -func resetPasswordByRuntime(id int) (string, error) { +func resetPasswordByRuntime(id int, password string) (string, error) { c := config.FindContainer(id) if c != nil && c.IsKVM() { - return kvmManager.ResetSSHPassword(id) + return kvmManager.ResetSSHPassword(id, password) } - return lxcManager.ResetSSHPassword(id) + return lxcManager.ResetSSHPassword(id, password) } func assignIPv6ByRuntime(id int) (*config.Container, error) { diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 07bccf7..9ba4d1d 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -2,6 +2,7 @@ package kvm import ( "bytes" + "context" "crypto/rand" "encoding/base64" "encoding/binary" @@ -114,7 +115,22 @@ func ImageDownloadedInfo(id string) (bool, int64) { return true, info.Size() } +// DownloadProgress reports KVM image download/conversion progress. +type DownloadProgress struct { + Stage string + DownloadedBytes int64 + TotalBytes int64 + Percent int +} + +// DownloadProgressFunc receives download progress updates. +type DownloadProgressFunc func(DownloadProgress) + func DownloadImage(image Image) error { + return DownloadImageWithProgress(context.Background(), image, nil) +} + +func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error { if err := os.MkdirAll(CacheDir(), 0755); err != nil { return err } @@ -134,11 +150,15 @@ func DownloadImage(image Image) error { tmp := target + ".tmp" _ = os.Remove(tmp) if image.Distro == "windows" { - if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil { + if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil { _ = os.Remove(tmp) return err } - } else if err := downloadFile(image.URL, tmp); err != nil { + } else if err := downloadFile(ctx, image.URL, tmp, progress); err != nil { + _ = os.Remove(tmp) + return err + } + if err := ctx.Err(); err != nil { _ = os.Remove(tmp) return err } @@ -153,8 +173,12 @@ func DownloadImage(image Image) error { return err } } else { - if err := normalizeQCOW2(tmp, target); err != nil { + if progress != nil { + progress(DownloadProgress{Stage: "converting", Percent: 100}) + } + if err := normalizeQCOW2(ctx, tmp, target); err != nil { _ = os.Remove(tmp) + _ = os.Remove(target) return err } } @@ -168,11 +192,11 @@ func DeleteImage(id string) error { type downloadResponseValidator func(*http.Response) error -func downloadFile(url, target string) error { - return downloadFileWithValidator(url, target, nil) +func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error { + return downloadFileWithValidator(ctx, url, target, nil, progress) } -func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error { +func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error { client := http.Client{ Timeout: 30 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -186,7 +210,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali return nil }, } - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err } @@ -210,7 +234,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali return err } defer out.Close() - if _, err := io.Copy(out, resp.Body); err != nil { + total := resp.ContentLength + if total < 0 { + total = 0 + } + if progress != nil { + progress(DownloadProgress{Stage: "downloading", TotalBytes: total}) + } + buf := make([]byte, 256*1024) + var downloaded int64 + for { + if err := ctx.Err(); err != nil { + return err + } + n, readErr := resp.Body.Read(buf) + if n > 0 { + written, writeErr := out.Write(buf[:n]) + downloaded += int64(written) + if writeErr != nil { + return writeErr + } + if written != n { + return io.ErrShortWrite + } + if progress != nil { + percent := 0 + if total > 0 { + percent = int(downloaded * 100 / total) + if percent > 99 { + percent = 99 + } + } + progress(DownloadProgress{Stage: "downloading", DownloadedBytes: downloaded, TotalBytes: total, Percent: percent}) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + if err := ctx.Err(); err != nil { return err } return out.Sync() @@ -266,11 +331,11 @@ func validateWindowsISO(path, target string) error { return nil } -func normalizeQCOW2(src, target string) error { +func normalizeQCOW2(ctx context.Context, src, target string) error { if err := requireCommand("qemu-img"); err != nil { return err } - cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target) + cmd := exec.CommandContext(ctx, "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)) } @@ -647,7 +712,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { return m.StartContainer(id) } -func (m *Manager) ResetSSHPassword(id int) (string, error) { +func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { c := config.FindContainer(id) if c == nil { return "", fmt.Errorf("container not found: %d", id) @@ -658,7 +723,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) { if c.Status != "running" { return "", fmt.Errorf("KVM VM must be running before password reset") } - password := generateRandomString(16) + if strings.TrimSpace(password) == "" { + password = generateRandomString(16) + } if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil { c.SSHPassword = password c.SSHHostKey = "" @@ -1459,7 +1526,7 @@ func ensureVirtioWinISO() error { virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" tmp := virtioPath + ".tmp" _ = os.Remove(tmp) - if err := downloadFile(virtioURL, tmp); err != nil { + if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil { _ = os.Remove(tmp) return fmt.Errorf("failed to download virtio-win.iso: %v", err) } diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 0cf2428..4e28032 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -11,14 +11,13 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "regexp" "strconv" "strings" "sync" "time" - "golang.org/x/sys/unix" - "clicd/internal/config" ) @@ -1005,11 +1004,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err != nil { return err } - rootStat, ok := rootInfo.Sys().(*unix.Stat_t) + rootDev, _, _, ok := fileStatFields(rootInfo) if !ok { return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) } - rootDev := rootStat.Dev if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if walkErr != nil { @@ -1019,18 +1017,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err != nil { return err } - stat, ok := info.Sys().(*unix.Stat_t) + dev, uid, gid, ok := fileStatFields(info) if !ok { return fmt.Errorf("failed to read uid/gid for %s", path) } - if path != rootfsPath && stat.Dev != rootDev { + if path != rootfsPath && dev != rootDev { if info.IsDir() { return filepath.SkipDir } return nil } - uid := int(stat.Uid) - gid := int(stat.Gid) if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { return nil } @@ -1040,7 +1036,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if gid >= 0 && gid < 65536 { gid += gidBase } - return unix.Lchown(path, uid, gid) + return os.Lchown(path, uid, gid) }); err != nil { return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) } @@ -1048,7 +1044,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { return err } - if err := unix.Lchown(marker, uidBase, gidBase); err != nil { + if err := os.Lchown(marker, uidBase, gidBase); err != nil { return err } @@ -1063,6 +1059,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { return nil } +func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) { + if info == nil || info.Sys() == nil { + return 0, 0, 0, false + } + stat := reflect.ValueOf(info.Sys()) + if stat.Kind() == reflect.Pointer { + if stat.IsNil() { + return 0, 0, 0, false + } + stat = stat.Elem() + } + if stat.Kind() != reflect.Struct { + return 0, 0, 0, false + } + devValue, devOK := numericField(stat, "Dev") + uidValue, uidOK := numericField(stat, "Uid") + gidValue, gidOK := numericField(stat, "Gid") + if !devOK || !uidOK || !gidOK { + return 0, 0, 0, false + } + return devValue, int(uidValue), int(gidValue), true +} + +func numericField(v reflect.Value, name string) (uint64, bool) { + field := v.FieldByName(name) + if !field.IsValid() { + return 0, false + } + switch field.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return field.Uint(), true + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + value := field.Int() + if value < 0 { + return 0, false + } + return uint64(value), true + default: + return 0, false + } +} + func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { rootAbs, err := filepath.Abs(rootfsPath) if err != nil { @@ -1823,14 +1861,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33 } // ResetSSHPassword resets the root password of a container -func (m *Manager) ResetSSHPassword(id int) (string, error) { +func (m *Manager) ResetSSHPassword(id int, password string) (string, error) { c := config.FindContainer(id) if c == nil { return "", fmt.Errorf("container not found: %d", id) } lxcName := c.LxcName() - newPassword := generateRandomString(16) + newPassword := strings.TrimSpace(password) + if newPassword == "" { + newPassword = generateRandomString(16) + } if c.Status == "running" { c.SSHPassword = newPassword diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 979bff3..ec6dd31 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -80,6 +80,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) + mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel))) mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete))) mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) diff --git a/frontend/src/components/ContainerCard.tsx b/frontend/src/components/ContainerCard.tsx index f5ce855..c20f2ef 100644 --- a/frontend/src/components/ContainerCard.tsx +++ b/frontend/src/components/ContainerCard.tsx @@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro {/* Header */}
-
+
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2cf5a69..c9892cc 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -83,14 +83,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && (
-
+
CLICD
)} {collapsed && ( -
+
)} diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index 7e02156..6f4ee7e 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -144,6 +144,10 @@ export default function ContainerDetail() { const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [savingResource, setSavingResource] = useState(false) const [showPassword, setShowPassword] = useState(false) + const [showResetPassword, setShowResetPassword] = useState(false) + const [resetPasswordDraft, setResetPasswordDraft] = useState('') + const [resetPasswordResult, setResetPasswordResult] = useState('') + const [resetPasswordSaving, setResetPasswordSaving] = useState(false) const [showSnapshots, setShowSnapshots] = useState(false) const [snapshots, setSnapshots] = useState([]) const [snapshotQuota, setSnapshotQuota] = useState(3) @@ -443,20 +447,58 @@ export default function ContainerDetail() { } } + const generateResetPassword = () => { + const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + const digits = '23456789' + const symbols = '!@#$%*-_+=' + const all = letters + digits + symbols + const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)] + let password = pick(letters) + pick(digits) + while (password.length < 16) password += pick(all) + setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join('')) + setResetPasswordResult('') + } + + const resetPasswordError = (password: string) => { + if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位' + if (/\s/.test(password)) return '密码不能包含空白字符' + if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母' + if (!/\d/.test(password)) return '密码至少需要包含数字' + return '' + } + const handleResetPassword = async () => { - if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return + if (!containerIdentifier) return + const password = resetPasswordDraft.trim() + const validationError = resetPasswordError(password) + if (validationError) { + await dialog.alert('密码格式不正确', validationError) + return + } + setResetPasswordSaving(true) try { - const res = await resetSSHPassword(containerIdentifier) + const res = await resetSSHPassword(containerIdentifier, password) if (res.data.success) { - await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`) + const nextPassword = (res.data.data as { password: string })?.password || password + setResetPasswordResult(nextPassword) + setResetPasswordDraft(nextPassword) await fetchContainer() } - } catch (err) { + } catch (err: unknown) { console.error(err) - dialog.alert('密码重置失败', '请稍后重试') + const error = err as { response?: { data?: { message?: string } } } + dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试') + } finally { + setResetPasswordSaving(false) } } + const openResetPassword = () => { + setResetPasswordDraft('') + setResetPasswordResult('') + setShowResetPassword(true) + } + const handleAssignIPv6 = async () => { if (!containerIdentifier) return setActionLoading('ipv6') @@ -782,7 +824,7 @@ export default function ContainerDetail() {
-
+
{getTemplateIcon(container.template || '') || }
@@ -874,7 +916,18 @@ export default function ContainerDetail() { )}
- + + + 重置 SSH 密码 + + ) : undefined} + > {isSubUserPolicyBlocked ? (
虚拟机被策略临时封禁,连接信息暂不可用。 @@ -925,12 +978,6 @@ export default function ContainerDetail() { )}
- {!isSubUser && ( - - )} )} @@ -1083,6 +1130,60 @@ export default function ContainerDetail() { { fetchContainer(); fetchUsage() }} charts={charts} /> + {showResetPassword && ( + setShowResetPassword(false)}> +
+
+ +
+ { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }} + placeholder="请输入 8-64 位,至少包含字母和数字" + className={inputClass} + /> + +
+ {resetPasswordDraft && resetPasswordError(resetPasswordDraft) && ( +

{resetPasswordError(resetPasswordDraft)}

+ )} +
+ {resetPasswordResult && ( +
+
密码已修改成功
+
+ {resetPasswordResult} + +
+
+ )} +

+ Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。 +

+
+ + +
+
+
+ )} + {showSSH && ( setShowSSH(false)} wide>
diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index fc882e1..a564334 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -9,8 +9,9 @@ import { ToggleRight, Loader2, AlertCircle, + X, } from 'lucide-react' -import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' +import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api' import { useDialog } from '../components/Dialog' export default function ImageManagement() { @@ -34,10 +35,14 @@ export default function ImageManagement() { useEffect(() => { fetchImages() - const interval = setInterval(fetchImages, 5000) - return () => clearInterval(interval) }, [fetchImages]) + useEffect(() => { + const hasDownloads = images.some((img) => img.downloading) + const interval = setInterval(fetchImages, hasDownloads ? 1500 : 5000) + return () => clearInterval(interval) + }, [fetchImages, images]) + const handleDownload = async (templateId: string) => { setActionLoading(templateId) setError('') @@ -51,6 +56,19 @@ export default function ImageManagement() { } } + const handleCancelDownload = async (templateId: string) => { + setActionLoading(templateId) + setError('') + try { + await cancelImageDownload(templateId) + await fetchImages() + } catch (err: unknown) { + setError(apiErrorMessage(err, '取消失败')) + } finally { + setActionLoading(null) + } + } + const handleDelete = async (templateId: string) => { if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return setActionLoading(templateId) @@ -125,6 +143,7 @@ export default function ImageManagement() { downloadedCount={lxcImages.filter((img) => img.downloaded).length} totalCount={lxcImages.length} onDownload={handleDownload} + onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} /> @@ -136,6 +155,7 @@ export default function ImageManagement() { downloadedCount={kvmImages.filter((img) => img.downloaded).length} totalCount={kvmImages.length} onDownload={handleDownload} + onCancelDownload={handleCancelDownload} onDelete={handleDelete} onToggle={handleToggle} /> @@ -150,6 +170,7 @@ function ImageTable({ downloadedCount, totalCount, onDownload, + onCancelDownload, onDelete, onToggle, }: { @@ -159,6 +180,7 @@ function ImageTable({ downloadedCount: number totalCount: number onDownload: (id: string) => void + onCancelDownload: (id: string) => void onDelete: (id: string) => void onToggle: (id: string, enabled: boolean) => void }) { @@ -202,7 +224,7 @@ function ImageTable({
- + {getTemplateIcon(img.id)}
@@ -242,13 +264,18 @@ function ImageTable({ )} {img.downloading && ( - - - 下载中... - + )} - {img.downloaded && ( + {img.downloaded && !img.downloading && ( <>