Compare commits

...

33 Commits

Author SHA1 Message Date
MengMengCode 33603f5776 release: v1.1.6 2026-06-09 00:12:19 +08:00
MengMengCode 9ad7bcc97a 完善API文档 2026-06-09 00:10:45 +08:00
MengMengCode f3a1687a18 修复了一些已知问题 2026-06-08 21:18:17 +08:00
MengMengCode 49b13af91c release: v1.1.5 2026-06-08 20:10:34 +08:00
MengMengCode e79609281f 增强API集成能力,划分KEY功能权限 2026-06-08 19:25:55 +08:00
MengMengCode 2fa130a2b6 标记并清理 CLICD 创建的 libvirt default 网络 2026-06-08 16:21:23 +08:00
MengMengCode 14d2192b05 完善卸载网络规则清理 2026-06-08 16:18:24 +08:00
MengMengCode 9f5ad94a83 清理卸载时的 LXC 镜像缓存 2026-06-08 16:10:55 +08:00
MengMengCode ac6587f2bc 增强安装脚本发行版下载回退 2026-06-08 16:03:32 +08:00
MengMengCode 6fad37b844 修复安装脚本下载失败处理 2026-06-08 15:55:17 +08:00
MengMengCode d0eb92eaab 修复 2026-06-08 15:49:42 +08:00
MengMengCode 5207082cd1 release: v1.1.4 2026-06-08 15:44:58 +08:00
MengMengCode 608b50f18a 修复了一些功能 2026-06-08 15:44:35 +08:00
MengMengCode b58a6b1030 release: v1.1.3 2026-06-08 14:41:51 +08:00
MengMengCode 366f889a8c 优化安装脚本执行逻辑 2026-06-08 14:40:06 +08:00
MengMengCode 814441e9a0 release: v1.1.2 2026-06-08 02:24:04 +08:00
MengMengCode aed11af105 修复了一些已知问题 2026-06-08 02:23:48 +08:00
MengMengCode 3d95bb33c1 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-08 01:21:12 +08:00
MengMengCode ade1c6c093 优化功能体验 2026-06-08 01:21:10 +08:00
Meng Meng 5c4cc1cab3 Merge pull request #4 from MengMengCode/dependabot/npm_and_yarn/frontend/vite-8.0.16
build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
2026-06-07 23:42:42 +08:00
copilot-swe-agent[bot] 109e47170f fix: resolve frontend dependency conflicts for Vite 8 build 2026-06-07 15:40:09 +00:00
dependabot[bot] 34637cc79d build(deps-dev): bump vite from 5.4.21 to 8.0.16 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.21 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 13:11:00 +00:00
47 changed files with 6969 additions and 1607 deletions
+1
View File
@@ -58,6 +58,7 @@ backend/tmp/
*.swp
*.swo
*~
*.claude/
# OS
.DS_Store
+5 -5
View File
@@ -1,18 +1,18 @@
module clicd
go 1.23.0
go 1.24.0
toolchain go1.24.5
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0
golang.org/x/term v0.28.0
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
)
require (
golang.org/x/sys v0.31.0
golang.org/x/sys v0.38.0
modernc.org/sqlite v1.29.10
)
+8 -8
View File
@@ -1,7 +1,7 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
+363 -124
View File
@@ -2,122 +2,293 @@ package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"clicd/internal/config"
"golang.org/x/crypto/argon2"
)
type ApiKey struct {
ID string `json:"id"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
ID string `json:"id"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
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
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "apikey:read") {
return
}
listApiKeys(w, r)
case http.MethodPost:
if !requireScope(w, r, "apikey:create") {
return
}
createApiKey(w, r)
default:
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) {
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"})
return
}
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
config.DeleteApiKey(keyID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func apiKeyIDFromPath(path string) string {
path = strings.TrimPrefix(path, "/api/api-keys/")
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
return strings.Trim(path, "/")
}
func listApiKeys(w http.ResponseWriter, r *http.Request) {
keys := make([]ApiKey, 0)
for _, k := range config.AppConfig.ApiKeys {
keys = append(keys, ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
})
keys = append(keys, apiKeyResponse(k))
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
}
func createApiKey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
var req apiKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
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
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)
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")
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
key := config.ApiKeyConfig{
ID: generateShortID(),
Name: req.Name,
KeyHash: hashKey(rawKey),
Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now,
ID: generateShortID(),
Name: strings.TrimSpace(req.Name),
KeyHash: keyHash,
Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
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.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{
Success: true,
Message: "API key created. Save this key now - it won't be shown again.",
Data: ApiKey{
ID: key.ID,
Name: key.Name,
Key: rawKey,
Prefix: key.Prefix,
IPWhitelist: key.IPWhitelist,
CreatedAt: key.CreatedAt,
},
Data: resp,
})
}
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 {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
const (
apiKeyHashPrefix = "argon2id"
apiKeyHashTime = uint32(3)
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 {
@@ -128,20 +299,75 @@ func legacyHashKey(key string) string {
return hex.EncodeToString(b)
}
// validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
func matchApiKey(rawKey string) (idx int, needsRehash bool) {
legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
if k.IPWhitelist == "" {
return true
}
return isIPAllowed(clientIP, k.IPWhitelist)
for i, k := range config.AppConfig.ApiKeys {
if verifyAPIKeyHash(rawKey, k.KeyHash) {
return i, false
}
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
return i, true
}
}
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 {
@@ -156,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
}
func isValidApiKeyRequest(r *http.Request) bool {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return false
}
if !validateApiKey(apiKey, clientIP(r)) {
return false
}
updateApiKeyLastUsed(apiKey)
return true
_, ok := validateApiKeyRequest(r)
return ok
}
// isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP)
// Strip port if present
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
clientIP = clientIP[:idx]
clientIP = normalizeIPString(clientIP)
client := net.ParseIP(clientIP)
if client == nil {
return false
}
for _, entry := range strings.Split(whitelist, "\n") {
entry = strings.TrimSpace(entry)
@@ -180,77 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
continue
}
if strings.Contains(entry, "/") {
// CIDR match
if ipInCIDR(clientIP, entry) {
_, network, err := net.ParseCIDR(entry)
if err == nil && network.Contains(client) {
return true
}
} else if entry == clientIP {
continue
}
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
return true
}
}
return false
}
func ipInCIDR(ipStr, cidr string) bool {
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 {
func normalizeIPString(s string) string {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
s = s[:idx]
if host, _, err := net.SplitHostPort(s); err == nil {
return strings.Trim(host, "[]")
}
return net.ParseIP(s)
return strings.Trim(s, "[]")
}
func ip4ToUint32(ip net.IP) uint32 {
ip = ip.To4()
if ip == nil {
return 0
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
func ipInCIDR(ipStr, cidr string) bool {
ip := net.ParseIP(normalizeIPString(ipStr))
_, network, err := net.ParseCIDR(cidr)
return err == nil && ip != nil && network.Contains(ip)
}
// updateApiKeyLastUsed marks the key as recently used
// updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey)
now := time.Now().Format("2006-01-02 15:04:05")
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return
}
key, ok := validateApiKeyDetails(rawKey, "")
if !ok {
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.
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
key, ok := validateApiKeyRequest(r)
if !ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return
}
updateApiKeyLastUsed(apiKey)
next(w, r)
next(w, withAuthContext(r, authContextFromAPIKey(key)))
}
}
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)
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"strings"
"testing"
"clicd/internal/config"
)
func TestHashAPIKeyUsesSaltedArgon2idHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
h1, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
h2, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("expected salted hashes to differ")
}
if !strings.HasPrefix(h1, apiKeyHashPrefix+"$") || !strings.HasPrefix(h2, apiKeyHashPrefix+"$") {
t.Fatalf("expected argon2id hashes, got %q and %q", h1, h2)
}
if !verifyAPIKeyHash(raw, h1) || !verifyAPIKeyHash(raw, h2) {
t.Fatal("argon2id hashes did not verify")
}
if verifyAPIKeyHash(raw+"x", h1) {
t.Fatal("argon2id hash verified wrong key")
}
}
func TestValidateApiKeyAllowsArgon2idAndUpdatesLastUsed(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid argon2id key")
}
updateApiKeyLastUsed(raw)
if config.AppConfig.ApiKeys[0].LastUsed == "" {
t.Fatal("LastUsed was not updated")
}
}
func TestValidateApiKeyMigratesLegacyHash(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "legacy",
Name: "legacy",
KeyHash: legacyHashKey(raw),
}},
}
if !validateApiKey(raw, "127.0.0.1") {
t.Fatal("validateApiKey rejected valid legacy key")
}
migrated := config.AppConfig.ApiKeys[0].KeyHash
if migrated == legacyHashKey(raw) {
t.Fatal("legacy key hash was not migrated")
}
if !verifyAPIKeyHash(raw, migrated) {
t.Fatal("migrated key hash does not verify")
}
}
func TestValidateApiKeyAppliesIPWhitelist(t *testing.T) {
raw := "clicd_sk_0123456789abcdef0123456789abcdef"
hash, err := hashAPIKey(raw)
if err != nil {
t.Fatal(err)
}
config.AppConfig = &config.ClicdConfig{
ApiKeys: []config.ApiKeyConfig{{
ID: "key1",
Name: "test",
KeyHash: hash,
IPWhitelist: "192.0.2.10",
}},
}
if validateApiKey(raw, "198.51.100.10") {
t.Fatal("validateApiKey allowed disallowed IP")
}
if !validateApiKey(raw, "192.0.2.10") {
t.Fatal("validateApiKey rejected allowed IP")
}
}
+165 -4
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely.
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
break
}
}
if !foundSubUser {
return nil, false
}
}
return claims, ok
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
}
func isSubUserRequest(r *http.Request) bool {
if ctx, ok := authContextFromRequest(r); ok {
return ctx.Type == authTypeSubUser
}
claims, ok := claimsFromRequest(r)
if !ok {
return false
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
if claims, ok := claimsFromToken(tokenString); ok {
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
}
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.
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
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"})
return
}
@@ -0,0 +1,7 @@
//go:build !linux
package api
func getRootDiskInfo() (DiskInfo, bool) {
return DiskInfo{}, false
}
+21
View File
@@ -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
}
+142 -2
View File
@@ -2,10 +2,12 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"clicd/internal/config"
"clicd/internal/lxc"
@@ -18,17 +20,41 @@ var lxcManager = lxc.NewManager()
func HandleContainers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r)
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)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleContainerListAlias supports legacy integrations that call
// /api/containers/list or /api/v1/containers/list.
func HandleContainerListAlias(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r)
}
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
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)
c := containerByIdentifier(parts[0])
id := 0
@@ -48,6 +74,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
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 {
// For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/")
@@ -59,45 +89,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
}
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 {
case action == "start" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "start")
case action == "stop" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "stop")
case action == "restart" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "restart")
case action == "reinstall" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:reinstall") {
return
}
HandleSingleTaskAction(w, r, id, "reinstall")
case action == "delete" && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:delete") {
return
}
HandleSingleTaskAction(w, r, id, "delete")
case action == "reset-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:password") {
return
}
resetSSHPassword(w, r, id)
case action == "usage" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getUsage(w, r, id)
case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getTraffic(w, r, id)
case action == "traffic-reset" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:traffic") {
return
}
resetTraffic(w, r, id)
case action == "traffic-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:traffic") {
return
}
updateTrafficLimit(w, r, id)
case action == "resource-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateResourceLimit(w, r, id)
case action == "random-port" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getRandomPort(w, r, id)
case action == "expiry" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost:
if !requireScope(w, r, "ipv6:assign") {
return
}
assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:network") {
return
}
addPortMapping(w, r, id)
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/"))
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/"))
case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getContainer(w, r, id)
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
@@ -346,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
if isSubUserRequest(r) {
HandleEnabledImages(w, r)
return
@@ -360,7 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "dashboard:read") {
return
}
containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
running := 0
stopped := 0
for _, c := range containers {
@@ -384,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "host:read") {
return
}
info := getHostInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
@@ -394,7 +494,24 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return
}
newPassword, err := resetPasswordByRuntime(id)
var req struct {
Password string `json:"password"`
}
if r.Body != nil {
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil && err.Error() != "EOF" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
password := strings.TrimSpace(req.Password)
if password != "" {
if err := validateSSHPassword(password); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
newPassword, err := resetPasswordByRuntime(id, password)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -406,6 +523,29 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
})
}
func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
File diff suppressed because it is too large Load Diff
+294 -92
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -8,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"sync"
"time"
"clicd/internal/config"
"clicd/internal/kvm"
@@ -16,22 +18,143 @@ import (
// ImageInfo represents a template image with its download/enable status.
type ImageInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Description string `json:"description"`
Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Description string `json:"description"`
Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"`
Progress int `json:"progress"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
Stage string `json:"stage,omitempty"`
Error string `json:"error,omitempty"`
SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
Desktop string `json:"desktop,omitempty"`
}
var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]bool{}
var imageDownloads = map[string]*imageDownloadStatus{}
type imageDownloadStatus struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
Cancel context.CancelFunc
UpdatedAt time.Time
}
type imageDownloadSnapshot struct {
Downloading bool
Progress int
DownloadedBytes int64
TotalBytes int64
Stage string
Error string
}
func imageDownloadInfo(id string) imageDownloadSnapshot {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return imageDownloadSnapshot{}
}
return imageDownloadSnapshot{
Downloading: st.Downloading,
Progress: st.Progress,
DownloadedBytes: st.DownloadedBytes,
TotalBytes: st.TotalBytes,
Stage: st.Stage,
Error: st.Error,
}
}
func startImageDownload(id, stage string) (context.Context, bool) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
if st := imageDownloads[id]; st != nil && st.Downloading {
return nil, false
}
ctx, cancel := context.WithCancel(context.Background())
imageDownloads[id] = &imageDownloadStatus{
Downloading: true,
Stage: stage,
Cancel: cancel,
UpdatedAt: time.Now(),
}
return ctx, true
}
func updateImageDownload(id string, update func(*imageDownloadStatus)) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
update(st)
st.UpdatedAt = time.Now()
}
func finishImageDownload(id string, err error) {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
if st == nil {
return
}
st.Downloading = false
st.Cancel = nil
st.UpdatedAt = time.Now()
if err != nil {
st.Error = err.Error()
return
}
delete(imageDownloads, id)
}
func clearImageDownload(id string) {
imageDownloadsMu.Lock()
delete(imageDownloads, id)
imageDownloadsMu.Unlock()
}
func isImageDownloadActive(id string) bool {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
st := imageDownloads[id]
return st != nil && st.Downloading
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
func cleanupLXCImageDownloadTemp(id string) {
tmpName := lxcImageDownloadTempName(id)
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
}
func cleanupOldImageDownloadErrors() {
imageDownloadsMu.Lock()
defer imageDownloadsMu.Unlock()
cutoff := time.Now().Add(-10 * time.Minute)
for id, st := range imageDownloads {
if !st.Downloading && st.UpdatedAt.Before(cutoff) {
delete(imageDownloads, id)
}
}
}
// isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool {
@@ -98,60 +221,78 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
for _, t := range templates {
_, downloading := imageDownloads[t.ID]
dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationLXC,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: downloading,
SizeBytes: size,
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationLXC,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
})
}
for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID]
dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := ""
if t.Distro == "windows" {
manualPath = kvm.ImagePath(t.ID)
}
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationKVM,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: downloading,
SizeBytes: size,
ManualPath: manualPath,
ID: t.ID,
Name: t.Name,
Type: config.VirtualizationKVM,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: dl.Downloading,
Progress: dl.Progress,
DownloadedBytes: dl.DownloadedBytes,
TotalBytes: dl.TotalBytes,
Stage: dl.Stage,
Error: dl.Error,
SizeBytes: size,
ManualPath: manualPath,
Desktop: t.Desktop,
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
}
// HandleImageDownload downloads a template image from the LXC image server.
// HandleImageDownload starts a template image download in the background.
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:download") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -170,82 +311,130 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
imageDownloadsMu.Lock()
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
ctx, ok := startImageDownload(image.ID, "downloading")
if !ok {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
ensureImageEnabled(image.ID)
if err := kvm.DownloadImage(*image); err != nil {
message := "Download failed: " + err.Error()
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
go func(image kvm.Image) {
err := kvm.DownloadImageWithProgress(ctx, image, func(p kvm.DownloadProgress) {
updateImageDownload(image.ID, func(st *imageDownloadStatus) {
if p.Stage != "" {
st.Stage = p.Stage
}
if p.DownloadedBytes > 0 || p.TotalBytes > 0 {
st.DownloadedBytes = p.DownloadedBytes
st.TotalBytes = p.TotalBytes
}
st.Progress = p.Percent
})
})
if err != nil {
if ctx.Err() != nil {
os.Remove(kvm.ImagePath(image.ID) + ".tmp")
os.Remove(kvm.ImagePath(image.ID))
finishImageDownload(image.ID, nil)
return
}
finishImageDownload(image.ID, err)
return
}
ensureImageEnabled(image.ID)
finishImageDownload(image.ID, nil)
}(*image)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID)
clearImageDownload(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
// Already downloading?
imageDownloadsMu.Lock()
if imageDownloads[req.TemplateID] {
imageDownloadsMu.Unlock()
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
if !ok {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
return
}
imageDownloads[req.TemplateID] = true
imageDownloadsMu.Unlock()
defer func() {
imageDownloadsMu.Lock()
delete(imageDownloads, req.TemplateID)
imageDownloadsMu.Unlock()
}()
// Auto-enable on download
ensureImageEnabled(tmpl.ID)
// Download via lxc-create with a temp container, then destroy it.
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
cmd := exec.Command("lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
go func(tmpl lxc.Template) {
// Download via lxc-create with a temp container, then destroy it.
tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
updateImageDownload(tmpl.ID, func(st *imageDownloadStatus) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
if err != nil {
if ctx.Err() != nil {
finishImageDownload(tmpl.ID, nil)
return
}
err = fmt.Errorf("Download failed: %v, output: %s", err, string(output))
finishImageDownload(tmpl.ID, err)
return
}
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
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
}
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.
@@ -254,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:delete") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -262,6 +454,10 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if isImageDownloadActive(req.TemplateID) {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before deleting"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
@@ -300,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:toggle") {
return
}
var req struct {
TemplateID string `json:"template_id"`
@@ -326,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "image:read") {
return
}
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
@@ -336,7 +538,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM,
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
})
}
}
+3
View File
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "ipv6:read") {
return
}
status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
+3
View File
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
+3 -3
View File
@@ -72,12 +72,12 @@ func reinstallByRuntime(id int, templateID string) error {
return lxcManager.ReinstallContainer(id, templateID)
}
func resetPasswordByRuntime(id int) (string, error) {
func resetPasswordByRuntime(id int, password string) (string, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.ResetSSHPassword(id)
return kvmManager.ResetSSHPassword(id, password)
}
return lxcManager.ResetSSHPassword(id)
return lxcManager.ResetSSHPassword(id, password)
}
func assignIPv6ByRuntime(id int) (*config.Container, error) {
+43 -2
View File
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
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.
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
case http.MethodPut:
if !requireScope(w, r, "security:settings") {
return
}
var req struct {
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()})
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{
"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"})
return
}
if !requireScope(w, r, "security:check") {
return
}
var req struct {
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"})
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)
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"})
return
}
if !requireScope(w, r, "security:read") {
return
}
containerName := r.URL.Query().Get("container")
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{}{}})
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)})
}
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
critical := 0
high := 0
medium := 0
low := 0
alerts := mergedSecurityAlerts()
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
for _, a := range alerts {
switch a.Severity {
case "critical":
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
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 {
ss := ensureScanner()
ss.mu.Lock()
+3
View File
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs))
+37 -9
View File
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(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) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID)
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")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
}
func requestUser(r *http.Request) string {
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"
return requestActor(r)
}
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
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
}
+3
View File
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
return
}
if !requireScope(w, r, "terminal:ssh") {
return
}
var req struct {
ContainerName string `json:"container_name"`
}
+95 -27
View File
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:create") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
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 {
return config.FindContainerByIdentifier(identifier)
}
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
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"})
return
}
if !requireScope(w, r, "audit:read") {
return
}
logs := config.AppConfig.AuditLogs
if logs == nil {
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
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)
return
}
if path == "/api/containers" {
if path == containerListPath {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}
if len(path) > len("/api/containers/") {
rest := path[len("/api/containers/"):]
if strings.HasPrefix(path, containerPrefix) {
rest := path[len(containerPrefix):]
parts := splitPath(rest)
if len(parts) > 0 && 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 {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return 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 {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
if isTaskAllowedForRequest(r, 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
}
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 {
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 {
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:read") {
return
}
result := make([]SubUserListItem, 0, len(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
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)
subUserID := parts[0]
action := ""
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "subuser:update") {
return
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
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"})
case action == "audit-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "audit:read") {
return
}
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "loginlog:read") {
return
}
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
+50 -27
View File
@@ -11,19 +11,27 @@ import (
)
type SwapInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
}
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo()
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"})
return
}
if !requireScope(w, r, "swap:manage") {
return
}
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
}
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 err error
switch req.Action {
case "create":
if req.SizeMB <= 0 {
req.SizeMB = 2048
}
err := createSwap(req.SizeMB)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = createSwap(req.SizeMB)
}
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable":
err := enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = enableSwap()
msg = "SWAP 已启用"
case "disable":
err := disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = disableSwap()
msg = "SWAP 已禁用"
case "resize":
if req.SizeMB <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = disableSwap()
}
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)
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
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()
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})
}
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 {
info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error {
swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建")
}
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput()
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 fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+68 -13
View File
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
}
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()
defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs)
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
}
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
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
for _, cfg := range configs {
cfgCopy := cfg
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
IP: ip,
UserAgent: userAgent,
}
q.enqueueTask(task)
result = append(result, task.ID)
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
TemplateID: t.TemplateID,
Config: string(cfgJSON),
User: t.User,
IP: t.IP,
UserAgent: t.UserAgent,
})
}
config.SaveTasks(saved)
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
name = c.Name
}
// Determine user from JWT claims
user := "admin"
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
// Determine user from authenticated request context.
user := requestActor(r)
ip := clientIP(r)
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"})
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 {
Containers []lxc.ContainerConfig `json:"containers"`
}
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
}
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})
}
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
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 {
Action string `json:"action"`
Containers []int `json:"containers"`
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
}
var taskType TaskType
var requiredScope string
switch req.Action {
case "start":
taskType = TaskStart
requiredScope = "container:power"
case "stop":
taskType = TaskStop
requiredScope = "container:power"
case "restart":
taskType = TaskRestart
requiredScope = "container:power"
case "delete":
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:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
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})
}
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
// URL: /api/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
if !requireScope(w, r, "task:delete") {
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 == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return
}
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)
// Also remove from both queues if pending
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"})
return
}
if !requireScope(w, r, "task:read") {
return
}
tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
@@ -691,6 +744,8 @@ func RestoreTasks() {
TemplateID: st.TemplateID,
Config: cfg,
User: st.User,
IP: st.IP,
UserAgent: st.UserAgent,
}
if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried
+43 -6
View File
@@ -18,7 +18,10 @@ import (
type webVNCTicket struct {
ContainerName string
ContainerUUID string
Username string
SubUser bool
ClientIP string
UserAgent string
ExpiresAt time.Time
}
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return
}
if !requireScope(w, r, "terminal:vnc") {
return
}
var req struct {
ContainerName string `json:"container_name"`
}
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return
}
username, isSubUser := vncRequesterIdentity(r)
ticket := randomHex(32)
webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name,
ContainerUUID: c.UUID,
SubUser: isSubUserRequest(r),
Username: username,
SubUser: isSubUser,
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(60 * time.Second),
}
webVNCTickets.Unlock()
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
return
}
item, ok := consumeWebVNCTicket(ticket, containerName)
item, ok := consumeWebVNCTicket(ticket, containerName, r)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
}
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)
var writeMu sync.Mutex
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
reason := <-done
_ = vncConn.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 {
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
return ""
}
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
now := time.Now()
webVNCTickets.Lock()
defer webVNCTickets.Unlock()
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
return webVNCTicket{}, false
}
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) {
+245 -11
View File
@@ -20,6 +20,12 @@ import (
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.
func Run() {
reader := bufio.NewReader(os.Stdin)
@@ -198,11 +204,18 @@ func cliCreateContainer(reader *bufio.Reader) {
container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
fmt.Print(formatSSHAccess(container.SSHPort))
}
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) {
id, name := selectContainer(reader, "开机")
if id == 0 {
@@ -532,13 +545,14 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
return err
}
backupDir := "/root/clicd-backups"
backupDir := clicdBackupDir
if err := os.MkdirAll(backupDir, 0700); err != nil {
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 := 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)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
@@ -548,8 +562,8 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := "/usr/local/bin/clicd.new"
if err := copyFile(newBinary, tmpBin, 0755); err != nil {
tmpBin := clicdNewBinaryPath
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
return err
}
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
}
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)
if err != nil {
out.Close()
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if _, err := io.Copy(out, in); err != nil {
if err := out.Chmod(mode); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
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 {
@@ -697,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
destroyAllLXCContainers()
destroyAllKVMDomains()
removeCLICDLibvirtDefaultNetwork()
cleanupCLICDNetworking()
removeCLICDHostHooks()
removeCLICDQuotaRecords()
@@ -783,8 +842,60 @@ func removeKVMDomain(name string) {
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() {
removeCLICDNATRules()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
for _, bridge := range []string{"lxcbr0", "virbr0"} {
deleteFilterRule("FORWARD", "-i", 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() {
if commandExists("iptables") {
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) {
if !commandExists("ip6tables") {
return
+54
View File
@@ -0,0 +1,54 @@
package cli
import (
"strings"
"testing"
)
func TestSafeReleaseBackupComponent(t *testing.T) {
tests := map[string]string{
"v1.2.3": "1.2.3",
" release/candidate ": "release_candidate",
"../../etc/passwd": "etc_passwd",
"": "unknown",
}
for input, want := range tests {
if got := safeReleaseBackupComponent(input); got != want {
t.Fatalf("safeReleaseBackupComponent(%q) = %q, want %q", input, got, want)
}
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{
"../clicd",
"..\\clicd",
"subdir/clicd",
"",
}
for _, name := range unsafeNames {
if _, err := copyFileToBackup("missing-source", name, 0755); err == nil || !strings.Contains(err.Error(), "unsafe backup file name") {
t.Fatalf("copyFileToBackup(%q) error = %v, want unsafe backup file name", name, err)
}
}
}
func TestFormatSSHAccessDoesNotExposePassword(t *testing.T) {
out := formatSSHAccess(2222)
if strings.Contains(out, "/") {
t.Fatalf("formatSSHAccess output contains credential separator: %q", out)
}
if strings.Contains(strings.ToLower(out), "password123") {
t.Fatalf("formatSSHAccess output exposed password: %q", out)
}
if !strings.Contains(out, "2222 -> 22") {
t.Fatalf("formatSSHAccess output = %q, want SSH port mapping", out)
}
}
func TestFormatSSHAccessHandlesMissingPort(t *testing.T) {
out := formatSSHAccess(0)
if !strings.Contains(out, "端口未分配") {
t.Fatalf("formatSSHAccess output = %q, want missing port message", out)
}
}
+20 -7
View File
@@ -33,6 +33,8 @@ type SavedTask struct {
TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"`
User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// SavedLoginLog for persisting login logs
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
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
@@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) {
}
if AppConfig.ApiKeys == nil {
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 {
AppConfig.AuditLogs = make([]AuditLog, 0)
+97 -10
View File
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
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 {
cfgPath := getConfigPath()
ext := filepath.Ext(cfgPath)
@@ -185,7 +207,12 @@ func ensureSchema() error {
prefix TEXT,
ip_whitelist 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -210,6 +237,8 @@ func ensureSchema() error {
created_at TEXT,
template_id TEXT,
user TEXT,
ip TEXT,
user_agent TEXT,
cfg_name TEXT,
cfg_virtualization TEXT,
cfg_template_id TEXT,
@@ -263,9 +292,55 @@ func ensureSchema() error {
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
}
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, &notNull, &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) {
meta := map[string]string{}
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 {
for _, k := range AppConfig.ApiKeys {
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
scopes := encodeStringSlice(k.Scopes)
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
}
}
@@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error {
for _, task := range AppConfig.Tasks {
cfg := parseSavedTaskConfig(task.Config)
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_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_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
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) {
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 {
return nil, err
}
@@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
result := []ApiKeyConfig{}
for rows.Next() {
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
}
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)
}
return result, rows.Err()
@@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) {
func loadTasks() ([]SavedTask, error) {
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_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,
@@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
var ip, userAgent sql.NullString
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.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
@@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) {
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
cfg.AssignIPv6 = assignIPv6 != 0
result = append(result, t)
configs = append(configs, cfg)
+237 -28
View File
@@ -2,7 +2,9 @@ package kvm
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
@@ -35,6 +37,7 @@ type Manager struct {
}
const ipv6GatewayLinkLocal = "fe80::1"
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
type usageSample struct {
CPUUsec uint64
@@ -114,7 +117,22 @@ func ImageDownloadedInfo(id string) (bool, int64) {
return true, info.Size()
}
// DownloadProgress reports KVM image download/conversion progress.
type DownloadProgress struct {
Stage string
DownloadedBytes int64
TotalBytes int64
Percent int
}
// DownloadProgressFunc receives download progress updates.
type DownloadProgressFunc func(DownloadProgress)
func DownloadImage(image Image) error {
return DownloadImageWithProgress(context.Background(), image, nil)
}
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
if err := os.MkdirAll(CacheDir(), 0755); err != nil {
return err
}
@@ -134,11 +152,15 @@ func DownloadImage(image Image) error {
tmp := target + ".tmp"
_ = os.Remove(tmp)
if image.Distro == "windows" {
if err := downloadFileWithValidator(image.URL, tmp, validateWindowsISOResponse(target)); err != nil {
if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
_ = os.Remove(tmp)
return err
}
} else if err := downloadFile(image.URL, tmp); err != nil {
} else if err := downloadFile(ctx, image.URL, tmp, progress); err != nil {
_ = os.Remove(tmp)
return err
}
if err := ctx.Err(); err != nil {
_ = os.Remove(tmp)
return err
}
@@ -153,8 +175,12 @@ func DownloadImage(image Image) error {
return err
}
} else {
if err := normalizeQCOW2(tmp, target); err != nil {
if progress != nil {
progress(DownloadProgress{Stage: "converting", Percent: 100})
}
if err := normalizeQCOW2(ctx, tmp, target); err != nil {
_ = os.Remove(tmp)
_ = os.Remove(target)
return err
}
}
@@ -168,11 +194,11 @@ func DeleteImage(id string) error {
type downloadResponseValidator func(*http.Response) error
func downloadFile(url, target string) error {
return downloadFileWithValidator(url, target, nil)
func downloadFile(ctx context.Context, url, target string, progress DownloadProgressFunc) error {
return downloadFileWithValidator(ctx, url, target, nil, progress)
}
func downloadFileWithValidator(url, target string, validate downloadResponseValidator) error {
func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error {
client := http.Client{
Timeout: 30 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -186,7 +212,7 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return nil
},
}
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
@@ -210,7 +236,48 @@ func downloadFileWithValidator(url, target string, validate downloadResponseVali
return err
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
total := resp.ContentLength
if total < 0 {
total = 0
}
if progress != nil {
progress(DownloadProgress{Stage: "downloading", TotalBytes: total})
}
buf := make([]byte, 256*1024)
var downloaded int64
for {
if err := ctx.Err(); err != nil {
return err
}
n, readErr := resp.Body.Read(buf)
if n > 0 {
written, writeErr := out.Write(buf[:n])
downloaded += int64(written)
if writeErr != nil {
return writeErr
}
if written != n {
return io.ErrShortWrite
}
if progress != nil {
percent := 0
if total > 0 {
percent = int(downloaded * 100 / total)
if percent > 99 {
percent = 99
}
}
progress(DownloadProgress{Stage: "downloading", DownloadedBytes: downloaded, TotalBytes: total, Percent: percent})
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
if err := ctx.Err(); err != nil {
return err
}
return out.Sync()
@@ -266,11 +333,11 @@ func validateWindowsISO(path, target string) error {
return nil
}
func normalizeQCOW2(src, target string) error {
func normalizeQCOW2(ctx context.Context, src, target string) error {
if err := requireCommand("qemu-img"); err != nil {
return err
}
cmd := exec.Command("qemu-img", "convert", "-O", "qcow2", src, target)
cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "qcow2", src, target)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("qemu-img convert failed: %v, output: %s", err, string(output))
}
@@ -371,13 +438,21 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
} else {
if image.Desktop != "" {
if cfg.RAMMB < 2048 {
cfg.RAMMB = 2048
}
if cfg.DiskGB < 20 {
cfg.DiskGB = 20
}
}
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6); err != nil {
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
return nil, err
}
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
}
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -639,7 +714,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
return m.StartContainer(id)
}
func (m *Manager) ResetSSHPassword(id int) (string, error) {
func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id)
if c == nil {
return "", fmt.Errorf("container not found: %d", id)
@@ -650,7 +725,9 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if c.Status != "running" {
return "", fmt.Errorf("KVM VM must be running before password reset")
}
password := generateRandomString(16)
if strings.TrimSpace(password) == "" {
password = generateRandomString(16)
}
if err := runKVMGuestAgentSSHSetup(c.VirshName(), password); err == nil {
c.SSHPassword = password
c.SSHHostKey = ""
@@ -663,10 +740,14 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
if err := m.EnsureSSH(id); err != nil {
return "", err
}
chpasswdInput, err := chpasswdStdin("root", password)
if err != nil {
return "", err
}
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second,
})
if err != nil {
@@ -678,8 +759,8 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err
}
defer session.Close()
cmd := fmt.Sprintf("printf 'root:%s\\n' | chpasswd", shellQuote(password))
if output, err := session.CombinedOutput(cmd); err != nil {
session.Stdin = bytes.NewReader(chpasswdInput)
if output, err := session.CombinedOutput("chpasswd"); err != nil {
return "", fmt.Errorf("failed to reset password: %v, output: %s", err, string(output))
}
c.SSHPassword = password
@@ -706,7 +787,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
}
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -731,7 +812,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
}
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err
@@ -1396,10 +1477,13 @@ func ensureDefaultNetwork() error {
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))
}
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
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
if !strings.Contains(strings.ToLower(string(out)), "active:") || !strings.Contains(strings.ToLower(string(out)), "yes") {
if !libvirtNetworkActive(string(out)) {
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
}
@@ -1411,6 +1495,19 @@ func ensureDefaultNetwork() error {
return nil
}
func libvirtNetworkActive(info string) bool {
for _, line := range strings.Split(info, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "Active") {
return strings.EqualFold(strings.TrimSpace(value), "yes")
}
}
return false
}
func createOverlayDisk(base, target string, diskGB int) error {
if diskGB < 1 {
diskGB = 5
@@ -1438,7 +1535,7 @@ func ensureVirtioWinISO() error {
virtioURL := "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
tmp := virtioPath + ".tmp"
_ = os.Remove(tmp)
if err := downloadFile(virtioURL, tmp); err != nil {
if err := downloadFile(context.Background(), virtioURL, tmp, nil); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("failed to download virtio-win.iso: %v", err)
}
@@ -1668,8 +1765,11 @@ func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string) error {
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
guestSetup := kvmSSHSetupScript(password)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
}
@@ -1743,7 +1843,12 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n")
}
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
func isKVMDesktopTemplate(templateID string) bool {
image := FindImage(templateID)
return image != nil && image.Desktop != ""
}
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
if vcpu < 1 {
vcpu = 1
}
@@ -1767,6 +1872,12 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<outbound average='%d'/>
</bandwidth>`, averageKiB, averageKiB)
}
video := "<video><model type='virtio'/></video>"
input := ""
if desktop {
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>"
}
return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name>
%s
@@ -1810,10 +1921,10 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<memballoon model='virtio'>
<stats period='10'/>
</memballoon>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>
<video><model type='virtio'/></video>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
%s
</devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth)
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
}
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
@@ -2122,7 +2233,7 @@ func (m *Manager) EnsureSSH(id int) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second,
})
if err != nil {
@@ -2263,6 +2374,64 @@ fi
`
}
func kvmDesktopSetupScript(image Image) string {
if strings.ToLower(strings.TrimSpace(image.Desktop)) != "xfce" {
return ""
}
packages := ""
switch image.Distro {
case "ubuntu":
packages = "xubuntu-desktop"
case "debian":
packages = "task-xfce-desktop"
default:
return ""
}
return `if command -v apt-get >/dev/null 2>&1; then
{
exec >>/var/log/clicd-desktop-setup.log 2>&1
echo "CLICD XFCE setup started at $(date -Is)"
export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
apt-get update || true
apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold ` + packages + ` || apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold xfce4 lightdm lightdm-gtk-greeter dbus-x11 xorg || true
if command -v useradd >/dev/null 2>&1 && ! id clicd >/dev/null 2>&1; then
useradd -m -s /bin/bash clicd || true
fi
if command -v chpasswd >/dev/null 2>&1 && id clicd >/dev/null 2>&1; then
printf 'clicd:%s\n' "$ROOT_PASSWORD" | chpasswd || true
fi
usermod -aG sudo clicd >/dev/null 2>&1 || true
usermod -aG autologin clicd >/dev/null 2>&1 || true
if id clicd >/dev/null 2>&1; then
printf 'startxfce4\n' >/home/clicd/.xsession || true
chown clicd:clicd /home/clicd/.xsession >/dev/null 2>&1 || true
fi
mkdir -p /etc/lightdm/lightdm.conf.d
cat >/etc/lightdm/lightdm.conf.d/50-clicd-autologin.conf <<'EOF'
[Seat:*]
autologin-user=clicd
autologin-user-timeout=0
user-session=xfce
greeter-session=lightdm-gtk-greeter
EOF
if [ -x /usr/sbin/lightdm ]; then
printf '/usr/sbin/lightdm\n' >/etc/X11/default-display-manager || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl set-default graphical.target >/dev/null 2>&1 || true
systemctl enable display-manager.service >/dev/null 2>&1 || true
systemctl enable lightdm.service >/dev/null 2>&1 || true
systemctl restart lightdm.service >/dev/null 2>&1 || systemctl start lightdm.service >/dev/null 2>&1 || true
fi
apt-get clean || true
echo "CLICD XFCE setup finished at $(date -Is)"
} || true
fi
`
}
func qemuGuestPing(name string) error {
out, err := exec.Command("virsh", "qemu-agent-command", name, `{"execute":"guest-ping"}`).CombinedOutput()
if err != nil {
@@ -3030,7 +3199,7 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
client, err := ssh.Dial("tcp", net.JoinHostPort(c.IP, "22"), &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: kvmHostKeyCallback(c),
Timeout: 8 * time.Second,
})
if err != nil {
@@ -3217,6 +3386,46 @@ func shellQuote(value string) string {
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 {
if count <= 0 {
return nil
+95
View File
@@ -0,0 +1,95 @@
package kvm
import (
"crypto/ed25519"
"crypto/rand"
"reflect"
"testing"
"clicd/internal/config"
"golang.org/x/crypto/ssh"
)
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
if err != nil {
t.Fatalf("chpasswdStdin returned error: %v", err)
}
want := []byte("root:" + password + "\n")
if !reflect.DeepEqual(got, want) {
t.Fatalf("chpasswdStdin = %#v, want %#v", got, want)
}
}
func TestChpasswdStdinRejectsNewlines(t *testing.T) {
tests := []struct {
name string
username string
password string
}{
{name: "username newline", username: "root\nadmin", password: "safe"},
{name: "username colon", username: "root:admin", password: "safe"},
{name: "password newline", username: "root", password: "safe\nroot:evil"},
{name: "password carriage return", username: "root", password: "safe\rroot:evil"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := chpasswdStdin(tc.username, tc.password); err == nil {
t.Fatal("chpasswdStdin returned nil error")
}
})
}
}
func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
key1 := testSSHPublicKey(t)
key2 := testSSHPublicKey(t)
saves := 0
c := &config.Container{}
save := func() error {
saves++
return nil
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("first host key verification returned error: %v", err)
}
if c.SSHHostKey == "" {
t.Fatal("first host key verification did not capture fingerprint")
}
if c.SSHHostKey != sshHostKeyFingerprint(key1) {
t.Fatalf("captured fingerprint = %q, want %q", c.SSHHostKey, sshHostKeyFingerprint(key1))
}
if saves != 1 {
t.Fatalf("save count = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key1, save); err != nil {
t.Fatalf("same host key verification returned error: %v", err)
}
if saves != 1 {
t.Fatalf("save count after same key = %d, want 1", saves)
}
if err := verifyKVMHostKey(c, key2, save); err == nil {
t.Fatal("mismatched host key verification returned nil error")
}
}
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatal(err)
}
return signer.PublicKey()
}
+15
View File
@@ -12,6 +12,7 @@ type Image struct {
Arch string `json:"arch"`
Description string `json:"description"`
URL string `json:"url"`
Desktop string `json:"desktop,omitempty"`
}
func GetImages() []Image {
@@ -22,6 +23,13 @@ func GetImages() []Image {
Description: "Ubuntu 24.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
},
{
ID: "kvm-ubuntu-noble-xfce", Name: "Ubuntu 24.04 XFCE KVM",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
Desktop: "xfce",
},
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
@@ -34,6 +42,13 @@ func GetImages() []Image {
Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "amd64",
+320 -69
View File
@@ -11,14 +11,13 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
"clicd/internal/config"
)
@@ -391,7 +390,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
@@ -403,9 +402,9 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
setCmd := m.rootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword)))
setCmd.Run()
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
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)
return nil
@@ -430,7 +429,7 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = 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
}
@@ -452,7 +451,7 @@ method=ignore
path := filepath.Join(nmDir, "eth0.nmconnection")
_ = 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")
@@ -467,16 +466,19 @@ IPv6AcceptRA=no
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
}
if !isRHELFamily {
_ = exec.Command("chroot", rootfsPath, "systemctl", "enable", "systemd-networkd").Run()
_ = m.runRootfsCommand(rootfsPath, "systemctl", "enable", "systemd-networkd")
}
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
_ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
if err != nil {
return err
}
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
@@ -990,6 +992,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)
}
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 {
uidBase, gidBase, err := unprivilegedIDMap()
if err != nil {
@@ -997,6 +1020,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil {
return nil
}
@@ -1005,11 +1031,10 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil {
return err
}
rootStat, ok := rootInfo.Sys().(*unix.Stat_t)
rootDev, _, _, ok := fileStatFields(rootInfo)
if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
}
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -1019,18 +1044,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err != nil {
return err
}
stat, ok := info.Sys().(*unix.Stat_t)
dev, uid, gid, ok := fileStatFields(info)
if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path)
}
if path != rootfsPath && stat.Dev != rootDev {
if path != rootfsPath && dev != rootDev {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
uid := int(stat.Uid)
gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
return nil
}
@@ -1040,7 +1063,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if gid >= 0 && gid < 65536 {
gid += gidBase
}
return unix.Lchown(path, uid, gid)
return os.Lchown(path, uid, gid)
}); err != nil {
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
}
@@ -1048,7 +1071,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
return err
}
if err := unix.Lchown(marker, uidBase, gidBase); err != nil {
if err := os.Lchown(marker, uidBase, gidBase); err != nil {
return err
}
@@ -1063,6 +1086,48 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil
}
func fileStatFields(info os.FileInfo) (dev uint64, uid int, gid int, ok bool) {
if info == nil || info.Sys() == nil {
return 0, 0, 0, false
}
stat := reflect.ValueOf(info.Sys())
if stat.Kind() == reflect.Pointer {
if stat.IsNil() {
return 0, 0, 0, false
}
stat = stat.Elem()
}
if stat.Kind() != reflect.Struct {
return 0, 0, 0, false
}
devValue, devOK := numericField(stat, "Dev")
uidValue, uidOK := numericField(stat, "Uid")
gidValue, gidOK := numericField(stat, "Gid")
if !devOK || !uidOK || !gidOK {
return 0, 0, 0, false
}
return devValue, int(uidValue), int(gidValue), true
}
func numericField(v reflect.Value, name string) (uint64, bool) {
field := v.FieldByName(name)
if !field.IsValid() {
return 0, false
}
switch field.Kind() {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return field.Uint(), true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value := field.Int()
if value < 0 {
return 0, false
}
return uint64(value), true
default:
return 0, false
}
}
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath)
if err != nil {
@@ -1584,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig()
}
script := sshSetupScript(c.SSHPassword, true)
script := sshSetupScript(true)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
@@ -1596,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error {
if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
}
if err := m.quickEnsureSSHPassword(lxcName, c.SSHPassword); err != nil {
return fmt.Errorf("failed to set SSH password in container %d: %v", id, err)
}
if c.IP == "" {
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
@@ -1614,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error {
}
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
if password == "" {
return fmt.Errorf("empty SSH password")
if err := validateRootPassword(password); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password)))
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
@@ -1628,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
return nil
}
func validateRootPassword(password string) error {
if password == "" {
return fmt.Errorf("empty SSH password")
}
if strings.ContainsAny(password, "\r\n") || strings.ContainsRune(password, '\x00') {
return fmt.Errorf("SSH password contains unsupported control characters")
}
return nil
}
func rootPasswordInput(password string) string {
return "root:" + password + "\n"
}
func (m *Manager) containerPortListening(lxcName string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -1635,9 +1717,8 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
}
func sshSetupScript(password string, startService bool) string {
func sshSetupScript(startService bool) string {
script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
# On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
@@ -1761,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no
set_sshd_option UsePAM no
if [ -n "$ROOT_PASSWORD" ]; then
printf '%s:%s\n' root "$ROOT_PASSWORD" | chpasswd || exit 31
passwd -u root >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add sshd default >/dev/null 2>&1 || true
fi
@@ -1823,14 +1899,17 @@ pgrep -x sshd >/dev/null 2>&1 || exit 33
}
// ResetSSHPassword resets the root password of a container
func (m *Manager) ResetSSHPassword(id int) (string, error) {
func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
c := config.FindContainer(id)
if c == nil {
return "", fmt.Errorf("container not found: %d", id)
}
lxcName := c.LxcName()
newPassword := generateRandomString(16)
newPassword := strings.TrimSpace(password)
if newPassword == "" {
newPassword = generateRandomString(16)
}
if c.Status == "running" {
c.SSHPassword = newPassword
@@ -1843,13 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return "", err
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, newPassword, c.Template); err != nil {
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
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)))
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
return "", fmt.Errorf("failed to set password: %v", err)
}
c.SSHPassword = newPassword
config.SaveConfig()
@@ -1858,22 +1935,133 @@ func (m *Manager) ResetSSHPassword(id int) (string, error) {
return newPassword, nil
}
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) *exec.Cmd {
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, error) {
cleanRootfsPath, err := m.safeRootfsPath(rootfsPath)
if err != nil {
return nil, err
}
safeArgs, err := safeRootfsCommandArgs(args)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil {
uidBase, gidBase, mapErr := unprivilegedIDMap()
if mapErr == nil {
cmdArgs := []string{
"-m", fmt.Sprintf("u:0:%d:65536", uidBase),
"-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", rootfsPath,
"--", "chroot", "--", cleanRootfsPath,
}
cmdArgs = append(cmdArgs, args...)
return exec.Command("lxc-usernsexec", cmdArgs...)
cmdArgs = append(cmdArgs, safeArgs...)
return exec.Command("lxc-usernsexec", cmdArgs...), nil
}
}
cmdArgs := append([]string{rootfsPath}, args...)
return exec.Command("chroot", cmdArgs...)
cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
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) setRootfsPassword(rootfsPath, password string) error {
if err := validateRootPassword(password); err != nil {
return err
}
cmd, err := m.rootfsCommand(rootfsPath, "chpasswd")
if err != nil {
return err
}
cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output))
}
return nil
}
func safeRootfsCommandArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, fmt.Errorf("empty rootfs command")
}
allowed := map[string]bool{
"chpasswd": true,
"rc-update": true,
"sh": true,
"systemctl": true,
}
if !allowed[args[0]] || strings.HasPrefix(args[0], "-") || strings.Contains(args[0], "/") {
return nil, fmt.Errorf("rootfs command is not allowed: %s", args[0])
}
for _, arg := range args {
if strings.ContainsRune(arg, '\x00') {
return nil, fmt.Errorf("rootfs command argument contains NUL byte")
}
}
if args[0] == "sh" {
if len(args) != 3 || args[1] != "-c" {
return nil, fmt.Errorf("unsupported rootfs shell invocation")
}
if !isCLICDManagedRootfsScript(args[2]) {
return nil, fmt.Errorf("refusing unmanaged rootfs shell script")
}
}
return append([]string(nil), args...), nil
}
func isCLICDManagedRootfsScript(script string) bool {
return strings.Contains(script, "99-clicd.conf") &&
strings.Contains(script, "install_sshd") &&
!strings.Contains(script, "ROOT_PASSWORD") &&
!strings.Contains(script, "chpasswd")
}
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)
}
parts := strings.Split(rel, string(os.PathSeparator))
if len(parts) != 2 || parts[1] != "rootfs" {
return "", fmt.Errorf("refusing nested or malformed rootfs path: %s", cleanRootfsPath)
}
if strings.HasPrefix(parts[0], "-") || !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(parts[0]) {
return "", fmt.Errorf("refusing unsafe container directory name: %s", parts[0])
}
return cleanRootfsPath, nil
}
func (m *Manager) cleanupContainerStorage(lxcName string) error {
@@ -2115,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
return imported, nil
}
func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) error {
if tmpl == nil {
return fmt.Errorf("template is nil")
}
tmpName := fmt.Sprintf("clicd-reinstall-%s-%s", lxcName, generateRandomString(8))
tmpDir := filepath.Join(m.LxcPath, tmpName)
if err := os.RemoveAll(tmpDir); err != nil {
return fmt.Errorf("failed to clean temporary reinstall directory: %v", err)
}
defer m.cleanupTemporaryContainer(tmpName)
args := []string{
"-n", tmpName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
output, err := exec.Command("lxc-create", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
}
tmpRootfs := filepath.Join(tmpDir, "rootfs")
if !rootfsHasInit(tmpRootfs) {
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.ensureDiskImageMounted(lxcName); err != nil {
return err
}
m.unmountRootfsChildMounts(rootfsPath)
if err := os.MkdirAll(rootfsPath, 0755); err != nil {
return err
}
if err := removeDirectoryContents(rootfsPath); err != nil {
return fmt.Errorf("failed to clear old rootfs: %v", err)
}
if err := copyRootfsContents(tmpRootfs, rootfsPath); err != nil {
return err
}
if !rootfsHasInit(rootfsPath) {
return fmt.Errorf("replacement rootfs copy failed: init not found")
}
return nil
}
func (m *Manager) cleanupTemporaryContainer(lxcName string) {
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
}
func removeDirectoryContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}
func copyRootfsContents(src, dst string) error {
output, err := exec.Command("cp", "-a", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to copy replacement rootfs: %v, output: %s", err, string(output))
}
return nil
}
// ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id)
@@ -2138,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Clean port mappings temporarily
m.CleanPortMappings(id)
// Destroy old LXC but keep config
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
exec.Command("umount", "-R", "-l", rootfs).Run()
os.RemoveAll(rootfs)
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
// Create new container with same LXC name (preserves ID)
cmd := exec.Command("lxc-create",
"-n", lxcName,
"-t", "download",
"--",
"-d", tmpl.Distro,
"-r", tmpl.Release,
"-a", tmpl.Arch,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
// Download the new OS into a temporary container, then replace only the
// existing rootfs. The target container directory and config are preserved.
if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
return err
}
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
@@ -2197,15 +2448,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16)
}
if err := m.preconfigureSSH(rootfsPath, c.SSHPassword, templateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err
}
setCmd := m.rootfsCommand(rootfsPath,
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword)))
setCmd.Run()
if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
}
// Update template and keep everything else the same
c.Template = templateID
+90
View File
@@ -0,0 +1,90 @@
package lxc
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestRootfsCommandAddsSeparatorForAllowedCommand(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, "chpasswd")
if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "chpasswd"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
}
}
func TestRootfsCommandRejectsUnmanagedCommand(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}
if _, err := m.rootfsCommand(rootfs, "true"); err == nil {
t.Fatal("rootfsCommand allowed unmanaged command")
}
}
func TestRootfsCommandRejectsLeadingDashContainerName(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}
if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
t.Fatal("rootfsCommand allowed leading-dash container name")
}
}
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, "chpasswd"); 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)
}
}
+47 -1
View File
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin")
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")
if r.Method == http.MethodOptions {
@@ -76,15 +76,18 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
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/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
@@ -112,6 +115,49 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
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/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
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)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
+1 -10
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.0"
Version = "1.1.6"
Repo = "MengMengCode/CLICD"
)
@@ -11,12 +11,3 @@ func Current() string {
}
return Version
}
+563 -713
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.0.0",
"version": "1.1.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@novnc/novnc": "1.6.0",
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.7",
@@ -21,11 +21,11 @@
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^8.0.16"
}
}
+2
View File
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
import HostReport from './pages/HostReport'
import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
@@ -64,6 +65,7 @@ function App() {
<Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
<Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} />
</Route>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
{/* Header */}
<div className="flex items-center justify-between mb-4">
<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" />
</div>
<div>
+16 -2
View File
@@ -4,6 +4,7 @@ import {
ChevronLeft,
ChevronRight,
Code2,
Cpu,
Camera,
LayoutDashboard,
LogOut,
@@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
const isSecurityPage = location.pathname.startsWith('/security')
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">
{!collapsed && (
<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" />
</div>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div>
)}
{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" />
</div>
)}
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span>API </span>}
</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
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+46 -2
View File
@@ -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 target = screenRef.current
if (!target) return
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
}
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.resizeSession = false
rfb.focusOnClick = true
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} catch (err) {
console.error(err)
setStatus('error')
setErrorMsg('WebVNC 初始化失败')
const message = err instanceof Error && err.message ? `${err.message}` : ''
setErrorMsg(`WebVNC 初始化失败${message}`)
}
}
File diff suppressed because it is too large Load Diff
+140 -13
View File
@@ -144,6 +144,10 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = 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 [snapshots, setSnapshots] = useState<Snapshot[]>([])
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[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(secureShuffle(password.split('')).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 () => {
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 {
const res = await resetSSHPassword(containerIdentifier)
const res = await resetSSHPassword(containerIdentifier, password)
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()
}
} catch (err) {
} catch (err: unknown) {
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 () => {
if (!containerIdentifier) return
setActionLoading('ipv6')
@@ -782,7 +824,7 @@ export default function ContainerDetail() {
<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 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" />}
</div>
<div>
@@ -874,7 +916,18 @@ export default function ContainerDetail() {
)}
<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 ? (
<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>
{!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>
@@ -1083,6 +1130,60 @@ export default function ContainerDetail() {
<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 && (
<Modal title={`WebSSH - ${container.name}`} onClose={() => setShowSSH(false)} wide>
<div className="h-[70vh] min-h-[520px]">
@@ -1953,6 +2054,32 @@ function TrafficBar({ container }: { container: Container }) {
)
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id
+328
View File
@@ -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 || '-'
}
+79 -12
View File
@@ -9,8 +9,9 @@ import {
ToggleRight,
Loader2,
AlertCircle,
X,
} 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'
export default function ImageManagement() {
@@ -34,10 +35,14 @@ export default function ImageManagement() {
useEffect(() => {
fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [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) => {
setActionLoading(templateId)
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) => {
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId)
@@ -125,6 +143,7 @@ export default function ImageManagement() {
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
totalCount={lxcImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
@@ -136,6 +155,7 @@ export default function ImageManagement() {
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
@@ -150,6 +170,7 @@ function ImageTable({
downloadedCount,
totalCount,
onDownload,
onCancelDownload,
onDelete,
onToggle,
}: {
@@ -159,6 +180,7 @@ function ImageTable({
downloadedCount: number
totalCount: number
onDownload: (id: string) => void
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
}) {
@@ -202,7 +224,7 @@ function ImageTable({
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-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)}
</span>
<div>
@@ -242,13 +264,18 @@ function ImageTable({
)}
{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">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</span>
<button
onClick={() => onCancelDownload(img.id)}
disabled={isBusy}
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
onClick={() => onToggle(img.id, img.enabled)}
@@ -287,10 +314,33 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
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">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
<div className="inline-flex flex-col gap-1">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700"
title={downloadStatusTitle(img)}
>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
{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>
)
}
@@ -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) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
}
+2 -9
View File
@@ -3,13 +3,6 @@ import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() {
const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('')
@@ -47,7 +40,7 @@ export default function Login() {
<div className="w-full max-w-md">
<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="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" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
@@ -113,7 +106,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.6</p>
</div>
</div>
)
+69 -57
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
@@ -20,7 +20,6 @@ export default function Settings() {
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const fetchLogs = useCallback(async () => {
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 () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); 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 }
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
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 {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) {
const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改')
else results.push('用户名修改失败')
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
}
if (newPwd) {
const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改')
else results.push('密码修改失败')
results.push(res.data.success ? '密码已修改' : '密码修改失败')
}
if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效')
setOldPwd(''); setNewPwd(''); setNewUsername('')
dialog.alert('完成', `${results.join('')}。下次登录生效`)
setOldPwd('')
setNewPwd('')
setNewUsername('')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
@@ -67,48 +81,48 @@ export default function Settings() {
if (loading) {
return (
<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>
)
}
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div>
<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>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<UserCog className="w-4 h-4" />
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></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" />
<label className="mb-1 block text-xs text-gray-500"></label>
<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>
<label className="block text-xs text-gray-500 mb-1"></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 位" />
<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 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></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 位" />
<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 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></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="输入当前密码以确认修改" />
<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 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</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>
{/* Login Logs */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<LogIn className="w-4 h-4" />
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<LogIn className="h-4 w-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
@@ -117,23 +131,23 @@ export default function Settings() {
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<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="text-left py-2 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="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
<tr className="border-b border-gray-100 text-gray-400">
<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="py-2 text-left font-medium"></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="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
<tr key={i}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={`${log.time}-${index}`}>
<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-500 font-mono">{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="py-1.5 font-mono text-gray-500">{log.ip}</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">
<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 ? '成功' : '失败'}
</span>
</td>
@@ -143,23 +157,22 @@ export default function Settings() {
</table>
</div>
{logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span>
<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}/{totalPages} </span>
<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(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>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
<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="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, totalPages) }, (_, i) => {
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
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(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(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(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>
)}
@@ -171,7 +184,6 @@ export default function Settings() {
}
function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS')
+114 -5
View File
@@ -94,6 +94,7 @@ export interface Template {
release: string
arch: string
variant?: string
desktop?: string
description: string
}
@@ -135,6 +136,16 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[]
}
export interface IPv4PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
subnet_mask: string
gateway: string
source: string
}
export interface DashboardStats {
total_containers: number
running: number
@@ -160,6 +171,93 @@ export interface HostInfo {
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 {
memory_usage_bytes: number
memory_total_bytes?: number
@@ -244,8 +342,8 @@ export const restartContainer = (id: ContainerIdentifier) =>
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
@@ -357,15 +455,24 @@ export interface ImageInfo {
downloaded: boolean
enabled: boolean
downloading: boolean
progress: number
downloaded_bytes: number
total_bytes: number
stage?: string
error?: string
size_bytes: number
manual_path?: string
desktop?: string
}
export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
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) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
@@ -383,6 +490,9 @@ export const getDashboard = () =>
export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info')
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
// Snapshots
export interface Snapshot {
id: string
@@ -446,10 +556,9 @@ export const getWebSSHUrl = (containerName: string) => {
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 params = new URLSearchParams({ container: containerName })
if (ticket) params.set('ticket', ticket)
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
}
+648 -103
View File
File diff suppressed because it is too large Load Diff