Compare commits

...

9 Commits

Author SHA1 Message Date
MengMengCode b58a6b1030 release: v1.1.3 2026-06-08 14:41:51 +08:00
MengMengCode 366f889a8c 优化安装脚本执行逻辑 2026-06-08 14:40:06 +08:00
MengMengCode 814441e9a0 release: v1.1.2 2026-06-08 02:24:04 +08:00
MengMengCode aed11af105 修复了一些已知问题 2026-06-08 02:23:48 +08:00
MengMengCode 3d95bb33c1 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-08 01:21:12 +08:00
MengMengCode ade1c6c093 优化功能体验 2026-06-08 01:21:10 +08:00
Meng Meng 5c4cc1cab3 Merge pull request #4 from MengMengCode/dependabot/npm_and_yarn/frontend/vite-8.0.16
build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
2026-06-07 23:42:42 +08:00
copilot-swe-agent[bot] 109e47170f fix: resolve frontend dependency conflicts for Vite 8 build 2026-06-07 15:40:09 +00:00
dependabot[bot] 34637cc79d build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.21 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 15:31:31 +00:00
23 changed files with 1923 additions and 949 deletions
+1
View File
@@ -58,6 +58,7 @@ backend/tmp/
*.swp *.swp
*.swo *.swo
*~ *~
*.claude/
# OS # OS
.DS_Store .DS_Store
+97 -24
View File
@@ -2,10 +2,10 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -13,6 +13,8 @@ import (
"time" "time"
"clicd/internal/config" "clicd/internal/config"
"golang.org/x/crypto/argon2"
) )
type ApiKey struct { type ApiKey struct {
@@ -79,14 +81,23 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
// Generate key: clicd_sk_ + 32 hex chars // Generate key: clicd_sk_ + 32 hex chars
rawBytes := make([]byte, 16) rawBytes := make([]byte, 16)
rand.Read(rawBytes) if _, err := rand.Read(rawBytes); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate API key"})
return
}
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes) rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
keyHash, err := hashAPIKey(rawKey)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to store API key"})
return
}
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
key := config.ApiKeyConfig{ key := config.ApiKeyConfig{
ID: generateShortID(), ID: generateShortID(),
Name: req.Name, Name: req.Name,
KeyHash: hashKey(rawKey), KeyHash: keyHash,
Prefix: rawKey[:13] + "...", Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist), IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now, CreatedAt: now,
@@ -114,10 +125,59 @@ func generateShortID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// hashKey creates a simple hash for storage (not reversible) const (
func hashKey(key string) string { apiKeyHashPrefix = "argon2id"
sum := sha256.Sum256([]byte(key)) apiKeyHashTime = uint32(3)
return hex.EncodeToString(sum[:]) apiKeyHashMemory = uint32(64 * 1024)
apiKeyHashThreads = uint8(1)
apiKeyHashSaltLength = 16
apiKeyHashKeyLength = uint32(32)
)
// hashAPIKey stores API keys using a salted slow password-hash style function.
func hashAPIKey(key string) (string, error) {
salt := make([]byte, apiKeyHashSaltLength)
if _, err := rand.Read(salt); err != nil {
return "", err
}
return hashAPIKeyWithSalt(key, salt), nil
}
func hashAPIKeyWithSalt(key string, salt []byte) string {
digest := argon2.IDKey([]byte(key), salt, apiKeyHashTime, apiKeyHashMemory, apiKeyHashThreads, apiKeyHashKeyLength)
return fmt.Sprintf("%s$v=19$m=%d,t=%d,p=%d$%s$%s",
apiKeyHashPrefix,
apiKeyHashMemory,
apiKeyHashTime,
apiKeyHashThreads,
hex.EncodeToString(salt),
hex.EncodeToString(digest),
)
}
func verifyAPIKeyHash(rawKey, storedHash string) bool {
parts := strings.Split(storedHash, "$")
if len(parts) != 5 || parts[0] != apiKeyHashPrefix || parts[1] != "v=19" {
return false
}
var memory, iterations uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
return false
}
if memory != apiKeyHashMemory || iterations != apiKeyHashTime || threads != apiKeyHashThreads {
return false
}
salt, err := hex.DecodeString(parts[3])
if err != nil || len(salt) == 0 {
return false
}
expected, err := hex.DecodeString(parts[4])
if err != nil || len(expected) == 0 {
return false
}
digest := argon2.IDKey([]byte(rawKey), salt, iterations, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(digest, expected) == 1
} }
func legacyHashKey(key string) string { func legacyHashKey(key string) string {
@@ -128,21 +188,37 @@ func legacyHashKey(key string) string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// validateApiKey checks if the given key is valid and IP is allowed func matchApiKey(rawKey string) (idx int, needsRehash bool) {
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey) legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys { for i, k := range config.AppConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 || if verifyAPIKeyHash(rawKey, k.KeyHash) {
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 { return i, false
if k.IPWhitelist == "" {
return true
} }
return isIPAllowed(clientIP, k.IPWhitelist) if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
return i, true
} }
} }
return -1, false
}
// validateApiKey checks if the given key is valid and IP is allowed.
func validateApiKey(rawKey, clientIP string) bool {
idx, needsRehash := matchApiKey(rawKey)
if idx < 0 {
return false return false
} }
k := config.AppConfig.ApiKeys[idx]
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
return false
}
if needsRehash {
if newHash, err := hashAPIKey(rawKey); err == nil {
config.AppConfig.ApiKeys[idx].KeyHash = newHash
config.SaveConfig()
}
}
return true
}
func apiKeyFromRequest(r *http.Request) string { func apiKeyFromRequest(r *http.Request) string {
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" { if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
@@ -228,17 +304,14 @@ func ip4ToUint32(ip net.IP) uint32 {
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
} }
// updateApiKeyLastUsed marks the key as recently used // updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) { func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey) idx, _ := matchApiKey(rawKey)
now := time.Now().Format("2006-01-02 15:04:05") if idx < 0 {
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return return
} }
} config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
config.SaveConfig()
} }
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"strings"
"testing"
"clicd/internal/config"
)
func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
h1, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
h2, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("expected salted hashes to differ")
}
if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") {
t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2)
}
if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) {
t.Fatal("argon2id hashes did not verify")
}
if verifyAPIKeyHash(raw+"x", h1) {
t.Fatal("argon2id hash verified wrong key")
}
}
func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid argon2id key")
}
updateApiKeyLastUsed(raw)
if config.AppConfig.ApiKeys[0].LastUsed == "" {
t.Fatal("LastUsed was not updated")
}
}
func TestValidateApiKeyMigratesLegacyHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "legacy",
Name: "legacy",
KeyHash: legacyHashKey(raw),
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid legacy key")
}
migrated := config.AppConfig.ApiKeys[0].KeyHash
if migrated == legacyHashKey(raw) {
t.Fatal("legacy key hash was not migrated")
}
if !verifyAPIKeyHash(raw, migrated) {
t.Fatal("migrated key hash does not verify")
}
}
func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
IPWhitelist: "192.0.2.10",
}},
}
if validateApiKey(raw, "198.51.100.10") {
t.Fatal("validateApiKey allowed disallowed IP")
}
if !validateApiKey(raw, "192.0.2.10") {
t.Fatal("validateApiKey rejected allowed IP")
}
}
+43 -1
View File
@@ -2,10 +2,12 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "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: "容器已到期,不允许此操作"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return 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 { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return 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) { func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil { if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
+228 -46
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -8,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/kvm" "clicd/internal/kvm"
@@ -26,13 +28,133 @@ type ImageInfo struct {
Downloaded bool `json:"downloaded"` Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"` 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"` SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"` ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"` Desktop string `json:"desktop,omitempty"`
} }
var imageDownloadsMu sync.Mutex 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. // isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool { func isImageDownloaded(distro, release, arch string) bool {
@@ -101,11 +223,12 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
} }
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates { for _, t := range templates {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
@@ -117,12 +240,17 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size, SizeBytes: size,
}) })
} }
for _, t := range kvm.GetImages() { for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID] dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := "" manualPath := ""
if t.Distro == "windows" { if t.Distro == "windows" {
@@ -138,7 +266,12 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size, SizeBytes: size,
ManualPath: manualPath, ManualPath: manualPath,
Desktop: t.Desktop, Desktop: t.Desktop,
@@ -148,7 +281,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images}) 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) { func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) 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 { if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
imageDownloadsMu.Lock() ctx, ok := startImageDownload(image.ID, "downloading")
if imageDownloads[req.TemplateID] { if !ok {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true go func(image kvm.Image) {
imageDownloadsMu.Unlock() err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
defer func() { updateImageDownload(image.ID, func(st *imageDownloadStatus) {
imageDownloadsMu.Lock() if p.Stage != "" {
delete(imageDownloads, req.TemplateID) st.Stage = p.Stage
imageDownloadsMu.Unlock() }
}() if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
ensureImageEnabled(image.ID) st.DownloadedBytes = p.DownloadedBytes
if err := kvm.DownloadImage(*image); err != nil { st.TotalBytes = p.TotalBytes
message := "Download failed: " + err.Error() }
st.Progress = p.Percent
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message}) })
})
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 return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) finishImageDownload(image.ID, err)
return
}
ensureImageEnabled(image.ID)
finishImageDownload(image.ID, nil)
}(*image)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return return
} }
// Already downloaded? Just enable if needed. // Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) { if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID) ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return return
} }
// Already downloading? ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
imageDownloadsMu.Lock() if !ok {
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"}) jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return return
} }
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
// Auto-enable on download
ensureImageEnabled(tmpl.ID)
go func(tmpl lxc.Template) {
// Download via lxc-create with a temp container, then destroy it. // Download via lxc-create with a temp container, then destroy it.
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID) tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--", args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch} "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" { if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant) args = append(args, "--variant", tmpl.Variant)
} }
cmd := exec.Command("lxc-create", args...) updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally. // Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run() cleanupLXCImageDownloadTemp(tmpl.ID)
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil { if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{ if ctx.Err() != nil {
Success: false, finishImageDownload(tmpl.ID, nil)
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)), 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 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. // 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"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return 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) tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil { if tmpl == nil {
+3 -3
View File
@@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error {
return lxcManager.ReinstallContainer(id, templateID) return lxcManager.ReinstallContainer(id, templateID)
} }
func resetPasswordByRuntime(id int) (string, error) { func resetPasswordByRuntime(id int, password string) (string, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { 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) { func assignIPv6ByRuntime(id int) (*config.Container, error) {
+68 -11
View File
@@ -20,6 +20,11 @@ import (
var manager = lxc.NewManager() var manager = lxc.NewManager()
const (
clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
)
// Run starts the CLI interface. // Run starts the CLI interface.
func Run() { func Run() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -198,11 +203,18 @@ func cliCreateContainer(reader *bufio.Reader) {
container := config.FindContainerByName(name) container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name) fmt.Printf("容器 %s 创建成功\n", name)
if container != nil { if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) fmt.Print(formatSSHAccess(container.SSHPort))
} }
restartWebPanelForConfigChange() restartWebPanelForConfigChange()
} }
func formatSSHAccess(sshPort int) string {
if sshPort <= 0 {
return "SSH: root, 端口未分配。密码已保存,请在 Web 面板中查看或重置。\n"
}
return fmt.Sprintf("SSH: root, port %d -> 22。密码已保存,请在 Web 面板中查看或重置。\n", sshPort)
}
func cliStartContainer(reader *bufio.Reader) { func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "开机") id, name := selectContainer(reader, "开机")
if id == 0 { if id == 0 {
@@ -532,13 +544,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
return err return err
} }
backupDir := "/root/clicd-backups" backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil { if err := os.MkdirAll(backupDir, 0700); err != nil {
return err return err
} }
backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405"))) backupName := fmt.Sprintf("clicd.%s.%s", safeReleaseBackupComponent(latest), time.Now().Format("20060102-150405"))
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil { if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil { backupPath, err := copyFileToBackup("/usr/local/bin/clicd", backupName, 0755)
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err) return fmt.Errorf("备份旧二进制失败: %w", err)
} }
fmt.Printf("旧版本已备份: %s\n", backupPath) fmt.Printf("旧版本已备份: %s\n", backupPath)
@@ -548,8 +561,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err := stopService("clicd"); err != nil { if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err) fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
} }
tmpBin := "/usr/local/bin/clicd.new" tmpBin := clicdNewBinaryPath
if err := copyFile(newBinary, tmpBin, 0755); err != nil { if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err return err
} }
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil { if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
@@ -614,25 +627,69 @@ func findFile(root, name string) (string, error) {
return found, nil return found, nil
} }
func copyFile(src, dst string, mode os.FileMode) error { func copyFileToBackup(src, fileName string, mode os.FileMode) (string, error) {
if fileName == "" || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || strings.Contains(fileName, "..") {
return "", fmt.Errorf("unsafe backup file name: %s", fileName)
}
dst := filepath.Join(clicdBackupDir, fileName)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return "", err
}
if err := copyIntoOpenFile(src, out, mode); err != nil {
return "", err
}
return dst, nil
}
func copyFileToUpgradeTemp(src string, mode os.FileMode) error {
out, err := os.OpenFile(clicdNewBinaryPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
return copyIntoOpenFile(src, out, mode)
}
func copyIntoOpenFile(src string, out *os.File, mode os.FileMode) error {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
out.Close()
return err return err
} }
defer in.Close() defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) if _, err := io.Copy(out, in); err != nil {
if err != nil { out.Close()
return err return err
} }
if _, err := io.Copy(out, in); err != nil { if err := out.Chmod(mode); err != nil {
out.Close() out.Close()
return err return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
return err return err
} }
return os.Chmod(dst, mode) return nil
}
func safeReleaseBackupComponent(tag string) string {
tag = strings.TrimPrefix(strings.TrimSpace(tag), "v")
var b strings.Builder
for _, r := range tag {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
component := strings.Trim(b.String(), "._-")
if component == "" {
return "unknown"
}
if len(component) > 64 {
return component[:64]
}
return component
} }
func sameVersion(current, latest string) bool { func sameVersion(current, latest string) bool {
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"strings"
"testing"
)
func TestSafeReleaseBackupComponent(t *testing.T) {
tests := map[string]string{
"v1.2.3": "1.2.3",
" release/candidate ": "release_candidate",
"../../etc/passwd": "etc_passwd",
"": "unknown",
}
for input, want := range tests {
if got := safeReleaseBackupComponent(input); got != want {
t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want)
}
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{
"../clicd",
"..\\clicd",
"subdir/clicd",
"",
}
for _, name := range unsafeNames {
if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") {
t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err)
}
}
}
func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) {
out := formatSSHAccess(2222)
if strings.Contains(out, "/") {
t.Fatalf("formatSSHAccess output contains credential separator: %q", out)
}
if strings.Contains(strings.ToLower(out), "password123") {
t.Fatalf("formatSSHAccess output exposed password: %q", out)
}
if !strings.Contains(out, "2222 -> 22") {
t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out)
}
}
func TestFormatSSHAccessHandlesMissingPort(t *testing.T) {
out := formatSSHAccess(0)
if !strings.Contains(out, "端口未分配") {
t.Fatalf("formatSSHAccess output = %q, want missing port message", out)
}
}
+130 -18
View File
@@ -2,7 +2,9 @@ package kvm
import ( import (
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
@@ -114,7 +116,22 @@ func ImageDownloadedInfo(id string) (bool, int64) {
return true, info.Size() 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 { 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 { if err := os.MkdirAll(CacheDir(), 0755); err != nil {
return err return err
} }
@@ -134,11 +151,15 @@ func DownloadImage(image Image) error {
tmp := target + ".tmp" tmp := target + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if image.Distro == "windows" { 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) _ = os.Remove(tmp)
return err 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) _ = os.Remove(tmp)
return err return err
} }
@@ -153,8 +174,12 @@ func DownloadImage(image Image) error {
return err return err
} }
} else { } 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(tmp)
_ = os.Remove(target)
return err return err
} }
} }
@@ -168,11 +193,11 @@ func DeleteImage(id string) error {
type downloadResponseValidator func(*http.Response) error type downloadResponseValidator func(*http.Response) error
func downloadFile(url, target string) error { func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error {
return downloadFileWithValidator(url, target, nil) 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{ client := http.Client{
Timeout: 30 * time.Minute, Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -186,7 +211,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return nil return nil
}, },
} }
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -210,7 +235,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return err return err
} }
defer out.Close() 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 err
} }
return out.Sync() return out.Sync()
@@ -266,11 +332,11 @@ func validateWindowsISO(path, target string) error {
return nil return nil
} }
func normalizeQCOW2(src, target string) error { func normalizeQCOW2(ctx context.Context, src, target string) error {
if err := requireCommand("qemu-img"); err != nil { if err := requireCommand("qemu-img"); err != nil {
return err 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 { if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output)) return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
} }
@@ -647,7 +713,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
return m.StartContainer(id) 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) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
@@ -658,7 +724,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if c.Status != "running" { if c.Status != "running" {
return "", fmt.Errorf("KVM VM must be running before password reset") 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 { if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
c.SSHPassword = password c.SSHPassword = password
c.SSHHostKey = "" c.SSHHostKey = ""
@@ -671,10 +739,14 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if err := m.EnsureSSH(id); err != nil { if err := m.EnsureSSH(id); err != nil {
return "", err return "", err
} }
chpasswdInput, err := chpasswdStdin("root", password)
if err != nil {
return "", err
}
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -686,8 +758,8 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err return "", err
} }
defer session.Close() defer session.Close()
cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password)) session.Stdin = bytes.NewReader(chpasswdInput)
if output, err := session.CombinedOutput(cmd); err != nil { if output, err := session.CombinedOutput("chpasswd"); err != nil {
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output)) return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
} }
c.SSHPassword = password c.SSHPassword = password
@@ -1459,7 +1531,7 @@ func ensureVirtioWinISO() error {
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
tmp := virtioPath + ".tmp" tmp := virtioPath + ".tmp"
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := downloadFile(virtioURL, tmp); err != nil { if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
return fmt.Errorf("failed to download virtio-win.iso: %v", err) return fmt.Errorf("failed to download virtio-win.iso: %v", err)
} }
@@ -2157,7 +2229,7 @@ func (m *Manager) EnsureSSH(id int) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -3123,7 +3195,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{ client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root", User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second, Timeout: 8 * time.Second,
}) })
if err != nil { if err != nil {
@@ -3310,6 +3382,46 @@ func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
} }
func chpasswdStdin(username, password string) ([]byte, error) {
if username == "" || strings.ContainsAny(username, ":\n\r") {
return nil, fmt.Errorf("invalid chpasswd username")
}
if strings.ContainsAny(password, "\n\r") {
return nil, fmt.Errorf("password cannot contain newlines")
}
return []byte(username + ":" + password + "\n"), nil
}
func kvmHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return verifyKVMHostKey(c, key, config.SaveConfig)
}
}
func verifyKVMHostKey(c *config.Container, key ssh.PublicKey, save func() error) error {
if c == nil {
return fmt.Errorf("KVM container is nil")
}
fingerprint := sshHostKeyFingerprint(key)
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
return fmt.Errorf("KVM SSH host key mismatch")
}
if c.SSHHostKey == "" {
c.SSHHostKey = fingerprint
if save != nil {
if err := save(); err != nil {
return fmt.Errorf("failed to save KVM SSH host key: %v", err)
}
}
}
return nil
}
func sshHostKeyFingerprint(key ssh.PublicKey) string {
sum := sha256.Sum256(key.Marshal())
return hex.EncodeToString(sum[:])
}
func allocateDefaultEqualPorts(c *config.Container, count int) []int { func allocateDefaultEqualPorts(c *config.Container, count int) []int {
if count <= 0 { if count <= 0 {
return nil return nil
+95
View File
@@ -0,0 +1,95 @@
package kvm
import (
"crypto/ed25519"
"crypto/rand"
"reflect"
"testing"
"clicd/internal/config"
"golang.org/x/crypto/ssh"
)
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
if err != nil {
t.Fatalf("chpasswdStdin returned error: %v", err)
}
want := []byte("root:" + password + "\n")
if !reflect.DeepEqual(got, want) {
t.Fatalf("chpasswdStdin = %#v, want %#v", got, want)
}
}
func TestChpasswdStdinRejectsNewlines(t *testing.T) {
tests := []struct {
name string
username string
password string
}{
{name: "username newline", username: "root\nadmin", password: "safe"},
{name: "username colon", username: "root:admin", password: "safe"},
{name: "password newline", username: "root", password: "safe\nroot:evil"},
{name: "password carriage return", username: "root", password: "safe\rroot:evil"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := chpasswdStdin(tc.username, tc.password); err == nil {
t.Fatal("chpasswdStdin returned nil error")
}
})
}
}
func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
key1 := testSSHPublicKey(t)
key2 := testSSHPublicKey(t)
saves := 0
c := &config.Container{}
save := func() error {
saves++
return nil
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("first host key verification returned error: %v", err)
}
if c.SSHHostKey == "" {
t.Fatal("first host key verification did not capture fingerprint")
}
if c.SSHHostKey != sshHostKeyFingerprint(key1) {
t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1))
}
if saves != 1 {
t.Fatalf("save count = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("same host key verification returned error: %v", err)
}
if saves != 1 {
t.Fatalf("save count after same key = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key2, save); err == nil {
t.Fatal("mismatched host key verification returned nil error")
}
}
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
return signer.PublicKey()
}
+126 -29
View File
@@ -11,14 +11,13 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/sys/unix"
"clicd/internal/config" "clicd/internal/config"
) )
@@ -403,9 +402,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged, // Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
setCmd := m.rootfsCommand(rootfsPath, if err := m.runRootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))) "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
setCmd.Run() fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
}
fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name) fmt.Printf("Container %d (%s) created successfully\n", id, cfg.Name)
return nil return nil
@@ -430,7 +430,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n" content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
_ = os.MkdirAll(filepath.Dir(interfaces), 0755) _ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = os.WriteFile(interfaces, []byte(content), 0644) _ = os.WriteFile(interfaces, []byte(content), 0644)
_ = exec.Command("chroot", rootfsPath, "rc-update", "add", "networking", "boot").Run() _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
return return
} }
@@ -452,7 +452,7 @@ method=ignore
path := filepath.Join(nmDir, "eth0.nmconnection") path := filepath.Join(nmDir, "eth0.nmconnection")
_ = os.WriteFile(path, []byte(keyfile), 0600) _ = os.WriteFile(path, []byte(keyfile), 0600)
} }
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "NetworkManager").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "NetworkManager")
} }
networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network") networkdDir := filepath.Join(rootfsPath, "etc", "systemd", "network")
@@ -467,7 +467,7 @@ IPv6AcceptRA=no
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
} }
if !isRHELFamily { if !isRHELFamily {
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run() _ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
} }
} }
@@ -476,7 +476,10 @@ func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error
_ = templateID _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
if err != nil {
return err
}
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
@@ -1005,11 +1008,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
rootStat, ok := rootInfo.Sys().(*unix.Stat_t) rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok { if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) 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 err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -1019,18 +1021,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil { if err != nil {
return err return err
} }
stat, ok := info.Sys().(*unix.Stat_t) dev, uid, gid, ok := fileStatFields(info)
if !ok { if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path) 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() { if info.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil return nil
} }
@@ -1040,7 +1040,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 { if gid >= 0 && gid < 65536 {
gid += gidBase gid += gidBase
} }
return unix.Lchown(path, uid, gid) return os.Lchown(path, uid, gid)
}); err != nil { }); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err) return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
} }
@@ -1048,7 +1048,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil { if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err return err
} }
if err := unix.Lchown(marker, uidBase, gidBase); err != nil { if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err return err
} }
@@ -1063,6 +1063,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil 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) { func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath) rootAbs, err := filepath.Abs(rootfsPath)
if err != nil { if err != nil {
@@ -1823,14 +1865,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
} }
// ResetSSHPassword resets the root password of a container // 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) c := config.FindContainer(id)
if c == nil { if c == nil {
return "", fmt.Errorf("container not found: %d", id) return "", fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
newPassword := generateRandomString(16) newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" { if c.Status == "running" {
c.SSHPassword = newPassword c.SSHPassword = newPassword
@@ -1846,7 +1891,10 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil { if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err) return "", fmt.Errorf("failed to configure SSH: %v", err)
} }
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword)))
if err != nil {
return "", err
}
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output)) return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
@@ -1858,22 +1906,70 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return newPassword, nil return newPassword, nil
} }
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd { func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
uidBase, gidBase, mapErr := unprivilegedIDMap() uidBase, gidBase, mapErr := unprivilegedIDMap()
if mapErr == nil { if mapErr == nil {
cmdArgs := []string{ cmdArgs := []string{
"-m", fmt.Sprintf("u:0:%d:65536", uidBase), "-m", fmt.Sprintf("u:0:%d:65536", uidBase),
"-m", fmt.Sprintf("g:0:%d:65536", gidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", rootfsPath, "--", "chroot", "--", cleanRootfsPath,
} }
cmdArgs = append(cmdArgs, args...) cmdArgs = append(cmdArgs, args...)
return exec.Command("lxc-usernsexec", cmdArgs...) return exec.Command("lxc-usernsexec", cmdArgs...), nil
} }
} }
cmdArgs := append([]string{rootfsPath}, args...) cmdArgs := append([]string{"--", cleanRootfsPath}, args...)
return exec.Command("chroot", cmdArgs...) return exec.Command("chroot", cmdArgs...), nil
}
func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
cmd, err := m.rootfsCommand(rootfsPath, args...)
if err != nil {
return err
}
return cmd.Run()
}
func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rootfsPath == "" {
return "", fmt.Errorf("empty rootfs path")
}
if !filepath.IsAbs(rootfsPath) {
return "", fmt.Errorf("rootfs path must be absolute: %s", rootfsPath)
}
cleanRootfsPath := filepath.Clean(rootfsPath)
cleanLxcPath, err := filepath.Abs(m.LxcPath)
if err != nil {
return "", fmt.Errorf("failed to resolve LXC path: %v", err)
}
cleanLxcPath = filepath.Clean(cleanLxcPath)
if cleanRootfsPath == cleanLxcPath {
return "", fmt.Errorf("refusing LXC base path as rootfs: %s", cleanRootfsPath)
}
if filepath.Base(cleanRootfsPath) != "rootfs" {
return "", fmt.Errorf("refusing non-rootfs path: %s", cleanRootfsPath)
}
if filepath.Dir(cleanRootfsPath) == cleanLxcPath {
return "", fmt.Errorf("refusing rootfs directly under LXC path: %s", cleanRootfsPath)
}
rel, err := filepath.Rel(cleanLxcPath, cleanRootfsPath)
if err != nil {
return "", fmt.Errorf("failed to validate rootfs path: %v", err)
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath)
}
return cleanRootfsPath, nil
} }
func (m *Manager) cleanupContainerStorage(lxcName string) error { func (m *Manager) cleanupContainerStorage(lxcName string) error {
@@ -2203,9 +2299,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
setCmd := m.rootfsCommand(rootfsPath, if err := m.runRootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))) "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
setCmd.Run() fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
}
// Update template and keep everything else the same // Update template and keep everything else the same
c.Template = templateID c.Template = templateID
+83
View File
@@ -0,0 +1,83 @@
package lxc
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
base := t.TempDir()
rootfs := filepath.Join(base, "-ct", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "true")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "true"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
base := t.TempDir()
outside := t.TempDir()
m := &Manager{LxcPath: base}
tests := []struct {
name string
path string
}{
{name: "outside base", path: filepath.Join(outside, "ct-1", "rootfs")},
{name: "base path", path: base},
{name: "not rootfs", path: filepath.Join(base, "ct-1", "not-rootfs")},
{name: "rootfs directly under base", path: filepath.Join(base, "rootfs")},
{name: "relative rootfs", path: filepath.Join("ct-1", "rootfs")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := m.rootfsCommand(tc.path, "true"); err == nil {
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
}
})
}
}
func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
parent := t.TempDir()
base := filepath.Join(parent, "lxc")
siblingRootfs := filepath.Join(parent, "lxc-evil", "ct-1", "rootfs")
m := &Manager{LxcPath: base}
if _, err := m.safeRootfsPath(siblingRootfs); err == nil || !strings.Contains(err.Error(), "unsafe rootfs path") {
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
}
}
+1
View File
@@ -80,6 +80,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload))) 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/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.1" Version = "1.1.3"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+563 -713
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.1", "version": "1.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,7 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "1.6.0", "@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -21,11 +21,11 @@
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^8.0.16"
} }
} }
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" /> <Server className="w-5 h-5 text-gray-700" />
</div> </div>
<div> <div>
+2 -2
View File
@@ -83,14 +83,14 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800"> <div className="w-7 h-7 flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
+114 -13
View File
@@ -144,6 +144,10 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = 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 [showSnapshots, setShowSnapshots] = useState(false)
const [snapshots, setSnapshots] = useState<Snapshot[]>([]) const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [snapshotQuota, setSnapshotQuota] = useState(3) 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 () => { 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 { try {
const res = await resetSSHPassword(containerIdentifier) const res = await resetSSHPassword(containerIdentifier, password)
if (res.data.success) { 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() await fetchContainer()
} }
} catch (err) { } catch (err: unknown) {
console.error(err) 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 () => { const handleAssignIPv6 = async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
setActionLoading('ipv6') setActionLoading('ipv6')
@@ -782,7 +824,7 @@ export default function ContainerDetail() {
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-14 h-14 bg-slate-100 rounded-lg flex items-center justify-center"> <div className="w-14 h-14 flex items-center justify-center">
{getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />} {getTemplateIcon(container.template || '') || <Cpu className="w-7 h-7 text-slate-700" />}
</div> </div>
<div> <div>
@@ -874,7 +916,18 @@ export default function ContainerDetail() {
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息"> <Panel
title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
>
<Key className="w-3.5 h-3.5" />
SSH
</button>
) : undefined}
>
{isSubUserPolicyBlocked ? ( {isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
@@ -925,12 +978,6 @@ export default function ContainerDetail() {
)} )}
</div> </div>
</div> </div>
{!isSubUser && (
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
<Key className="w-3 h-3" />
SSH
</button>
)}
</> </>
)} )}
</Panel> </Panel>
@@ -1083,6 +1130,60 @@ export default function ContainerDetail() {
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} /> <ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={() => { fetchContainer(); fetchUsage() }} charts={charts} />
{showResetPassword && (
<Modal title="重置 SSH 密码" onClose={() => setShowResetPassword(false)}>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"> SSH </label>
<div className="flex gap-2">
<input
type="text"
value={resetPasswordDraft}
onChange={(e) => { setResetPasswordDraft(e.target.value); setResetPasswordResult('') }}
placeholder="请输入 8-64 位,至少包含字母和数字"
className={inputClass}
/>
<button
type="button"
onClick={generateResetPassword}
className="px-3 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-50 hover:text-black"
title="生成随机密码"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (
<p className="mt-1 text-xs text-red-600">{resetPasswordError(resetPasswordDraft)}</p>
)}
</div>
{resetPasswordResult && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md">
<div className="text-xs text-green-700 mb-1"></div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm text-green-900 break-all">{resetPasswordResult}</span>
<button onClick={() => copyText(resetPasswordResult)} className="p-1 text-green-700 hover:text-green-900 rounded" title="复制">
<Copy className="w-4 h-4" />
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 leading-relaxed">
Linux LXC/KVM root SSH KVM guest agent SSH
</p>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowResetPassword(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button
onClick={handleResetPassword}
disabled={resetPasswordSaving || !resetPasswordDraft || !!resetPasswordError(resetPasswordDraft)}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
{resetPasswordSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</Modal>
)}
{showSSH && ( {showSSH && (
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide> <Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
<div className="h-[70vh] min-h-[520px]"> <div className="h-[70vh] min-h-[520px]">
+78 -11
View File
@@ -9,8 +9,9 @@ import {
ToggleRight, ToggleRight,
Loader2, Loader2,
AlertCircle, AlertCircle,
X,
} from 'lucide-react' } 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' import { useDialog } from '../components/Dialog'
export default function ImageManagement() { export default function ImageManagement() {
@@ -34,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => { useEffect(() => {
fetchImages() fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages]) }, [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) => { const handleDownload = async (templateId: string) => {
setActionLoading(templateId) setActionLoading(templateId)
setError('') 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) => { const handleDelete = async (templateId: string) => {
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId) setActionLoading(templateId)
@@ -125,6 +143,7 @@ export default function ImageManagement() {
downloadedCount={lxcImages.filter((img) => img.downloaded).length} downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length} totalCount={lxcImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -136,6 +155,7 @@ export default function ImageManagement() {
downloadedCount={kvmImages.filter((img) => img.downloaded).length} downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length} totalCount={kvmImages.length}
onDownload={handleDownload} onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
@@ -150,6 +170,7 @@ function ImageTable({
downloadedCount, downloadedCount,
totalCount, totalCount,
onDownload, onDownload,
onCancelDownload,
onDelete, onDelete,
onToggle, onToggle,
}: { }: {
@@ -159,6 +180,7 @@ function ImageTable({
downloadedCount: number downloadedCount: number
totalCount: number totalCount: number
onDownload: (id: string) => void onDownload: (id: string) => void
onCancelDownload: (id: string) => void
onDelete: (id: string) => void onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void onToggle: (id: string, enabled: boolean) => void
}) { }) {
@@ -202,7 +224,7 @@ function ImageTable({
<tr key={img.id} className="hover:bg-gray-50 transition-colors"> <tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0"> <span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)} {getTemplateIcon(img.id)}
</span> </span>
<div> <div>
@@ -242,13 +264,18 @@ function ImageTable({
)} )}
{img.downloading && ( {img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium"> <button
<Loader2 className="w-3.5 h-3.5 animate-spin" /> onClick={() => onCancelDownload(img.id)}
... disabled={isBusy}
</span> className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
title="取消下载并清理临时文件"
>
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
{isBusy ? '取消中...' : '取消'}
</button>
)} )}
{img.downloaded && ( {img.downloaded && !img.downloading && (
<> <>
<button <button
onClick={() => onToggle(img.id, img.enabled)} onClick={() => onToggle(img.id, img.enabled)}
@@ -287,10 +314,33 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) { function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) { if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
return ( return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"> <div className="inline-flex flex-col gap-1">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
title={downloadStatusTitle(img)}
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{downloadStatusLabel(img)}
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
</span>
)}
</div>
)
}
if (img.error) {
return (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600"
title={img.error}
>
<AlertCircle className="w-3 h-3" />
</span> </span>
) )
} }
@@ -318,6 +368,23 @@ function StatusBadge({ img }: { img: ImageInfo }) {
) )
} }
function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'canceling') return '取消中'
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
return '下载中'
}
function downloadStatusTitle(img: ImageInfo) {
const parts = [downloadStatusLabel(img)]
if (img.stage) parts.push(`阶段:${img.stage}`)
if (img.downloaded_bytes > 0 || img.total_bytes > 0) {
parts.push(`${formatSize(img.downloaded_bytes)} / ${formatSize(img.total_bytes)}`)
}
return parts.join('')
}
function isWindowsImage(img: ImageInfo) { function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows') return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
} }
+2 -2
View File
@@ -40,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8"> <div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4"> <div className="w-16 h-16 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" /> <AppIcon className="w-10 h-10" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1> <h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
@@ -106,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.1</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.3</p>
</div> </div>
</div> </div>
) )
+11 -3
View File
@@ -245,8 +245,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) => export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId }) api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) => export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`) api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) => export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`) api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -358,6 +358,11 @@ export interface ImageInfo {
downloaded: boolean downloaded: boolean
enabled: boolean enabled: boolean
downloading: boolean downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number size_bytes: number
manual_path?: string manual_path?: string
desktop?: string desktop?: string
@@ -367,7 +372,10 @@ export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images') api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) => export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout api.post<APIResponse>('/images/download', { template_id: templateId })
export const cancelImageDownload = (templateId: string) =>
api.post<APIResponse>('/images/cancel', { template_id: templateId })
export const deleteImage = (templateId: string) => export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } }) api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
+65 -16
View File
@@ -354,8 +354,14 @@ remove_clicd_quota_records() {
} }
remove_clicd_tmp_files() { remove_clicd_tmp_files() {
current_dir="$(pwd -P 2>/dev/null || pwd)"
for path in /tmp/clicd-* /tmp/clicd.*; do for path in /tmp/clicd-* /tmp/clicd.*; do
[ -e "$path" ] || [ -L "$path" ] || continue [ -e "$path" ] || [ -L "$path" ] || continue
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
if [ "$abs_path" = "$current_dir" ]; then
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
continue
fi
rm -rf "$path" rm -rf "$path"
log "已删除 $path" log "已删除 $path"
done done
@@ -674,17 +680,43 @@ EOF
sysctl --system >/dev/null 2>&1 || true sysctl --system >/dev/null 2>&1 || true
} }
systemd_unit_exists() {
unit="$1"
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
}
systemd_enable_now_if_exists() {
unit="$1"
if systemd_unit_exists "$unit"; then
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
return
fi
log "未检测到 systemd 单元 $unit,跳过。"
}
systemd_existing_units() {
for unit in "$@"; do
if systemd_unit_exists "$unit"; then
printf ' %s' "$unit"
fi
done
}
setup_runtime_services() { setup_runtime_services() {
log "正在配置 LXC 和 KVM 服务..." log "正在配置 LXC 和 KVM 服务..."
if is_systemd; then if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemd_enable_now_if_exists lxcfs.service
systemctl enable --now lxc-net >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc-net.service
systemctl enable --now lxc >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc.service
systemctl enable --now libvirtd >/dev/null 2>&1 || true if systemd_unit_exists libvirtd.service; then
systemctl enable --now virtqemud >/dev/null 2>&1 || true systemd_enable_now_if_exists libvirtd.service
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true else
systemd_enable_now_if_exists virtqemud.service
systemd_enable_now_if_exists virtqemud.socket
fi
systemd_enable_now_if_exists virtlogd.socket
return return
fi fi
@@ -756,13 +788,26 @@ try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)" root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)" root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then case "$root_fs" in
warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。" ext4)
;;
xfs|btrfs|zfs|overlay|unknown|"")
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。"
return
;;
*)
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
return
;;
esac
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
if ! has_cmd tune2fs; then if ! has_cmd tune2fs; then
warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。" log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
@@ -771,7 +816,7 @@ try_enable_project_quota() {
return return
fi fi
warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。" log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
} }
download_release_if_needed() { download_release_if_needed() {
@@ -821,19 +866,23 @@ install_binary() {
} }
install_systemd_service() { install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF' libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
cat > /etc/systemd/system/clicd.service << EOF
[Unit] [Unit]
Description=CLICD - LXC/KVM Container Manager Description=CLICD - LXC/KVM Container Manager
After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket After=network-online.target${lxc_after}${libvirt_after}
Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket Wants=network-online.target${libvirt_wants}
StartLimitIntervalSec=60
StartLimitBurst=10
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/clicd server ExecStart=/usr/local/bin/clicd server
Restart=always Restart=always
RestartSec=5 RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=10
LimitNOFILE=1048576 LimitNOFILE=1048576
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin