mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b13af91c | |||
| e79609281f | |||
| 2fa130a2b6 | |||
| 14d2192b05 | |||
| 9f5ad94a83 | |||
| ac6587f2bc | |||
| 6fad37b844 | |||
| d0eb92eaab | |||
| 5207082cd1 | |||
| 608b50f18a | |||
| b58a6b1030 | |||
| 366f889a8c | |||
| 814441e9a0 | |||
| aed11af105 | |||
| 3d95bb33c1 | |||
| ade1c6c093 | |||
| 5c4cc1cab3 | |||
| 109e47170f | |||
| 34637cc79d |
@@ -58,6 +58,7 @@ backend/tmp/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
*.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+363
-124
@@ -2,122 +2,293 @@ 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"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiKey struct {
|
type ApiKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
LastUsed string `json:"last_used"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultApiKeyScopes = []string{
|
||||||
|
"dashboard:read",
|
||||||
|
"container:read",
|
||||||
|
"task:read",
|
||||||
|
"image:read",
|
||||||
|
"snapshot:read",
|
||||||
|
"routing:read",
|
||||||
|
"ipv6:read",
|
||||||
|
"host:read",
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
||||||
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "apikey:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listApiKeys(w, r)
|
listApiKeys(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "apikey:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createApiKey(w, r)
|
createApiKey(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleApiKeyDelete handles DELETE for a specific API key
|
// HandleApiKeyDelete handles PATCH and DELETE for a specific API key
|
||||||
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
switch r.Method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
if !requireScope(w, r, "apikey:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateApiKey(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "apikey:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteApiKey(w, r)
|
||||||
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
|
}
|
||||||
if keyID == "" {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
func apiKeyIDFromPath(path string) string {
|
||||||
return
|
path = strings.TrimPrefix(path, "/api/api-keys/")
|
||||||
}
|
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
|
||||||
config.DeleteApiKey(keyID)
|
return strings.Trim(path, "/")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
keys := make([]ApiKey, 0)
|
keys := make([]ApiKey, 0)
|
||||||
for _, k := range config.AppConfig.ApiKeys {
|
for _, k := range config.AppConfig.ApiKeys {
|
||||||
keys = append(keys, ApiKey{
|
keys = append(keys, apiKeyResponse(k))
|
||||||
ID: k.ID,
|
|
||||||
Name: k.Name,
|
|
||||||
Prefix: k.Prefix,
|
|
||||||
IPWhitelist: k.IPWhitelist,
|
|
||||||
CreatedAt: k.CreatedAt,
|
|
||||||
LastUsed: k.LastUsed,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req apiKeyRequest
|
||||||
Name string `json:"name"`
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 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")
|
||||||
|
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
|
||||||
key := config.ApiKeyConfig{
|
key := config.ApiKeyConfig{
|
||||||
ID: generateShortID(),
|
ID: generateShortID(),
|
||||||
Name: req.Name,
|
Name: strings.TrimSpace(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,
|
||||||
|
Scopes: scopes,
|
||||||
|
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
|
||||||
|
Disabled: req.Disabled,
|
||||||
|
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
|
||||||
}
|
}
|
||||||
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
||||||
config.SaveConfig()
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
|
||||||
|
|
||||||
|
resp := apiKeyResponse(key)
|
||||||
|
resp.Key = rawKey
|
||||||
jsonResponse(w, http.StatusCreated, APIResponse{
|
jsonResponse(w, http.StatusCreated, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "API key created. Save this key now - it won't be shown again.",
|
Message: "API key created. Save this key now - it won't be shown again.",
|
||||||
Data: ApiKey{
|
Data: resp,
|
||||||
ID: key.ID,
|
|
||||||
Name: key.Name,
|
|
||||||
Key: rawKey,
|
|
||||||
Prefix: key.Prefix,
|
|
||||||
IPWhitelist: key.IPWhitelist,
|
|
||||||
CreatedAt: key.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||||
|
if keyID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apiKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range config.AppConfig.ApiKeys {
|
||||||
|
if config.AppConfig.ApiKeys[i].ID != keyID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Name) != "" {
|
||||||
|
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
|
||||||
|
}
|
||||||
|
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
|
||||||
|
if len(req.Scopes) > 0 {
|
||||||
|
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
|
||||||
|
}
|
||||||
|
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
|
||||||
|
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
|
||||||
|
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||||
|
if keyID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := keyID
|
||||||
|
for _, k := range config.AppConfig.ApiKeys {
|
||||||
|
if k.ID == keyID {
|
||||||
|
name = k.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.DeleteApiKey(keyID)
|
||||||
|
auditRequest(r, "apikey.delete", name, "", true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
|
||||||
|
return ApiKey{
|
||||||
|
ID: k.ID,
|
||||||
|
Name: k.Name,
|
||||||
|
Prefix: k.Prefix,
|
||||||
|
IPWhitelist: k.IPWhitelist,
|
||||||
|
CreatedAt: k.CreatedAt,
|
||||||
|
LastUsed: k.LastUsed,
|
||||||
|
Scopes: normalizeApiKeyScopes(k.Scopes),
|
||||||
|
ExpiresAt: k.ExpiresAt,
|
||||||
|
Disabled: k.Disabled,
|
||||||
|
ContainerUUIDs: k.ContainerUUIDs,
|
||||||
|
LastUsedIP: k.LastUsedIP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func generateShortID() string {
|
func generateShortID() string {
|
||||||
b := make([]byte, 4)
|
b := make([]byte, 4)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
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 +299,75 @@ 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 {
|
||||||
|
_, ok := validateApiKeyDetails(rawKey, clientIP)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
|
||||||
|
idx, needsRehash := matchApiKey(rawKey)
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
k := &config.AppConfig.ApiKeys[idx]
|
||||||
|
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if needsRehash {
|
||||||
|
if newHash, err := hashAPIKey(rawKey); err == nil {
|
||||||
|
config.AppConfig.ApiKeys[idx].KeyHash = newHash
|
||||||
|
config.SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(k.Scopes) == 0 {
|
||||||
|
k.Scopes = []string{"*"}
|
||||||
|
}
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
|
||||||
|
apiKey := apiKeyFromRequest(r)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
updateApiKeyLastUsedForKey(key, clientIP(r))
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
|
||||||
|
actor := "api:" + key.ID
|
||||||
|
if key.Name != "" {
|
||||||
|
actor = "api:" + key.Name
|
||||||
|
}
|
||||||
|
return AuthContext{
|
||||||
|
Type: authTypeAPIKey,
|
||||||
|
ApiKeyID: key.ID,
|
||||||
|
ApiKeyName: key.Name,
|
||||||
|
Actor: actor,
|
||||||
|
Scopes: normalizeApiKeyScopes(key.Scopes),
|
||||||
|
ContainerUUIDs: key.ContainerUUIDs,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyFromRequest(r *http.Request) string {
|
func apiKeyFromRequest(r *http.Request) string {
|
||||||
@@ -156,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isValidApiKeyRequest(r *http.Request) bool {
|
func isValidApiKeyRequest(r *http.Request) bool {
|
||||||
apiKey := apiKeyFromRequest(r)
|
_, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" {
|
return ok
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !validateApiKey(apiKey, clientIP(r)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||||
func isIPAllowed(clientIP, whitelist string) bool {
|
func isIPAllowed(clientIP, whitelist string) bool {
|
||||||
clientIP = strings.TrimSpace(clientIP)
|
clientIP = normalizeIPString(clientIP)
|
||||||
// Strip port if present
|
client := net.ParseIP(clientIP)
|
||||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
if client == nil {
|
||||||
clientIP = clientIP[:idx]
|
return false
|
||||||
}
|
}
|
||||||
for _, entry := range strings.Split(whitelist, "\n") {
|
for _, entry := range strings.Split(whitelist, "\n") {
|
||||||
entry = strings.TrimSpace(entry)
|
entry = strings.TrimSpace(entry)
|
||||||
@@ -180,77 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(entry, "/") {
|
if strings.Contains(entry, "/") {
|
||||||
// CIDR match
|
_, network, err := net.ParseCIDR(entry)
|
||||||
if ipInCIDR(clientIP, entry) {
|
if err == nil && network.Contains(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if entry == clientIP {
|
continue
|
||||||
|
}
|
||||||
|
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipInCIDR(ipStr, cidr string) bool {
|
func normalizeIPString(s string) string {
|
||||||
parts := strings.Split(cidr, "/")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Simple prefix match for IPv4
|
|
||||||
ip := netParseIP(ipStr)
|
|
||||||
cidrIP := netParseIP(parts[0])
|
|
||||||
if ip == nil || cidrIP == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
bits, err := strconv.Atoi(parts[1])
|
|
||||||
if err != nil || bits < 0 || bits > 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
mask := uint32(0xFFFFFFFF) << (32 - bits)
|
|
||||||
ipVal := ip4ToUint32(ip)
|
|
||||||
cidrVal := ip4ToUint32(cidrIP)
|
|
||||||
return (ipVal & mask) == (cidrVal & mask)
|
|
||||||
}
|
|
||||||
|
|
||||||
func netParseIP(s string) net.IP {
|
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||||
s = s[:idx]
|
return strings.Trim(host, "[]")
|
||||||
}
|
}
|
||||||
return net.ParseIP(s)
|
return strings.Trim(s, "[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ip4ToUint32(ip net.IP) uint32 {
|
func ipInCIDR(ipStr, cidr string) bool {
|
||||||
ip = ip.To4()
|
ip := net.ParseIP(normalizeIPString(ipStr))
|
||||||
if ip == nil {
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
return 0
|
return err == nil && ip != nil && network.Contains(ip)
|
||||||
}
|
|
||||||
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)
|
key, ok := validateApiKeyDetails(rawKey, "")
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
if !ok {
|
||||||
for i := range config.AppConfig.ApiKeys {
|
return
|
||||||
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
|
|
||||||
config.AppConfig.ApiKeys[i].LastUsed = now
|
|
||||||
config.SaveConfig()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
updateApiKeyLastUsedForKey(key, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
|
||||||
|
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if ip != "" {
|
||||||
|
key.LastUsedIP = ip
|
||||||
|
}
|
||||||
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
apiKey := apiKeyFromRequest(r)
|
key, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
if !ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
next(w, r)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeApiKeyScopes(scopes []string) []string {
|
||||||
|
return normalizeRequestedScopes(scopes, []string{"*"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
|
||||||
|
result := normalizeStringSlice(scopes)
|
||||||
|
if len(result) == 0 {
|
||||||
|
return append([]string(nil), fallback...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringSlice(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validApiKeyTime(value string) bool {
|
||||||
|
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyExpired(value string) bool {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil && !time.Now().Before(expiresAt)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,6 +29,132 @@ type APIResponse struct {
|
|||||||
Data interface{} `json:"data,omitempty"`
|
Data interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authContextKey struct{}
|
||||||
|
|
||||||
|
type AuthContext struct {
|
||||||
|
Type string
|
||||||
|
Username string
|
||||||
|
ApiKeyID string
|
||||||
|
ApiKeyName string
|
||||||
|
Actor string
|
||||||
|
Scopes []string
|
||||||
|
ContainerUUIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
authTypeAdmin = "admin"
|
||||||
|
authTypeSubUser = "sub_user"
|
||||||
|
authTypeAPIKey = "api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
|
||||||
|
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
|
||||||
|
return ctx, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestActor(r *http.Request) string {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
|
||||||
|
return ctx.Actor
|
||||||
|
}
|
||||||
|
if claims, ok := claimsFromRequest(r); ok {
|
||||||
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
|
return "user:" + subUser
|
||||||
|
}
|
||||||
|
if username, _ := claims["username"].(string); username != "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(r *http.Request, scope string) bool {
|
||||||
|
ctx, ok := authContextFromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch ctx.Type {
|
||||||
|
case authTypeAdmin:
|
||||||
|
return true
|
||||||
|
case authTypeSubUser:
|
||||||
|
return subUserScopeAllowed(scope)
|
||||||
|
case authTypeAPIKey:
|
||||||
|
return scopeAllowed(ctx.Scopes, scope)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subUserScopeAllowed(scope string) bool {
|
||||||
|
switch scope {
|
||||||
|
case "container:read", "container:power", "container:reinstall", "container:network",
|
||||||
|
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
|
||||||
|
"terminal:ssh", "terminal:vnc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyScope(r *http.Request, scopes ...string) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if hasScope(r, scope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeAllowed(scopes []string, required string) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
scope = strings.TrimSpace(scope)
|
||||||
|
if scope == "*" || scope == "admin:*" || scope == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(scope, ":*") {
|
||||||
|
prefix := strings.TrimSuffix(scope, "*")
|
||||||
|
if strings.HasPrefix(required, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
|
||||||
|
if hasScope(r, scope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireScope(w, r, scope) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if hasAnyScope(r, scopes...) {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
|
||||||
|
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||||
tokenVersion := int(tokenVersionFloat)
|
tokenVersion := int(tokenVersionFloat)
|
||||||
|
foundSubUser := false
|
||||||
for i := range config.AppConfig.SubUsers {
|
for i := range config.AppConfig.SubUsers {
|
||||||
if config.AppConfig.SubUsers[i].Username == subUser {
|
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||||
|
foundSubUser = true
|
||||||
stored := config.AppConfig.SubUsers[i].TokenVersion
|
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||||
// If stored version > 0, require token_version to match exactly.
|
// If stored version > 0, require token_version to match exactly.
|
||||||
// This also rejects legacy tokens that lack token_version entirely.
|
// This also rejects legacy tokens that lack token_version entirely.
|
||||||
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !foundSubUser {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, ok
|
return claims, ok
|
||||||
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isSubUserRequest(r *http.Request) bool {
|
func isSubUserRequest(r *http.Request) bool {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
return ctx.Type == authTypeSubUser
|
||||||
|
}
|
||||||
claims, ok := claimsFromRequest(r)
|
claims, ok := claimsFromRequest(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenString := tokenFromRequest(r)
|
tokenString := tokenFromRequest(r)
|
||||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
if claims, ok := claimsFromToken(tokenString); ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
|
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
|
||||||
|
if values, ok := claims["container_uuids"].([]interface{}); ok {
|
||||||
|
for _, value := range values {
|
||||||
|
if uuid, ok := value.(string); ok {
|
||||||
|
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, auth))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, _ := claims["username"].(string)
|
||||||
|
if username == "" {
|
||||||
|
username = config.AppConfig.AdminUser
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
next(w, r)
|
if key, ok := validateApiKeyRequest(r); ok {
|
||||||
|
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if isSubUserRequest(r) {
|
ctx, _ := authContextFromRequest(r)
|
||||||
|
if ctx.Type == authTypeSubUser {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
func getRootDiskInfo() (DiskInfo, bool) {
|
||||||
|
return DiskInfo{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
func getRootDiskInfo() (DiskInfo, bool) {
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err := unix.Statfs("/", &stat); err != nil {
|
||||||
|
return DiskInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||||
|
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
return DiskInfo{
|
||||||
|
TotalGB: total,
|
||||||
|
UsedGB: total - free,
|
||||||
|
FreeGB: free,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -18,8 +20,18 @@ var lxcManager = lxc.NewManager()
|
|||||||
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainers(w, r)
|
listContainers(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAccessRestrictedRequest(r) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
createContainer(w, r)
|
createContainer(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
@@ -28,7 +40,8 @@ func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
||||||
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
|
||||||
|
path = strings.TrimPrefix(path, "/api/containers/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
id := 0
|
id := 0
|
||||||
@@ -48,6 +61,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSnapshotAction && id == 0 {
|
if isSnapshotAction && id == 0 {
|
||||||
// For orphaned snapshots, resolve containerID from the snapshot itself
|
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
@@ -59,45 +76,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id = snapshot.ContainerID
|
id = snapshot.ContainerID
|
||||||
}
|
}
|
||||||
|
if isSnapshotAction {
|
||||||
|
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "start" && r.Method == http.MethodPost:
|
case action == "start" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "start")
|
HandleSingleTaskAction(w, r, id, "start")
|
||||||
case action == "stop" && r.Method == http.MethodPost:
|
case action == "stop" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "stop")
|
HandleSingleTaskAction(w, r, id, "stop")
|
||||||
case action == "restart" && r.Method == http.MethodPost:
|
case action == "restart" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "restart")
|
HandleSingleTaskAction(w, r, id, "restart")
|
||||||
case action == "reinstall" && r.Method == http.MethodPost:
|
case action == "reinstall" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:reinstall") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "reinstall")
|
HandleSingleTaskAction(w, r, id, "reinstall")
|
||||||
case action == "delete" && r.Method == http.MethodDelete:
|
case action == "delete" && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "container:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "delete")
|
HandleSingleTaskAction(w, r, id, "delete")
|
||||||
case action == "reset-password" && r.Method == http.MethodPost:
|
case action == "reset-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:password") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetSSHPassword(w, r, id)
|
resetSSHPassword(w, r, id)
|
||||||
case action == "usage" && r.Method == http.MethodGet:
|
case action == "usage" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getUsage(w, r, id)
|
getUsage(w, r, id)
|
||||||
case action == "traffic" && r.Method == http.MethodGet:
|
case action == "traffic" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getTraffic(w, r, id)
|
getTraffic(w, r, id)
|
||||||
case action == "traffic-reset" && r.Method == http.MethodPost:
|
case action == "traffic-reset" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetTraffic(w, r, id)
|
resetTraffic(w, r, id)
|
||||||
case action == "traffic-limit" && r.Method == http.MethodPut:
|
case action == "traffic-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateTrafficLimit(w, r, id)
|
updateTrafficLimit(w, r, id)
|
||||||
case action == "resource-limit" && r.Method == http.MethodPut:
|
case action == "resource-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateResourceLimit(w, r, id)
|
updateResourceLimit(w, r, id)
|
||||||
case action == "random-port" && r.Method == http.MethodGet:
|
case action == "random-port" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getRandomPort(w, r, id)
|
getRandomPort(w, r, id)
|
||||||
case action == "expiry" && r.Method == http.MethodPut:
|
case action == "expiry" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateExpiry(w, r, id)
|
updateExpiry(w, r, id)
|
||||||
case action == "ipv6" && r.Method == http.MethodPost:
|
case action == "ipv6" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "ipv6:assign") {
|
||||||
|
return
|
||||||
|
}
|
||||||
assignIPv6(w, r, id)
|
assignIPv6(w, r, id)
|
||||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
handleContainerSnapshots(w, r, id, action)
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
addPortMapping(w, r, id)
|
addPortMapping(w, r, id)
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case r.Method == http.MethodGet:
|
case r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getContainer(w, r, id)
|
getContainer(w, r, id)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
@@ -346,6 +423,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSubUserRequest(r) {
|
if isSubUserRequest(r) {
|
||||||
HandleEnabledImages(w, r)
|
HandleEnabledImages(w, r)
|
||||||
return
|
return
|
||||||
@@ -360,7 +440,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "dashboard:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
containers, _ := listByRuntime()
|
containers, _ := listByRuntime()
|
||||||
|
containers = filterContainersForRequest(r, containers)
|
||||||
running := 0
|
running := 0
|
||||||
stopped := 0
|
stopped := 0
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
@@ -384,6 +468,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "host:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
info := getHostInfo()
|
info := getHostInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
}
|
}
|
||||||
@@ -394,7 +481,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 +510,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 {
|
||||||
|
|||||||
+1198
-14
File diff suppressed because it is too large
Load Diff
+293
-93
@@ -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,23 +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"`
|
||||||
Desktop string `json:"desktop,omitempty"`
|
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 {
|
||||||
@@ -99,61 +221,78 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
Desktop: t.Desktop,
|
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"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -172,82 +311,130 @@ 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
|
||||||
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
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.
|
||||||
@@ -256,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -264,6 +454,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 {
|
||||||
@@ -302,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:toggle") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -328,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "ipv6:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
status := lxcManager.DetectIPv6Status()
|
status := lxcManager.DetectIPv6Status()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "routing:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
nat4Mappings := make([]nat4Route, 0)
|
nat4Mappings := make([]nat4Route, 0)
|
||||||
usedPorts := map[int]bool{}
|
usedPorts := map[int]bool{}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSecuritySettings returns or updates security automation settings.
|
// HandleSecuritySettings returns or updates security automation settings.
|
||||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
}})
|
}})
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
|
if !requireScope(w, r, "security:settings") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
AutoShutdown bool `json:"auto_shutdown"`
|
AutoShutdown bool `json:"auto_shutdown"`
|
||||||
}
|
}
|
||||||
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
}})
|
}})
|
||||||
@@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:check") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ensureScanner().checkContainer(c.Name, c.IP)
|
ensureScanner().checkContainer(c.Name, c.IP)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
||||||
@@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
containerName := r.URL.Query().Get("container")
|
containerName := r.URL.Query().Get("container")
|
||||||
if containerName == "" {
|
if containerName == "" {
|
||||||
@@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
||||||
}
|
}
|
||||||
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
critical := 0
|
critical := 0
|
||||||
high := 0
|
high := 0
|
||||||
medium := 0
|
medium := 0
|
||||||
low := 0
|
low := 0
|
||||||
alerts := mergedSecurityAlerts()
|
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
|
||||||
for _, a := range alerts {
|
for _, a := range alerts {
|
||||||
switch a.Severity {
|
switch a.Severity {
|
||||||
case "critical":
|
case "critical":
|
||||||
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
filtered := make([]SecurityAlert, 0, len(alerts))
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
filtered = append(filtered, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func mergedSecurityAlerts() []SecurityAlert {
|
func mergedSecurityAlerts() []SecurityAlert {
|
||||||
ss := ensureScanner()
|
ss := ensureScanner()
|
||||||
ss.mu.Lock()
|
ss.mu.Lock()
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Return in reverse (newest first)
|
// Return in reverse (newest first)
|
||||||
reversed := make([]LoginLog, len(loginLogs))
|
reversed := make([]LoginLog, len(loginLogs))
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||||
|
snapshots = filterSnapshotsForRequest(r, snapshots)
|
||||||
sortSnapshotsNewestFirst(snapshots)
|
sortSnapshotsNewestFirst(snapshots)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
||||||
}
|
}
|
||||||
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||||
switch {
|
switch {
|
||||||
case action == "snapshots" && r.Method == http.MethodGet:
|
case action == "snapshots" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainerSnapshots(w, r, containerID)
|
listContainerSnapshots(w, r, containerID)
|
||||||
case action == "snapshots" && r.Method == http.MethodPost:
|
case action == "snapshots" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createContainerSnapshot(w, r, containerID)
|
createContainerSnapshot(w, r, containerID)
|
||||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotSchedule(w, r, containerID)
|
updateSnapshotSchedule(w, r, containerID)
|
||||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotQuota(w, r, containerID)
|
updateSnapshotQuota(w, r, containerID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:restore") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "snapshot:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
default:
|
default:
|
||||||
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requestUser(r *http.Request) string {
|
func requestUser(r *http.Request) string {
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
return requestActor(r)
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
return "user:" + subUser
|
|
||||||
}
|
|
||||||
if username, _ := claims["username"].(string); username != "" {
|
|
||||||
return username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "admin"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||||
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
|||||||
return tj.Before(ti)
|
return tj.Before(ti)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
filtered := make([]config.Snapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
filtered = append(filtered, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:ssh") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
|||||||
return allowed, true
|
return allowed, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
|
||||||
|
return subUserAccess{}, false
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
|
||||||
|
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
|
||||||
|
for _, uuid := range ctx.ContainerUUIDs {
|
||||||
|
allowed.uuids[uuid] = true
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
|
||||||
|
legacy, ok := subUserAllowedContainers(r)
|
||||||
|
if ok {
|
||||||
|
return legacy, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subUserAllowedContainers(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||||
|
_, restricted := requestAllowedContainers(r)
|
||||||
|
return restricted
|
||||||
|
}
|
||||||
|
|
||||||
func containerByIdentifier(identifier string) *config.Container {
|
func containerByIdentifier(identifier string) *config.Container {
|
||||||
return config.FindContainerByIdentifier(identifier)
|
return config.FindContainerByIdentifier(identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c := containerByIdentifier(identifier)
|
c := containerByIdentifier(identifier)
|
||||||
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logs := config.AppConfig.AuditLogs
|
logs := config.AppConfig.AuditLogs
|
||||||
if logs == nil {
|
if logs == nil {
|
||||||
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
if path == "/api/tasks" && r.Method == http.MethodGet {
|
containerPrefix := "/api/containers/"
|
||||||
|
containerListPath := "/api/containers"
|
||||||
|
tasksPath := "/api/tasks"
|
||||||
|
if strings.HasPrefix(path, "/api/v1/") {
|
||||||
|
containerPrefix = "/api/v1/containers/"
|
||||||
|
containerListPath = "/api/v1/containers"
|
||||||
|
tasksPath = "/api/v1/tasks"
|
||||||
|
}
|
||||||
|
if path == tasksPath && r.Method == http.MethodGet {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "/api/containers" {
|
if path == containerListPath {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||||
return
|
return
|
||||||
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(path) > len("/api/containers/") {
|
if strings.HasPrefix(path, containerPrefix) {
|
||||||
rest := path[len("/api/containers/"):]
|
rest := path[len(containerPrefix):]
|
||||||
parts := splitPath(rest)
|
parts := splitPath(rest)
|
||||||
if len(parts) > 0 && parts[0] != "" {
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
@@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return containers
|
return containers
|
||||||
}
|
}
|
||||||
filtered := make([]config.Container, 0, len(containers))
|
filtered := make([]config.Container, 0, len(containers))
|
||||||
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
|
||||||
if !isSubUser {
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
filtered := make([]*Task, 0, len(tasks))
|
filtered := make([]*Task, 0, len(tasks))
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
if isTaskAllowedForRequest(r, task) {
|
||||||
filtered = append(filtered, task)
|
filtered = append(filtered, task)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.ContainerName != "" {
|
|
||||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
|
||||||
filtered = append(filtered, task)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Config.Name != "" {
|
|
||||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
|
||||||
filtered = append(filtered, task)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if task.ContainerName != "" {
|
||||||
|
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.Config.Name != "" {
|
||||||
|
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.UUID != "" && allowed.uuids[c.UUID] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.Name != "" && allowed.names[c.Name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSubUserBlockedAction(action string, method string) bool {
|
func isSubUserBlockedAction(action string, method string) bool {
|
||||||
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// HandleSubUserAction handles actions on a specific sub-user
|
// HandleSubUserAction handles actions on a specific sub-user
|
||||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
|
||||||
|
path = strings.TrimPrefix(path, "/api/sub-users/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
subUserID := parts[0]
|
subUserID := parts[0]
|
||||||
action := ""
|
action := ""
|
||||||
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "rotate-password" && r.Method == http.MethodPost:
|
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "subuser:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
password := generateRandomStr(16)
|
password := generateRandomStr(16)
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||||
target.PassHash = string(hash)
|
target.PassHash = string(hash)
|
||||||
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||||
|
|
||||||
case action == "audit-logs" && r.Method == http.MethodGet:
|
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter audit logs for this sub-user
|
// Filter audit logs for this sub-user
|
||||||
logs := filterSubUserAuditLogs(target.Username)
|
logs := filterSubUserAuditLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
case action == "login-logs" && r.Method == http.MethodGet:
|
case action == "login-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter login logs for this sub-user
|
// Filter login logs for this sub-user
|
||||||
logs := filterSubUserLoginLogs(target.Username)
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|||||||
@@ -11,19 +11,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SwapInfo struct {
|
type SwapInfo struct {
|
||||||
TotalMB int64 `json:"total_mb"`
|
TotalMB int64 `json:"total_mb"`
|
||||||
UsedMB int64 `json:"used_mb"`
|
UsedMB int64 `json:"used_mb"`
|
||||||
FreeMB int64 `json:"free_mb"`
|
FreeMB int64 `json:"free_mb"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SwapFile string `json:"swap_file"`
|
SwapFile string `json:"swap_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minSwapSizeMB = 128
|
||||||
|
maxSwapSizeMB = 262144
|
||||||
|
)
|
||||||
|
|
||||||
// HandleSwapInfo returns current swap status
|
// HandleSwapInfo returns current swap status
|
||||||
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info := getSwapInfo()
|
info := getSwapInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:manage") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"` // create, enable, disable, resize
|
Action string `json:"action"` // create, enable, disable, resize
|
||||||
SizeMB int `json:"size_mb"` // for create/resize
|
SizeMB int `json:"size_mb"` // for create/resize
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var msg string
|
var msg string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "create":
|
case "create":
|
||||||
if req.SizeMB <= 0 {
|
if req.SizeMB <= 0 {
|
||||||
req.SizeMB = 2048
|
req.SizeMB = 2048
|
||||||
}
|
}
|
||||||
err := createSwap(req.SizeMB)
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
if err != nil {
|
err = createSwap(req.SizeMB)
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
||||||
|
|
||||||
case "enable":
|
case "enable":
|
||||||
err := enableSwap()
|
err = enableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已启用"
|
msg = "SWAP 已启用"
|
||||||
|
|
||||||
case "disable":
|
case "disable":
|
||||||
err := disableSwap()
|
err = disableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已禁用"
|
msg = "SWAP 已禁用"
|
||||||
|
|
||||||
case "resize":
|
case "resize":
|
||||||
if req.SizeMB <= 0 {
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
|
err = disableSwap()
|
||||||
return
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = createSwap(req.SizeMB)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = enableSwap()
|
||||||
}
|
}
|
||||||
disableSwap()
|
|
||||||
createSwap(req.SizeMB)
|
|
||||||
enableSwap()
|
|
||||||
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info := getSwapInfo()
|
info := getSwapInfo()
|
||||||
|
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSwapSize(sizeMB int) error {
|
||||||
|
if sizeMB < minSwapSizeMB {
|
||||||
|
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
|
||||||
|
}
|
||||||
|
if sizeMB > maxSwapSizeMB {
|
||||||
|
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getSwapInfo() SwapInfo {
|
func getSwapInfo() SwapInfo {
|
||||||
info := SwapInfo{SwapFile: "/swapfile"}
|
info := SwapInfo{SwapFile: "/swapfile"}
|
||||||
|
|
||||||
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
|
|||||||
func enableSwap() error {
|
func enableSwap() error {
|
||||||
swapFile := "/swapfile"
|
swapFile := "/swapfile"
|
||||||
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
||||||
|
if getSwapInfo().Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("swap 文件不存在,请先创建")
|
return fmt.Errorf("swap 文件不存在,请先创建")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +203,7 @@ func disableSwap() error {
|
|||||||
cmd := exec.Command("swapoff", swapFile)
|
cmd := exec.Command("swapoff", swapFile)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(string(output), "No such") {
|
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
||||||
|
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
return q.enqueueBatchCreateList(configs)
|
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||||
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
|
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
var result []string
|
var result []string
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
cfgCopy := cfg
|
cfgCopy := cfg
|
||||||
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
Config: cfgCopy,
|
Config: cfgCopy,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
q.enqueueTask(task)
|
q.enqueueTask(task)
|
||||||
result = append(result, task.ID)
|
result = append(result, task.ID)
|
||||||
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
|
|||||||
TemplateID: t.TemplateID,
|
TemplateID: t.TemplateID,
|
||||||
Config: string(cfgJSON),
|
Config: string(cfgJSON),
|
||||||
User: t.User,
|
User: t.User,
|
||||||
|
IP: t.IP,
|
||||||
|
UserAgent: t.UserAgent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
config.SaveTasks(saved)
|
config.SaveTasks(saved)
|
||||||
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
name = c.Name
|
name = c.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine user from JWT claims
|
// Determine user from authenticated request context.
|
||||||
user := "admin"
|
user := requestActor(r)
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
user = "user:" + subUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
userAgent := r.Header.Get("User-Agent")
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
@@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "container:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAccessRestrictedRequest(r) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Containers []lxc.ContainerConfig `json:"containers"`
|
Containers []lxc.ContainerConfig `json:"containers"`
|
||||||
}
|
}
|
||||||
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
requestNames[name] = true
|
requestNames[name] = true
|
||||||
}
|
}
|
||||||
ids := globalQueue.EnqueueBatchCreate(req.Containers)
|
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Containers []int `json:"containers"`
|
Containers []int `json:"containers"`
|
||||||
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
|
var requiredScope string
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "stop":
|
case "stop":
|
||||||
taskType = TaskStop
|
taskType = TaskStop
|
||||||
|
requiredScope = "container:power"
|
||||||
case "restart":
|
case "restart":
|
||||||
taskType = TaskRestart
|
taskType = TaskRestart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "delete":
|
case "delete":
|
||||||
taskType = TaskDelete
|
taskType = TaskDelete
|
||||||
|
requiredScope = "container:delete"
|
||||||
|
case "reinstall":
|
||||||
|
if req.TemplateID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskType = TaskReinstall
|
||||||
|
requiredScope = "container:reinstall"
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, requiredScope) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range req.Containers {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// URL: /api/tasks/{id}
|
if !requireScope(w, r, "task:delete") {
|
||||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
return
|
||||||
|
}
|
||||||
|
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
|
||||||
|
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||||
|
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
|
||||||
if taskID == "" {
|
if taskID == "" {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalQueue.mu.Lock()
|
globalQueue.mu.Lock()
|
||||||
|
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
delete(globalQueue.tasks, taskID)
|
delete(globalQueue.tasks, taskID)
|
||||||
// Also remove from both queues if pending
|
// Also remove from both queues if pending
|
||||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||||
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "task:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
tasks := globalQueue.GetTasks()
|
tasks := globalQueue.GetTasks()
|
||||||
tasks = filterTasksForRequest(r, tasks)
|
tasks = filterTasksForRequest(r, tasks)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||||
@@ -691,6 +744,8 @@ func RestoreTasks() {
|
|||||||
TemplateID: st.TemplateID,
|
TemplateID: st.TemplateID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
User: st.User,
|
User: st.User,
|
||||||
|
IP: st.IP,
|
||||||
|
UserAgent: st.UserAgent,
|
||||||
}
|
}
|
||||||
if st.Status == "pending" || st.Status == "running" {
|
if st.Status == "pending" || st.Status == "running" {
|
||||||
// Reset running tasks back to pending so they get retried
|
// Reset running tasks back to pending so they get retried
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
type webVNCTicket struct {
|
type webVNCTicket struct {
|
||||||
ContainerName string
|
ContainerName string
|
||||||
ContainerUUID string
|
ContainerUUID string
|
||||||
|
Username string
|
||||||
SubUser bool
|
SubUser bool
|
||||||
|
ClientIP string
|
||||||
|
UserAgent string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:vnc") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username, isSubUser := vncRequesterIdentity(r)
|
||||||
ticket := randomHex(32)
|
ticket := randomHex(32)
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||||
webVNCTickets.items[ticket] = webVNCTicket{
|
webVNCTickets.items[ticket] = webVNCTicket{
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
ContainerUUID: c.UUID,
|
ContainerUUID: c.UUID,
|
||||||
SubUser: isSubUserRequest(r),
|
Username: username,
|
||||||
|
SubUser: isSubUser,
|
||||||
|
ClientIP: clientIP(r),
|
||||||
|
UserAgent: r.UserAgent(),
|
||||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
}
|
}
|
||||||
webVNCTickets.Unlock()
|
webVNCTickets.Unlock()
|
||||||
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
item, ok := consumeWebVNCTicket(ticket, containerName, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
|
||||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
|
||||||
|
|
||||||
done := make(chan string, 2)
|
done := make(chan string, 2)
|
||||||
var writeMu sync.Mutex
|
var writeMu sync.Mutex
|
||||||
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
reason := <-done
|
reason := <-done
|
||||||
_ = vncConn.Close()
|
_ = vncConn.Close()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vncRequesterIdentity(r *http.Request) (string, bool) {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
switch ctx.Type {
|
||||||
|
case authTypeSubUser:
|
||||||
|
return ctx.Username, true
|
||||||
|
case authTypeAPIKey:
|
||||||
|
return ctx.Actor, false
|
||||||
|
case authTypeAdmin:
|
||||||
|
return ctx.Username, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claims, ok := claimsFromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return "api-key", false
|
||||||
|
}
|
||||||
|
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
|
||||||
|
return subUser, true
|
||||||
|
}
|
||||||
|
if username, ok := claims["username"].(string); ok && username != "" {
|
||||||
|
return username, false
|
||||||
|
}
|
||||||
|
return "unknown", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func webVNCTicketFromRequest(r *http.Request) string {
|
func webVNCTicketFromRequest(r *http.Request) string {
|
||||||
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
defer webVNCTickets.Unlock()
|
defer webVNCTickets.Unlock()
|
||||||
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
|||||||
return webVNCTicket{}, false
|
return webVNCTicket{}, false
|
||||||
}
|
}
|
||||||
delete(webVNCTickets.items, ticket)
|
delete(webVNCTickets.items, ticket)
|
||||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
return item, item.ContainerName == containerName &&
|
||||||
|
item.ClientIP == clientIP(r) &&
|
||||||
|
item.UserAgent == r.UserAgent() &&
|
||||||
|
now.Before(item.ExpiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||||
|
|||||||
+245
-11
@@ -20,6 +20,12 @@ import (
|
|||||||
|
|
||||||
var manager = lxc.NewManager()
|
var manager = lxc.NewManager()
|
||||||
|
|
||||||
|
const (
|
||||||
|
clicdBackupDir = "/root/clicd-backups"
|
||||||
|
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
|
||||||
|
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||||
|
)
|
||||||
|
|
||||||
// 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 +204,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 +545,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 +562,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 +628,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 {
|
||||||
@@ -697,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
|
|||||||
|
|
||||||
destroyAllLXCContainers()
|
destroyAllLXCContainers()
|
||||||
destroyAllKVMDomains()
|
destroyAllKVMDomains()
|
||||||
|
removeCLICDLibvirtDefaultNetwork()
|
||||||
cleanupCLICDNetworking()
|
cleanupCLICDNetworking()
|
||||||
removeCLICDHostHooks()
|
removeCLICDHostHooks()
|
||||||
removeCLICDQuotaRecords()
|
removeCLICDQuotaRecords()
|
||||||
@@ -783,8 +842,60 @@ func removeKVMDomain(name string) {
|
|||||||
runQuiet("virsh", "undefine", name)
|
runQuiet("virsh", "undefine", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeCLICDLibvirtDefaultNetwork() {
|
||||||
|
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if libvirtDefaultUsedByNonCLICDDomain() {
|
||||||
|
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Removing CLICD-created libvirt default network...")
|
||||||
|
runQuiet("virsh", "net-destroy", "default")
|
||||||
|
runQuiet("virsh", "net-undefine", "default")
|
||||||
|
removePath(libvirtDefaultNetworkMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func libvirtDefaultUsedByNonCLICDDomain() bool {
|
||||||
|
if !commandExists("virsh") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
name := strings.TrimSpace(line)
|
||||||
|
if name == "" || isCLICDKVMDomain(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usesLibvirtDefaultNetwork(name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func usesLibvirtDefaultNetwork(domain string) bool {
|
||||||
|
out, err := exec.Command("virsh", "domiflist", domain).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
for _, field := range fields {
|
||||||
|
if field == "default" || field == "virbr0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupCLICDNetworking() {
|
func cleanupCLICDNetworking() {
|
||||||
removeCLICDNATRules()
|
removeCLICDNATRules()
|
||||||
|
cleanupCLICDIPv6Runtime()
|
||||||
|
cleanupCLICDIPv6BridgeRoutes()
|
||||||
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||||
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
||||||
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
||||||
@@ -793,6 +904,123 @@ func cleanupCLICDNetworking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDIPv6Runtime() {
|
||||||
|
if config.AppConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
cleanupCLICDContainerIPv6(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDContainerIPv6(c config.Container) {
|
||||||
|
bridge := "lxcbr0"
|
||||||
|
if c.IsKVM() {
|
||||||
|
bridge = "virbr0"
|
||||||
|
}
|
||||||
|
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
|
||||||
|
if mac != "" && bridge == "virbr0" {
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.IPv6) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := strings.TrimSpace(c.IPv6)
|
||||||
|
if slash := strings.Index(addr, "/"); slash >= 0 {
|
||||||
|
addr = addr[:slash]
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(c.IPv6)
|
||||||
|
if !strings.Contains(source, "/") {
|
||||||
|
source += "/128"
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIP6NATSource(source)
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||||
|
if mac != "" && bridge == "virbr0" {
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||||
|
}
|
||||||
|
|
||||||
|
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||||
|
if strings.TrimSpace(c.IPv6Interface) != "" {
|
||||||
|
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDIPv6BridgeRoutes() {
|
||||||
|
if !commandExists("ip") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||||
|
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
|
||||||
|
if err == nil {
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := fields[0]
|
||||||
|
addr := strings.TrimSuffix(source, "/128")
|
||||||
|
deleteIP6NATSource(source)
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||||
|
removeProxyNDPForAddress(addr)
|
||||||
|
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeProxyNDPForAddress(addr string) {
|
||||||
|
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || fields[0] != addr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(fields); i++ {
|
||||||
|
if fields[i] == "dev" {
|
||||||
|
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteIP6NATSource(source string) {
|
||||||
|
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := false
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || fields[0] != "-A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[0] = "-D"
|
||||||
|
args := append([]string{"-t", "nat"}, fields...)
|
||||||
|
deleted = runCommandOK("ip6tables", args...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func removeCLICDNATRules() {
|
func removeCLICDNATRules() {
|
||||||
if commandExists("iptables") {
|
if commandExists("iptables") {
|
||||||
for {
|
for {
|
||||||
@@ -822,6 +1050,12 @@ func deleteFilterRule(args ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteIP6FilterRule(args ...string) {
|
||||||
|
fullArgs := append([]string{"-D"}, args...)
|
||||||
|
for runCommandOK("ip6tables", fullArgs...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteIP6TablesBridgeRules(bridge string) {
|
func deleteIP6TablesBridgeRules(bridge string) {
|
||||||
if !commandExists("ip6tables") {
|
if !commandExists("ip6tables") {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ type SavedTask struct {
|
|||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
Config string `json:"config,omitempty"`
|
Config string `json:"config,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SavedLoginLog for persisting login logs
|
// SavedLoginLog for persisting login logs
|
||||||
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
|
|||||||
|
|
||||||
// SubUser represents a sub-user with access to specific containers
|
// SubUser represents a sub-user with access to specific containers
|
||||||
type ApiKeyConfig struct {
|
type ApiKeyConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
KeyHash string `json:"key_hash"`
|
KeyHash string `json:"key_hash"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
LastUsed string `json:"last_used"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteApiKey removes an API key by ID
|
// DeleteApiKey removes an API key by ID
|
||||||
@@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) {
|
|||||||
}
|
}
|
||||||
if AppConfig.ApiKeys == nil {
|
if AppConfig.ApiKeys == nil {
|
||||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||||
|
} else {
|
||||||
|
for i := range AppConfig.ApiKeys {
|
||||||
|
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
|
||||||
|
AppConfig.ApiKeys[i].Scopes = []string{"*"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if AppConfig.AuditLogs == nil {
|
if AppConfig.AuditLogs == nil {
|
||||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encodeStringSlice(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringSlice(raw string) []string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
func getDBPath() string {
|
func getDBPath() string {
|
||||||
cfgPath := getConfigPath()
|
cfgPath := getConfigPath()
|
||||||
ext := filepath.Ext(cfgPath)
|
ext := filepath.Ext(cfgPath)
|
||||||
@@ -185,7 +207,12 @@ func ensureSchema() error {
|
|||||||
prefix TEXT,
|
prefix TEXT,
|
||||||
ip_whitelist TEXT,
|
ip_whitelist TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
last_used TEXT
|
last_used TEXT,
|
||||||
|
scopes TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
disabled INTEGER,
|
||||||
|
container_uuids TEXT,
|
||||||
|
last_used_ip TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -210,6 +237,8 @@ func ensureSchema() error {
|
|||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
template_id TEXT,
|
template_id TEXT,
|
||||||
user TEXT,
|
user TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
cfg_name TEXT,
|
cfg_name TEXT,
|
||||||
cfg_virtualization TEXT,
|
cfg_virtualization TEXT,
|
||||||
cfg_template_id TEXT,
|
cfg_template_id TEXT,
|
||||||
@@ -263,9 +292,55 @@ func ensureSchema() error {
|
|||||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ensureSchemaMigrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSchemaMigrations() error {
|
||||||
|
for _, column := range []struct {
|
||||||
|
table string
|
||||||
|
name string
|
||||||
|
def string
|
||||||
|
}{
|
||||||
|
{"api_keys", "scopes", "TEXT"},
|
||||||
|
{"api_keys", "expires_at", "TEXT"},
|
||||||
|
{"api_keys", "disabled", "INTEGER"},
|
||||||
|
{"api_keys", "container_uuids", "TEXT"},
|
||||||
|
{"api_keys", "last_used_ip", "TEXT"},
|
||||||
|
{"tasks", "ip", "TEXT"},
|
||||||
|
{"tasks", "user_agent", "TEXT"},
|
||||||
|
} {
|
||||||
|
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureColumn(table, name, def string) error {
|
||||||
|
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var columnName, columnType string
|
||||||
|
var notNull, pk int
|
||||||
|
var defaultValue interface{}
|
||||||
|
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if columnName == name {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||||
meta := map[string]string{}
|
meta := map[string]string{}
|
||||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||||
@@ -468,8 +543,10 @@ func saveSubUsers(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func saveAPIKeys(tx *sql.Tx) error {
|
func saveAPIKeys(tx *sql.Tx) error {
|
||||||
for _, k := range AppConfig.ApiKeys {
|
for _, k := range AppConfig.ApiKeys {
|
||||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
scopes := encodeStringSlice(k.Scopes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
|
||||||
|
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
for _, task := range AppConfig.Tasks {
|
for _, task := range AppConfig.Tasks {
|
||||||
cfg := parseSavedTaskConfig(task.Config)
|
cfg := parseSavedTaskConfig(task.Config)
|
||||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv6, cfg_expires_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||||
@@ -669,7 +746,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
|
|||||||
result := []ApiKeyConfig{}
|
result := []ApiKeyConfig{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k ApiKeyConfig
|
var k ApiKeyConfig
|
||||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
|
||||||
|
var disabled sql.NullInt64
|
||||||
|
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
k.Scopes = decodeStringSlice(scopes.String)
|
||||||
|
k.ExpiresAt = expiresAt.String
|
||||||
|
k.Disabled = disabled.Valid && disabled.Int64 != 0
|
||||||
|
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
|
||||||
|
k.LastUsedIP = lastUsedIP.String
|
||||||
result = append(result, k)
|
result = append(result, k)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) {
|
|||||||
|
|
||||||
func loadTasks() ([]SavedTask, error) {
|
func loadTasks() ([]SavedTask, error) {
|
||||||
rows, err := db.Query(`SELECT
|
rows, err := db.Query(`SELECT
|
||||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||||
@@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv6 int
|
var assignIPv6 int
|
||||||
|
var ip, userAgent sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||||
@@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.IP = ip.String
|
||||||
|
t.UserAgent = userAgent.String
|
||||||
cfg.AssignIPv6 = assignIPv6 != 0
|
cfg.AssignIPv6 = assignIPv6 != 0
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
|
|||||||
+134
-18
@@ -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"
|
||||||
@@ -35,6 +37,7 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ipv6GatewayLinkLocal = "fe80::1"
|
const ipv6GatewayLinkLocal = "fe80::1"
|
||||||
|
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
type usageSample struct {
|
type usageSample struct {
|
||||||
CPUUsec uint64
|
CPUUsec uint64
|
||||||
@@ -114,7 +117,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 +152,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 +175,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 +194,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 +212,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 +236,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 +333,11 @@ func validateWindowsISO(path, target string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeQCOW2(src, target string) error {
|
func normalizeQCOW2(ctx context.Context, src, target string) error {
|
||||||
if err := requireCommand("qemu-img"); err != nil {
|
if err := requireCommand("qemu-img"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target)
|
cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "qcow2", src, target)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
|
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
|
||||||
}
|
}
|
||||||
@@ -647,7 +714,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
return m.StartContainer(id)
|
return m.StartContainer(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return "", fmt.Errorf("container not found: %d", id)
|
return "", fmt.Errorf("container not found: %d", id)
|
||||||
@@ -658,7 +725,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
|||||||
if c.Status != "running" {
|
if c.Status != "running" {
|
||||||
return "", fmt.Errorf("KVM VM must be running before password reset")
|
return "", fmt.Errorf("KVM VM must be running before password reset")
|
||||||
}
|
}
|
||||||
password := generateRandomString(16)
|
if strings.TrimSpace(password) == "" {
|
||||||
|
password = generateRandomString(16)
|
||||||
|
}
|
||||||
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
|
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
|
||||||
c.SSHPassword = password
|
c.SSHPassword = password
|
||||||
c.SSHHostKey = ""
|
c.SSHHostKey = ""
|
||||||
@@ -671,10 +740,14 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
|
|||||||
if err := m.EnsureSSH(id); err != nil {
|
if err := m.EnsureSSH(id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
chpasswdInput, err := chpasswdStdin("root", password)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -686,8 +759,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
|
||||||
@@ -1404,6 +1477,9 @@ func ensureDefaultNetwork() error {
|
|||||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||||
|
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 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 {
|
||||||
@@ -1459,7 +1535,7 @@ func ensureVirtioWinISO() error {
|
|||||||
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
|
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
|
||||||
tmp := virtioPath + ".tmp"
|
tmp := virtioPath + ".tmp"
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
if err := downloadFile(virtioURL, tmp); err != nil {
|
if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
return fmt.Errorf("failed to download virtio-win.iso: %v", err)
|
return fmt.Errorf("failed to download virtio-win.iso: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2157,7 +2233,7 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3123,7 +3199,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
|
|||||||
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
|
||||||
User: "root",
|
User: "root",
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: kvmHostKeyCallback(c),
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3310,6 +3386,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()
|
||||||
|
}
|
||||||
+156
-33
@@ -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 {
|
||||||
@@ -990,6 +993,27 @@ func parseSubIDRange(path, user string) (int, error) {
|
|||||||
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
|
||||||
|
// Unprivileged container root maps to a subordinate host UID, so it needs
|
||||||
|
// execute permission on the LXC parent and container directories to reach
|
||||||
|
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
|
||||||
|
// lxc-start to abort with "Could not access /var/lib/lxc".
|
||||||
|
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0001 != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, mode|0001); err != nil {
|
||||||
|
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||||
uidBase, gidBase, err := unprivilegedIDMap()
|
uidBase, gidBase, err := unprivilegedIDMap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -997,6 +1021,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
}
|
}
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||||
|
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := os.Stat(marker); err == nil {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1005,11 +1032,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 +1045,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 +1064,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 +1072,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 +1087,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 +1889,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 +1915,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 +1930,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 {
|
||||||
@@ -2138,13 +2258,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
// Clean port mappings temporarily
|
// Clean port mappings temporarily
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
|
||||||
// Destroy old LXC but keep config
|
// Destroy old LXC but keep config. lxc-destroy can leave the config
|
||||||
|
// directory behind when rootfs mounts are still present, which makes the
|
||||||
|
// following lxc-create fail with "Container already exists".
|
||||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
|
||||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||||
os.RemoveAll(rootfs)
|
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||||
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
|
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||||
|
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
|
||||||
|
|
||||||
// Create new container with same LXC name (preserves ID)
|
// Create new container with same LXC name (preserves ID)
|
||||||
cmd := exec.Command("lxc-create",
|
cmd := exec.Command("lxc-create",
|
||||||
@@ -2203,9 +2325,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
@@ -80,11 +80,13 @@ 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))))
|
||||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
@@ -112,6 +114,48 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
|
||||||
|
// Versioned external API routes
|
||||||
|
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||||
|
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||||
|
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
|
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
|
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||||
|
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||||
|
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||||
|
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||||
|
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
|
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||||
|
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||||
|
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||||
|
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||||
|
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
|
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
|
||||||
|
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||||
|
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||||
|
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||||
|
mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary))))
|
||||||
|
mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings)))
|
||||||
|
mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||||
|
mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||||
|
mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys)))
|
||||||
|
mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
api.HandleSwapInfo(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.HandleSwapManage(w, r)
|
||||||
|
})))
|
||||||
|
|
||||||
// Version (public)
|
// Version (public)
|
||||||
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.1"
|
Version = "1.1.5"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
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.1.1",
|
"version": "1.1.5",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
|
|||||||
import Security from './pages/Security'
|
import Security from './pages/Security'
|
||||||
import AuditLogs from './pages/AuditLogs'
|
import AuditLogs from './pages/AuditLogs'
|
||||||
import ApiIntegration from './pages/ApiIntegration'
|
import ApiIntegration from './pages/ApiIntegration'
|
||||||
|
import HostReport from './pages/HostReport'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
import Snapshots from './pages/Snapshots'
|
import Snapshots from './pages/Snapshots'
|
||||||
@@ -64,6 +65,7 @@ function App() {
|
|||||||
<Route path="routing" element={<Routing />} />
|
<Route path="routing" element={<Routing />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
|
<Route path="host-report" element={<HostReport />} />
|
||||||
<Route path="sub-users" element={<SubUserManagement />} />
|
<Route path="sub-users" element={<SubUserManagement />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
Code2,
|
||||||
|
Cpu,
|
||||||
Camera,
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||||
|
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||||
const isSecurityPage = location.pathname.startsWith('/security')
|
const isSecurityPage = location.pathname.startsWith('/security')
|
||||||
const isSettingsPage = location.pathname.startsWith('/settings')
|
const isSettingsPage = location.pathname.startsWith('/settings')
|
||||||
|
|
||||||
@@ -83,14 +85,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>
|
||||||
)}
|
)}
|
||||||
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>API 集成</span>}
|
{!collapsed && <span>API 集成</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/host-report')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
isHostReportPage
|
||||||
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cpu className="w-4 h-4" />
|
||||||
|
{!collapsed && <span>宿主机信息</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -21,6 +21,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureResizeObserver = () => {
|
||||||
|
if ('ResizeObserver' in window) return
|
||||||
|
|
||||||
|
class FallbackResizeObserver {
|
||||||
|
private target: Element | null = null
|
||||||
|
private timer = 0
|
||||||
|
private lastWidth = -1
|
||||||
|
private lastHeight = -1
|
||||||
|
|
||||||
|
constructor(private callback: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
observe = (target: Element) => {
|
||||||
|
this.target = target
|
||||||
|
this.check()
|
||||||
|
this.timer = window.setInterval(this.check, 250)
|
||||||
|
window.addEventListener('resize', this.check)
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve = () => this.disconnect()
|
||||||
|
|
||||||
|
disconnect = () => {
|
||||||
|
if (this.timer) window.clearInterval(this.timer)
|
||||||
|
this.timer = 0
|
||||||
|
window.removeEventListener('resize', this.check)
|
||||||
|
this.target = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private check = () => {
|
||||||
|
if (!this.target) return
|
||||||
|
const contentRect = this.target.getBoundingClientRect()
|
||||||
|
if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return
|
||||||
|
this.lastWidth = contentRect.width
|
||||||
|
this.lastHeight = contentRect.height
|
||||||
|
this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver
|
||||||
|
}
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
const target = screenRef.current
|
const target = screenRef.current
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
ensureResizeObserver()
|
||||||
|
const rfb = new RFB(target, getWebVNCUrl(containerName), {
|
||||||
|
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
|
||||||
|
})
|
||||||
rfb.scaleViewport = true
|
rfb.scaleViewport = true
|
||||||
rfb.resizeSession = false
|
rfb.resizeSession = false
|
||||||
rfb.focusOnClick = true
|
rfb.focusOnClick = true
|
||||||
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setErrorMsg('WebVNC 初始化失败')
|
const message = err instanceof Error && err.message ? `:${err.message}` : ''
|
||||||
|
setErrorMsg(`WebVNC 初始化失败${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
|
import {
|
||||||
import api, { APIResponse } from '../services/api'
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
Edit3,
|
||||||
|
Key,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import api, { APIResponse, Container } from '../services/api'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
interface ApiKeyItem {
|
interface ApiKeyItem {
|
||||||
@@ -11,132 +23,431 @@ interface ApiKeyItem {
|
|||||||
ip_whitelist: string
|
ip_whitelist: string
|
||||||
created_at: string
|
created_at: string
|
||||||
last_used: string
|
last_used: string
|
||||||
|
scopes?: string[]
|
||||||
|
expires_at?: string
|
||||||
|
disabled?: boolean
|
||||||
|
container_uuids?: string[]
|
||||||
|
last_used_ip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKeyForm {
|
||||||
|
name: string
|
||||||
|
ipWhitelist: string
|
||||||
|
scopes: string[]
|
||||||
|
expiresAt: string
|
||||||
|
disabled: boolean
|
||||||
|
containerUUIDs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = window.location.origin
|
const BASE_URL = window.location.origin
|
||||||
|
|
||||||
|
const scopeGroups = [
|
||||||
|
{
|
||||||
|
title: '总览与只读',
|
||||||
|
scopes: [
|
||||||
|
['dashboard:read', '控制面板'],
|
||||||
|
['host:read', '主机资源'],
|
||||||
|
['routing:read', '路由信息'],
|
||||||
|
['ipv6:read', 'IPv6 状态'],
|
||||||
|
['task:read', '任务列表'],
|
||||||
|
['image:read', '镜像列表'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '容器',
|
||||||
|
scopes: [
|
||||||
|
['container:read', '查看容器'],
|
||||||
|
['container:create', '创建容器'],
|
||||||
|
['container:power', '开关机/重启'],
|
||||||
|
['container:reinstall', '重装系统'],
|
||||||
|
['container:delete', '删除容器'],
|
||||||
|
['container:resize', '资源/到期'],
|
||||||
|
['container:traffic', '流量管理'],
|
||||||
|
['container:network', '端口映射'],
|
||||||
|
['container:password', '重置密码'],
|
||||||
|
['ipv6:assign', '分配 IPv6'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '快照与终端',
|
||||||
|
scopes: [
|
||||||
|
['snapshot:read', '查看快照'],
|
||||||
|
['snapshot:create', '创建快照'],
|
||||||
|
['snapshot:delete', '删除快照'],
|
||||||
|
['snapshot:restore', '恢复快照'],
|
||||||
|
['snapshot:schedule', '计划/配额'],
|
||||||
|
['terminal:ssh', 'WebSSH 票据'],
|
||||||
|
['terminal:vnc', 'WebVNC 票据'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平台管理',
|
||||||
|
scopes: [
|
||||||
|
['image:download', '下载镜像'],
|
||||||
|
['image:delete', '删除镜像'],
|
||||||
|
['image:toggle', '启停镜像'],
|
||||||
|
['security:read', '安全数据'],
|
||||||
|
['security:check', '安全扫描'],
|
||||||
|
['security:settings', '安全设置'],
|
||||||
|
['swap:read', 'Swap 信息'],
|
||||||
|
['swap:manage', 'Swap 管理'],
|
||||||
|
['subuser:read', '子用户列表'],
|
||||||
|
['subuser:create', '创建子用户'],
|
||||||
|
['subuser:update', '更新子用户'],
|
||||||
|
['audit:read', '操作日志'],
|
||||||
|
['loginlog:read', '登录日志'],
|
||||||
|
['apikey:read', 'Key 列表'],
|
||||||
|
['apikey:create', '创建 Key'],
|
||||||
|
['apikey:update', '更新 Key'],
|
||||||
|
['apikey:delete', '删除 Key'],
|
||||||
|
['admin:access', '管理员接口'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultReadScopes = [
|
||||||
|
'dashboard:read',
|
||||||
|
'container:read',
|
||||||
|
'task:read',
|
||||||
|
'image:read',
|
||||||
|
'snapshot:read',
|
||||||
|
'routing:read',
|
||||||
|
'ipv6:read',
|
||||||
|
'host:read',
|
||||||
|
]
|
||||||
|
|
||||||
|
const endpointGroups = [
|
||||||
|
{
|
||||||
|
title: '总览',
|
||||||
|
endpoints: [
|
||||||
|
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||||
|
['GET', '/api/v1/host-info', '主机资源'],
|
||||||
|
['GET', '/api/v1/routing', 'NAT/IPv6 路由'],
|
||||||
|
['GET', '/api/v1/ipv6/status', 'IPv6 状态'],
|
||||||
|
['GET', '/api/v1/tasks', '任务队列'],
|
||||||
|
['DELETE', '/api/v1/tasks/{task_id}', '删除任务'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '容器',
|
||||||
|
endpoints: [
|
||||||
|
['GET', '/api/v1/containers', '容器列表'],
|
||||||
|
['POST', '/api/v1/containers', '创建容器'],
|
||||||
|
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
|
||||||
|
['POST', '/api/v1/containers/{id}/start', '开机'],
|
||||||
|
['POST', '/api/v1/containers/{id}/stop', '关机'],
|
||||||
|
['POST', '/api/v1/containers/{id}/restart', '重启'],
|
||||||
|
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
||||||
|
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
||||||
|
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
||||||
|
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
||||||
|
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/resource-limit', '调整资源限制'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
||||||
|
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
||||||
|
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '端口与快照',
|
||||||
|
endpoints: [
|
||||||
|
['GET', '/api/v1/containers/{id}/random-port', '随机可用端口'],
|
||||||
|
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
|
||||||
|
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
|
||||||
|
['GET', '/api/v1/snapshots', '快照总览'],
|
||||||
|
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
|
||||||
|
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
|
||||||
|
['DELETE', '/api/v1/containers/{id}/snapshots/{snapshot_id}', '删除快照'],
|
||||||
|
['POST', '/api/v1/containers/{id}/snapshots/{snapshot_id}/restore', '恢复快照'],
|
||||||
|
['POST', '/api/v1/containers/{id}/snapshots/schedule', '计划快照'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/snapshots/quota', '快照配额'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平台管理',
|
||||||
|
endpoints: [
|
||||||
|
['GET', '/api/v1/templates', '模板列表'],
|
||||||
|
['GET', '/api/v1/images', '镜像管理列表'],
|
||||||
|
['POST', '/api/v1/images/download', '下载镜像'],
|
||||||
|
['POST', '/api/v1/images/cancel', '取消镜像下载'],
|
||||||
|
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
|
||||||
|
['PUT', '/api/v1/images/toggle', '启用/禁用镜像'],
|
||||||
|
['GET', '/api/v1/security/alerts', '安全告警'],
|
||||||
|
['POST', '/api/v1/security/check', '立即安全检查'],
|
||||||
|
['GET', '/api/v1/security/logs?container={name}', '安全连接日志'],
|
||||||
|
['GET', '/api/v1/security/summary', '安全汇总'],
|
||||||
|
['GET', '/api/v1/security/settings', '安全设置'],
|
||||||
|
['PUT', '/api/v1/security/settings', '更新安全设置'],
|
||||||
|
['GET', '/api/v1/swap', 'Swap 信息'],
|
||||||
|
['POST', '/api/v1/swap', '调整 Swap'],
|
||||||
|
['POST', '/api/v1/batch-create', '批量创建容器'],
|
||||||
|
['POST', '/api/v1/batch-action', '批量开关机/删除/重装'],
|
||||||
|
['POST', '/api/v1/ssh-ticket', '创建 WebSSH 票据'],
|
||||||
|
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号与日志',
|
||||||
|
endpoints: [
|
||||||
|
['POST', '/api/v1/sub-user/create', '创建子用户链接'],
|
||||||
|
['GET', '/api/v1/sub-users', '子用户列表'],
|
||||||
|
['POST', '/api/v1/sub-users/{id}/rotate-password', '轮换子用户密码'],
|
||||||
|
['GET', '/api/v1/sub-users/{id}/audit-logs', '子用户操作日志'],
|
||||||
|
['GET', '/api/v1/sub-users/{id}/login-logs', '子用户登录日志'],
|
||||||
|
['GET', '/api/v1/audit-logs', '操作日志'],
|
||||||
|
['GET', '/api/v1/login-logs', '登录日志'],
|
||||||
|
['GET', '/api/v1/api-keys', 'API Key 列表'],
|
||||||
|
['POST', '/api/v1/api-keys', '创建 API Key'],
|
||||||
|
['PATCH', '/api/v1/api-keys/{id}', '更新 API Key'],
|
||||||
|
['DELETE', '/api/v1/api-keys/{id}', '删除 API Key'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const emptyForm = (): ApiKeyForm => ({
|
||||||
|
name: '',
|
||||||
|
ipWhitelist: '',
|
||||||
|
scopes: [...defaultReadScopes],
|
||||||
|
expiresAt: '',
|
||||||
|
disabled: false,
|
||||||
|
containerUUIDs: [],
|
||||||
|
})
|
||||||
|
|
||||||
export default function ApiIntegration() {
|
export default function ApiIntegration() {
|
||||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||||
|
const [containers, setContainers] = useState<Container[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [editingKey, setEditingKey] = useState<ApiKeyItem | null>(null)
|
||||||
const [newIPs, setNewIPs] = useState('')
|
const [form, setForm] = useState<ApiKeyForm>(emptyForm)
|
||||||
const [creating, setCreating] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [newKey, setNewKey] = useState('')
|
const [newKey, setNewKey] = useState('')
|
||||||
const [showDocs, setShowDocs] = useState(true)
|
|
||||||
const [copiedKey, setCopiedKey] = useState(false)
|
const [copiedKey, setCopiedKey] = useState(false)
|
||||||
|
const [showDocs, setShowDocs] = useState(true)
|
||||||
|
|
||||||
const fetchKeys = useCallback(async () => {
|
const containerNameByUUID = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
containers.forEach(c => map.set(c.uuid, c.name))
|
||||||
|
return map
|
||||||
|
}, [containers])
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.get<APIResponse<ApiKeyItem[]>>('/api-keys')
|
const [keyRes, containerRes] = await Promise.all([
|
||||||
setKeys(res.data.data || [])
|
api.get<APIResponse<ApiKeyItem[]>>('/api-keys'),
|
||||||
} catch { /* ignore */ }
|
api.get<APIResponse<Container[]>>('/containers'),
|
||||||
finally { setLoading(false) }
|
])
|
||||||
|
setKeys(keyRes.data.data || [])
|
||||||
|
setContainers(containerRes.data.data || [])
|
||||||
|
} catch {
|
||||||
|
// keep the page usable if one request fails
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { fetchKeys() }, [fetchKeys])
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
const createKey = async () => {
|
const openCreate = () => {
|
||||||
if (!newName.trim()) return
|
setEditingKey(null)
|
||||||
setCreating(true)
|
setForm(emptyForm())
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (item: ApiKeyItem) => {
|
||||||
|
setEditingKey(item)
|
||||||
|
setForm({
|
||||||
|
name: item.name,
|
||||||
|
ipWhitelist: item.ip_whitelist || '',
|
||||||
|
scopes: item.scopes?.length ? item.scopes : ['*'],
|
||||||
|
expiresAt: toDateTimeLocal(item.expires_at || ''),
|
||||||
|
disabled: Boolean(item.disabled),
|
||||||
|
containerUUIDs: item.container_uuids || [],
|
||||||
|
})
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveKey = async () => {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
ip_whitelist: form.ipWhitelist.trim(),
|
||||||
|
scopes: form.scopes,
|
||||||
|
expires_at: fromDateTimeLocal(form.expiresAt),
|
||||||
|
disabled: form.disabled,
|
||||||
|
container_uuids: form.containerUUIDs,
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', {
|
if (editingKey) {
|
||||||
name: newName.trim(),
|
const res = await api.patch<APIResponse<ApiKeyItem>>(`/api-keys/${editingKey.id}`, payload)
|
||||||
ip_whitelist: newIPs.trim(),
|
if (res.data.data) {
|
||||||
})
|
setKeys(prev => prev.map(k => (k.id === editingKey.id ? res.data.data! : k)))
|
||||||
if (res.data.data?.key) {
|
}
|
||||||
setNewKey(res.data.data.key)
|
} else {
|
||||||
setKeys(prev => [res.data.data!, ...prev])
|
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', payload)
|
||||||
|
if (res.data.data) {
|
||||||
|
setKeys(prev => [res.data.data!, ...prev])
|
||||||
|
if (res.data.data.key) setNewKey(res.data.data.key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setNewName('')
|
setShowForm(false)
|
||||||
setNewIPs('')
|
} catch {
|
||||||
setShowCreate(false)
|
// axios interceptor handles auth; form stays open
|
||||||
} catch { /* ignore */ }
|
} finally {
|
||||||
finally { setCreating(false) }
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteKey = async (id: string) => {
|
const deleteKey = async (id: string) => {
|
||||||
if (!window.confirm('确定要删除此 API Key 吗?')) return
|
if (!window.confirm('确定删除这个 API Key 吗?')) return
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api-keys/${id}`)
|
await api.delete(`/api-keys/${id}`)
|
||||||
setKeys(prev => prev.filter(k => k.id !== id))
|
setKeys(prev => prev.filter(k => k.id !== id))
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyKey = async () => {
|
const copyKey = async () => {
|
||||||
const copied = await copyToClipboard(newKey)
|
const copied = await copyToClipboard(newKey)
|
||||||
if (copied) {
|
if (copied) {
|
||||||
setCopiedKey(true)
|
setCopiedKey(true)
|
||||||
setTimeout(() => setCopiedKey(false), 2000)
|
setTimeout(() => setCopiedKey(false), 1600)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleScope = (scope: string) => {
|
||||||
|
setForm(prev => {
|
||||||
|
if (scope === '*') {
|
||||||
|
return { ...prev, scopes: prev.scopes.includes('*') ? [...defaultReadScopes] : ['*'] }
|
||||||
|
}
|
||||||
|
const withoutAll = prev.scopes.filter(s => s !== '*')
|
||||||
|
const scopes = withoutAll.includes(scope)
|
||||||
|
? withoutAll.filter(s => s !== scope)
|
||||||
|
: [...withoutAll, scope]
|
||||||
|
return { ...prev, scopes: scopes.length ? scopes : [...defaultReadScopes] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleContainer = (uuid: string) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
containerUUIDs: prev.containerUUIDs.includes(uuid)
|
||||||
|
? prev.containerUUIDs.filter(item => item !== uuid)
|
||||||
|
: [...prev.containerUUIDs, uuid],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold text-black">API 集成</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-1">管理 API Key 与查看接口文档</p>
|
<h1 className="text-2xl font-bold text-black">API 集成</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">管理外部调用凭据、权限范围与平台 API 文档</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
创建 Key
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Keys */}
|
{newKey && (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
|
<div className="text-sm font-semibold text-amber-800">新的 API Key 已生成</div>
|
||||||
<Key className="w-4 h-4" />API Keys
|
<button onClick={() => setNewKey('')} className="rounded p-1 text-amber-700 hover:bg-amber-100" title="关闭">
|
||||||
</h2>
|
<X className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-2">
|
</button>
|
||||||
<button onClick={fetchKeys} className="p-1.5 text-gray-400 hover:text-black rounded" title="刷新"><RefreshCw className="w-3.5 h-3.5" /></button>
|
</div>
|
||||||
<button onClick={() => setShowCreate(true)} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Plus className="w-3.5 h-3.5" />创建 Key
|
<code className="min-w-0 flex-1 break-all rounded border border-amber-300 bg-white px-3 py-2 font-mono text-xs text-gray-800">
|
||||||
|
{newKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyKey}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-md bg-amber-600 px-3 py-2 text-xs text-white hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
{copiedKey ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
{copiedKey ? '已复制' : '复制'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{newKey && (
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<span className="text-sm font-semibold text-amber-800">新 API Key 已生成</span>
|
<Key className="h-4 w-4" />
|
||||||
<button onClick={() => setNewKey('')} className="text-amber-600 hover:text-amber-800 text-xs">关闭</button>
|
API Keys
|
||||||
</div>
|
</h2>
|
||||||
<p className="text-xs text-amber-700 mb-2">此 Key 仅显示一次,请立即复制保存。</p>
|
<button onClick={fetchData} className="rounded p-1.5 text-gray-400 hover:text-black" title="刷新">
|
||||||
<div className="flex items-center gap-2">
|
<RefreshCw className="h-4 w-4" />
|
||||||
<code className="flex-1 px-3 py-2 bg-white border border-amber-300 rounded text-xs font-mono text-gray-800 break-all">{newKey}</code>
|
</button>
|
||||||
<button onClick={copyKey} className="px-3 py-2 bg-amber-600 text-white rounded-md text-xs hover:bg-amber-700 whitespace-nowrap">
|
</div>
|
||||||
{copiedKey ? '已复制' : '复制'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-8 text-center text-sm text-gray-400">加载中...</div>
|
<div className="py-10 text-center text-sm text-gray-400">加载中...</div>
|
||||||
) : keys.length === 0 ? (
|
) : keys.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-gray-400">暂无 API Key,点击"创建 Key"开始</div>
|
<div className="py-10 text-center text-sm text-gray-400">暂无 API Key</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
|
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
|
||||||
<th className="px-3 py-2">名称</th>
|
<th className="px-4 py-3">名称</th>
|
||||||
<th className="px-3 py-2">Key 前缀</th>
|
<th className="px-4 py-3">权限</th>
|
||||||
<th className="px-3 py-2">IP 白名单</th>
|
<th className="px-4 py-3">绑定容器</th>
|
||||||
<th className="px-3 py-2">创建时间</th>
|
<th className="px-4 py-3">限制</th>
|
||||||
<th className="px-3 py-2">最后使用</th>
|
<th className="px-4 py-3">最后使用</th>
|
||||||
<th className="px-3 py-2 text-right">操作</th>
|
<th className="px-4 py-3 text-right">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{keys.map(k => (
|
{keys.map(item => (
|
||||||
<tr key={k.id} className="hover:bg-gray-50">
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
<td className="px-3 py-2.5 font-medium text-gray-800">{k.name}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{k.prefix}</td>
|
<div className="flex items-center gap-2">
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.ip_whitelist || '不限制'}</td>
|
<span className="font-medium text-gray-900">{item.name}</span>
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.created_at}</td>
|
{item.disabled && (
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.last_used || '未使用'}</td>
|
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">已禁用</span>
|
||||||
<td className="px-3 py-2.5 text-right">
|
)}
|
||||||
<button onClick={() => deleteKey(k.id)} className="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
|
</div>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<div className="mt-1 font-mono text-xs text-gray-400">{item.prefix}</div>
|
||||||
</button>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ScopeSummary scopes={item.scopes || ['*']} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">
|
||||||
|
{item.container_uuids?.length
|
||||||
|
? item.container_uuids.map(uuid => containerNameByUUID.get(uuid) || uuid).join('、')
|
||||||
|
: '全部容器'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">
|
||||||
|
<div>{item.ip_whitelist ? 'IP 白名单' : '不限 IP'}</div>
|
||||||
|
<div>{item.expires_at ? `到期 ${item.expires_at}` : '长期有效'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">
|
||||||
|
<div>{item.last_used || '从未使用'}</div>
|
||||||
|
{item.last_used_ip && <div className="font-mono text-[11px] text-gray-400">{item.last_used_ip}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button onClick={() => openEdit(item)} className="rounded p-1.5 text-gray-400 hover:text-black" title="编辑">
|
||||||
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteKey(item.id)} className="rounded p-1.5 text-gray-400 hover:text-red-600" title="删除">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -146,150 +457,201 @@ export default function ApiIntegration() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Key Modal */}
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
{showCreate && (
|
<button
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
onClick={() => setShowDocs(value => !value)}
|
||||||
<div className="absolute inset-0 bg-black/30" onClick={() => setShowCreate(false)} />
|
className="flex w-full items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 text-left"
|
||||||
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h3 className="text-base font-semibold text-black">创建 API Key</h3>
|
<ShieldCheck className="h-4 w-4" />
|
||||||
<button onClick={() => setShowCreate(false)} className="p-1 text-gray-400 hover:text-black rounded"><X className="w-4 h-4" /></button>
|
API 文档
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">名称</label>
|
|
||||||
<input
|
|
||||||
value={newName}
|
|
||||||
onChange={e => setNewName(e.target.value)}
|
|
||||||
placeholder="例如:自动化脚本、CI/CD"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
||||||
onKeyDown={e => e.key === 'Enter' && createKey()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-gray-500 mb-1">IP 白名单(每行一个,留空不限制)</label>
|
|
||||||
<textarea
|
|
||||||
value={newIPs}
|
|
||||||
onChange={e => setNewIPs(e.target.value)}
|
|
||||||
placeholder={`1.2.3.4\n10.0.0.0/24`}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono resize-none"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1">支持单个 IP 或 CIDR 网段。留空表示允许所有 IP。</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50">取消</button>
|
|
||||||
<button onClick={createKey} disabled={creating || !newName.trim()} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
|
|
||||||
{creating ? '创建中...' : '创建'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* API Documentation */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
|
|
||||||
<Code className="w-4 h-4" />API 文档
|
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={() => setShowDocs(!showDocs)} className="text-xs text-gray-500 hover:text-black">
|
{showDocs ? <ChevronUp className="h-4 w-4 text-gray-400" /> : <ChevronDown className="h-4 w-4 text-gray-400" />}
|
||||||
{showDocs ? '收起' : '展开'}
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDocs && (
|
{showDocs && (
|
||||||
<div className="space-y-6 text-sm">
|
<div className="space-y-6 p-5">
|
||||||
<section>
|
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
|
||||||
<h3 className="font-semibold text-black mb-2">认证方式</h3>
|
<div>curl -H "X-API-Key: clicd_sk_xxxx" {BASE_URL}/api/v1/containers</div>
|
||||||
<p className="text-gray-600 mb-3">所有 API 使用 <strong>POST</strong> 方法,在请求头中携带 API Key:</p>
|
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</div>
|
||||||
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs space-y-2">
|
</div>
|
||||||
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"X-API-Key: clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
|
|
||||||
<div className="text-gray-500"># 或 Bearer 方式</div>
|
|
||||||
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"Authorization: Bearer clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
{endpointGroups.map(group => (
|
||||||
<h3 className="font-semibold text-black mb-2">容器管理</h3>
|
<section key={group.title}>
|
||||||
<Endpoint method="POST" path="/api/containers/list" desc="获取容器列表" />
|
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
|
||||||
<Endpoint method="POST" path="/api/containers/detail" desc="获取容器详情" body='{"id": 1}' />
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
<Endpoint method="POST" path="/api/containers/create" desc="创建容器" body={`{\n "name": "my-container",\n "template_id": "ubuntu-noble",\n "vcpu": 2,\n "ram_mb": 1024,\n "disk_gb": 20,\n "network_bw_mbps": 100,\n "monthly_traffic_gb": 1000,\n "io_speed_mbps": 500\n}`} />
|
{group.endpoints.map(([method, path, desc]) => (
|
||||||
<Endpoint method="POST" path="/api/containers/start" desc="启动容器" body='{"id": 1}' />
|
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(280px,1fr)_180px]">
|
||||||
<Endpoint method="POST" path="/api/containers/stop" desc="停止容器" body='{"id": 1}' />
|
<span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span>
|
||||||
<Endpoint method="POST" path="/api/containers/restart" desc="重启容器" body='{"id": 1}' />
|
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
|
||||||
<Endpoint method="POST" path="/api/containers/delete" desc="删除容器" body='{"id": 1}' />
|
<span className="text-gray-500">{desc}</span>
|
||||||
<Endpoint method="POST" path="/api/containers/reinstall" desc="重装系统" body='{"id": 1, "template_id": "debian-bookworm"}' />
|
</div>
|
||||||
<Endpoint method="POST" path="/api/containers/usage" desc="获取资源用量" body='{"id": 1}' />
|
))}
|
||||||
<Endpoint method="POST" path="/api/containers/traffic" desc="获取流量统计" body='{"id": 1}' />
|
</div>
|
||||||
<Endpoint method="POST" path="/api/containers/traffic-reset" desc="重置流量" body='{"id": 1}' />
|
</section>
|
||||||
<Endpoint method="POST" path="/api/containers/traffic-limit" desc="修改流量限制" body='{"id": 1, "traffic_mode": "total", "monthly_traffic_gb": 1000}' />
|
))}
|
||||||
<Endpoint method="POST" path="/api/containers/resource-limit" desc="修改资源限制" body='{"id": 1, "vcpu": 2, "ram_mb": 2048, "io_speed_mbps": 500, "network_bw_mbps": 100}' />
|
|
||||||
<Endpoint method="POST" path="/api/containers/expiry" desc="修改到期时间" body='{"id": 1, "expires_at": "2026-12-31 23:59:59"}' />
|
|
||||||
<Endpoint method="POST" path="/api/containers/reset-password" desc="重置 SSH 密码" body='{"id": 1}' />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="font-semibold text-black mb-2">端口映射</h3>
|
|
||||||
<Endpoint method="POST" path="/api/containers/port-mappings/add" desc="添加映射" body='{"id": 1, "container_port": 8080, "host_port": 8080, "protocol": "tcp", "description": "Web"}' />
|
|
||||||
<Endpoint method="POST" path="/api/containers/port-mappings/update" desc="更新映射" body='{"id": 1, "index": 0, "container_port": 8080, "host_port": 9090, "protocol": "tcp", "description": "API"}' />
|
|
||||||
<Endpoint method="POST" path="/api/containers/port-mappings/delete" desc="删除映射" body='{"id": 1, "index": 0}' />
|
|
||||||
<Endpoint method="POST" path="/api/containers/random-port" desc="获取随机空闲端口" body='{"id": 1}' />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="font-semibold text-black mb-2">仪表盘 & 系统</h3>
|
|
||||||
<Endpoint method="POST" path="/api/dashboard" desc="容器统计概览" />
|
|
||||||
<Endpoint method="POST" path="/api/host-info" desc="宿主机资源信息" />
|
|
||||||
<Endpoint method="POST" path="/api/templates" desc="可用系统模板列表" />
|
|
||||||
<Endpoint method="POST" path="/api/tasks" desc="任务队列" />
|
|
||||||
<Endpoint method="POST" path="/api/tasks/delete" desc="删除任务" body='{"id": "task-1"}' />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="font-semibold text-black mb-2">批量操作</h3>
|
|
||||||
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
|
|
||||||
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="font-semibold text-black mb-2">子用户 & 日志</h3>
|
|
||||||
<Endpoint method="POST" path="/api/sub-user/create" desc="创建管理链接" body='{"container_name": "my-container"}' />
|
|
||||||
<Endpoint method="POST" path="/api/audit-logs" desc="操作日志" />
|
|
||||||
<Endpoint method="POST" path="/api/login-logs" desc="登录日志" />
|
|
||||||
<Endpoint method="POST" path="/api/security/alerts" desc="安全告警" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="font-semibold text-black mb-2">响应格式</h3>
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 font-mono text-xs text-gray-700">
|
|
||||||
{`{
|
|
||||||
"success": true,
|
|
||||||
"message": "操作成功",
|
|
||||||
"data": { ... }
|
|
||||||
}`}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Endpoint({ method, path, desc, body }: { method: string; path: string; desc: string; body?: string }) {
|
{showForm && (
|
||||||
return (
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="flex items-start gap-3 py-2 border-b border-gray-50">
|
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
|
||||||
<span className="shrink-0 px-1.5 py-0.5 rounded border text-[10px] font-mono font-bold bg-blue-50 text-blue-700 border-blue-200">{method}</span>
|
<div className="relative flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||||
<code className="shrink-0 text-xs text-gray-800 font-mono">{path}</code>
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||||
<span className="text-xs text-gray-500 min-w-0">{desc}</span>
|
<h3 className="text-base font-semibold text-black">{editingKey ? '编辑 API Key' : '创建 API Key'}</h3>
|
||||||
{body && (
|
<button onClick={() => setShowForm(false)} className="rounded p-1 text-gray-400 hover:text-black" title="关闭">
|
||||||
<details className="text-xs">
|
<X className="h-4 w-4" />
|
||||||
<summary className="text-gray-400 cursor-pointer hover:text-gray-600">Body</summary>
|
</button>
|
||||||
<pre className="mt-1 p-2 bg-gray-50 rounded text-xs text-gray-600 overflow-x-auto">{body}</pre>
|
</div>
|
||||||
</details>
|
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1fr_1.2fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-gray-500">名称</span>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
placeholder="CI/CD、计费系统、自动化脚本"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-gray-500">IP 白名单</span>
|
||||||
|
<textarea
|
||||||
|
value={form.ipWhitelist}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, ipWhitelist: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className="w-full resize-none rounded-md border border-gray-300 px-3 py-2 font-mono text-sm"
|
||||||
|
placeholder={`1.2.3.4\n10.0.0.0/24`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-gray-500">过期时间</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.expiresAt}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, expiresAt: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.disabled}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, disabled: e.target.checked }))}
|
||||||
|
className="h-4 w-4 accent-black"
|
||||||
|
/>
|
||||||
|
禁用这个 Key
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs text-gray-500">绑定容器</div>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-gray-200 p-2">
|
||||||
|
<label className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.containerUUIDs.length === 0}
|
||||||
|
onChange={() => setForm(prev => ({ ...prev, containerUUIDs: [] }))}
|
||||||
|
className="h-4 w-4 accent-black"
|
||||||
|
/>
|
||||||
|
全部容器
|
||||||
|
</label>
|
||||||
|
{containers.map(container => (
|
||||||
|
<label key={container.uuid} className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.containerUUIDs.includes(container.uuid)}
|
||||||
|
onChange={() => toggleContainer(container.uuid)}
|
||||||
|
className="h-4 w-4 accent-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">{container.name}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-gray-400">{container.uuid.slice(0, 8)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs text-gray-500">权限范围</div>
|
||||||
|
<button onClick={() => toggleScope('*')} className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
|
||||||
|
{form.scopes.includes('*') ? '取消全权限' : '全权限'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{scopeGroups.map(group => (
|
||||||
|
<div key={group.title}>
|
||||||
|
<div className="mb-2 text-xs font-medium text-gray-700">{group.title}</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{group.scopes.map(([scope, label]) => (
|
||||||
|
<label key={scope} className="flex items-center gap-2 rounded border border-gray-200 px-2 py-2 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.scopes.includes('*') || form.scopes.includes(scope)}
|
||||||
|
disabled={form.scopes.includes('*')}
|
||||||
|
onChange={() => toggleScope(scope)}
|
||||||
|
className="h-4 w-4 accent-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:block">{scope}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
|
||||||
|
<button onClick={() => setShowForm(false)} className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveKey}
|
||||||
|
disabled={saving || !form.name.trim()}
|
||||||
|
className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScopeSummary({ scopes }: { scopes: string[] }) {
|
||||||
|
if (scopes.includes('*')) {
|
||||||
|
return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600">全权限</span>
|
||||||
|
}
|
||||||
|
const visible = scopes.slice(0, 3)
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-xs flex-wrap gap-1">
|
||||||
|
{visible.map(scope => (
|
||||||
|
<span key={scope} className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-600">
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{scopes.length > visible.length && (
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">+{scopes.length - visible.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocal(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.replace(' ', 'T').slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDateTimeLocal(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
return `${value.replace('T', ' ')}:00`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
RefreshCw,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { getHostReport, HostProbeReport } from '../services/api'
|
||||||
|
|
||||||
|
export default function HostReport() {
|
||||||
|
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchReport = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getHostReport()
|
||||||
|
setReport(res.data.data || null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport()
|
||||||
|
}, [fetchReport])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !report ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
||||||
|
) : !report ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
||||||
|
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
||||||
|
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||||
|
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProbeSection title="系统概览">
|
||||||
|
<ProbeRows rows={[
|
||||||
|
['主机名', report.hostname],
|
||||||
|
['操作系统', report.os],
|
||||||
|
['内核', report.kernel],
|
||||||
|
['生成时间', report.generated_at],
|
||||||
|
['CPU 架构', report.cpu.architecture],
|
||||||
|
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
||||||
|
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
||||||
|
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
||||||
|
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
||||||
|
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
||||||
|
]} />
|
||||||
|
</ProbeSection>
|
||||||
|
|
||||||
|
<ProbeSection title="公网与路由">
|
||||||
|
<ProbeRows rows={[
|
||||||
|
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
||||||
|
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
||||||
|
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
||||||
|
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
||||||
|
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
||||||
|
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
||||||
|
]} />
|
||||||
|
</ProbeSection>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="内存条"
|
||||||
|
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
||||||
|
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
||||||
|
rows={(report.memory.modules || []).map(m => [
|
||||||
|
m.locator || '-',
|
||||||
|
m.size || '-',
|
||||||
|
m.type || '-',
|
||||||
|
m.speed || '-',
|
||||||
|
m.manufacturer || '-',
|
||||||
|
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="硬盘与健康"
|
||||||
|
empty="未检测到硬盘"
|
||||||
|
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
||||||
|
rows={report.disks.map(d => [
|
||||||
|
`${d.path || d.name}\n${d.serial || ''}`,
|
||||||
|
d.model || '-',
|
||||||
|
formatBytes(d.size_bytes),
|
||||||
|
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
||||||
|
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||||
|
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
||||||
|
formatLifeUsed(d.smart?.life_used_percent),
|
||||||
|
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
||||||
|
formatBytes(d.smart?.read_data_bytes || 0),
|
||||||
|
formatBytes(d.smart?.written_data_bytes || 0),
|
||||||
|
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
||||||
|
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="网卡"
|
||||||
|
empty="未检测到网卡"
|
||||||
|
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
||||||
|
rows={report.network_interfaces.map(n => [
|
||||||
|
`${n.name}\n${n.model || ''}`,
|
||||||
|
n.state || '-',
|
||||||
|
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
|
||||||
|
n.mac || '-',
|
||||||
|
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
|
||||||
|
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="显卡"
|
||||||
|
empty="未检测到显卡"
|
||||||
|
headers={['名称', '厂商', '类型', '驱动']}
|
||||||
|
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeSection title="环境支持">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{report.environment.map(item => (
|
||||||
|
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||||
|
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||||
|
{item.required ? '必要' : '可选'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ProbeSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
|
||||||
|
<div className="font-medium text-gray-500">{label}</div>
|
||||||
|
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
|
||||||
|
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="align-top">
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
|
||||||
|
{cell || '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
|
||||||
|
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
|
||||||
|
const parts = [
|
||||||
|
prefix.prefix || '-',
|
||||||
|
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
|
||||||
|
prefix.gateway ? `via ${prefix.gateway}` : '',
|
||||||
|
prefix.interface ? `dev ${prefix.interface}` : '',
|
||||||
|
prefix.source ? `[${prefix.source}]` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
|
||||||
|
const value = prefix.prefix || prefix.address || '-'
|
||||||
|
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
|
||||||
|
return `${cidr} via ${prefix.gateway || '-'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMB(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
|
||||||
|
return `${value} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
let next = value
|
||||||
|
let index = 0
|
||||||
|
while (next >= 1024 && index < units.length - 1) {
|
||||||
|
next /= 1024
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLifeUsed(value?: number) {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPowerOnDays(hours: number) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const rest = hours % 24
|
||||||
|
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommands(read?: number, write?: number) {
|
||||||
|
if (!read && !write) return '-'
|
||||||
|
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||||
|
return `${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
||||||
|
const rows: string[] = []
|
||||||
|
if (wear) rows.push(`磨损 ${wear}`)
|
||||||
|
if (erase) rows.push(`擦写 ${erase}`)
|
||||||
|
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
||||||
|
return rows.length ? rows.join('\n') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeModeLabel(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case 'kvm_lxc':
|
||||||
|
return '支持 KVM + LXC'
|
||||||
|
case 'lxc_only':
|
||||||
|
return '仅支持 LXC'
|
||||||
|
default:
|
||||||
|
return '未满足运行环境'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diskHealthLabel(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case 'ok':
|
||||||
|
return '健康'
|
||||||
|
case 'failed':
|
||||||
|
return '异常'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpuTypeLabel(value: string) {
|
||||||
|
if (value === 'integrated') return '核显'
|
||||||
|
if (value === 'discrete') return '独显'
|
||||||
|
return value || '-'
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Login() {
|
|||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4">
|
<div className="w-16 h-16 flex items-center justify-center mb-4">
|
||||||
<AppIcon className="w-10 h-10" />
|
<AppIcon className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
|
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
|
||||||
@@ -106,7 +106,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.1</p>
|
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.5</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
|
import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
@@ -20,7 +20,6 @@ export default function Settings() {
|
|||||||
const [oldPwd, setOldPwd] = useState('')
|
const [oldPwd, setOldPwd] = useState('')
|
||||||
const [newPwd, setNewPwd] = useState('')
|
const [newPwd, setNewPwd] = useState('')
|
||||||
const [newUsername, setNewUsername] = useState('')
|
const [newUsername, setNewUsername] = useState('')
|
||||||
const [pwdForUser, setPwdForUser] = useState('')
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,30 +32,45 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
|
useEffect(() => {
|
||||||
|
fetchLogs()
|
||||||
|
const timer = setInterval(fetchLogs, 15000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [fetchLogs])
|
||||||
|
|
||||||
const handleSaveAccount = async () => {
|
const handleSaveAccount = async () => {
|
||||||
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
|
if (!oldPwd) {
|
||||||
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
|
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||||
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
|
return
|
||||||
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
|
}
|
||||||
|
if (!newPwd && !newUsername) {
|
||||||
|
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPwd && newPwd.length < 6) {
|
||||||
|
dialog.alert('提示', '新密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newUsername && newUsername.length < 3) {
|
||||||
|
dialog.alert('提示', '用户名至少 3 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let results: string[] = []
|
const results: string[] = []
|
||||||
try {
|
try {
|
||||||
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
|
|
||||||
if (newUsername) {
|
if (newUsername) {
|
||||||
const res = await changeUsername(newUsername, oldPwd)
|
const res = await changeUsername(newUsername, oldPwd)
|
||||||
if (res.data.success) results.push('用户名已修改')
|
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
|
||||||
else results.push('用户名修改失败')
|
|
||||||
}
|
}
|
||||||
if (newPwd) {
|
if (newPwd) {
|
||||||
const res = await changePassword(oldPwd, newPwd)
|
const res = await changePassword(oldPwd, newPwd)
|
||||||
if (res.data.success) results.push('密码已修改')
|
results.push(res.data.success ? '密码已修改' : '密码修改失败')
|
||||||
else results.push('密码修改失败')
|
|
||||||
}
|
}
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
dialog.alert('完成', results.join(',') + '。下次登录生效')
|
dialog.alert('完成', `${results.join(',')}。下次登录生效`)
|
||||||
setOldPwd(''); setNewPwd(''); setNewUsername('')
|
setOldPwd('')
|
||||||
|
setNewPwd('')
|
||||||
|
setNewUsername('')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { message?: string } } }
|
const e = err as { response?: { data?: { message?: string } } }
|
||||||
@@ -67,48 +81,48 @@ export default function Settings() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(logs.length / pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">账号管理与登录日志</p>
|
<p className="mt-1 text-sm text-gray-500">账号管理与登录日志</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Settings */}
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
<UserCog className="h-4 w-4" />账号设置
|
||||||
<UserCog className="w-4 h-4" />账号设置
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">当前用户名</label>
|
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||||
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">新用户名(留空则不修改)</label>
|
<label className="mb-1 block text-xs text-gray-500">新用户名(留空则不修改)</label>
|
||||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
|
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-100 pt-3">
|
<div className="border-t border-gray-100 pt-3">
|
||||||
<label className="block text-xs text-gray-500 mb-1">新密码(留空则不修改)</label>
|
<label className="mb-1 block text-xs text-gray-500">新密码(留空则不修改)</label>
|
||||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
|
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">当前密码(验证身份)</label>
|
<label className="mb-1 block text-xs text-gray-500">当前密码(验证身份)</label>
|
||||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
|
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800">保存修改</button>
|
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Logs */}
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
<LogIn className="h-4 w-4" />登录日志
|
||||||
<LogIn className="w-4 h-4" />登录日志
|
|
||||||
</h2>
|
</h2>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||||
@@ -117,23 +131,23 @@ export default function Settings() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-gray-400 border-b border-gray-100">
|
<tr className="border-b border-gray-100 text-gray-400">
|
||||||
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" />时间</span></th>
|
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />时间</span></th>
|
||||||
<th className="text-left py-2 font-medium">用户名</th>
|
<th className="py-2 text-left font-medium">用户名</th>
|
||||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
|
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
|
||||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" />设备</span></th>
|
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" />设备</span></th>
|
||||||
<th className="text-left py-2 font-medium">结果</th>
|
<th className="py-2 text-left font-medium">结果</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
|
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
|
||||||
<tr key={i}>
|
<tr key={`${log.time}-${index}`}>
|
||||||
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
|
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
|
||||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||||
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
|
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
|
||||||
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||||
{log.success ? '成功' : '失败'}
|
{log.success ? '成功' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -143,23 +157,22 @@ export default function Settings() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{logs.length > pageSize && (
|
{logs.length > pageSize && (
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{Math.ceil(logs.length / pageSize)} 页</span>
|
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{totalPages} 页</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">首页</button>
|
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||||
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
const totalPages = Math.ceil(logs.length / pageSize)
|
|
||||||
let start = Math.max(1, logPage - 2)
|
let start = Math.max(1, logPage - 2)
|
||||||
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
||||||
const page = start + i
|
const page = start + i
|
||||||
if (page > totalPages) return null
|
if (page > totalPages) return null
|
||||||
return (
|
return (
|
||||||
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
<button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||||
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">末页</button>
|
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,7 +184,6 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatUA(ua: string): string {
|
function formatUA(ua: string): string {
|
||||||
// Extract browser/OS info from UA string
|
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (ua.includes('Windows NT')) parts.push('Windows')
|
if (ua.includes('Windows NT')) parts.push('Windows')
|
||||||
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ export interface IPv6Status {
|
|||||||
prefixes: IPv6PrefixInfo[]
|
prefixes: IPv6PrefixInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPv4PrefixInfo {
|
||||||
|
interface: string
|
||||||
|
address: string
|
||||||
|
prefix: string
|
||||||
|
prefix_len: number
|
||||||
|
subnet_mask: string
|
||||||
|
gateway: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
total_containers: number
|
total_containers: number
|
||||||
running: number
|
running: number
|
||||||
@@ -161,6 +171,93 @@ export interface HostInfo {
|
|||||||
load: { load1: number; load5: number; load15: number }
|
load: { load1: number; load5: number; load15: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostProbeReport {
|
||||||
|
generated_at: string
|
||||||
|
hostname: string
|
||||||
|
kernel: string
|
||||||
|
os: string
|
||||||
|
cpu: {
|
||||||
|
model: string
|
||||||
|
cores: number
|
||||||
|
threads: number
|
||||||
|
architecture: string
|
||||||
|
flags: string[]
|
||||||
|
has_integrated_gpu: boolean
|
||||||
|
virtualization: boolean
|
||||||
|
virtualization_key: string
|
||||||
|
}
|
||||||
|
memory: {
|
||||||
|
total_mb: number
|
||||||
|
used_mb: number
|
||||||
|
free_mb: number
|
||||||
|
modules: Array<{
|
||||||
|
locator: string
|
||||||
|
size: string
|
||||||
|
type: string
|
||||||
|
speed: string
|
||||||
|
manufacturer: string
|
||||||
|
part_number: string
|
||||||
|
serial_number: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
disks: Array<{
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
model: string
|
||||||
|
serial: string
|
||||||
|
size_bytes: number
|
||||||
|
type: string
|
||||||
|
rotational: boolean
|
||||||
|
mountpoints: string[]
|
||||||
|
health: string
|
||||||
|
health_detail: string
|
||||||
|
smart?: {
|
||||||
|
available: boolean
|
||||||
|
life_used_percent?: number
|
||||||
|
power_on_hours?: number
|
||||||
|
power_cycle_count?: number
|
||||||
|
read_data_bytes?: number
|
||||||
|
written_data_bytes?: number
|
||||||
|
read_commands?: number
|
||||||
|
write_commands?: number
|
||||||
|
wear_leveling_count?: string
|
||||||
|
erase_count?: string
|
||||||
|
media_errors?: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
network_interfaces: Array<{
|
||||||
|
name: string
|
||||||
|
mac: string
|
||||||
|
state: string
|
||||||
|
speed_mbps: number
|
||||||
|
driver: string
|
||||||
|
model: string
|
||||||
|
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
}>
|
||||||
|
public_ipv4: string[]
|
||||||
|
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv4_prefixes: IPv4PrefixInfo[]
|
||||||
|
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv6_prefixes: IPv6PrefixInfo[]
|
||||||
|
gateways: Array<{ family: string; interface: string; gateway: string }>
|
||||||
|
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
|
||||||
|
runtime: {
|
||||||
|
lxc_available: boolean
|
||||||
|
kvm_available: boolean
|
||||||
|
dev_kvm: boolean
|
||||||
|
nested_virtualization: boolean
|
||||||
|
nested_detail: string
|
||||||
|
support_mode: string
|
||||||
|
}
|
||||||
|
system: {
|
||||||
|
uptime_seconds: number
|
||||||
|
uptime_text: string
|
||||||
|
process_count: number
|
||||||
|
}
|
||||||
|
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContainerUsage {
|
export interface ContainerUsage {
|
||||||
memory_usage_bytes: number
|
memory_usage_bytes: number
|
||||||
memory_total_bytes?: number
|
memory_total_bytes?: number
|
||||||
@@ -245,8 +342,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
|
|||||||
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
|
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
|
||||||
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
|
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
|
||||||
|
|
||||||
export const resetSSHPassword = (id: ContainerIdentifier) =>
|
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
||||||
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
|
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
|
||||||
|
|
||||||
export const getContainerUsage = (id: ContainerIdentifier) =>
|
export const getContainerUsage = (id: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
||||||
@@ -358,6 +455,11 @@ export interface ImageInfo {
|
|||||||
downloaded: boolean
|
downloaded: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
downloading: boolean
|
downloading: boolean
|
||||||
|
progress: number
|
||||||
|
downloaded_bytes: number
|
||||||
|
total_bytes: number
|
||||||
|
stage?: string
|
||||||
|
error?: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
manual_path?: string
|
manual_path?: string
|
||||||
desktop?: string
|
desktop?: string
|
||||||
@@ -367,7 +469,10 @@ export const getImages = () =>
|
|||||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||||
|
|
||||||
export const downloadImage = (templateId: string) =>
|
export const downloadImage = (templateId: string) =>
|
||||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
|
api.post<APIResponse>('/images/download', { template_id: templateId })
|
||||||
|
|
||||||
|
export const cancelImageDownload = (templateId: string) =>
|
||||||
|
api.post<APIResponse>('/images/cancel', { template_id: templateId })
|
||||||
|
|
||||||
export const deleteImage = (templateId: string) =>
|
export const deleteImage = (templateId: string) =>
|
||||||
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
||||||
@@ -385,6 +490,9 @@ export const getDashboard = () =>
|
|||||||
export const getHostInfo = () =>
|
export const getHostInfo = () =>
|
||||||
api.get<APIResponse<HostInfo>>('/host-info')
|
api.get<APIResponse<HostInfo>>('/host-info')
|
||||||
|
|
||||||
|
export const getHostReport = () =>
|
||||||
|
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||||
|
|
||||||
// Snapshots
|
// Snapshots
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
id: string
|
id: string
|
||||||
@@ -448,10 +556,9 @@ export const getWebSSHUrl = (containerName: string) => {
|
|||||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
export const getWebVNCUrl = (containerName: string) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const params = new URLSearchParams({ container: containerName })
|
const params = new URLSearchParams({ container: containerName })
|
||||||
if (ticket) params.set('ticket', ticket)
|
|
||||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+430
-32
@@ -8,6 +8,8 @@ ACTION="${1:-install}"
|
|||||||
ACTION_CONFIRM="${2:-}"
|
ACTION_CONFIRM="${2:-}"
|
||||||
ISSUE_URL="https://github.com/${REPO}/issues"
|
ISSUE_URL="https://github.com/${REPO}/issues"
|
||||||
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
||||||
|
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||||
|
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " CLICD 中文安装/卸载脚本"
|
echo " CLICD 中文安装/卸载脚本"
|
||||||
@@ -54,7 +56,7 @@ run_step() {
|
|||||||
step_name="$1"
|
step_name="$1"
|
||||||
shift
|
shift
|
||||||
log "开始:$step_name"
|
log "开始:$step_name"
|
||||||
if "$@" >> "$LOG_FILE" 2>&1; then
|
if ( "$@" ) >> "$LOG_FILE" 2>&1; then
|
||||||
log "完成:$step_name"
|
log "完成:$step_name"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -136,6 +138,7 @@ usage() {
|
|||||||
示例:
|
示例:
|
||||||
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
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
|
||||||
|
|
||||||
日志:${LOG_FILE}
|
日志:${LOG_FILE}
|
||||||
问题反馈:${ISSUE_URL}
|
问题反馈:${ISSUE_URL}
|
||||||
@@ -218,6 +221,39 @@ remove_lxc_container_dir() {
|
|||||||
log "已删除 $container_dir"
|
log "已删除 $container_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove_clicd_lxc_image_cache() {
|
||||||
|
log "正在删除 CLICD 使用的 LXC 镜像缓存..."
|
||||||
|
|
||||||
|
for container_dir in /var/lib/lxc/clicd-img-dl-*; do
|
||||||
|
[ -d "$container_dir" ] || continue
|
||||||
|
remove_lxc_container_dir "$container_dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
for image in \
|
||||||
|
"ubuntu noble amd64" \
|
||||||
|
"ubuntu jammy amd64" \
|
||||||
|
"debian bookworm amd64" \
|
||||||
|
"debian bullseye amd64" \
|
||||||
|
"alpine 3.21 amd64" \
|
||||||
|
"centos 9-Stream amd64" \
|
||||||
|
"archlinux current amd64" \
|
||||||
|
"fedora 44 amd64" \
|
||||||
|
"rockylinux 10 amd64"
|
||||||
|
do
|
||||||
|
set -- $image
|
||||||
|
distro="$1"
|
||||||
|
release="$2"
|
||||||
|
arch="$3"
|
||||||
|
cache_dir="/var/cache/lxc/download/$distro/$release/$arch"
|
||||||
|
remove_path "$cache_dir"
|
||||||
|
rmdir "/var/cache/lxc/download/$distro/$release" >/dev/null 2>&1 || true
|
||||||
|
rmdir "/var/cache/lxc/download/$distro" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
rmdir /var/cache/lxc/download >/dev/null 2>&1 || true
|
||||||
|
rmdir /var/cache/lxc >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
remove_kvm_domain() {
|
remove_kvm_domain() {
|
||||||
domain="$1"
|
domain="$1"
|
||||||
case "$domain" in
|
case "$domain" in
|
||||||
@@ -258,6 +294,48 @@ destroy_clicd_kvm_domains() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
domain_is_clicd_kvm() {
|
||||||
|
domain="$1"
|
||||||
|
case "$domain" in
|
||||||
|
vm-[0-9]*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'
|
||||||
|
}
|
||||||
|
|
||||||
|
libvirt_default_used_by_non_clicd_domain() {
|
||||||
|
if ! has_cmd virsh; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for domain in $(virsh list --all --name 2>/dev/null); do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if domain_is_clicd_kvm "$domain"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if virsh domiflist "$domain" 2>/dev/null | awk '$3 == "default" || $3 == "virbr0" {found = 1} END {exit found ? 0 : 1}'; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_clicd_libvirt_default_network() {
|
||||||
|
if ! has_cmd virsh || [ ! -f "$LIBVIRT_DEFAULT_MARKER" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if libvirt_default_used_by_non_clicd_domain; then
|
||||||
|
warn "检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "正在删除 CLICD 创建的 libvirt default NAT 网络..."
|
||||||
|
virsh net-destroy default >/dev/null 2>&1 || true
|
||||||
|
virsh net-undefine default >/dev/null 2>&1 || true
|
||||||
|
rm -f "$LIBVIRT_DEFAULT_MARKER"
|
||||||
|
}
|
||||||
|
|
||||||
delete_iptables_lines() {
|
delete_iptables_lines() {
|
||||||
table="$1"
|
table="$1"
|
||||||
chain="$2"
|
chain="$2"
|
||||||
@@ -295,6 +373,143 @@ delete_filter_rule() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete_ip6_filter_rule() {
|
||||||
|
if ! has_cmd ip6tables; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while ip6tables -D "$@" >/dev/null 2>&1; do
|
||||||
|
:
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_ip6tables_nat_source() {
|
||||||
|
source="$1"
|
||||||
|
if ! has_cmd ip6tables || [ -z "$source" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
rule="$(
|
||||||
|
ip6tables -t nat -S POSTROUTING 2>/dev/null |
|
||||||
|
grep -F -- "-s $source" |
|
||||||
|
grep -F -- " -j MASQUERADE" |
|
||||||
|
sed 's/^-A /-D /' |
|
||||||
|
head -n 1
|
||||||
|
)"
|
||||||
|
[ -n "$rule" ] || break
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
ip6tables -t nat $rule >/dev/null 2>&1 || break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
read_clicd_network_records() {
|
||||||
|
db="/root/.clicd/config.db"
|
||||||
|
legacy="/root/.clicd/config.json"
|
||||||
|
query="SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> '';"
|
||||||
|
|
||||||
|
if [ -f "$db" ] && has_cmd sqlite3; then
|
||||||
|
sqlite3 -separator '|' "$db" "$query" 2>/dev/null || true
|
||||||
|
elif [ -f "$db" ] && has_cmd python3; then
|
||||||
|
CLICD_DB="$db" python3 - <<'PY' 2>/dev/null || true
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db = os.environ.get("CLICD_DB")
|
||||||
|
for row in sqlite3.connect(db).execute(
|
||||||
|
"SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') "
|
||||||
|
"FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> ''"
|
||||||
|
):
|
||||||
|
print("|".join("" if value is None else str(value) for value in row))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$legacy" ] && has_cmd python3; then
|
||||||
|
CLICD_LEGACY_CONFIG="$legacy" python3 - <<'PY' 2>/dev/null || true
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = os.environ.get("CLICD_LEGACY_CONFIG")
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for item in data.get("containers", []):
|
||||||
|
virt = item.get("virtualization", "")
|
||||||
|
ipv6 = item.get("ipv6", "")
|
||||||
|
uplink = item.get("ipv6_interface", "")
|
||||||
|
mac = item.get("mac_address", "")
|
||||||
|
if ipv6 or mac:
|
||||||
|
print("|".join(str(value or "") for value in (virt, ipv6, uplink, mac)))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_record() {
|
||||||
|
virt="$1"
|
||||||
|
ipv6="$2"
|
||||||
|
uplink="$3"
|
||||||
|
mac="$4"
|
||||||
|
bridge="lxcbr0"
|
||||||
|
if [ "$virt" = "kvm" ]; then
|
||||||
|
bridge="virbr0"
|
||||||
|
fi
|
||||||
|
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$ipv6" ] || return
|
||||||
|
addr="${ipv6%%/*}"
|
||||||
|
source="$ipv6"
|
||||||
|
case "$source" in
|
||||||
|
*/*) ;;
|
||||||
|
*) source="$source/128" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
delete_ip6tables_nat_source "$source"
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
|
||||||
|
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
|
||||||
|
fi
|
||||||
|
|
||||||
|
if has_cmd ip; then
|
||||||
|
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
if [ -n "$uplink" ]; then
|
||||||
|
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_from_config() {
|
||||||
|
read_clicd_network_records | while IFS='|' read -r virt ipv6 uplink mac; do
|
||||||
|
cleanup_clicd_ipv6_record "$virt" "$ipv6" "$uplink" "$mac"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_bridge_routes() {
|
||||||
|
if ! has_cmd ip; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
for bridge in lxcbr0 virbr0; do
|
||||||
|
ip -6 route show dev "$bridge" 2>/dev/null | awk '$1 ~ /\/128$/ {print $1}' | while IFS= read -r source; do
|
||||||
|
[ -n "$source" ] || continue
|
||||||
|
addr="${source%%/*}"
|
||||||
|
delete_ip6tables_nat_source "$source"
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
|
||||||
|
ip -6 neigh show proxy 2>/dev/null | awk -v addr="$addr" '$1 == addr {for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | while IFS= read -r uplink; do
|
||||||
|
[ -n "$uplink" ] || continue
|
||||||
|
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
ip -6 addr del fe80::1/64 dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
delete_ip6tables_bridge_rules() {
|
delete_ip6tables_bridge_rules() {
|
||||||
if ! has_cmd ip6tables; then
|
if ! has_cmd ip6tables; then
|
||||||
return
|
return
|
||||||
@@ -315,6 +530,8 @@ cleanup_clicd_networking() {
|
|||||||
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
|
||||||
|
cleanup_clicd_ipv6_from_config
|
||||||
|
cleanup_clicd_ipv6_bridge_routes
|
||||||
|
|
||||||
for bridge in lxcbr0 virbr0; do
|
for bridge in lxcbr0 virbr0; do
|
||||||
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
||||||
@@ -354,8 +571,14 @@ remove_clicd_quota_records() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_clicd_tmp_files() {
|
remove_clicd_tmp_files() {
|
||||||
|
current_dir="$(pwd -P 2>/dev/null || pwd)"
|
||||||
for path in /tmp/clicd-* /tmp/clicd.*; do
|
for path in /tmp/clicd-* /tmp/clicd.*; do
|
||||||
[ -e "$path" ] || [ -L "$path" ] || continue
|
[ -e "$path" ] || [ -L "$path" ] || continue
|
||||||
|
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
|
||||||
|
if [ "$abs_path" = "$current_dir" ]; then
|
||||||
|
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
rm -rf "$path"
|
rm -rf "$path"
|
||||||
log "已删除 $path"
|
log "已删除 $path"
|
||||||
done
|
done
|
||||||
@@ -376,10 +599,12 @@ confirm_uninstall() {
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
|
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
|
||||||
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2
|
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
|
||||||
echo "如需确认卸载,请输入:YES" >&2
|
echo "如需确认卸载,请输入:YES" >&2
|
||||||
if [ -t 0 ]; then
|
if [ -r /dev/tty ]; then
|
||||||
read answer
|
IFS= read -r answer < /dev/tty
|
||||||
|
elif [ -t 0 ]; then
|
||||||
|
IFS= read -r answer
|
||||||
else
|
else
|
||||||
answer=""
|
answer=""
|
||||||
fi
|
fi
|
||||||
@@ -409,7 +634,9 @@ uninstall_clicd() {
|
|||||||
[ -d "$container_dir" ] || continue
|
[ -d "$container_dir" ] || continue
|
||||||
remove_lxc_container_dir "$container_dir"
|
remove_lxc_container_dir "$container_dir"
|
||||||
done
|
done
|
||||||
|
remove_clicd_lxc_image_cache
|
||||||
destroy_clicd_kvm_domains
|
destroy_clicd_kvm_domains
|
||||||
|
remove_clicd_libvirt_default_network
|
||||||
cleanup_clicd_networking
|
cleanup_clicd_networking
|
||||||
remove_clicd_host_hooks
|
remove_clicd_host_hooks
|
||||||
remove_clicd_quota_records
|
remove_clicd_quota_records
|
||||||
@@ -424,7 +651,7 @@ uninstall_clicd() {
|
|||||||
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
|
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
|
||||||
unmount_path_tree /var/lib/clicd
|
unmount_path_tree /var/lib/clicd
|
||||||
remove_path /var/lib/clicd
|
remove_path /var/lib/clicd
|
||||||
# /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。
|
# /var/cache/lxc 是 LXC 全局缓存,已按 CLICD 模板精确清理,生产环境不整体删除。
|
||||||
remove_path /var/cache/clicd
|
remove_path /var/cache/clicd
|
||||||
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
|
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
|
||||||
remove_clicd_tmp_files
|
remove_clicd_tmp_files
|
||||||
@@ -444,7 +671,7 @@ uninstall_clicd() {
|
|||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
|
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
|
||||||
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
|
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
|
||||||
echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。"
|
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
|
||||||
echo " 日志:$LOG_FILE"
|
echo " 日志:$LOG_FILE"
|
||||||
echo " 问题反馈:$ISSUE_URL"
|
echo " 问题反馈:$ISSUE_URL"
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
@@ -674,17 +901,43 @@ EOF
|
|||||||
sysctl --system >/dev/null 2>&1 || true
|
sysctl --system >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemd_unit_exists() {
|
||||||
|
unit="$1"
|
||||||
|
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
systemd_enable_now_if_exists() {
|
||||||
|
unit="$1"
|
||||||
|
if systemd_unit_exists "$unit"; then
|
||||||
|
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "未检测到 systemd 单元 $unit,跳过。"
|
||||||
|
}
|
||||||
|
|
||||||
|
systemd_existing_units() {
|
||||||
|
for unit in "$@"; do
|
||||||
|
if systemd_unit_exists "$unit"; then
|
||||||
|
printf ' %s' "$unit"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
setup_runtime_services() {
|
setup_runtime_services() {
|
||||||
log "正在配置 LXC 和 KVM 服务..."
|
log "正在配置 LXC 和 KVM 服务..."
|
||||||
|
|
||||||
if is_systemd; then
|
if is_systemd; then
|
||||||
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxcfs.service
|
||||||
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxc-net.service
|
||||||
systemctl enable --now lxc >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxc.service
|
||||||
systemctl enable --now libvirtd >/dev/null 2>&1 || true
|
if systemd_unit_exists libvirtd.service; then
|
||||||
systemctl enable --now virtqemud >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists libvirtd.service
|
||||||
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true
|
log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
|
||||||
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true
|
else
|
||||||
|
systemd_enable_now_if_exists virtqemud.service
|
||||||
|
systemd_enable_now_if_exists virtqemud.socket
|
||||||
|
fi
|
||||||
|
systemd_enable_now_if_exists virtlogd.socket
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -734,6 +987,8 @@ setup_default_libvirt_network() {
|
|||||||
EOF
|
EOF
|
||||||
virsh net-define "$net_xml"
|
virsh net-define "$net_xml"
|
||||||
rm -f "$net_xml"
|
rm -f "$net_xml"
|
||||||
|
mkdir -p "$(dirname "$LIBVIRT_DEFAULT_MARKER")"
|
||||||
|
touch "$LIBVIRT_DEFAULT_MARKER"
|
||||||
fi
|
fi
|
||||||
if ! libvirt_network_active; then
|
if ! libvirt_network_active; then
|
||||||
virsh net-start default
|
virsh net-start default
|
||||||
@@ -752,17 +1007,36 @@ setup_subids() {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_lxc_storage_access() {
|
||||||
|
log "Configuring LXC storage directory permissions..."
|
||||||
|
mkdir -p /var/lib/lxc
|
||||||
|
chmod 755 /var/lib/lxc
|
||||||
|
}
|
||||||
|
|
||||||
try_enable_project_quota() {
|
try_enable_project_quota() {
|
||||||
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
|
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
|
||||||
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
|
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
|
||||||
|
|
||||||
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
|
case "$root_fs" in
|
||||||
warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。"
|
ext4)
|
||||||
|
;;
|
||||||
|
xfs|btrfs|zfs|overlay|unknown|"")
|
||||||
|
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quota,CLICD 将使用兼容磁盘限制模式。"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
|
||||||
|
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! has_cmd tune2fs; then
|
if ! has_cmd tune2fs; then
|
||||||
warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。"
|
log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -771,7 +1045,60 @@ try_enable_project_quota() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。"
|
log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_file() {
|
||||||
|
url="$1"
|
||||||
|
dest="$2"
|
||||||
|
rm -f "$dest"
|
||||||
|
|
||||||
|
if has_cmd curl; then
|
||||||
|
curl -fL --retry 6 --retry-delay 2 --connect-timeout 20 --max-time 600 "$url" -o "$dest"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if has_cmd wget; then
|
||||||
|
wget --tries=6 --timeout=30 --waitretry=2 -O "$dest" "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
return 127
|
||||||
|
}
|
||||||
|
|
||||||
|
release_api_json() {
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
if has_cmd curl; then
|
||||||
|
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 120 "$api_url" 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if has_cmd wget; then
|
||||||
|
wget -qO- --tries=3 --timeout=30 "$api_url" 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_asset_url() {
|
||||||
|
asset_name="$1"
|
||||||
|
|
||||||
|
if [ "$CLICD_INSTALL_VERSION" != "latest" ]; then
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${asset_name}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_data="$(release_api_json)"
|
||||||
|
url="$(printf '%s\n' "$api_data" | sed -n 's/.*"browser_download_url": *"\([^"]*\/'"$asset_name"'\)".*/\1/p' | head -n 1)"
|
||||||
|
if [ -n "$url" ]; then
|
||||||
|
printf '%s\n' "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag="$(printf '%s\n' "$api_data" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)"
|
||||||
|
if [ -n "$tag" ]; then
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/download/${tag}/${asset_name}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/latest/download/${asset_name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
download_release_if_needed() {
|
download_release_if_needed() {
|
||||||
@@ -789,19 +1116,66 @@ download_release_if_needed() {
|
|||||||
log "正在下载发行版包:${download_url}"
|
log "正在下载发行版包:${download_url}"
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' 0
|
rm -f "$INSTALL_DOWNLOAD_MARKER"
|
||||||
|
printf '%s\n' "$tmp_dir" > "$INSTALL_DOWNLOAD_MARKER" || die "Failed to write install temp marker."
|
||||||
|
|
||||||
if has_cmd curl; then
|
if ! has_cmd curl && ! has_cmd wget; then
|
||||||
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
|
|
||||||
elif has_cmd wget; then
|
|
||||||
wget -O "$tmp_dir/$ASSET" "$download_url"
|
|
||||||
else
|
|
||||||
die "下载发行版包需要 curl 或 wget。"
|
die "下载发行版包需要 curl 或 wget。"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
|
archive_path="$tmp_dir/$ASSET"
|
||||||
cd "$tmp_dir/clicd-linux-amd64"
|
archive_urls="$download_url"
|
||||||
[ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
resolved_archive_url="$(release_asset_url "$ASSET")"
|
||||||
|
if [ "$resolved_archive_url" != "$download_url" ]; then
|
||||||
|
archive_urls="$archive_urls $resolved_archive_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive_ok=0
|
||||||
|
for url in $archive_urls; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
log "Trying release archive: $url"
|
||||||
|
if download_file "$url" "$archive_path" && [ -s "$archive_path" ]; then
|
||||||
|
archive_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
warn "Release archive download failed, trying next source: $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$archive_ok" = "1" ]; then
|
||||||
|
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
||||||
|
else
|
||||||
|
binary_asset="clicd-linux-amd64"
|
||||||
|
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
||||||
|
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
||||||
|
else
|
||||||
|
binary_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${binary_asset}"
|
||||||
|
fi
|
||||||
|
binary_urls="$binary_url"
|
||||||
|
resolved_binary_url="$(release_asset_url "$binary_asset")"
|
||||||
|
if [ "$resolved_binary_url" != "$binary_url" ]; then
|
||||||
|
binary_urls="$binary_urls $resolved_binary_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
binary_path="$tmp_dir/$binary_asset"
|
||||||
|
binary_ok=0
|
||||||
|
for url in $binary_urls; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
log "Trying release binary: $url"
|
||||||
|
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
||||||
|
mkdir -p "$tmp_dir/clicd-linux-amd64"
|
||||||
|
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
|
||||||
|
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
|
||||||
|
binary_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
warn "Release binary download failed, trying next source: $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
|
||||||
|
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_binary() {
|
install_binary() {
|
||||||
@@ -812,28 +1186,51 @@ install_binary() {
|
|||||||
rc-service clicd stop >/dev/null 2>&1 || true
|
rc-service clicd stop >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bin_src="./clicd"
|
||||||
|
download_dir=""
|
||||||
|
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
||||||
|
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
||||||
|
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
|
||||||
|
bin_src="$download_dir/clicd-linux-amd64/clicd"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
||||||
|
|
||||||
tmp_bin="/usr/local/bin/clicd.new.$$"
|
tmp_bin="/usr/local/bin/clicd.new.$$"
|
||||||
cp ./clicd "$tmp_bin"
|
cp "$bin_src" "$tmp_bin"
|
||||||
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 "已安装二进制:/usr/local/bin/clicd"
|
log "已安装二进制:/usr/local/bin/clicd"
|
||||||
|
|
||||||
|
if [ -n "$download_dir" ]; then
|
||||||
|
case "$download_dir" in
|
||||||
|
/tmp/*)
|
||||||
|
rm -rf "$download_dir"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$INSTALL_DOWNLOAD_MARKER"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_systemd_service() {
|
install_systemd_service() {
|
||||||
cat > /etc/systemd/system/clicd.service << 'EOF'
|
libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
|
||||||
|
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
|
||||||
|
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/clicd.service << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=CLICD - LXC/KVM Container Manager
|
Description=CLICD - LXC/KVM Container Manager
|
||||||
After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket
|
After=network-online.target${lxc_after}${libvirt_after}
|
||||||
Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket
|
Wants=network-online.target${libvirt_wants}
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=10
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/clicd server
|
ExecStart=/usr/local/bin/clicd server
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=10
|
|
||||||
LimitNOFILE=1048576
|
LimitNOFILE=1048576
|
||||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
@@ -918,6 +1315,7 @@ run_step "配置内核网络参数" configure_kernel_networking
|
|||||||
run_step "配置运行时服务" setup_runtime_services
|
run_step "配置运行时服务" setup_runtime_services
|
||||||
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
||||||
run_step "配置 UID/GID 映射" setup_subids
|
run_step "配置 UID/GID 映射" setup_subids
|
||||||
|
run_step "Configure LXC storage permissions" configure_lxc_storage_access
|
||||||
run_step "检查 project quota" try_enable_project_quota
|
run_step "检查 project quota" try_enable_project_quota
|
||||||
run_step "下载发行版包" download_release_if_needed
|
run_step "下载发行版包" download_release_if_needed
|
||||||
run_step "安装 CLICD 二进制" install_binary
|
run_step "安装 CLICD 二进制" install_binary
|
||||||
|
|||||||
Reference in New Issue
Block a user