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 */}
{resetPasswordError(resetPasswordDraft)}
+ )} ++ Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。 +
+