mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 814441e9a0 | |||
| aed11af105 | |||
| 3d95bb33c1 | |||
| ade1c6c093 | |||
| 5c4cc1cab3 | |||
| 109e47170f | |||
| 34637cc79d | |||
| 7d48889eea | |||
| 2bcdb9e095 | |||
| 2ab42e7f57 | |||
| 3257cbb2a3 | |||
| 1ff5d7a85e | |||
| a99781d418 | |||
| b993e57d05 | |||
| 7ae0c91813 | |||
| aab58aca6e | |||
| 0b27604f95 | |||
| 460614e274 |
+5
-5
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+563
-713
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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 初始化需要 genisoimage、mkisofs 或 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
|
||||||
|
|||||||
Reference in New Issue
Block a user