优化功能体验

This commit is contained in:
MengMengCode
2026-06-08 01:21:10 +08:00
parent 7d48889eea
commit ade1c6c093
12 changed files with 663 additions and 154 deletions
+43 -1
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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))))