Compare commits

...

15 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
37 changed files with 4799 additions and 604 deletions
+1
View File
@@ -58,6 +58,7 @@ backend/tmp/
*.swp *.swp
*.swo *.swo
*~ *~
*.claude/
# OS # OS
.DS_Store .DS_Store
+272 -106
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -18,66 +17,100 @@ import (
) )
type ApiKey struct { type ApiKey struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"` IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"` LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
}
type apiKeyRequest struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
Disabled bool `json:"disabled"`
ContainerUUIDs []string `json:"container_uuids"`
}
var defaultApiKeyScopes = []string{
"dashboard:read",
"container:read",
"task:read",
"image:read",
"snapshot:read",
"routing:read",
"ipv6:read",
"host:read",
} }
// HandleApiKeys handles GET (list) and POST (create) for API keys // HandleApiKeys handles GET (list) and POST (create) for API keys
func HandleApiKeys(w http.ResponseWriter, r *http.Request) { func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if !requireScope(w, r, "apikey:read") {
return
}
listApiKeys(w, r) listApiKeys(w, r)
case http.MethodPost: case http.MethodPost:
if !requireScope(w, r, "apikey:create") {
return
}
createApiKey(w, r) createApiKey(w, r)
default: default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
} }
} }
// HandleApiKeyDelete handles DELETE for a specific API key // HandleApiKeyDelete handles PATCH and DELETE for a specific API key
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) { func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { switch r.Method {
case http.MethodPatch:
if !requireScope(w, r, "apikey:update") {
return
}
updateApiKey(w, r)
case http.MethodDelete:
if !requireScope(w, r, "apikey:delete") {
return
}
deleteApiKey(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
} }
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/") }
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) func apiKeyIDFromPath(path string) string {
return path = strings.TrimPrefix(path, "/api/api-keys/")
} path = strings.TrimPrefix(path, "/api/v1/api-keys/")
config.DeleteApiKey(keyID) return strings.Trim(path, "/")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
} }
func listApiKeys(w http.ResponseWriter, r *http.Request) { func listApiKeys(w http.ResponseWriter, r *http.Request) {
keys := make([]ApiKey, 0) keys := make([]ApiKey, 0)
for _, k := range config.AppConfig.ApiKeys { for _, k := range config.AppConfig.ApiKeys {
keys = append(keys, ApiKey{ keys = append(keys, apiKeyResponse(k))
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
})
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
} }
func createApiKey(w http.ResponseWriter, r *http.Request) { func createApiKey(w http.ResponseWriter, r *http.Request) {
var req struct { var req apiKeyRequest
Name string `json:"name"` if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
IPWhitelist string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
return return
} }
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
// Generate key: clicd_sk_ + 32 hex chars // Generate key: clicd_sk_ + 32 hex chars
rawBytes := make([]byte, 16) rawBytes := make([]byte, 16)
@@ -94,31 +127,109 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
} }
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
key := config.ApiKeyConfig{ key := config.ApiKeyConfig{
ID: generateShortID(), ID: generateShortID(),
Name: req.Name, Name: strings.TrimSpace(req.Name),
KeyHash: keyHash, KeyHash: keyHash,
Prefix: rawKey[:13] + "...", Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist), IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now, CreatedAt: now,
Scopes: scopes,
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
Disabled: req.Disabled,
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
} }
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key) config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
config.SaveConfig() if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
resp := apiKeyResponse(key)
resp.Key = rawKey
jsonResponse(w, http.StatusCreated, APIResponse{ jsonResponse(w, http.StatusCreated, APIResponse{
Success: true, Success: true,
Message: "API key created. Save this key now - it won't be shown again.", Message: "API key created. Save this key now - it won't be shown again.",
Data: ApiKey{ Data: resp,
ID: key.ID,
Name: key.Name,
Key: rawKey,
Prefix: key.Prefix,
IPWhitelist: key.IPWhitelist,
CreatedAt: key.CreatedAt,
},
}) })
} }
func updateApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
var req apiKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].ID != keyID {
continue
}
if strings.TrimSpace(req.Name) != "" {
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
}
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
if len(req.Scopes) > 0 {
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
}
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
return
}
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
return
}
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
}
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
keyID := apiKeyIDFromPath(r.URL.Path)
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
name := keyID
for _, k := range config.AppConfig.ApiKeys {
if k.ID == keyID {
name = k.Name
break
}
}
config.DeleteApiKey(keyID)
auditRequest(r, "apikey.delete", name, "", true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
return ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
Scopes: normalizeApiKeyScopes(k.Scopes),
ExpiresAt: k.ExpiresAt,
Disabled: k.Disabled,
ContainerUUIDs: k.ContainerUUIDs,
LastUsedIP: k.LastUsedIP,
}
}
func generateShortID() string { func generateShortID() string {
b := make([]byte, 4) b := make([]byte, 4)
rand.Read(b) rand.Read(b)
@@ -203,13 +314,21 @@ func matchApiKey(rawKey string) (idx int, needsRehash bool) {
// validateApiKey checks if the given key is valid and IP is allowed. // validateApiKey checks if the given key is valid and IP is allowed.
func validateApiKey(rawKey, clientIP string) bool { func validateApiKey(rawKey, clientIP string) bool {
_, ok := validateApiKeyDetails(rawKey, clientIP)
return ok
}
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
idx, needsRehash := matchApiKey(rawKey) idx, needsRehash := matchApiKey(rawKey)
if idx < 0 { if idx < 0 {
return false return nil, false
} }
k := config.AppConfig.ApiKeys[idx] k := &config.AppConfig.ApiKeys[idx]
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) { if k.Disabled || apiKeyExpired(k.ExpiresAt) {
return false return nil, false
}
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
return nil, false
} }
if needsRehash { if needsRehash {
if newHash, err := hashAPIKey(rawKey); err == nil { if newHash, err := hashAPIKey(rawKey); err == nil {
@@ -217,7 +336,38 @@ func validateApiKey(rawKey, clientIP string) bool {
config.SaveConfig() config.SaveConfig()
} }
} }
return true if len(k.Scopes) == 0 {
k.Scopes = []string{"*"}
}
return k, true
}
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return nil, false
}
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
if !ok {
return nil, false
}
updateApiKeyLastUsedForKey(key, clientIP(r))
return key, true
}
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
actor := "api:" + key.ID
if key.Name != "" {
actor = "api:" + key.Name
}
return AuthContext{
Type: authTypeAPIKey,
ApiKeyID: key.ID,
ApiKeyName: key.Name,
Actor: actor,
Scopes: normalizeApiKeyScopes(key.Scopes),
ContainerUUIDs: key.ContainerUUIDs,
}
} }
func apiKeyFromRequest(r *http.Request) string { func apiKeyFromRequest(r *http.Request) string {
@@ -232,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
} }
func isValidApiKeyRequest(r *http.Request) bool { func isValidApiKeyRequest(r *http.Request) bool {
apiKey := apiKeyFromRequest(r) _, ok := validateApiKeyRequest(r)
if apiKey == "" { return ok
return false
}
if !validateApiKey(apiKey, clientIP(r)) {
return false
}
updateApiKeyLastUsed(apiKey)
return true
} }
// isIPAllowed checks if clientIP matches any entry in the whitelist // isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool { func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP) clientIP = normalizeIPString(clientIP)
// Strip port if present client := net.ParseIP(clientIP)
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") { if client == nil {
clientIP = clientIP[:idx] return false
} }
for _, entry := range strings.Split(whitelist, "\n") { for _, entry := range strings.Split(whitelist, "\n") {
entry = strings.TrimSpace(entry) entry = strings.TrimSpace(entry)
@@ -256,74 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
continue continue
} }
if strings.Contains(entry, "/") { if strings.Contains(entry, "/") {
// CIDR match _, network, err := net.ParseCIDR(entry)
if ipInCIDR(clientIP, entry) { if err == nil && network.Contains(client) {
return true return true
} }
} else if entry == clientIP { continue
}
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
return true return true
} }
} }
return false return false
} }
func ipInCIDR(ipStr, cidr string) bool { func normalizeIPString(s string) string {
parts := strings.Split(cidr, "/")
if len(parts) != 2 {
return false
}
// Simple prefix match for IPv4
ip := netParseIP(ipStr)
cidrIP := netParseIP(parts[0])
if ip == nil || cidrIP == nil {
return false
}
bits, err := strconv.Atoi(parts[1])
if err != nil || bits < 0 || bits > 32 {
return false
}
mask := uint32(0xFFFFFFFF) << (32 - bits)
ipVal := ip4ToUint32(ip)
cidrVal := ip4ToUint32(cidrIP)
return (ipVal & mask) == (cidrVal & mask)
}
func netParseIP(s string) net.IP {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") { if host, _, err := net.SplitHostPort(s); err == nil {
s = s[:idx] return strings.Trim(host, "[]")
} }
return net.ParseIP(s) return strings.Trim(s, "[]")
} }
func ip4ToUint32(ip net.IP) uint32 { func ipInCIDR(ipStr, cidr string) bool {
ip = ip.To4() ip := net.ParseIP(normalizeIPString(ipStr))
if ip == nil { _, network, err := net.ParseCIDR(cidr)
return 0 return err == nil && ip != nil && network.Contains(ip)
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
} }
// updateApiKeyLastUsed marks the key as recently used. // updateApiKeyLastUsed marks the key as recently used.
func updateApiKeyLastUsed(rawKey string) { func updateApiKeyLastUsed(rawKey string) {
idx, _ := matchApiKey(rawKey) key, ok := validateApiKeyDetails(rawKey, "")
if idx < 0 { if !ok {
return return
} }
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05") 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() config.SaveConfig()
} }
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc { func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
apiKey := apiKeyFromRequest(r) key, ok := validateApiKeyRequest(r)
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) { if !ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return return
} }
next(w, withAuthContext(r, authContextFromAPIKey(key)))
updateApiKeyLastUsed(apiKey)
next(w, r)
} }
} }
func normalizeApiKeyScopes(scopes []string) []string {
return normalizeRequestedScopes(scopes, []string{"*"})
}
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
result := normalizeStringSlice(scopes)
if len(result) == 0 {
return append([]string(nil), fallback...)
}
return result
}
func normalizeStringSlice(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func validApiKeyTime(value string) bool {
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil
}
func apiKeyExpired(value string) bool {
if strings.TrimSpace(value) == "" {
return false
}
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
return err == nil && !time.Now().Before(expiresAt)
}
+165 -4
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }
type authContextKey struct{}
type AuthContext struct {
Type string
Username string
ApiKeyID string
ApiKeyName string
Actor string
Scopes []string
ContainerUUIDs []string
}
const (
authTypeAdmin = "admin"
authTypeSubUser = "sub_user"
authTypeAPIKey = "api_key"
)
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
}
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
return ctx, ok
}
func requestActor(r *http.Request) string {
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
return ctx.Actor
}
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func hasScope(r *http.Request, scope string) bool {
ctx, ok := authContextFromRequest(r)
if !ok {
return true
}
switch ctx.Type {
case authTypeAdmin:
return true
case authTypeSubUser:
return subUserScopeAllowed(scope)
case authTypeAPIKey:
return scopeAllowed(ctx.Scopes, scope)
default:
return false
}
}
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
default:
return false
}
}
func hasAnyScope(r *http.Request, scopes ...string) bool {
for _, scope := range scopes {
if hasScope(r, scope) {
return true
}
}
return false
}
func scopeAllowed(scopes []string, required string) bool {
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == "admin:*" || scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasScope(r, scope) {
return true
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return false
}
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, scope) {
return
}
next(w, r)
}
}
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if hasAnyScope(r, scopes...) {
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
}
}
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) { func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if subUser, _ := claims["sub_user"].(string); subUser != "" { if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64) tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat) tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser { if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly. // If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely. // This also rejects legacy tokens that lack token_version entirely.
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
break break
} }
} }
if !foundSubUser {
return nil, false
}
} }
return claims, ok return claims, ok
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
} }
func isSubUserRequest(r *http.Request) bool { func isSubUserRequest(r *http.Request) bool {
if ctx, ok := authContextFromRequest(r); ok {
return ctx.Type == authTypeSubUser
}
claims, ok := claimsFromRequest(r) claims, ok := claimsFromRequest(r)
if !ok { if !ok {
return false return false
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r) tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) { if claims, ok := claimsFromToken(tokenString); ok {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) if subUser, _ := claims["sub_user"].(string); subUser != "" {
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
if values, ok := claims["container_uuids"].([]interface{}); ok {
for _, value := range values {
if uuid, ok := value.(string); ok {
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
}
}
}
next(w, withAuthContext(r, auth))
return
}
username, _ := claims["username"].(string)
if username == "" {
username = config.AppConfig.AdminUser
}
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
return return
} }
next(w, r) if key, ok := validateApiKeyRequest(r); ok {
next(w, withAuthContext(r, authContextFromAPIKey(key)))
return
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
} }
} }
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens. // AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if isSubUserRequest(r) { ctx, _ := authContextFromRequest(r)
if ctx.Type == authTypeSubUser {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return
}
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return return
} }
@@ -0,0 +1,7 @@
//go:build !linux
package api
func getRootDiskInfo() (DiskInfo, bool) {
return DiskInfo{}, false
}
+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
}
+99 -1
View File
@@ -20,17 +20,41 @@ var lxcManager = lxc.NewManager()
func HandleContainers(w http.ResponseWriter, r *http.Request) { func HandleContainers(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
listContainers(w, r) listContainers(w, r)
case http.MethodPost: case http.MethodPost:
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
createContainer(w, r) createContainer(w, r)
default: default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
} }
} }
// 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}/... // HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/") path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
path = strings.TrimPrefix(path, "/api/containers/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
id := 0 id := 0
@@ -50,6 +74,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
if isSnapshotAction && id == 0 { if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself // For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/") snapshotID := strings.TrimPrefix(action, "snapshots/")
@@ -61,45 +89,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
} }
id = snapshot.ContainerID id = snapshot.ContainerID
} }
if isSnapshotAction {
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
}
switch { switch {
case action == "start" && r.Method == http.MethodPost: case action == "start" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "start") HandleSingleTaskAction(w, r, id, "start")
case action == "stop" && r.Method == http.MethodPost: case action == "stop" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "stop") HandleSingleTaskAction(w, r, id, "stop")
case action == "restart" && r.Method == http.MethodPost: case action == "restart" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:power") {
return
}
HandleSingleTaskAction(w, r, id, "restart") HandleSingleTaskAction(w, r, id, "restart")
case action == "reinstall" && r.Method == http.MethodPost: case action == "reinstall" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:reinstall") {
return
}
HandleSingleTaskAction(w, r, id, "reinstall") HandleSingleTaskAction(w, r, id, "reinstall")
case action == "delete" && r.Method == http.MethodDelete: case action == "delete" && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:delete") {
return
}
HandleSingleTaskAction(w, r, id, "delete") HandleSingleTaskAction(w, r, id, "delete")
case action == "reset-password" && r.Method == http.MethodPost: case action == "reset-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:password") {
return
}
resetSSHPassword(w, r, id) resetSSHPassword(w, r, id)
case action == "usage" && r.Method == http.MethodGet: case action == "usage" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getUsage(w, r, id) getUsage(w, r, id)
case action == "traffic" && r.Method == http.MethodGet: case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getTraffic(w, r, id) getTraffic(w, r, id)
case action == "traffic-reset" && r.Method == http.MethodPost: case action == "traffic-reset" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:traffic") {
return
}
resetTraffic(w, r, id) resetTraffic(w, r, id)
case action == "traffic-limit" && r.Method == http.MethodPut: case action == "traffic-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:traffic") {
return
}
updateTrafficLimit(w, r, id) updateTrafficLimit(w, r, id)
case action == "resource-limit" && r.Method == http.MethodPut: case action == "resource-limit" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateResourceLimit(w, r, id) updateResourceLimit(w, r, id)
case action == "random-port" && r.Method == http.MethodGet: case action == "random-port" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getRandomPort(w, r, id) getRandomPort(w, r, id)
case action == "expiry" && r.Method == http.MethodPut: case action == "expiry" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:resize") {
return
}
updateExpiry(w, r, id) updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost: case action == "ipv6" && r.Method == http.MethodPost:
if !requireScope(w, r, "ipv6:assign") {
return
}
assignIPv6(w, r, id) assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action) handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost: case action == "port-mappings" && r.Method == http.MethodPost:
if !requireScope(w, r, "container:network") {
return
}
addPortMapping(w, r, id) addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete: case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "container:network") {
return
}
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case r.Method == http.MethodGet: case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
getContainer(w, r, id) getContainer(w, r, id)
default: default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
@@ -348,6 +436,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
if isSubUserRequest(r) { if isSubUserRequest(r) {
HandleEnabledImages(w, r) HandleEnabledImages(w, r)
return return
@@ -362,7 +453,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "dashboard:read") {
return
}
containers, _ := listByRuntime() containers, _ := listByRuntime()
containers = filterContainersForRequest(r, containers)
running := 0 running := 0
stopped := 0 stopped := 0
for _, c := range containers { for _, c := range containers {
@@ -386,6 +481,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "host:read") {
return
}
info := getHostInfo() info := getHostInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
} }
File diff suppressed because it is too large Load Diff
+18
View File
@@ -221,6 +221,9 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors() cleanupOldImageDownloadErrors()
@@ -287,6 +290,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:download") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -397,6 +403,9 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:download") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
} }
@@ -434,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:delete") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -484,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:toggle") {
return
}
var req struct { var req struct {
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
@@ -510,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "image:read") {
return
}
runtime := runtimeFromRequest(r.URL.Query().Get("type")) runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "ipv6:read") {
return
}
status := lxcManager.DetectIPv6Status() status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
} }
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0) nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{} usedPorts := map[int]bool{}
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
} }
// HandleSecuritySettings returns or updates security automation settings. // HandleSecuritySettings returns or updates security automation settings.
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) { func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown, "auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}}) }})
case http.MethodPut: case http.MethodPut:
if !requireScope(w, r, "security:settings") {
return
}
var req struct { var req struct {
AutoShutdown bool `json:"auto_shutdown"` AutoShutdown bool `json:"auto_shutdown"`
} }
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown, "auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}}) }})
@@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:check") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
@@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
return return
} }
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
ensureScanner().checkContainer(c.Name, c.IP) ensureScanner().checkContainer(c.Name, c.IP)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
@@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
return
}
containerName := r.URL.Query().Get("container") containerName := r.URL.Query().Get("container")
if containerName == "" { if containerName == "" {
@@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
return return
} }
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
} }
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "security:read") {
return
}
critical := 0 critical := 0
high := 0 high := 0
medium := 0 medium := 0
low := 0 low := 0
alerts := mergedSecurityAlerts() alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
for _, a := range alerts { for _, a := range alerts {
switch a.Severity { switch a.Severity {
case "critical": case "critical":
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
} }
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return alerts
}
filtered := make([]SecurityAlert, 0, len(alerts))
for _, alert := range alerts {
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, alert)
}
}
return filtered
}
func mergedSecurityAlerts() []SecurityAlert { func mergedSecurityAlerts() []SecurityAlert {
ss := ensureScanner() ss := ensureScanner()
ss.mu.Lock() ss.mu.Lock()
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first) // Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs)) reversed := make([]LoginLog, len(loginLogs))
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...) snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(snapshots) sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
} }
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) { func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch { switch {
case action == "snapshots" && r.Method == http.MethodGet: case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID) listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost: case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID) createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost: case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID) updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut: case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID) updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost: case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:restore") {
return
}
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore") snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID) restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete: case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/") snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID) deleteContainerSnapshot(w, r, containerID, snapshotID)
default: default:
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
} }
func requestUser(r *http.Request) string { func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok { return requestActor(r)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
} }
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) { func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
return tj.Before(ti) return tj.Before(ti)
}) })
} }
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return snapshots
}
filtered := make([]config.Snapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, snapshot)
}
}
return filtered
}
+3
View File
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
if !requireScope(w, r, "terminal:ssh") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
} }
+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"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "subuser:create") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
return allowed, true return allowed, true
} }
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
if ctx, ok := authContextFromRequest(r); ok {
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
return subUserAccess{}, false
}
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
for _, uuid := range ctx.ContainerUUIDs {
allowed.uuids[uuid] = true
}
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
legacy, ok := subUserAllowedContainers(r)
if ok {
return legacy, true
}
}
return allowed, true
}
}
return subUserAllowedContainers(r)
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
}
func containerByIdentifier(identifier string) *config.Container { func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier) return config.FindContainerByIdentifier(identifier)
} }
func isContainerAllowedForRequest(r *http.Request, identifier string) bool { func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r) allowed, restricted := requestAllowedContainers(r)
if !isSubUser { if !restricted {
return true return true
} }
c := containerByIdentifier(identifier) c := containerByIdentifier(identifier)
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "audit:read") {
return
}
logs := config.AppConfig.AuditLogs logs := config.AppConfig.AuditLogs
if logs == nil { if logs == nil {
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
path := r.URL.Path path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet { containerPrefix := "/api/containers/"
containerListPath := "/api/containers"
tasksPath := "/api/tasks"
if strings.HasPrefix(path, "/api/v1/") {
containerPrefix = "/api/v1/containers/"
containerListPath = "/api/v1/containers"
tasksPath = "/api/v1/tasks"
}
if path == tasksPath && r.Method == http.MethodGet {
next(w, r) next(w, r)
return return
} }
if path == "/api/containers" { if path == containerListPath {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return return
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return return
} }
if len(path) > len("/api/containers/") { if strings.HasPrefix(path, containerPrefix) {
rest := path[len("/api/containers/"):] rest := path[len(containerPrefix):]
parts := splitPath(rest) parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" { if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
@@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
} }
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container { func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r) allowed, restricted := requestAllowedContainers(r)
if !isSubUser { if !restricted {
return containers return containers
} }
filtered := make([]config.Container, 0, len(containers)) filtered := make([]config.Container, 0, len(containers))
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
} }
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task { func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks)) filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) { if isTaskAllowedForRequest(r, task) {
filtered = append(filtered, task) filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
} }
} }
return filtered return filtered
} }
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
if task == nil {
return false
}
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
return true
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
return false
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return c != nil && c.UUID != "" && allowed.uuids[c.UUID] if c == nil {
return false
}
if c.UUID != "" && allowed.uuids[c.UUID] {
return true
}
return c.Name != "" && allowed.names[c.Name]
} }
func isSubUserBlockedAction(action string, method string) bool { func isSubUserBlockedAction(action string, method string) bool {
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "subuser:read") {
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers)) result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
// HandleSubUserAction handles actions on a specific sub-user // HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/") path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
path = strings.TrimPrefix(path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
subUserID := parts[0] subUserID := parts[0]
action := "" action := ""
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
switch { switch {
case action == "rotate-password" && r.Method == http.MethodPost: case action == "rotate-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "subuser:update") {
return
}
password := generateRandomStr(16) password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash) target.PassHash = string(hash)
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet: case action == "audit-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "audit:read") {
return
}
// Filter audit logs for this sub-user // Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username) logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet: case action == "login-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "loginlog:read") {
return
}
// Filter login logs for this sub-user // Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username) logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
+50 -27
View File
@@ -11,19 +11,27 @@ import (
) )
type SwapInfo struct { type SwapInfo struct {
TotalMB int64 `json:"total_mb"` TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"` UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"` FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"` SwapFile string `json:"swap_file"`
} }
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status // HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) { func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo() info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "swap:manage") {
return
}
var req struct { var req struct {
Action string `json:"action"` // create, enable, disable, resize Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize SizeMB int `json:"size_mb"` // for create/resize
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
} }
var msg string var msg string
var err error
switch req.Action { switch req.Action {
case "create": case "create":
if req.SizeMB <= 0 { if req.SizeMB <= 0 {
req.SizeMB = 2048 req.SizeMB = 2048
} }
err := createSwap(req.SizeMB) if err = validateSwapSize(req.SizeMB); err == nil {
if err != nil { err = createSwap(req.SizeMB)
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
} }
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB) msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable": case "enable":
err := enableSwap() err = enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已启用" msg = "SWAP 已启用"
case "disable": case "disable":
err := disableSwap() err = disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已禁用" msg = "SWAP 已禁用"
case "resize": case "resize":
if req.SizeMB <= 0 { if err = validateSwapSize(req.SizeMB); err == nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"}) err = disableSwap()
return }
if err == nil {
err = createSwap(req.SizeMB)
}
if err == nil {
err = enableSwap()
} }
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB) msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return return
} }
if err != nil {
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
info := getSwapInfo() info := getSwapInfo()
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
} }
func validateSwapSize(sizeMB int) error {
if sizeMB < minSwapSizeMB {
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
}
if sizeMB > maxSwapSizeMB {
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
}
return nil
}
func getSwapInfo() SwapInfo { func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"} info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error { func enableSwap() error {
swapFile := "/swapfile" swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) { if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建") return fmt.Errorf("swap 文件不存在,请先创建")
} }
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile) cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
if strings.Contains(string(output), "No such") { if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
return nil return nil
} }
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output)) return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+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 { func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
}
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs) return q.enqueueBatchCreateList(configs, user, ip, userAgent)
} }
func (q *TaskQueue) ActiveCreateNames() map[string]bool { func (q *TaskQueue) ActiveCreateNames() map[string]bool {
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
return names return names
} }
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string { func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
var result []string var result []string
for _, cfg := range configs { for _, cfg := range configs {
cfgCopy := cfg cfgCopy := cfg
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
Status: "pending", Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy, Config: cfgCopy,
User: user,
IP: ip,
UserAgent: userAgent,
} }
q.enqueueTask(task) q.enqueueTask(task)
result = append(result, task.ID) result = append(result, task.ID)
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
TemplateID: t.TemplateID, TemplateID: t.TemplateID,
Config: string(cfgJSON), Config: string(cfgJSON),
User: t.User, User: t.User,
IP: t.IP,
UserAgent: t.UserAgent,
}) })
} }
config.SaveTasks(saved) config.SaveTasks(saved)
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
name = c.Name name = c.Name
} }
// Determine user from JWT claims // Determine user from authenticated request context.
user := "admin" user := requestActor(r)
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
ip := clientIP(r) ip := clientIP(r)
userAgent := r.Header.Get("User-Agent") userAgent := r.Header.Get("User-Agent")
@@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
var req struct { var req struct {
Containers []lxc.ContainerConfig `json:"containers"` Containers []lxc.ContainerConfig `json:"containers"`
} }
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
} }
requestNames[name] = true requestNames[name] = true
} }
ids := globalQueue.EnqueueBatchCreate(req.Containers) ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
} }
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return
}
var req struct { var req struct {
Action string `json:"action"` Action string `json:"action"`
Containers []int `json:"containers"` Containers []int `json:"containers"`
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
} }
var taskType TaskType var taskType TaskType
var requiredScope string
switch req.Action { switch req.Action {
case "start": case "start":
taskType = TaskStart taskType = TaskStart
requiredScope = "container:power"
case "stop": case "stop":
taskType = TaskStop taskType = TaskStop
requiredScope = "container:power"
case "restart": case "restart":
taskType = TaskRestart taskType = TaskRestart
requiredScope = "container:power"
case "delete": case "delete":
taskType = TaskDelete taskType = TaskDelete
requiredScope = "container:delete"
case "reinstall":
if req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall
requiredScope = "container:reinstall"
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return return
} }
if !requireScope(w, r, requiredScope) {
return
}
for _, id := range req.Containers {
c := config.FindContainer(id)
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
}
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID) ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
} }
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
// URL: /api/tasks/{id} if !requireScope(w, r, "task:delete") {
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/") return
}
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
if taskID == "" { if taskID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return return
} }
globalQueue.mu.Lock() globalQueue.mu.Lock()
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
return
}
delete(globalQueue.tasks, taskID) delete(globalQueue.tasks, taskID)
// Also remove from both queues if pending // Also remove from both queues if pending
newCreate := make([]*Task, 0, len(globalQueue.createQueue)) newCreate := make([]*Task, 0, len(globalQueue.createQueue))
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if !requireScope(w, r, "task:read") {
return
}
tasks := globalQueue.GetTasks() tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks) tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
@@ -691,6 +744,8 @@ func RestoreTasks() {
TemplateID: st.TemplateID, TemplateID: st.TemplateID,
Config: cfg, Config: cfg,
User: st.User, User: st.User,
IP: st.IP,
UserAgent: st.UserAgent,
} }
if st.Status == "pending" || st.Status == "running" { if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried // Reset running tasks back to pending so they get retried
+43 -6
View File
@@ -18,7 +18,10 @@ import (
type webVNCTicket struct { type webVNCTicket struct {
ContainerName string ContainerName string
ContainerUUID string ContainerUUID string
Username string
SubUser bool SubUser bool
ClientIP string
UserAgent string
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
if !requireScope(w, r, "terminal:vnc") {
return
}
var req struct { var req struct {
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
} }
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
username, isSubUser := vncRequesterIdentity(r)
ticket := randomHex(32) ticket := randomHex(32)
webVNCTickets.Lock() webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now()) cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{ webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name, ContainerName: c.Name,
ContainerUUID: c.UUID, ContainerUUID: c.UUID,
SubUser: isSubUserRequest(r), Username: username,
SubUser: isSubUser,
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(60 * time.Second), ExpiresAt: time.Now().Add(60 * time.Second),
} }
webVNCTickets.Unlock() webVNCTickets.Unlock()
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
item, ok := consumeWebVNCTicket(ticket, containerName) item, ok := consumeWebVNCTicket(ticket, containerName, r)
if !ok { if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return return
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
} }
defer ws.Close() defer ws.Close()
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort) log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
done := make(chan string, 2) done := make(chan string, 2)
var writeMu sync.Mutex var writeMu sync.Mutex
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
reason := <-done reason := <-done
_ = vncConn.Close() _ = vncConn.Close()
_ = ws.Close() _ = ws.Close()
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason) log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
}
func vncRequesterIdentity(r *http.Request) (string, bool) {
if ctx, ok := authContextFromRequest(r); ok {
switch ctx.Type {
case authTypeSubUser:
return ctx.Username, true
case authTypeAPIKey:
return ctx.Actor, false
case authTypeAdmin:
return ctx.Username, false
}
}
claims, ok := claimsFromRequest(r)
if !ok {
return "api-key", false
}
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
return subUser, true
}
if username, ok := claims["username"].(string); ok && username != "" {
return username, false
}
return "unknown", false
} }
func webVNCTicketFromRequest(r *http.Request) string { func webVNCTicketFromRequest(r *http.Request) string {
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
return "" return ""
} }
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
now := time.Now() now := time.Now()
webVNCTickets.Lock() webVNCTickets.Lock()
defer webVNCTickets.Unlock() defer webVNCTickets.Unlock()
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
return webVNCTicket{}, false return webVNCTicket{}, false
} }
delete(webVNCTickets.items, ticket) delete(webVNCTickets.items, ticket)
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) return item, item.ContainerName == containerName &&
item.ClientIP == clientIP(r) &&
item.UserAgent == r.UserAgent() &&
now.Before(item.ExpiresAt)
} }
func cleanupExpiredWebVNCTicketsLocked(now time.Time) { func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
+179 -2
View File
@@ -21,8 +21,9 @@ import (
var manager = lxc.NewManager() var manager = lxc.NewManager()
const ( const (
clicdBackupDir = "/root/clicd-backups" clicdBackupDir = "/root/clicd-backups"
clicdNewBinaryPath = "/usr/local/bin/clicd.new" clicdNewBinaryPath = "/usr/local/bin/clicd.new"
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
) )
// Run starts the CLI interface. // Run starts the CLI interface.
@@ -754,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
destroyAllLXCContainers() destroyAllLXCContainers()
destroyAllKVMDomains() destroyAllKVMDomains()
removeCLICDLibvirtDefaultNetwork()
cleanupCLICDNetworking() cleanupCLICDNetworking()
removeCLICDHostHooks() removeCLICDHostHooks()
removeCLICDQuotaRecords() removeCLICDQuotaRecords()
@@ -840,8 +842,60 @@ func removeKVMDomain(name string) {
runQuiet("virsh", "undefine", name) runQuiet("virsh", "undefine", name)
} }
func removeCLICDLibvirtDefaultNetwork() {
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
runQuiet("virsh", "net-destroy", "default")
runQuiet("virsh", "net-undefine", "default")
removePath(libvirtDefaultNetworkMarker)
}
func libvirtDefaultUsedByNonCLICDDomain() bool {
if !commandExists("virsh") {
return false
}
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || isCLICDKVMDomain(name) {
continue
}
if usesLibvirtDefaultNetwork(name) {
return true
}
}
return false
}
func usesLibvirtDefaultNetwork(domain string) bool {
out, err := exec.Command("virsh", "domiflist", domain).Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
for _, field := range fields {
if field == "default" || field == "virbr0" {
return true
}
}
}
return false
}
func cleanupCLICDNetworking() { func cleanupCLICDNetworking() {
removeCLICDNATRules() removeCLICDNATRules()
cleanupCLICDIPv6Runtime()
cleanupCLICDIPv6BridgeRoutes()
for _, bridge := range []string{"lxcbr0", "virbr0"} { for _, bridge := range []string{"lxcbr0", "virbr0"} {
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT") deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT") deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
@@ -850,6 +904,123 @@ func cleanupCLICDNetworking() {
} }
} }
func cleanupCLICDIPv6Runtime() {
if config.AppConfig == nil {
return
}
for _, c := range config.AppConfig.Containers {
cleanupCLICDContainerIPv6(c)
}
}
func cleanupCLICDContainerIPv6(c config.Container) {
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
if strings.TrimSpace(c.IPv6) == "" {
return
}
addr := strings.TrimSpace(c.IPv6)
if slash := strings.Index(addr, "/"); slash >= 0 {
addr = addr[:slash]
}
source := strings.TrimSpace(c.IPv6)
if !strings.Contains(source, "/") {
source += "/128"
}
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
if mac != "" && bridge == "virbr0" {
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
}
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
if strings.TrimSpace(c.IPv6Interface) != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
}
}
func cleanupCLICDIPv6BridgeRoutes() {
if !commandExists("ip") {
return
}
for _, bridge := range []string{"lxcbr0", "virbr0"} {
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
continue
}
source := fields[0]
addr := strings.TrimSuffix(source, "/128")
deleteIP6NATSource(source)
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
removeProxyNDPForAddress(addr)
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
}
}
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
}
}
func removeProxyNDPForAddress(addr string) {
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
if err != nil {
return
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != addr {
continue
}
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" {
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
}
}
}
}
func deleteIP6NATSource(source string) {
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
return
}
for {
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
if err != nil {
return
}
deleted := false
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "-A" {
continue
}
fields[0] = "-D"
args := append([]string{"-t", "nat"}, fields...)
deleted = runCommandOK("ip6tables", args...)
break
}
if !deleted {
return
}
}
}
func removeCLICDNATRules() { func removeCLICDNATRules() {
if commandExists("iptables") { if commandExists("iptables") {
for { for {
@@ -879,6 +1050,12 @@ func deleteFilterRule(args ...string) {
} }
} }
func deleteIP6FilterRule(args ...string) {
fullArgs := append([]string{"-D"}, args...)
for runCommandOK("ip6tables", fullArgs...) {
}
}
func deleteIP6TablesBridgeRules(bridge string) { func deleteIP6TablesBridgeRules(bridge string) {
if !commandExists("ip6tables") { if !commandExists("ip6tables") {
return return
+20 -7
View File
@@ -33,6 +33,8 @@ type SavedTask struct {
TemplateID string `json:"template_id,omitempty"` TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"` Config string `json:"config,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
} }
// SavedLoginLog for persisting login logs // SavedLoginLog for persisting login logs
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
// SubUser represents a sub-user with access to specific containers // SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct { type ApiKeyConfig struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
KeyHash string `json:"key_hash"` KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"` IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"` LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
} }
// DeleteApiKey removes an API key by ID // DeleteApiKey removes an API key by ID
@@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) {
} }
if AppConfig.ApiKeys == nil { if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0) AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
} else {
for i := range AppConfig.ApiKeys {
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
AppConfig.ApiKeys[i].Scopes = []string{"*"}
}
}
} }
if AppConfig.AuditLogs == nil { if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0) AppConfig.AuditLogs = make([]AuditLog, 0)
+97 -10
View File
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
return string(data) return string(data)
} }
func encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
}
data, err := json.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func decodeStringSlice(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func getDBPath() string { func getDBPath() string {
cfgPath := getConfigPath() cfgPath := getConfigPath()
ext := filepath.Ext(cfgPath) ext := filepath.Ext(cfgPath)
@@ -185,7 +207,12 @@ func ensureSchema() error {
prefix TEXT, prefix TEXT,
ip_whitelist TEXT, ip_whitelist TEXT,
created_at TEXT, created_at TEXT,
last_used TEXT last_used TEXT,
scopes TEXT,
expires_at TEXT,
disabled INTEGER,
container_uuids TEXT,
last_used_ip TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS audit_logs ( `CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -210,6 +237,8 @@ func ensureSchema() error {
created_at TEXT, created_at TEXT,
template_id TEXT, template_id TEXT,
user TEXT, user TEXT,
ip TEXT,
user_agent TEXT,
cfg_name TEXT, cfg_name TEXT,
cfg_virtualization TEXT, cfg_virtualization TEXT,
cfg_template_id TEXT, cfg_template_id TEXT,
@@ -263,9 +292,55 @@ func ensureSchema() error {
return fmt.Errorf("failed to create sqlite schema: %v", err) return fmt.Errorf("failed to create sqlite schema: %v", err)
} }
} }
return ensureSchemaMigrations()
}
func ensureSchemaMigrations() error {
for _, column := range []struct {
table string
name string
def string
}{
{"api_keys", "scopes", "TEXT"},
{"api_keys", "expires_at", "TEXT"},
{"api_keys", "disabled", "INTEGER"},
{"api_keys", "container_uuids", "TEXT"},
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
}
}
return nil return nil
} }
func ensureColumn(table, name, def string) error {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var columnName, columnType string
var notNull, pk int
var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &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) { func loadConfigFromDB() (*ClicdConfig, bool, error) {
meta := map[string]string{} meta := map[string]string{}
rows, err := db.Query("SELECT key, value FROM app_meta") rows, err := db.Query("SELECT key, value FROM app_meta")
@@ -468,8 +543,10 @@ func saveSubUsers(tx *sql.Tx) error {
func saveAPIKeys(tx *sql.Tx) error { func saveAPIKeys(tx *sql.Tx) error {
for _, k := range AppConfig.ApiKeys { for _, k := range AppConfig.ApiKeys {
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used) scopes := encodeStringSlice(k.Scopes)
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil { containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
return err return err
} }
} }
@@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error {
for _, task := range AppConfig.Tasks { for _, task := range AppConfig.Tasks {
cfg := parseSavedTaskConfig(task.Config) cfg := parseSavedTaskConfig(task.Config)
if _, err := tx.Exec(`INSERT INTO tasks( if _, err := tx.Exec(`INSERT INTO tasks(
id, type, container_id, container_name, status, error, created_at, template_id, user, id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit, cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
@@ -669,7 +746,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
} }
func loadAPIKeys() ([]ApiKeyConfig, error) { func loadAPIKeys() ([]ApiKeyConfig, error) {
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`) rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
result := []ApiKeyConfig{} result := []ApiKeyConfig{}
for rows.Next() { for rows.Next() {
var k ApiKeyConfig var k ApiKeyConfig
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil { var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
var disabled sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
return nil, err return nil, err
} }
k.Scopes = decodeStringSlice(scopes.String)
k.ExpiresAt = expiresAt.String
k.Disabled = disabled.Valid && disabled.Int64 != 0
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
k.LastUsedIP = lastUsedIP.String
result = append(result, k) result = append(result, k)
} }
return result, rows.Err() return result, rows.Err()
@@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) {
func loadTasks() ([]SavedTask, error) { func loadTasks() ([]SavedTask, error) {
rows, err := db.Query(`SELECT rows, err := db.Query(`SELECT
id, type, container_id, container_name, status, error, created_at, template_id, user, id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
@@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) {
var t SavedTask var t SavedTask
var cfg savedTaskConfig var cfg savedTaskConfig
var assignIPv6 int var assignIPv6 int
var ip, userAgent sql.NullString
if err := rows.Scan( if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit, &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
@@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
t.IP = ip.String
t.UserAgent = userAgent.String
cfg.AssignIPv6 = assignIPv6 != 0 cfg.AssignIPv6 = assignIPv6 != 0
result = append(result, t) result = append(result, t)
configs = append(configs, cfg) configs = append(configs, cfg)
+4
View File
@@ -37,6 +37,7 @@ type Manager struct {
} }
const ipv6GatewayLinkLocal = "fe80::1" const ipv6GatewayLinkLocal = "fe80::1"
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
type usageSample struct { type usageSample struct {
CPUUsec uint64 CPUUsec uint64
@@ -1476,6 +1477,9 @@ func ensureDefaultNetwork() error {
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil { if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out)) return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
} }
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
}
} }
// Start and autostart the default network // Start and autostart the default network
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil { if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
+204 -50
View File
@@ -390,7 +390,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) 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) fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
} }
@@ -402,8 +402,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged, // Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate. // otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
if err := m.runRootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(sshPassword))); err != nil {
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err) fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
} }
@@ -472,11 +471,11 @@ IPv6AcceptRA=no
} }
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot. // 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 _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
if err != nil { if err != nil {
return err return err
} }
@@ -993,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) return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
} }
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
// Unprivileged container root maps to a subordinate host UID, so it needs
// execute permission on the LXC parent and container directories to reach
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
// lxc-start to abort with "Could not access /var/lib/lxc".
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
info, err := os.Stat(path)
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0001 != 0 {
continue
}
if err := os.Chmod(path, mode|0001); err != nil {
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
}
}
return nil
}
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
uidBase, gidBase, err := unprivilegedIDMap() uidBase, gidBase, err := unprivilegedIDMap()
if err != nil { if err != nil {
@@ -1000,6 +1020,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
return nil return nil
} }
@@ -1626,7 +1649,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig() config.SaveConfig()
} }
script := sshSetupScript(c.SSHPassword, true) script := sshSetupScript(true)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
@@ -1638,6 +1661,9 @@ func (m *Manager) EnsureSSH(id int) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) 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 c.IP == "" {
if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" { if ip, ipErr := m.GetContainerIP(lxcName); ipErr == nil && ip != "" {
@@ -1656,13 +1682,13 @@ func (m *Manager) EnsureSSH(id int) error {
} }
func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error { func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
if password == "" { if err := validateRootPassword(password); err != nil {
return fmt.Errorf("empty SSH password") return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "chpasswd")
fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(password))) cmd.Stdin = strings.NewReader(rootPasswordInput(password))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output)) return fmt.Errorf("failed to update SSH password quickly: %v, output: %s", err, string(output))
@@ -1670,6 +1696,20 @@ func (m *Manager) quickEnsureSSHPassword(lxcName, password string) error {
return nil 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 { func (m *Manager) containerPortListening(lxcName string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -1677,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 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 script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04). # 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. # On modern distros, /etc/resolv.conf is a symlink managed by systemd-resolved.
@@ -1803,11 +1842,6 @@ set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no set_sshd_option ChallengeResponseAuthentication no
set_sshd_option UsePAM 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 if command -v rc-update >/dev/null 2>&1; then
rc-update add sshd default >/dev/null 2>&1 || true rc-update add sshd default >/dev/null 2>&1 || true
fi fi
@@ -1888,16 +1922,11 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
return "", err return "", err
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") 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) return "", fmt.Errorf("failed to configure SSH: %v", err)
} }
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(newPassword))) if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
if err != nil { return "", fmt.Errorf("failed to set password: %v", err)
return "", err
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to set password: %v, output: %s", err, string(output))
} }
c.SSHPassword = newPassword c.SSHPassword = newPassword
config.SaveConfig() config.SaveConfig()
@@ -1911,6 +1940,10 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
safeArgs, err := safeRootfsCommandArgs(args)
if err != nil {
return nil, err
}
marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(cleanRootfsPath, ".clicd-unprivileged-shifted")
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
@@ -1921,11 +1954,11 @@ func (m *Manager) rootfsCommand(rootfsPath string, args ...string) (*exec.Cmd, e
"-m", fmt.Sprintf("g:0:%d:65536", gidBase), "-m", fmt.Sprintf("g:0:%d:65536", gidBase),
"--", "chroot", "--", cleanRootfsPath, "--", "chroot", "--", cleanRootfsPath,
} }
cmdArgs = append(cmdArgs, args...) cmdArgs = append(cmdArgs, safeArgs...)
return exec.Command("lxc-usernsexec", cmdArgs...), nil return exec.Command("lxc-usernsexec", cmdArgs...), nil
} }
} }
cmdArgs := append([]string{"--", cleanRootfsPath}, args...) cmdArgs := append([]string{"--", cleanRootfsPath}, safeArgs...)
return exec.Command("chroot", cmdArgs...), nil return exec.Command("chroot", cmdArgs...), nil
} }
@@ -1937,6 +1970,58 @@ func (m *Manager) runRootfsCommand(rootfsPath string, args ...string) error {
return cmd.Run() 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) { func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rootfsPath == "" { if rootfsPath == "" {
return "", fmt.Errorf("empty rootfs path") return "", fmt.Errorf("empty rootfs path")
@@ -1969,6 +2054,13 @@ func (m *Manager) safeRootfsPath(rootfsPath string) (string, error) {
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("refusing unsafe rootfs path: %s", cleanRootfsPath) 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 return cleanRootfsPath, nil
} }
@@ -2211,6 +2303,85 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
return imported, nil 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 // ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id) c := config.FindContainer(id)
@@ -2234,26 +2405,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Clean port mappings temporarily // Clean port mappings temporarily
m.CleanPortMappings(id) m.CleanPortMappings(id)
// Destroy old LXC but keep config // Download the new OS into a temporary container, then replace only the
exec.Command("lxc-stop", "-n", lxcName, "-k").Run() // existing rootfs. The target container directory and config are preserved.
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() if err := m.replaceRootfsFromTemplate(lxcName, tmpl); err != nil {
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs") return err
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))
} }
if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil { if err := m.applyDiskLimit(lxcName, c.DiskGB); err != nil {
@@ -2293,14 +2448,13 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.SSHPassword == "" { if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16) 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) fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
} }
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err return err
} }
if err := m.runRootfsCommand(rootfsPath, if err := m.setRootfsPassword(rootfsPath, c.SSHPassword); err != nil {
"sh", "-c", fmt.Sprintf("printf '%%s:%%s\\n' root %s | chpasswd", shellQuote(c.SSHPassword))); err != nil {
fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err) fmt.Printf("Warning: failed to set root password in %s after reinstall: %v\n", lxcName, err)
} }
+20 -13
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) { func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
base := t.TempDir() base := t.TempDir()
rootfs := filepath.Join(base, "ct-1", "rootfs") rootfs := filepath.Join(base, "ct-1", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil { if err := os.MkdirAll(rootfs, 0755); err != nil {
@@ -16,18 +16,31 @@ func TestRootfsCommandAddsSeparatorAndPreservesArgs(t *testing.T) {
} }
m := &Manager{LxcPath: base} m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "sh", "-c", "true", "--flag") cmd, err := m.rootfsCommand(rootfs, "chpasswd")
if err != nil { if err != nil {
t.Fatalf("rootfsCommand returned error: %v", err) t.Fatalf("rootfsCommand returned error: %v", err)
} }
want := []string{"chroot", "--", rootfs, "sh", "-c", "true", "--flag"} want := []string{"chroot", "--", rootfs, "chpasswd"}
if !reflect.DeepEqual(cmd.Args, want) { if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want) t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
} }
} }
func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) { 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() base := t.TempDir()
rootfs := filepath.Join(base, "-ct", "rootfs") rootfs := filepath.Join(base, "-ct", "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil { if err := os.MkdirAll(rootfs, 0755); err != nil {
@@ -35,14 +48,8 @@ func TestRootfsCommandAllowsLeadingDashContainerName(t *testing.T) {
} }
m := &Manager{LxcPath: base} m := &Manager{LxcPath: base}
cmd, err := m.rootfsCommand(rootfs, "true") if _, err := m.rootfsCommand(rootfs, "chpasswd"); err == nil {
if err != nil { t.Fatal("rootfsCommand allowed leading-dash container name")
t.Fatalf("rootfsCommand returned error: %v", err)
}
want := []string{"chroot", "--", rootfs, "true"}
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %#v, want %#v", cmd.Args, want)
} }
} }
@@ -64,7 +71,7 @@ func TestRootfsCommandRejectsUnsafeRootfsPaths(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if _, err := m.rootfsCommand(tc.path, "true"); err == nil { if _, err := m.rootfsCommand(tc.path, "chpasswd"); err == nil {
t.Fatalf("rootfsCommand(%q) returned nil error", tc.path) t.Fatalf("rootfsCommand(%q) returned nil error", tc.path)
} }
}) })
+46 -1
View File
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
} }
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@@ -76,6 +76,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) 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", 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/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates))) mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages))) mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
@@ -86,6 +87,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
@@ -113,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.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/v1/containers/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) // Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion)) mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.2" Version = "1.1.6"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
import Security from './pages/Security' import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration' import ApiIntegration from './pages/ApiIntegration'
import HostReport from './pages/HostReport'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement' import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots' import Snapshots from './pages/Snapshots'
@@ -64,6 +65,7 @@ function App() {
<Route path="routing" element={<Routing />} /> <Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} /> <Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} /> <Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
<Route path="sub-users" element={<SubUserManagement />} /> <Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>
+14
View File
@@ -4,6 +4,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Code2, Code2,
Cpu,
Camera, Camera,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
@@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isRoutingPage = location.pathname.startsWith('/routing') const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
const isSecurityPage = location.pathname.startsWith('/security') const isSecurityPage = location.pathname.startsWith('/security')
const isSettingsPage = location.pathname.startsWith('/settings') const isSettingsPage = location.pathname.startsWith('/settings')
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span>API </span>} {!collapsed && <span>API </span>}
</button> </button>
<button
onClick={() => navigate('/host-report')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isHostReportPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Cpu className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button <button
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+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 connect = async () => {
const target = screenRef.current const target = screenRef.current
if (!target) return if (!target) return
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} }
try { try {
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket)) ensureResizeObserver()
const rfb = new RFB(target, getWebVNCUrl(containerName), {
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
})
rfb.scaleViewport = true rfb.scaleViewport = true
rfb.resizeSession = false rfb.resizeSession = false
rfb.focusOnClick = true rfb.focusOnClick = true
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('error') setStatus('error')
setErrorMsg('WebVNC 初始化失败') const message = err instanceof Error && err.message ? `${err.message}` : ''
setErrorMsg(`WebVNC 初始化失败${message}`)
} }
} }
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -452,10 +452,10 @@ export default function ContainerDetail() {
const digits = '23456789' const digits = '23456789'
const symbols = '!@#$%*-_+=' const symbols = '!@#$%*-_+='
const all = letters + digits + symbols const all = letters + digits + symbols
const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)] const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits) let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all) while (password.length < 16) password += pick(all)
setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join('')) setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordResult('') setResetPasswordResult('')
} }
@@ -2054,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 { function getTemplateIcon(id: string): ReactNode {
const size = 'w-6 h-6' const size = 'w-6 h-6'
id = id.startsWith('kvm-') ? id.slice(4) : id 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 || '-'
}
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.2</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.6</p>
</div> </div>
</div> </div>
) )
+69 -57
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useCallback, useEffect, useState } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react' import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
import { import {
changePassword, changePassword,
changeUsername, changeUsername,
@@ -20,7 +20,6 @@ export default function Settings() {
const [oldPwd, setOldPwd] = useState('') const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('') const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('') const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
try { try {
@@ -33,30 +32,45 @@ export default function Settings() {
} }
}, []) }, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs]) useEffect(() => {
fetchLogs()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs])
const handleSaveAccount = async () => { const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return } if (!oldPwd) {
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return } dialog.alert('提示', '请输入当前密码以确认修改')
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return } return
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return } }
if (!newPwd && !newUsername) {
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
return
}
if (newPwd && newPwd.length < 6) {
dialog.alert('提示', '新密码至少 6 位')
return
}
if (newUsername && newUsername.length < 3) {
dialog.alert('提示', '用户名至少 3 位')
return
}
let results: string[] = [] const results: string[] = []
try { try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) { if (newUsername) {
const res = await changeUsername(newUsername, oldPwd) const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改') results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
else results.push('用户名修改失败')
} }
if (newPwd) { if (newPwd) {
const res = await changePassword(oldPwd, newPwd) const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改') results.push(res.data.success ? '密码已修改' : '密码修改失败')
else results.push('密码修改失败')
} }
if (results.length > 0) { if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效') dialog.alert('完成', `${results.join('')}。下次登录生效`)
setOldPwd(''); setNewPwd(''); setNewUsername('') setOldPwd('')
setNewPwd('')
setNewUsername('')
} }
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } } const e = err as { response?: { data?: { message?: string } } }
@@ -67,48 +81,48 @@ export default function Settings() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div> </div>
) )
} }
const totalPages = Math.ceil(logs.length / pageSize)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-black"></h1> <h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p> <p className="mt-1 text-sm text-gray-500"></p>
</div> </div>
{/* Account Settings */} <div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5"> <h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2"> <UserCog className="h-4 w-4" />
<UserCog className="w-4 h-4" />
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" /> <input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" /> <input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div> </div>
<div className="border-t border-gray-100 pt-3"> <div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" /> <input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"></label> <label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" /> <input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div> </div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button> <button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div> </div>
</div> </div>
{/* Login Logs */} <div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5"> <h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2"> <LogIn className="h-4 w-4" />
<LogIn className="w-4 h-4" />
</h2> </h2>
{logs.length === 0 ? ( {logs.length === 0 ? (
<p className="text-sm text-gray-400"></p> <p className="text-sm text-gray-400"></p>
@@ -117,23 +131,23 @@ export default function Settings() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-gray-400 border-b border-gray-100"> <tr className="border-b border-gray-100 text-gray-400">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th> <th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" /></span></th>
<th className="text-left py-2 font-medium"></th> <th className="py-2 text-left font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th> <th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th> <th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" /></span></th>
<th className="text-left py-2 font-medium"></th> <th className="py-2 text-left font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => ( {logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
<tr key={i}> <tr key={`${log.time}-${index}`}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td> <td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td> <td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td> <td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td> <td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5"> <td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}> <span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'} {log.success ? '成功' : '失败'}
</span> </span>
</td> </td>
@@ -143,23 +157,22 @@ export default function Settings() {
</table> </table>
</div> </div>
{logs.length > pageSize && ( {logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span> <span className="text-xs text-gray-400"> {logs.length} {logPage}/{totalPages} </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => { {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2) let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4) if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i const page = start + i
if (page > totalPages) return null if (page > totalPages) return null
return ( return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button> <button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
) )
})} })}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button> <button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30"></button>
</div> </div>
</div> </div>
)} )}
@@ -171,7 +184,6 @@ export default function Settings() {
} }
function formatUA(ua: string): string { function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = [] const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows') if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS') else if (ua.includes('Mac OS X')) parts.push('macOS')
+101 -2
View File
@@ -136,6 +136,16 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[] prefixes: IPv6PrefixInfo[]
} }
export interface IPv4PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
subnet_mask: string
gateway: string
source: string
}
export interface DashboardStats { export interface DashboardStats {
total_containers: number total_containers: number
running: number running: number
@@ -161,6 +171,93 @@ export interface HostInfo {
load: { load1: number; load5: number; load15: number } load: { load1: number; load5: number; load15: number }
} }
export interface HostProbeReport {
generated_at: string
hostname: string
kernel: string
os: string
cpu: {
model: string
cores: number
threads: number
architecture: string
flags: string[]
has_integrated_gpu: boolean
virtualization: boolean
virtualization_key: string
}
memory: {
total_mb: number
used_mb: number
free_mb: number
modules: Array<{
locator: string
size: string
type: string
speed: string
manufacturer: string
part_number: string
serial_number: string
}>
}
disks: Array<{
name: string
path: string
model: string
serial: string
size_bytes: number
type: string
rotational: boolean
mountpoints: string[]
health: string
health_detail: string
smart?: {
available: boolean
life_used_percent?: number
power_on_hours?: number
power_cycle_count?: number
read_data_bytes?: number
written_data_bytes?: number
read_commands?: number
write_commands?: number
wear_leveling_count?: string
erase_count?: string
media_errors?: number
}
}>
network_interfaces: Array<{
name: string
mac: string
state: string
speed_mbps: number
driver: string
model: string
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
}>
public_ipv4: string[]
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv4_prefixes: IPv4PrefixInfo[]
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
ipv6_prefixes: IPv6PrefixInfo[]
gateways: Array<{ family: string; interface: string; gateway: string }>
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
runtime: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: string
}
system: {
uptime_seconds: number
uptime_text: string
process_count: number
}
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
}
export interface ContainerUsage { export interface ContainerUsage {
memory_usage_bytes: number memory_usage_bytes: number
memory_total_bytes?: number memory_total_bytes?: number
@@ -393,6 +490,9 @@ export const getDashboard = () =>
export const getHostInfo = () => export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info') api.get<APIResponse<HostInfo>>('/host-info')
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
// Snapshots // Snapshots
export interface Snapshot { export interface Snapshot {
id: string id: string
@@ -456,10 +556,9 @@ export const getWebSSHUrl = (containerName: string) => {
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
export const getWebVNCUrl = (containerName: string, ticket?: string) => { export const getWebVNCUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName }) const params = new URLSearchParams({ container: containerName })
if (ticket) params.set('ticket', ticket)
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}` return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
} }
+430 -32
View File
@@ -8,6 +8,8 @@ ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}" ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues" ISSUE_URL="https://github.com/${REPO}/issues"
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}" LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
echo "=====================================" echo "====================================="
echo " CLICD 中文安装/卸载脚本" echo " CLICD 中文安装/卸载脚本"
@@ -54,7 +56,7 @@ run_step() {
step_name="$1" step_name="$1"
shift shift
log "开始:$step_name" log "开始:$step_name"
if "$@" >> "$LOG_FILE" 2>&1; then if ( "$@" ) >> "$LOG_FILE" 2>&1; then
log "完成:$step_name" log "完成:$step_name"
return 0 return 0
fi fi
@@ -136,6 +138,7 @@ usage() {
示例: 示例:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
日志:${LOG_FILE} 日志:${LOG_FILE}
问题反馈:${ISSUE_URL} 问题反馈:${ISSUE_URL}
@@ -218,6 +221,39 @@ remove_lxc_container_dir() {
log "已删除 $container_dir" log "已删除 $container_dir"
} }
remove_clicd_lxc_image_cache() {
log "正在删除 CLICD 使用的 LXC 镜像缓存..."
for container_dir in /var/lib/lxc/clicd-img-dl-*; do
[ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir"
done
for image in \
"ubuntu noble amd64" \
"ubuntu jammy amd64" \
"debian bookworm amd64" \
"debian bullseye amd64" \
"alpine 3.21 amd64" \
"centos 9-Stream amd64" \
"archlinux current amd64" \
"fedora 44 amd64" \
"rockylinux 10 amd64"
do
set -- $image
distro="$1"
release="$2"
arch="$3"
cache_dir="/var/cache/lxc/download/$distro/$release/$arch"
remove_path "$cache_dir"
rmdir "/var/cache/lxc/download/$distro/$release" >/dev/null 2>&1 || true
rmdir "/var/cache/lxc/download/$distro" >/dev/null 2>&1 || true
done
rmdir /var/cache/lxc/download >/dev/null 2>&1 || true
rmdir /var/cache/lxc >/dev/null 2>&1 || true
}
remove_kvm_domain() { remove_kvm_domain() {
domain="$1" domain="$1"
case "$domain" in case "$domain" in
@@ -258,6 +294,48 @@ destroy_clicd_kvm_domains() {
done done
} }
domain_is_clicd_kvm() {
domain="$1"
case "$domain" in
vm-[0-9]*)
return 0
;;
esac
virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'
}
libvirt_default_used_by_non_clicd_domain() {
if ! has_cmd virsh; then
return 1
fi
for domain in $(virsh list --all --name 2>/dev/null); do
[ -n "$domain" ] || continue
if domain_is_clicd_kvm "$domain"; then
continue
fi
if virsh domiflist "$domain" 2>/dev/null | awk '$3 == "default" || $3 == "virbr0" {found = 1} END {exit found ? 0 : 1}'; then
return 0
fi
done
return 1
}
remove_clicd_libvirt_default_network() {
if ! has_cmd virsh || [ ! -f "$LIBVIRT_DEFAULT_MARKER" ]; then
return
fi
if libvirt_default_used_by_non_clicd_domain; then
warn "检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。"
return
fi
log "正在删除 CLICD 创建的 libvirt default NAT 网络..."
virsh net-destroy default >/dev/null 2>&1 || true
virsh net-undefine default >/dev/null 2>&1 || true
rm -f "$LIBVIRT_DEFAULT_MARKER"
}
delete_iptables_lines() { delete_iptables_lines() {
table="$1" table="$1"
chain="$2" chain="$2"
@@ -295,6 +373,143 @@ delete_filter_rule() {
done done
} }
delete_ip6_filter_rule() {
if ! has_cmd ip6tables; then
return
fi
while ip6tables -D "$@" >/dev/null 2>&1; do
:
done
}
delete_ip6tables_nat_source() {
source="$1"
if ! has_cmd ip6tables || [ -z "$source" ]; then
return
fi
while :; do
rule="$(
ip6tables -t nat -S POSTROUTING 2>/dev/null |
grep -F -- "-s $source" |
grep -F -- " -j MASQUERADE" |
sed 's/^-A /-D /' |
head -n 1
)"
[ -n "$rule" ] || break
# shellcheck disable=SC2086
ip6tables -t nat $rule >/dev/null 2>&1 || break
done
}
read_clicd_network_records() {
db="/root/.clicd/config.db"
legacy="/root/.clicd/config.json"
query="SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> '';"
if [ -f "$db" ] && has_cmd sqlite3; then
sqlite3 -separator '|' "$db" "$query" 2>/dev/null || true
elif [ -f "$db" ] && has_cmd python3; then
CLICD_DB="$db" python3 - <<'PY' 2>/dev/null || true
import os
import sqlite3
db = os.environ.get("CLICD_DB")
for row in sqlite3.connect(db).execute(
"SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') "
"FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> ''"
):
print("|".join("" if value is None else str(value) for value in row))
PY
fi
if [ -f "$legacy" ] && has_cmd python3; then
CLICD_LEGACY_CONFIG="$legacy" python3 - <<'PY' 2>/dev/null || true
import json
import os
path = os.environ.get("CLICD_LEGACY_CONFIG")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
for item in data.get("containers", []):
virt = item.get("virtualization", "")
ipv6 = item.get("ipv6", "")
uplink = item.get("ipv6_interface", "")
mac = item.get("mac_address", "")
if ipv6 or mac:
print("|".join(str(value or "") for value in (virt, ipv6, uplink, mac)))
PY
fi
}
cleanup_clicd_ipv6_record() {
virt="$1"
ipv6="$2"
uplink="$3"
mac="$4"
bridge="lxcbr0"
if [ "$virt" = "kvm" ]; then
bridge="virbr0"
fi
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
fi
[ -n "$ipv6" ] || return
addr="${ipv6%%/*}"
source="$ipv6"
case "$source" in
*/*) ;;
*) source="$source/128" ;;
esac
delete_ip6tables_nat_source "$source"
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
fi
if has_cmd ip; then
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
if [ -n "$uplink" ]; then
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
fi
fi
}
cleanup_clicd_ipv6_from_config() {
read_clicd_network_records | while IFS='|' read -r virt ipv6 uplink mac; do
cleanup_clicd_ipv6_record "$virt" "$ipv6" "$uplink" "$mac"
done
}
cleanup_clicd_ipv6_bridge_routes() {
if ! has_cmd ip; then
return
fi
for bridge in lxcbr0 virbr0; do
ip -6 route show dev "$bridge" 2>/dev/null | awk '$1 ~ /\/128$/ {print $1}' | while IFS= read -r source; do
[ -n "$source" ] || continue
addr="${source%%/*}"
delete_ip6tables_nat_source "$source"
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
ip -6 neigh show proxy 2>/dev/null | awk -v addr="$addr" '$1 == addr {for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | while IFS= read -r uplink; do
[ -n "$uplink" ] || continue
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
done
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
done
ip -6 addr del fe80::1/64 dev "$bridge" >/dev/null 2>&1 || true
done
}
delete_ip6tables_bridge_rules() { delete_ip6tables_bridge_rules() {
if ! has_cmd ip6tables; then if ! has_cmd ip6tables; then
return return
@@ -315,6 +530,8 @@ cleanup_clicd_networking() {
delete_iptables_lines nat PREROUTING 'clicd-' delete_iptables_lines nat PREROUTING 'clicd-'
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
cleanup_clicd_ipv6_from_config
cleanup_clicd_ipv6_bridge_routes
for bridge in lxcbr0 virbr0; do for bridge in lxcbr0 virbr0; do
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
@@ -354,8 +571,14 @@ remove_clicd_quota_records() {
} }
remove_clicd_tmp_files() { remove_clicd_tmp_files() {
current_dir="$(pwd -P 2>/dev/null || pwd)"
for path in /tmp/clicd-* /tmp/clicd.*; do for path in /tmp/clicd-* /tmp/clicd.*; do
[ -e "$path" ] || [ -L "$path" ] || continue [ -e "$path" ] || [ -L "$path" ] || continue
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
if [ "$abs_path" = "$current_dir" ]; then
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
continue
fi
rm -rf "$path" rm -rf "$path"
log "已删除 $path" log "已删除 $path"
done done
@@ -376,10 +599,12 @@ confirm_uninstall() {
fi fi
echo "" echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2 echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2 echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2 echo "如需确认卸载,请输入:YES" >&2
if [ -t 0 ]; then if [ -r /dev/tty ]; then
read answer IFS= read -r answer < /dev/tty
elif [ -t 0 ]; then
IFS= read -r answer
else else
answer="" answer=""
fi fi
@@ -409,7 +634,9 @@ uninstall_clicd() {
[ -d "$container_dir" ] || continue [ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir" remove_lxc_container_dir "$container_dir"
done done
remove_clicd_lxc_image_cache
destroy_clicd_kvm_domains destroy_clicd_kvm_domains
remove_clicd_libvirt_default_network
cleanup_clicd_networking cleanup_clicd_networking
remove_clicd_host_hooks remove_clicd_host_hooks
remove_clicd_quota_records remove_clicd_quota_records
@@ -424,7 +651,7 @@ uninstall_clicd() {
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。 # /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
unmount_path_tree /var/lib/clicd unmount_path_tree /var/lib/clicd
remove_path /var/lib/clicd remove_path /var/lib/clicd
# /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。 # /var/cache/lxc 是 LXC 全局缓存,已按 CLICD 模板精确清理,生产环境不整体删除。
remove_path /var/cache/clicd remove_path /var/cache/clicd
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。" warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
remove_clicd_tmp_files remove_clicd_tmp_files
@@ -444,7 +671,7 @@ uninstall_clicd() {
echo "=====================================" echo "====================================="
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、" echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。" echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。" echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE" echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL" echo " 问题反馈:$ISSUE_URL"
echo "=====================================" echo "====================================="
@@ -674,17 +901,43 @@ EOF
sysctl --system >/dev/null 2>&1 || true sysctl --system >/dev/null 2>&1 || true
} }
systemd_unit_exists() {
unit="$1"
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
}
systemd_enable_now_if_exists() {
unit="$1"
if systemd_unit_exists "$unit"; then
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
return
fi
log "未检测到 systemd 单元 $unit,跳过。"
}
systemd_existing_units() {
for unit in "$@"; do
if systemd_unit_exists "$unit"; then
printf ' %s' "$unit"
fi
done
}
setup_runtime_services() { setup_runtime_services() {
log "正在配置 LXC 和 KVM 服务..." log "正在配置 LXC 和 KVM 服务..."
if is_systemd; then if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemd_enable_now_if_exists lxcfs.service
systemctl enable --now lxc-net >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc-net.service
systemctl enable --now lxc >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc.service
systemctl enable --now libvirtd >/dev/null 2>&1 || true if systemd_unit_exists libvirtd.service; then
systemctl enable --now virtqemud >/dev/null 2>&1 || true systemd_enable_now_if_exists libvirtd.service
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true else
systemd_enable_now_if_exists virtqemud.service
systemd_enable_now_if_exists virtqemud.socket
fi
systemd_enable_now_if_exists virtlogd.socket
return return
fi fi
@@ -734,6 +987,8 @@ setup_default_libvirt_network() {
EOF EOF
virsh net-define "$net_xml" virsh net-define "$net_xml"
rm -f "$net_xml" rm -f "$net_xml"
mkdir -p "$(dirname "$LIBVIRT_DEFAULT_MARKER")"
touch "$LIBVIRT_DEFAULT_MARKER"
fi fi
if ! libvirt_network_active; then if ! libvirt_network_active; then
virsh net-start default virsh net-start default
@@ -752,17 +1007,36 @@ setup_subids() {
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
} }
configure_lxc_storage_access() {
log "Configuring LXC storage directory permissions..."
mkdir -p /var/lib/lxc
chmod 755 /var/lib/lxc
}
try_enable_project_quota() { try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)" root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)" root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then case "$root_fs" in
warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。" ext4)
;;
xfs|btrfs|zfs|overlay|unknown|"")
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。"
return
;;
*)
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
return
;;
esac
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
if ! has_cmd tune2fs; then if ! has_cmd tune2fs; then
warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。" log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
@@ -771,7 +1045,60 @@ try_enable_project_quota() {
return return
fi fi
warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。" log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
}
download_file() {
url="$1"
dest="$2"
rm -f "$dest"
if has_cmd curl; then
curl -fL --retry 6 --retry-delay 2 --connect-timeout 20 --max-time 600 "$url" -o "$dest"
return
fi
if has_cmd wget; then
wget --tries=6 --timeout=30 --waitretry=2 -O "$dest" "$url"
return
fi
return 127
}
release_api_json() {
api_url="https://api.github.com/repos/${REPO}/releases/latest"
if has_cmd curl; then
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 120 "$api_url" 2>/dev/null || true
return
fi
if has_cmd wget; then
wget -qO- --tries=3 --timeout=30 "$api_url" 2>/dev/null || true
return
fi
}
release_asset_url() {
asset_name="$1"
if [ "$CLICD_INSTALL_VERSION" != "latest" ]; then
printf '%s\n' "https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${asset_name}"
return
fi
api_data="$(release_api_json)"
url="$(printf '%s\n' "$api_data" | sed -n 's/.*"browser_download_url": *"\([^"]*\/'"$asset_name"'\)".*/\1/p' | head -n 1)"
if [ -n "$url" ]; then
printf '%s\n' "$url"
return
fi
tag="$(printf '%s\n' "$api_data" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)"
if [ -n "$tag" ]; then
printf '%s\n' "https://github.com/${REPO}/releases/download/${tag}/${asset_name}"
return
fi
printf '%s\n' "https://github.com/${REPO}/releases/latest/download/${asset_name}"
} }
download_release_if_needed() { download_release_if_needed() {
@@ -789,19 +1116,66 @@ download_release_if_needed() {
log "正在下载发行版包:${download_url}" log "正在下载发行版包:${download_url}"
tmp_dir="$(mktemp -d)" tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0 rm -f "$INSTALL_DOWNLOAD_MARKER"
printf '%s\n' "$tmp_dir" > "$INSTALL_DOWNLOAD_MARKER" || die "Failed to write install temp marker."
if has_cmd curl; then if ! has_cmd curl && ! has_cmd wget; then
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url"
else
die "下载发行版包需要 curl 或 wget。" die "下载发行版包需要 curl 或 wget。"
fi fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir" archive_path="$tmp_dir/$ASSET"
cd "$tmp_dir/clicd-linux-amd64" archive_urls="$download_url"
[ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。" resolved_archive_url="$(release_asset_url "$ASSET")"
if [ "$resolved_archive_url" != "$download_url" ]; then
archive_urls="$archive_urls $resolved_archive_url"
fi
archive_ok=0
for url in $archive_urls; do
[ -n "$url" ] || continue
log "Trying release archive: $url"
if download_file "$url" "$archive_path" && [ -s "$archive_path" ]; then
archive_ok=1
break
fi
warn "Release archive download failed, trying next source: $url"
done
if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else
binary_asset="clicd-linux-amd64"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else
binary_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${binary_asset}"
fi
binary_urls="$binary_url"
resolved_binary_url="$(release_asset_url "$binary_asset")"
if [ "$resolved_binary_url" != "$binary_url" ]; then
binary_urls="$binary_urls $resolved_binary_url"
fi
binary_path="$tmp_dir/$binary_asset"
binary_ok=0
for url in $binary_urls; do
[ -n "$url" ] || continue
log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
mkdir -p "$tmp_dir/clicd-linux-amd64"
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
binary_ok=1
break
fi
warn "Release binary download failed, trying next source: $url"
done
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
} }
install_binary() { install_binary() {
@@ -812,28 +1186,51 @@ install_binary() {
rc-service clicd stop >/dev/null 2>&1 || true rc-service clicd stop >/dev/null 2>&1 || true
fi fi
bin_src="./clicd"
download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
bin_src="$download_dir/clicd-linux-amd64/clicd"
fi
fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
tmp_bin="/usr/local/bin/clicd.new.$$" tmp_bin="/usr/local/bin/clicd.new.$$"
cp ./clicd "$tmp_bin" cp "$bin_src" "$tmp_bin"
chmod +x "$tmp_bin" chmod +x "$tmp_bin"
mv -f "$tmp_bin" /usr/local/bin/clicd mv -f "$tmp_bin" /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd chmod +x /usr/local/bin/clicd
log "已安装二进制:/usr/local/bin/clicd" log "已安装二进制:/usr/local/bin/clicd"
if [ -n "$download_dir" ]; then
case "$download_dir" in
/tmp/*)
rm -rf "$download_dir"
;;
esac
rm -f "$INSTALL_DOWNLOAD_MARKER"
fi
} }
install_systemd_service() { install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF' libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
cat > /etc/systemd/system/clicd.service << EOF
[Unit] [Unit]
Description=CLICD - LXC/KVM Container Manager Description=CLICD - LXC/KVM Container Manager
After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket After=network-online.target${lxc_after}${libvirt_after}
Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket Wants=network-online.target${libvirt_wants}
StartLimitIntervalSec=60
StartLimitBurst=10
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/clicd server ExecStart=/usr/local/bin/clicd server
Restart=always Restart=always
RestartSec=5 RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=10
LimitNOFILE=1048576 LimitNOFILE=1048576
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -918,6 +1315,7 @@ run_step "配置内核网络参数" configure_kernel_networking
run_step "配置运行时服务" setup_runtime_services run_step "配置运行时服务" setup_runtime_services
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
run_step "配置 UID/GID 映射" setup_subids run_step "配置 UID/GID 映射" setup_subids
run_step "Configure LXC storage permissions" configure_lxc_storage_access
run_step "检查 project quota" try_enable_project_quota run_step "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary run_step "安装 CLICD 二进制" install_binary