mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
优化功能体验
This commit is contained in:
@@ -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 {
|
||||
|
||||
+275
-93
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+80
-13
@@ -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)
|
||||
}
|
||||
|
||||
+53
-12
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
Reference in New Issue
Block a user