Compare commits

..

18 Commits

Author SHA1 Message Date
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
MengMengCode 7d48889eea release: v1.1.1 2026-06-07 23:29:06 +08:00
MengMengCode 2bcdb9e095 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-07 23:10:21 +08:00
MengMengCode 2ab42e7f57 支持部署KVM XFCE桌面系统 2026-06-07 23:10:19 +08:00
Meng Meng 3257cbb2a3 Merge pull request #2 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.45.0
build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
2026-06-07 22:06:55 +08:00
dependabot[bot] 1ff5d7a85e build(deps): bump golang.org/x/crypto from 0.28.0 to 0.45.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:29:47 +00:00
Meng Meng a99781d418 Merge pull request #1 from MengMengCode/dependabot/go_modules/backend/github.com/golang-jwt/jwt/v5-5.2.2
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
2026-06-07 21:27:51 +08:00
MengMengCode b993e57d05 fix: repair login page encoding 2026-06-07 21:19:24 +08:00
MengMengCode 7ae0c91813 release: v1.1.0 2026-06-07 21:14:31 +08:00
MengMengCode aab58aca6e release: v1.1.0 2026-06-07 21:11:53 +08:00
MengMengCode 0b27604f95 FIX 2026-06-07 21:11:29 +08:00
dependabot[bot] 460614e274 Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:11:00 +00:00
25 changed files with 2223 additions and 1056 deletions
+5 -5
View File
@@ -1,18 +1,18 @@
module clicd module clicd
go 1.23.0 go 1.24.0
toolchain go1.24.5 toolchain go1.24.5
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.45.0
golang.org/x/term v0.28.0 golang.org/x/term v0.37.0
) )
require ( require (
golang.org/x/sys v0.31.0 golang.org/x/sys v0.38.0
modernc.org/sqlite v1.29.10 modernc.org/sqlite v1.29.10
) )
+8 -8
View File
@@ -1,7 +1,7 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
+100 -27
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,20 +188,36 @@ 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 if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
} return i, true
return isIPAllowed(clientIP, k.IPWhitelist)
} }
} }
return false 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
}
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 {
@@ -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 { return
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
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 {
+276 -92
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"
@@ -16,22 +18,143 @@ import (
// ImageInfo represents a template image with its download/enable status. // ImageInfo represents a template image with its download/enable status.
type ImageInfo struct { type ImageInfo struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Distro string `json:"distro"` Distro string `json:"distro"`
Release string `json:"release"` Release string `json:"release"`
Arch string `json:"arch"` Arch string `json:"arch"`
Description string `json:"description"` Description string `json:"description"`
Downloaded bool `json:"downloaded"` Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"` Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"` Progress int `json:"progress"`
ManualPath string `json:"manual_path,omitempty"` 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 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 {
@@ -100,53 +223,65 @@ 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,
Name: t.Name, Name: t.Name,
Type: config.VirtualizationLXC, Type: config.VirtualizationLXC,
Distro: t.Distro, Distro: t.Distro,
Release: t.Release, Release: t.Release,
Arch: t.Arch, Arch: t.Arch,
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
SizeBytes: size, Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
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" {
manualPath = kvm.ImagePath(t.ID) manualPath = kvm.ImagePath(t.ID)
} }
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Type: config.VirtualizationKVM, Type: config.VirtualizationKVM,
Distro: t.Distro, Distro: t.Distro,
Release: t.Release, Release: t.Release,
Arch: t.Arch, Arch: t.Arch,
Description: t.Description, Description: t.Description,
Downloaded: downloaded, Downloaded: downloaded,
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: dl.Downloading,
SizeBytes: size, Progress: dl.Progress,
ManualPath: manualPath, 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}) 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"})
@@ -170,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}) })
return })
} if err != nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) 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 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() { go func(tmpl lxc.Template) {
imageDownloadsMu.Lock() // Download via lxc-create with a temp container, then destroy it.
delete(imageDownloads, req.TemplateID) tmpName := lxcImageDownloadTempName(tmpl.ID)
imageDownloadsMu.Unlock() args := []string{"-n", tmpName, "-t", "download", "--",
}() "-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
// Auto-enable on download args = append(args, "--variant", tmpl.Variant)
ensureImageEnabled(tmpl.ID) }
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
// Download via lxc-create with a temp container, then destroy it. st.Stage = "lxc-create"
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)),
}) })
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 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.
@@ -262,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 {
@@ -336,7 +520,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{ result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
}) })
} }
} }
+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)
}
}
+233 -28
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))
} }
@@ -371,13 +437,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
} }
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
} else { } else {
if image.Desktop != "" {
if cfg.RAMMB < 2048 {
cfg.RAMMB = 2048
}
if cfg.DiskGB < 20 {
cfg.DiskGB = 20
}
}
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil { if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err return nil, err
} }
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil { if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
return nil, err return nil, err
} }
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
} }
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml") xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -639,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)
@@ -650,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 = ""
@@ -663,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 {
@@ -678,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
@@ -706,7 +786,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
} }
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -731,7 +811,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
} }
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err return err
@@ -1399,7 +1479,7 @@ func ensureDefaultNetwork() error {
} }
// Start and autostart the default network // Start and autostart the default network
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil { if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") { if !libvirtNetworkActive(string(out)) {
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil { if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut)) return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
} }
@@ -1411,6 +1491,19 @@ func ensureDefaultNetwork() error {
return nil return nil
} }
func libvirtNetworkActive(info string) bool {
for _, line := range strings.Split(info, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "Active") {
return strings.EqualFold(strings.TrimSpace(value), "yes")
}
}
return false
}
func createOverlayDisk(base, target string, diskGB int) error { func createOverlayDisk(base, target string, diskGB int) error {
if diskGB < 1 { if diskGB < 1 {
diskGB = 5 diskGB = 5
@@ -1438,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)
} }
@@ -1668,8 +1761,11 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
} }
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error { func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
guestSetup := kvmSSHSetupScript(password) guestSetup := kvmSSHSetupScript(password)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
if strings.TrimSpace(ipv6) != "" { if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6) guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
} }
@@ -1743,7 +1839,12 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string { func isKVMDesktopTemplate(templateID string) bool {
image := FindImage(templateID)
return image != nil && image.Desktop != ""
}
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
if vcpu < 1 { if vcpu < 1 {
vcpu = 1 vcpu = 1
} }
@@ -1767,6 +1868,12 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<outbound average='%d'/> <outbound average='%d'/>
</bandwidth>`, averageKiB, averageKiB) </bandwidth>`, averageKiB, averageKiB)
} }
video := "<video><model type='virtio'/></video>"
input := ""
if desktop {
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>"
}
return fmt.Sprintf(`<domain type='kvm'> return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name> <name>%s</name>
%s %s
@@ -1810,10 +1917,10 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<memballoon model='virtio'> <memballoon model='virtio'>
<stats period='10'/> <stats period='10'/>
</memballoon> </memballoon>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/> <graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
<video><model type='virtio'/></video> %s
</devices> </devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth) </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
} }
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string { func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
@@ -2122,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 {
@@ -2263,6 +2370,64 @@ fi
` `
} }
func kvmDesktopSetupScript(image Image) string {
if strings.ToLower(strings.TrimSpace(image.Desktop)) != "xfce" {
return ""
}
packages := ""
switch image.Distro {
case "ubuntu":
packages = "xubuntu-desktop"
case "debian":
packages = "task-xfce-desktop"
default:
return ""
}
return `if command -v apt-get >/dev/null 2>&1; then
{
exec >>/var/log/clicd-desktop-setup.log 2>&1
echo "CLICD XFCE setup started at $(date -Is)"
export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
apt-get update || true
apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold ` + packages + ` || apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold xfce4 lightdm lightdm-gtk-greeter dbus-x11 xorg || true
if command -v useradd >/dev/null 2>&1 && ! id clicd >/dev/null 2>&1; then
useradd -m -s /bin/bash clicd || true
fi
if command -v chpasswd >/dev/null 2>&1 && id clicd >/dev/null 2>&1; then
printf 'clicd:%s\n' "$ROOT_PASSWORD" | chpasswd || true
fi
usermod -aG sudo clicd >/dev/null 2>&1 || true
usermod -aG autologin clicd >/dev/null 2>&1 || true
if id clicd >/dev/null 2>&1; then
printf 'startxfce4\n' >/home/clicd/.xsession || true
chown clicd:clicd /home/clicd/.xsession >/dev/null 2>&1 || true
fi
mkdir -p /etc/lightdm/lightdm.conf.d
cat >/etc/lightdm/lightdm.conf.d/50-clicd-autologin.conf <<'EOF'
[Seat:*]
autologin-user=clicd
autologin-user-timeout=0
user-session=xfce
greeter-session=lightdm-gtk-greeter
EOF
if [ -x /usr/sbin/lightdm ]; then
printf '/usr/sbin/lightdm\n' >/etc/X11/default-display-manager || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl set-default graphical.target >/dev/null 2>&1 || true
systemctl enable display-manager.service >/dev/null 2>&1 || true
systemctl enable lightdm.service >/dev/null 2>&1 || true
systemctl restart lightdm.service >/dev/null 2>&1 || systemctl start lightdm.service >/dev/null 2>&1 || true
fi
apt-get clean || true
echo "CLICD XFCE setup finished at $(date -Is)"
} || true
fi
`
}
func qemuGuestPing(name string) error { func qemuGuestPing(name string) error {
out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput() out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput()
if err != nil { if err != nil {
@@ -3030,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 {
@@ -3217,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()
}
+15
View File
@@ -12,6 +12,7 @@ type Image struct {
Arch string `json:"arch"` Arch string `json:"arch"`
Description string `json:"description"` Description string `json:"description"`
URL string `json:"url"` URL string `json:"url"`
Desktop string `json:"desktop,omitempty"`
} }
func GetImages() []Image { func GetImages() []Image {
@@ -22,6 +23,13 @@ func GetImages() []Image {
Description: "Ubuntu 24.04 LTS cloud image for KVM", Description: "Ubuntu 24.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
}, },
{
ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
Desktop: "xfce",
},
{ {
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM", ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64", Distro: "ubuntu", Release: "jammy", Arch: "amd64",
@@ -34,6 +42,13 @@ func GetImages() []Image {
Description: "Debian 12 generic cloud image for KVM", Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
}, },
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{ {
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM", ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64", Distro: "debian", Release: "bullseye", Arch: "amd64",
+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 -10
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.0" Version = "1.1.2"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
@@ -11,12 +11,3 @@ func Current() string {
} }
return Version return Version
} }
+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.0.0", "version": "1.1.2",
"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]">
+79 -12
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="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" /> <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" />
{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 -9
View File
@@ -3,13 +3,6 @@ import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon' import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() { export default function Login() {
const { login, accessCodeLogin } = useAuth() const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -47,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>
@@ -113,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.2</p>
</div> </div>
</div> </div>
) )
+13 -3
View File
@@ -94,6 +94,7 @@ export interface Template {
release: string release: string
arch: string arch: string
variant?: string variant?: string
desktop?: string
description: string description: string
} }
@@ -244,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`)
@@ -357,15 +358,24 @@ 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
} }
export const getImages = () => 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 } })
+232 -85
View File
@@ -5,17 +5,36 @@ REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}" CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz" ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}" ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues"
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
echo "=====================================" echo "====================================="
echo " CLICD Installer" echo " CLICD 中文安装/卸载脚本"
echo "=====================================" echo "====================================="
write_log_file() {
if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true)" "$*" >> "$LOG_FILE" 2>/dev/null || true
fi
}
log() { log() {
echo "[clicd] $*" echo "[clicd] $*"
write_log_file "[clicd] $*"
}
warn() {
echo "[clicd][警告] $*" >&2
write_log_file "[警告] $*"
} }
die() { die() {
echo "ERROR: $*" >&2 echo "[clicd][错误] $*" >&2
write_log_file "[错误] $*"
echo "" >&2
echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2
echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2
exit 1 exit 1
} }
@@ -31,13 +50,70 @@ is_openrc() {
has_cmd rc-service && has_cmd rc-update has_cmd rc-service && has_cmd rc-update
} }
run_step() {
step_name="$1"
shift
log "开始:$step_name"
if "$@" >> "$LOG_FILE" 2>&1; then
log "完成:$step_name"
return 0
fi
rc="$?"
echo "" >&2
echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2
echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
echo "" >&2
echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2
exit "$rc"
}
check_os_compatibility() {
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
case "$(uname -m 2>/dev/null || echo unknown)" in
x86_64|amd64)
;;
*)
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)"
;;
esac
if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi
case "$OS_ID" in
ubuntu|debian|alpine|centos|rhel|rocky|almalinux|fedora)
;;
*)
if ! has_cmd apt-get && ! has_cmd apk && ! has_cmd dnf && ! has_cmd yum; then
die "暂不支持当前 Linux 发行版:${OS_ID} ${OS_LIKE}。请提交 issue 并附上 /etc/os-release。"
fi
warn "发行版 ${OS_ID} 不在主要支持列表,将按检测到的软件包管理器尝试安装。"
;;
esac
}
check_storage_compatibility() {
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || echo unknown)"
avail_kb="$(df -Pk / 2>/dev/null | awk 'NR==2 {print $4}' || echo 0)"
log "存储检测:根文件系统=${root_fs} 可用空间=${avail_kb}KB"
if [ "${avail_kb:-0}" -lt 5242880 ]; then
warn "根分区可用空间低于 5GB,下载镜像或创建 KVM/LXC 时可能失败。"
fi
}
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Please run as root: sudo ./install.sh" echo "请使用 root 权限运行:sudo ./install.sh"
echo "Or: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh" echo "或执行:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "Uninstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall" echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "问题反馈:$ISSUE_URL"
exit 1 exit 1
fi fi
: > "$LOG_FILE" 2>/dev/null || true
log "日志文件:$LOG_FILE"
log "仓库地址:https://github.com/${REPO}"
log "问题反馈:$ISSUE_URL"
OS_ID="unknown" OS_ID="unknown"
OS_LIKE="" OS_LIKE=""
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@@ -48,17 +124,21 @@ fi
usage() { usage() {
cat << EOF cat << EOF
Usage: 用法:
./install.sh ./install.sh 安装或升级 CLICD
./install.sh uninstall ./install.sh uninstall 卸载 CLICD(会删除容器、虚拟机、镜像缓存和配置数据)
Environment: 环境变量:
CLICD_REPO=owner/repo CLICD_REPO=owner/repo 默认:${REPO}
CLICD_VERSION=latest|v1.0.0 CLICD_VERSION=latest|v1.0.0 默认:latest
CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE}
Examples: 示例:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
日志:${LOG_FILE}
问题反馈:${ISSUE_URL}
EOF EOF
} }
@@ -68,7 +148,7 @@ remove_path() {
return return
fi fi
rm -rf "$path" rm -rf "$path"
log "Removed $path" log "已删除 $path"
} }
unmount_path_tree() { unmount_path_tree() {
@@ -125,17 +205,17 @@ remove_lxc_container_dir() {
detach_container_loop_devices "$container_dir" detach_container_loop_devices "$container_dir"
if rm -rf "$container_dir" >/dev/null 2>&1; then if rm -rf "$container_dir" >/dev/null 2>&1; then
log "Removed $container_dir" log "已删除 $container_dir"
return return
fi fi
log "Retrying removal after terminating processes using $container_dir..." log "检测到 $container_dir 被占用,终止占用进程后重试删除..."
kill_path_users "$container_dir/rootfs" kill_path_users "$container_dir/rootfs"
kill_path_users "$container_dir" kill_path_users "$container_dir"
unmount_path_tree "$container_dir" unmount_path_tree "$container_dir"
detach_container_loop_devices "$container_dir" detach_container_loop_devices "$container_dir"
rm -rf "$container_dir" rm -rf "$container_dir"
log "Removed $container_dir" log "已删除 $container_dir"
} }
remove_kvm_domain() { remove_kvm_domain() {
@@ -158,7 +238,7 @@ remove_kvm_domain() {
return return
fi fi
log "Removing KVM domain $domain..." log "正在删除 KVM 虚拟机域 $domain..."
virsh destroy "$domain" >/dev/null 2>&1 || true virsh destroy "$domain" >/dev/null 2>&1 || true
virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 || virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 ||
virsh undefine "$domain" --nvram >/dev/null 2>&1 || virsh undefine "$domain" --nvram >/dev/null 2>&1 ||
@@ -171,7 +251,7 @@ destroy_clicd_kvm_domains() {
return return
fi fi
log "Destroying CLICD KVM domains..." log "正在销毁 CLICD 创建的 KVM 虚拟机..."
virsh list --all --name 2>/dev/null | while IFS= read -r domain; do virsh list --all --name 2>/dev/null | while IFS= read -r domain; do
[ -n "$domain" ] || continue [ -n "$domain" ] || continue
remove_kvm_domain "$domain" remove_kvm_domain "$domain"
@@ -231,7 +311,7 @@ delete_ip6tables_bridge_rules() {
} }
cleanup_clicd_networking() { cleanup_clicd_networking() {
log "Cleaning CLICD firewall and bridge rules..." log "正在清理 CLICD 防火墙和网桥规则..."
delete_iptables_lines nat PREROUTING 'clicd-' delete_iptables_lines nat PREROUTING 'clicd-'
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
@@ -269,7 +349,7 @@ remove_clicd_quota_records() {
grep -v 'clicd-' "$file" > "$tmp" || true grep -v 'clicd-' "$file" > "$tmp" || true
cat "$tmp" > "$file" cat "$tmp" > "$file"
rm -f "$tmp" rm -f "$tmp"
log "Cleaned CLICD quota records from $file" log "已清理 $file 中的 CLICD 配额记录"
done done
} }
@@ -277,7 +357,7 @@ remove_clicd_tmp_files() {
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
rm -rf "$path" rm -rf "$path"
log "Removed $path" log "已删除 $path"
done done
} }
@@ -289,8 +369,28 @@ remove_clicd_swapfile() {
remove_path /swapfile remove_path /swapfile
} }
confirm_uninstall() {
if [ "${CLICD_UNINSTALL_CONFIRM:-}" = "1" ] || [ "${CLICD_UNINSTALL_CONFIRM:-}" = "yes" ] || [ "$ACTION_CONFIRM" = "--yes" ] || [ "$ACTION_CONFIRM" = "-y" ]; then
return
fi
echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2
if [ -t 0 ]; then
read answer
else
answer=""
fi
if [ "$answer" != "YES" ]; then
die "已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。"
fi
}
uninstall_clicd() { uninstall_clicd() {
log "Uninstalling CLICD..." confirm_uninstall
log "正在卸载 CLICD..."
if has_cmd systemctl; then if has_cmd systemctl; then
systemctl stop clicd >/dev/null 2>&1 || true systemctl stop clicd >/dev/null 2>&1 || true
@@ -304,8 +404,8 @@ uninstall_clicd() {
rc-update del clicd default >/dev/null 2>&1 || true rc-update del clicd default >/dev/null 2>&1 || true
fi fi
log "Destroying LXC containers under /var/lib/lxc..." log "正在删除 CLICD 创建的 LXC 容器(/var/lib/lxc/ct-数字)..."
for container_dir in /var/lib/lxc/*; do for container_dir in /var/lib/lxc/ct-[0-9]*; do
[ -d "$container_dir" ] || continue [ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir" remove_lxc_container_dir "$container_dir"
done done
@@ -321,13 +421,12 @@ uninstall_clicd() {
remove_path /var/log/clicd.log remove_path /var/log/clicd.log
remove_path /var/log/clicd.err remove_path /var/log/clicd.err
remove_path /root/.clicd remove_path /root/.clicd
unmount_path_tree /var/lib/lxc # /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
remove_path /var/lib/lxc
unmount_path_tree /var/lib/clicd unmount_path_tree /var/lib/clicd
remove_path /var/lib/clicd remove_path /var/lib/clicd
remove_path /var/cache/lxc # /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。
remove_path /var/cache/clicd remove_path /var/cache/clicd
remove_path /root/clicd-backups warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
remove_clicd_tmp_files remove_clicd_tmp_files
remove_clicd_swapfile remove_clicd_swapfile
@@ -341,11 +440,13 @@ uninstall_clicd() {
echo "" echo ""
echo "=====================================" echo "====================================="
echo " CLICD Uninstalled" echo " CLICD 卸载完成"
echo "=====================================" echo "====================================="
echo " Removed service, binary, SQLite/config data, LXC containers," echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD KVM domains, VM images, image caches, firewall rules," echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " host hooks, quota records, temp files, backups, and swapfile." echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo "=====================================" echo "====================================="
} }
@@ -361,12 +462,12 @@ case "$ACTION" in
exit 0 exit 0
;; ;;
*) *)
die "Unknown action: $ACTION" die "未知操作:$ACTION"
;; ;;
esac esac
install_apk() { install_apk() {
log "Installing dependencies with apk..." log "正在使用 apk 安装依赖..."
apk update apk update
apk add --no-cache \ apk add --no-cache \
ca-certificates \ ca-certificates \
@@ -393,12 +494,12 @@ install_apk() {
libvirt-qemu libvirt-qemu
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
install_apt() { install_apt() {
log "Installing dependencies with apt..." log "正在使用 apt 安装依赖..."
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update apt-get update
apt-get install -y \ apt-get install -y \
@@ -445,7 +546,7 @@ enable_el_repos() {
} }
install_dnf() { install_dnf() {
log "Installing dependencies with dnf..." log "正在使用 dnf 安装依赖..."
enable_el_repos enable_el_repos
dnf install -y \ dnf install -y \
ca-certificates \ ca-certificates \
@@ -475,12 +576,12 @@ install_dnf() {
genisoimage genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do for pkg in lxcfs xorriso edk2-ovmf; do
dnf install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
install_yum() { install_yum() {
log "Installing dependencies with yum..." log "正在使用 yum 安装依赖..."
enable_el_repos enable_el_repos
yum install -y \ yum install -y \
ca-certificates \ ca-certificates \
@@ -510,7 +611,7 @@ install_yum() {
genisoimage genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do for pkg in lxcfs xorriso edk2-ovmf; do
yum install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg" yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -528,7 +629,7 @@ install_dependencies() {
elif has_cmd yum; then elif has_cmd yum; then
install_yum install_yum
else else
die "dnf/yum not found on $OS_ID" die "当前系统 $OS_ID 未找到 dnf/yum,无法安装依赖。"
fi fi
;; ;;
*) *)
@@ -541,27 +642,27 @@ install_dependencies() {
elif has_cmd yum; then elif has_cmd yum; then
install_yum install_yum
else else
die "Unsupported Linux distribution: ${OS_ID} ${OS_LIKE}" die "暂不支持当前 Linux 发行版:${OS_ID} ${OS_LIKE}。请提交 issue 并附上 /etc/os-release。"
fi fi
;; ;;
esac esac
has_cmd lxc-create || die "lxc-create is still missing after dependency installation." has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "iptables is still missing after dependency installation." has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "iproute2/ip command is still missing after dependency installation." has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
has_cmd virsh || die "virsh is still missing after dependency installation." has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd qemu-img || die "qemu-img is still missing after dependency installation." has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
has_cmd cloud-localds || die "cloud-localds is still missing after dependency installation." has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
die "one of genisoimage, mkisofs, or xorriso is required for Windows KVM setup." die "Windows KVM 初始化需要 genisoimagemkisofs xorriso 中任意一个。"
fi fi
if [ ! -e /dev/kvm ]; then if [ ! -e /dev/kvm ]; then
log "Warning: /dev/kvm was not found. KVM VMs require hardware virtualization or nested virtualization." warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi fi
} }
configure_kernel_networking() { configure_kernel_networking() {
log "Enabling kernel forwarding settings..." log "正在启用内核转发配置..."
cat > /etc/sysctl.d/99-clicd.conf << 'EOF' cat > /etc/sysctl.d/99-clicd.conf << 'EOF'
net.ipv4.ip_forward = 1 net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1 net.ipv6.conf.all.forwarding = 1
@@ -574,7 +675,7 @@ EOF
} }
setup_runtime_services() { setup_runtime_services() {
log "Configuring LXC and KVM services..." log "正在配置 LXC KVM 服务..."
if is_systemd; then if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemctl enable --now lxcfs >/dev/null 2>&1 || true
@@ -603,11 +704,49 @@ setup_runtime_services() {
return return
fi fi
die "No supported service manager found. CLICD supports systemd or OpenRC." die "未检测到支持的服务管理器。CLICD 当前支持 systemd OpenRC"
}
libvirt_network_active() {
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
}
setup_default_libvirt_network() {
if ! has_cmd virsh; then
warn "未找到 virsh,跳过 libvirt default NAT 网络检查。"
return
fi
log "正在检查 libvirt default NAT 网络..."
if ! virsh net-info default >/dev/null 2>&1; then
net_xml="$(mktemp /tmp/clicd-default-net.XXXXXX.xml)"
cat > "$net_xml" << 'EOF'
<network>
<name>default</name>
<bridge name='virbr0'/>
<forward mode='nat'/>
<ip address='192.168.122.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
</ip>
</network>
EOF
virsh net-define "$net_xml"
rm -f "$net_xml"
fi
if ! libvirt_network_active; then
virsh net-start default
fi
virsh net-autostart default >/dev/null
if ! libvirt_network_active; then
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
fi
log "libvirt default NAT 网络已启用。"
} }
setup_subids() { setup_subids() {
log "Setting up subordinate UID/GID ranges..." log "正在配置 subordinate UID/GID 范围..."
touch /etc/subuid /etc/subgid touch /etc/subuid /etc/subgid
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
@@ -618,21 +757,21 @@ try_enable_project_quota() {
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 if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "Project quota auto-enable skipped for root filesystem: ${root_fs:-unknown}" warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。"
return return
fi fi
if ! has_cmd tune2fs; then if ! has_cmd tune2fs; then
log "Project quota auto-enable skipped because tune2fs is unavailable." warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。"
return return
fi fi
if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then
log "Ext4 project quota support already appears to be enabled." log "检测到 ext4 project quota 已可用。"
return return
fi fi
log "Ext4 project quota is not enabled. Disk limits will fall back to loopback images." warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。"
} }
download_release_if_needed() { download_release_if_needed() {
@@ -646,8 +785,8 @@ download_release_if_needed() {
download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}" download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}"
fi fi
log "clicd binary not found in current directory." log "当前目录未找到 clicd 二进制,将下载发行版包。"
log "Downloading release package: ${download_url}" log "正在下载发行版包:${download_url}"
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0 trap 'rm -rf "$tmp_dir"' 0
@@ -657,12 +796,12 @@ download_release_if_needed() {
elif has_cmd wget; then elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url" wget -O "$tmp_dir/$ASSET" "$download_url"
else else
die "curl or wget is required to download the release package." die "下载发行版包需要 curl wget"
fi fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir" tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
cd "$tmp_dir/clicd-linux-amd64" cd "$tmp_dir/clicd-linux-amd64"
[ -f "./clicd" ] || die "Downloaded release package did not contain clicd." [ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
} }
install_binary() { install_binary() {
@@ -678,21 +817,24 @@ install_binary() {
chmod +x "$tmp_bin" chmod +x "$tmp_bin"
mv -f "$tmp_bin" /usr/local/bin/clicd mv -f "$tmp_bin" /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd chmod +x /usr/local/bin/clicd
log "Installed binary: /usr/local/bin/clicd" log "已安装二进制:/usr/local/bin/clicd"
} }
install_systemd_service() { install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF' cat > /etc/systemd/system/clicd.service << 'EOF'
[Unit] [Unit]
Description=CLICD - LXC/KVM Container Manager Description=CLICD - LXC/KVM Container Manager
After=network.target lxc.service libvirtd.service virtqemud.service After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket
Wants=libvirtd.service Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket
[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
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
[Install] [Install]
@@ -729,51 +871,56 @@ EOF
} }
install_service() { install_service() {
log "Installing CLICD service..." log "正在安装 CLICD 服务..."
if is_systemd; then if is_systemd; then
install_systemd_service install_systemd_service
elif is_openrc; then elif is_openrc; then
install_openrc_service install_openrc_service
else else
die "No supported service manager found. CLICD supports systemd or OpenRC." die "未检测到支持的服务管理器。CLICD 当前支持 systemd OpenRC"
fi fi
} }
print_summary() { print_summary() {
echo "" echo ""
echo "=====================================" echo "====================================="
echo " Installation Complete" echo " 安装完成"
echo "=====================================" echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999" echo " Web 面板:http://YOUR_SERVER_IP:8999"
echo " Binary: /usr/local/bin/clicd" echo " 二进制:/usr/local/bin/clicd"
echo " 安装日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
if is_systemd; then if is_systemd; then
echo " Service: systemctl {start|stop|restart|status} clicd" echo " 服务:systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f" echo " 运行日志:journalctl -u clicd -f"
elif is_openrc; then elif is_openrc; then
echo " Service: rc-service clicd {start|stop|restart|status}" echo " 服务:rc-service clicd {start|stop|restart|status}"
echo " Logs: tail -f /var/log/clicd.log /var/log/clicd.err" echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err"
fi fi
echo "=====================================" echo "====================================="
echo "" echo ""
echo "Initial credentials, if this was the first run:" echo "首次安装时的初始账号信息:"
if is_systemd; then if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi fi
echo "" echo ""
echo "If no password is shown, this server already had /root/.clicd/config.db." echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db"
echo "The existing admin password cannot be recovered from the bcrypt hash." echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。"
} }
install_dependencies run_step "兼容性检查" check_os_compatibility
configure_kernel_networking run_step "存储环境检查" check_storage_compatibility
setup_runtime_services run_step "安装系统依赖" install_dependencies
setup_subids run_step "配置内核网络参数" configure_kernel_networking
try_enable_project_quota run_step "配置运行时服务" setup_runtime_services
download_release_if_needed run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
install_binary run_step "配置 UID/GID 映射" setup_subids
install_service run_step "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary
run_step "安装并启动 CLICD 服务" install_service
sleep 2 sleep 2
print_summary print_summary