From e79609281fc9c7151888e6e5a2ea0730487195b6 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BAAPI=E9=9B=86=E6=88=90?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=8C=E5=88=92=E5=88=86KEY=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/apikey.go | 378 ++++++++---- backend/internal/api/auth.go | 164 ++++- backend/internal/api/handlers.go | 87 ++- backend/internal/api/images.go | 18 + backend/internal/api/ipv6.go | 3 + backend/internal/api/routing.go | 3 + backend/internal/api/security.go | 45 +- backend/internal/api/settings.go | 3 + backend/internal/api/snapshots.go | 46 +- backend/internal/api/ssh.go | 3 + backend/internal/api/subuser.go | 122 +++- backend/internal/api/swap.go | 77 ++- backend/internal/api/taskqueue.go | 81 ++- backend/internal/api/vnc.go | 13 + backend/internal/config/config.go | 27 +- backend/internal/config/store_sqlite.go | 107 +++- backend/internal/lxc/lxc.go | 10 +- backend/internal/server/server.go | 43 +- backend/internal/server/web/.gitkeep | 1 + frontend/src/pages/ApiIntegration.tsx | 790 +++++++++++++++++------- 20 files changed, 1596 insertions(+), 425 deletions(-) diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go index bb289dc..47331ec 100644 --- a/backend/internal/api/apikey.go +++ b/backend/internal/api/apikey.go @@ -8,7 +8,6 @@ import ( "fmt" "net" "net/http" - "strconv" "strings" "time" @@ -18,66 +17,100 @@ import ( ) type ApiKey struct { - ID string `json:"id"` - Name string `json:"name"` - Key string `json:"key,omitempty"` - Prefix string `json:"prefix"` - IPWhitelist string `json:"ip_whitelist"` - CreatedAt string `json:"created_at"` - LastUsed string `json:"last_used"` + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key,omitempty"` + Prefix string `json:"prefix"` + IPWhitelist string `json:"ip_whitelist"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Disabled bool `json:"disabled,omitempty"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + LastUsedIP string `json:"last_used_ip,omitempty"` +} + +type apiKeyRequest struct { + Name string `json:"name"` + IPWhitelist string `json:"ip_whitelist"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + Disabled bool `json:"disabled"` + ContainerUUIDs []string `json:"container_uuids"` +} + +var defaultApiKeyScopes = []string{ + "dashboard:read", + "container:read", + "task:read", + "image:read", + "snapshot:read", + "routing:read", + "ipv6:read", + "host:read", } // HandleApiKeys handles GET (list) and POST (create) for API keys func HandleApiKeys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + if !requireScope(w, r, "apikey:read") { + return + } listApiKeys(w, r) case http.MethodPost: + if !requireScope(w, r, "apikey:create") { + return + } createApiKey(w, r) default: jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) } } -// HandleApiKeyDelete handles DELETE for a specific API key +// HandleApiKeyDelete handles PATCH and DELETE for a specific API key func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { + switch r.Method { + case http.MethodPatch: + if !requireScope(w, r, "apikey:update") { + return + } + updateApiKey(w, r) + case http.MethodDelete: + if !requireScope(w, r, "apikey:delete") { + return + } + deleteApiKey(w, r) + default: jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) - return } - keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/") - if keyID == "" { - jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) - return - } - config.DeleteApiKey(keyID) - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"}) +} + +func apiKeyIDFromPath(path string) string { + path = strings.TrimPrefix(path, "/api/api-keys/") + path = strings.TrimPrefix(path, "/api/v1/api-keys/") + return strings.Trim(path, "/") } func listApiKeys(w http.ResponseWriter, r *http.Request) { keys := make([]ApiKey, 0) for _, k := range config.AppConfig.ApiKeys { - keys = append(keys, ApiKey{ - ID: k.ID, - Name: k.Name, - Prefix: k.Prefix, - IPWhitelist: k.IPWhitelist, - CreatedAt: k.CreatedAt, - LastUsed: k.LastUsed, - }) + keys = append(keys, apiKeyResponse(k)) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys}) } func createApiKey(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - IPWhitelist string `json:"ip_whitelist"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + var req apiKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"}) return } + if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"}) + return + } // Generate key: clicd_sk_ + 32 hex chars rawBytes := make([]byte, 16) @@ -94,31 +127,109 @@ func createApiKey(w http.ResponseWriter, r *http.Request) { } now := time.Now().Format("2006-01-02 15:04:05") + scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes) key := config.ApiKeyConfig{ - ID: generateShortID(), - Name: req.Name, - KeyHash: keyHash, - Prefix: rawKey[:13] + "...", - IPWhitelist: strings.TrimSpace(req.IPWhitelist), - CreatedAt: now, + ID: generateShortID(), + Name: strings.TrimSpace(req.Name), + KeyHash: keyHash, + Prefix: rawKey[:13] + "...", + IPWhitelist: strings.TrimSpace(req.IPWhitelist), + CreatedAt: now, + Scopes: scopes, + ExpiresAt: strings.TrimSpace(req.ExpiresAt), + Disabled: req.Disabled, + ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs), } config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key) - config.SaveConfig() + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"}) + return + } + auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "") + resp := apiKeyResponse(key) + resp.Key = rawKey jsonResponse(w, http.StatusCreated, APIResponse{ Success: true, Message: "API key created. Save this key now - it won't be shown again.", - Data: ApiKey{ - ID: key.ID, - Name: key.Name, - Key: rawKey, - Prefix: key.Prefix, - IPWhitelist: key.IPWhitelist, - CreatedAt: key.CreatedAt, - }, + Data: resp, }) } +func updateApiKey(w http.ResponseWriter, r *http.Request) { + keyID := apiKeyIDFromPath(r.URL.Path) + if keyID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) + return + } + var req apiKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"}) + return + } + for i := range config.AppConfig.ApiKeys { + if config.AppConfig.ApiKeys[i].ID != keyID { + continue + } + if strings.TrimSpace(req.Name) != "" { + config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name) + } + config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist) + if len(req.Scopes) > 0 { + config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes) + } + config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt) + config.AppConfig.ApiKeys[i].Disabled = req.Disabled + config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs) + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"}) + return + } + auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "") + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])}) + return + } + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"}) +} + +func deleteApiKey(w http.ResponseWriter, r *http.Request) { + keyID := apiKeyIDFromPath(r.URL.Path) + if keyID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"}) + return + } + name := keyID + for _, k := range config.AppConfig.ApiKeys { + if k.ID == keyID { + name = k.Name + break + } + } + config.DeleteApiKey(keyID) + auditRequest(r, "apikey.delete", name, "", true, "") + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"}) +} + +func apiKeyResponse(k config.ApiKeyConfig) ApiKey { + return ApiKey{ + ID: k.ID, + Name: k.Name, + Prefix: k.Prefix, + IPWhitelist: k.IPWhitelist, + CreatedAt: k.CreatedAt, + LastUsed: k.LastUsed, + Scopes: normalizeApiKeyScopes(k.Scopes), + ExpiresAt: k.ExpiresAt, + Disabled: k.Disabled, + ContainerUUIDs: k.ContainerUUIDs, + LastUsedIP: k.LastUsedIP, + } +} + func generateShortID() string { b := make([]byte, 4) rand.Read(b) @@ -203,13 +314,21 @@ func matchApiKey(rawKey string) (idx int, needsRehash bool) { // validateApiKey checks if the given key is valid and IP is allowed. func validateApiKey(rawKey, clientIP string) bool { + _, ok := validateApiKeyDetails(rawKey, clientIP) + return ok +} + +func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) { idx, needsRehash := matchApiKey(rawKey) if idx < 0 { - return false + return nil, false } - k := config.AppConfig.ApiKeys[idx] - if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) { - return false + k := &config.AppConfig.ApiKeys[idx] + if k.Disabled || apiKeyExpired(k.ExpiresAt) { + return nil, false + } + if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) { + return nil, false } if needsRehash { if newHash, err := hashAPIKey(rawKey); err == nil { @@ -217,7 +336,38 @@ func validateApiKey(rawKey, clientIP string) bool { 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 { @@ -232,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string { } func isValidApiKeyRequest(r *http.Request) bool { - apiKey := apiKeyFromRequest(r) - if apiKey == "" { - return false - } - if !validateApiKey(apiKey, clientIP(r)) { - return false - } - updateApiKeyLastUsed(apiKey) - return true + _, ok := validateApiKeyRequest(r) + return ok } // isIPAllowed checks if clientIP matches any entry in the whitelist func isIPAllowed(clientIP, whitelist string) bool { - clientIP = strings.TrimSpace(clientIP) - // Strip port if present - if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") { - clientIP = clientIP[:idx] + clientIP = normalizeIPString(clientIP) + client := net.ParseIP(clientIP) + if client == nil { + return false } for _, entry := range strings.Split(whitelist, "\n") { entry = strings.TrimSpace(entry) @@ -256,74 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool { continue } if strings.Contains(entry, "/") { - // CIDR match - if ipInCIDR(clientIP, entry) { + _, network, err := net.ParseCIDR(entry) + if err == nil && network.Contains(client) { return true } - } else if entry == clientIP { + continue + } + if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) { return true } } return false } -func ipInCIDR(ipStr, cidr string) bool { - parts := strings.Split(cidr, "/") - if len(parts) != 2 { - return false - } - // Simple prefix match for IPv4 - ip := netParseIP(ipStr) - cidrIP := netParseIP(parts[0]) - if ip == nil || cidrIP == nil { - return false - } - bits, err := strconv.Atoi(parts[1]) - if err != nil || bits < 0 || bits > 32 { - return false - } - mask := uint32(0xFFFFFFFF) << (32 - bits) - ipVal := ip4ToUint32(ip) - cidrVal := ip4ToUint32(cidrIP) - return (ipVal & mask) == (cidrVal & mask) -} - -func netParseIP(s string) net.IP { +func normalizeIPString(s string) string { s = strings.TrimSpace(s) - if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") { - s = s[:idx] + if host, _, err := net.SplitHostPort(s); err == nil { + return strings.Trim(host, "[]") } - return net.ParseIP(s) + return strings.Trim(s, "[]") } -func ip4ToUint32(ip net.IP) uint32 { - ip = ip.To4() - if ip == nil { - return 0 - } - return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) +func ipInCIDR(ipStr, cidr string) bool { + ip := net.ParseIP(normalizeIPString(ipStr)) + _, network, err := net.ParseCIDR(cidr) + return err == nil && ip != nil && network.Contains(ip) } // updateApiKeyLastUsed marks the key as recently used. func updateApiKeyLastUsed(rawKey string) { - idx, _ := matchApiKey(rawKey) - if idx < 0 { + key, ok := validateApiKeyDetails(rawKey, "") + if !ok { 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() } // ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer. func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - apiKey := apiKeyFromRequest(r) - if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) { + key, ok := validateApiKeyRequest(r) + if !ok { jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) return } - - updateApiKeyLastUsed(apiKey) - next(w, r) + next(w, withAuthContext(r, authContextFromAPIKey(key))) } } + +func normalizeApiKeyScopes(scopes []string) []string { + return normalizeRequestedScopes(scopes, []string{"*"}) +} + +func normalizeRequestedScopes(scopes []string, fallback []string) []string { + result := normalizeStringSlice(scopes) + if len(result) == 0 { + return append([]string(nil), fallback...) + } + return result +} + +func normalizeStringSlice(values []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func validApiKeyTime(value string) bool { + _, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) + return err == nil +} + +func apiKeyExpired(value string) bool { + if strings.TrimSpace(value) == "" { + return false + } + expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) + return err == nil && !time.Now().Before(expiresAt) +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index a8c25d7..10157f4 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "strings" @@ -28,6 +29,132 @@ type APIResponse struct { Data interface{} `json:"data,omitempty"` } +type authContextKey struct{} + +type AuthContext struct { + Type string + Username string + ApiKeyID string + ApiKeyName string + Actor string + Scopes []string + ContainerUUIDs []string +} + +const ( + authTypeAdmin = "admin" + authTypeSubUser = "sub_user" + authTypeAPIKey = "api_key" +) + +func withAuthContext(r *http.Request, auth AuthContext) *http.Request { + return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth)) +} + +func authContextFromRequest(r *http.Request) (AuthContext, bool) { + ctx, ok := r.Context().Value(authContextKey{}).(AuthContext) + return ctx, ok +} + +func requestActor(r *http.Request) string { + if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" { + return ctx.Actor + } + if claims, ok := claimsFromRequest(r); ok { + if subUser, _ := claims["sub_user"].(string); subUser != "" { + return "user:" + subUser + } + if username, _ := claims["username"].(string); username != "" { + return username + } + } + return "admin" +} + +func hasScope(r *http.Request, scope string) bool { + ctx, ok := authContextFromRequest(r) + if !ok { + return true + } + switch ctx.Type { + case authTypeAdmin: + return true + case authTypeSubUser: + return subUserScopeAllowed(scope) + case authTypeAPIKey: + return scopeAllowed(ctx.Scopes, scope) + default: + return false + } +} + +func subUserScopeAllowed(scope string) bool { + switch scope { + case "container:read", "container:power", "container:reinstall", "container:network", + "dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule", + "terminal:ssh", "terminal:vnc": + return true + default: + return false + } +} + +func hasAnyScope(r *http.Request, scopes ...string) bool { + for _, scope := range scopes { + if hasScope(r, scope) { + return true + } + } + return false +} + +func scopeAllowed(scopes []string, required string) bool { + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope == "*" || scope == "admin:*" || scope == required { + return true + } + if strings.HasSuffix(scope, ":*") { + prefix := strings.TrimSuffix(scope, "*") + if strings.HasPrefix(required, prefix) { + return true + } + } + } + return false +} + +func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool { + if hasScope(r, scope) { + return true + } + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"}) + return false +} + +func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireScope(w, r, scope) { + return + } + next(w, r) + } +} + +func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if hasAnyScope(r, scopes...) { + next(w, r) + return + } + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"}) + } +} + +func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) { + config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg) +} + func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -101,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) { } func isSubUserRequest(r *http.Request) bool { + if ctx, ok := authContextFromRequest(r); ok { + return ctx.Type == authTypeSubUser + } claims, ok := claimsFromRequest(r) if !ok { return false @@ -215,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tokenString := tokenFromRequest(r) - if !isValidToken(tokenString) && !isValidApiKeyRequest(r) { - jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) + if claims, ok := claimsFromToken(tokenString); ok { + if subUser, _ := claims["sub_user"].(string); subUser != "" { + auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser} + if values, ok := claims["container_uuids"].([]interface{}); ok { + for _, value := range values { + if uuid, ok := value.(string); ok { + auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid) + } + } + } + next(w, withAuthContext(r, auth)) + return + } + username, _ := claims["username"].(string) + if username == "" { + username = config.AppConfig.AdminUser + } + next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username})) return } - next(w, r) + if key, ok := validateApiKeyRequest(r); ok { + next(w, withAuthContext(r, authContextFromAPIKey(key))) + return + } + + jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) } } // AdminMiddleware requires a valid administrator token and rejects sub-user tokens. func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - if isSubUserRequest(r) { + ctx, _ := authContextFromRequest(r) + if ctx.Type == authTypeSubUser { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"}) + return + } + if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"}) return } diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 9a1f29e..71573bf 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -20,8 +20,18 @@ var lxcManager = lxc.NewManager() func HandleContainers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + if !requireScope(w, r, "container:read") { + return + } listContainers(w, r) case http.MethodPost: + if !requireScope(w, r, "container:create") { + return + } + if isAccessRestrictedRequest(r) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"}) + return + } createContainer(w, r) default: jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) @@ -30,7 +40,8 @@ func HandleContainers(w http.ResponseWriter, r *http.Request) { // HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/... func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/containers/") + path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/") + path = strings.TrimPrefix(path, "/api/containers/") parts := strings.SplitN(path, "/", 2) c := containerByIdentifier(parts[0]) id := 0 @@ -50,6 +61,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) return } + if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } if isSnapshotAction && id == 0 { // For orphaned snapshots, resolve containerID from the snapshot itself snapshotID := strings.TrimPrefix(action, "snapshots/") @@ -61,45 +76,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { } id = snapshot.ContainerID } + if isSnapshotAction { + if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } + } switch { case action == "start" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:power") { + return + } HandleSingleTaskAction(w, r, id, "start") case action == "stop" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:power") { + return + } HandleSingleTaskAction(w, r, id, "stop") case action == "restart" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:power") { + return + } HandleSingleTaskAction(w, r, id, "restart") case action == "reinstall" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:reinstall") { + return + } HandleSingleTaskAction(w, r, id, "reinstall") case action == "delete" && r.Method == http.MethodDelete: + if !requireScope(w, r, "container:delete") { + return + } HandleSingleTaskAction(w, r, id, "delete") case action == "reset-password" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:password") { + return + } resetSSHPassword(w, r, id) case action == "usage" && r.Method == http.MethodGet: + if !requireScope(w, r, "container:read") { + return + } getUsage(w, r, id) case action == "traffic" && r.Method == http.MethodGet: + if !requireScope(w, r, "container:read") { + return + } getTraffic(w, r, id) case action == "traffic-reset" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:traffic") { + return + } resetTraffic(w, r, id) case action == "traffic-limit" && r.Method == http.MethodPut: + if !requireScope(w, r, "container:traffic") { + return + } updateTrafficLimit(w, r, id) case action == "resource-limit" && r.Method == http.MethodPut: + if !requireScope(w, r, "container:resize") { + return + } updateResourceLimit(w, r, id) case action == "random-port" && r.Method == http.MethodGet: + if !requireScope(w, r, "container:network") { + return + } getRandomPort(w, r, id) case action == "expiry" && r.Method == http.MethodPut: + if !requireScope(w, r, "container:resize") { + return + } updateExpiry(w, r, id) case action == "ipv6" && r.Method == http.MethodPost: + if !requireScope(w, r, "ipv6:assign") { + return + } assignIPv6(w, r, id) case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): handleContainerSnapshots(w, r, id, action) case action == "port-mappings" && r.Method == http.MethodPost: + if !requireScope(w, r, "container:network") { + return + } addPortMapping(w, r, id) case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: + if !requireScope(w, r, "container:network") { + return + } updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete: + if !requireScope(w, r, "container:network") { + return + } deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/")) case r.Method == http.MethodGet: + if !requireScope(w, r, "container:read") { + return + } getContainer(w, r, id) default: jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) @@ -348,6 +423,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:read") { + return + } if isSubUserRequest(r) { HandleEnabledImages(w, r) return @@ -362,7 +440,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "dashboard:read") { + return + } containers, _ := listByRuntime() + containers = filterContainersForRequest(r, containers) running := 0 stopped := 0 for _, c := range containers { @@ -386,6 +468,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "host:read") { + return + } info := getHostInfo() jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) } diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index ae6a83b..99335a0 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -221,6 +221,9 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:read") { + return + } enabledSet := getEnabledImageSet() cleanupOldImageDownloadErrors() @@ -287,6 +290,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:download") { + return + } var req struct { TemplateID string `json:"template_id"` @@ -397,6 +403,9 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:download") { + return + } var req struct { TemplateID string `json:"template_id"` } @@ -434,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:delete") { + return + } var req struct { TemplateID string `json:"template_id"` @@ -484,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:toggle") { + return + } var req struct { TemplateID string `json:"template_id"` @@ -510,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "image:read") { + return + } runtime := runtimeFromRequest(r.URL.Query().Get("type")) enabledSet := getEnabledImageSet() diff --git a/backend/internal/api/ipv6.go b/backend/internal/api/ipv6.go index 9087299..384b03a 100644 --- a/backend/internal/api/ipv6.go +++ b/backend/internal/api/ipv6.go @@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "ipv6:read") { + return + } status := lxcManager.DetectIPv6Status() jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) } diff --git a/backend/internal/api/routing.go b/backend/internal/api/routing.go index c48a8be..ddccab1 100644 --- a/backend/internal/api/routing.go +++ b/backend/internal/api/routing.go @@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "routing:read") { + return + } nat4Mappings := make([]nat4Route, 0) usedPorts := map[int]bool{} diff --git a/backend/internal/api/security.go b/backend/internal/api/security.go index d7b9962..f8937c9 100644 --- a/backend/internal/api/security.go +++ b/backend/internal/api/security.go @@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "security:read") { + return + } - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()}) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())}) } // HandleSecuritySettings returns or updates security automation settings. func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + if !requireScope(w, r, "security:read") { + return + } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ "auto_shutdown": config.AppConfig.SecurityAutoShutdown, }}) case http.MethodPut: + if !requireScope(w, r, "security:settings") { + return + } var req struct { AutoShutdown bool `json:"auto_shutdown"` } @@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) return } + auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "") jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{ "auto_shutdown": config.AppConfig.SecurityAutoShutdown, }}) @@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "security:check") { + return + } var req struct { ContainerName string `json:"container_name"` @@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"}) return } + if !isContainerAllowedForRequest(r, c.UUID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } ensureScanner().checkContainer(c.Name, c.IP) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"}) @@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "security:read") { + return + } containerName := r.URL.Query().Get("container") if containerName == "" { @@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}}) return } + if !isContainerAllowedForRequest(r, c.UUID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"}) + return + } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)}) } @@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "security:read") { + return + } critical := 0 high := 0 medium := 0 low := 0 - alerts := mergedSecurityAlerts() + alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts()) for _, a := range alerts { switch a.Severity { case "critical": @@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary}) } +func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert { + allowed, restricted := requestAllowedContainers(r) + if !restricted { + return alerts + } + filtered := make([]SecurityAlert, 0, len(alerts)) + for _, alert := range alerts { + if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) { + filtered = append(filtered, alert) + } + } + return filtered +} + func mergedSecurityAlerts() []SecurityAlert { ss := ensureScanner() ss.mu.Lock() diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index cc24fd3..2606724 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "loginlog:read") { + return + } // Return in reverse (newest first) reversed := make([]LoginLog, len(loginLogs)) diff --git a/backend/internal/api/snapshots.go b/backend/internal/api/snapshots.go index 4b6d69d..9574266 100644 --- a/backend/internal/api/snapshots.go +++ b/backend/internal/api/snapshots.go @@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "snapshot:read") { + return + } snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...) + snapshots = filterSnapshotsForRequest(r, snapshots) sortSnapshotsNewestFirst(snapshots) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots}) } @@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) { func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) { switch { case action == "snapshots" && r.Method == http.MethodGet: + if !requireScope(w, r, "snapshot:read") { + return + } listContainerSnapshots(w, r, containerID) case action == "snapshots" && r.Method == http.MethodPost: + if !requireScope(w, r, "snapshot:create") { + return + } createContainerSnapshot(w, r, containerID) case action == "snapshots/schedule" && r.Method == http.MethodPost: + if !requireScope(w, r, "snapshot:schedule") { + return + } updateSnapshotSchedule(w, r, containerID) case action == "snapshots/quota" && r.Method == http.MethodPut: + if !requireScope(w, r, "snapshot:schedule") { + return + } updateSnapshotQuota(w, r, containerID) case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost: + if !requireScope(w, r, "snapshot:restore") { + return + } snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore") restoreContainerSnapshot(w, r, containerID, snapshotID) case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete: + if !requireScope(w, r, "snapshot:delete") { + return + } snapshotID := strings.TrimPrefix(action, "snapshots/") deleteContainerSnapshot(w, r, containerID, snapshotID) default: @@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI } func requestUser(r *http.Request) string { - if claims, ok := claimsFromRequest(r); ok { - if subUser, _ := claims["sub_user"].(string); subUser != "" { - return "user:" + subUser - } - if username, _ := claims["username"].(string); username != "" { - return username - } - } - return "admin" + return requestActor(r) } func sortSnapshotsNewestFirst(snapshots []config.Snapshot) { @@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) { return tj.Before(ti) }) } + +func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot { + allowed, restricted := requestAllowedContainers(r) + if !restricted { + return snapshots + } + filtered := make([]config.Snapshot, 0, len(snapshots)) + for _, snapshot := range snapshots { + if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) { + filtered = append(filtered, snapshot) + } + } + return filtered +} diff --git a/backend/internal/api/ssh.go b/backend/internal/api/ssh.go index 8136233..9c476c7 100644 --- a/backend/internal/api/ssh.go +++ b/backend/internal/api/ssh.go @@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) { return } + if !requireScope(w, r, "terminal:ssh") { + return + } var req struct { ContainerName string `json:"container_name"` } diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index a6ee4b9..70ddcbe 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "subuser:create") { + return + } var req struct { ContainerName string `json:"container_name"` @@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) { return allowed, true } +func requestAllowedContainers(r *http.Request) (subUserAccess, bool) { + if ctx, ok := authContextFromRequest(r); ok { + if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 { + return subUserAccess{}, false + } + if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey { + allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)} + for _, uuid := range ctx.ContainerUUIDs { + allowed.uuids[uuid] = true + } + if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 { + legacy, ok := subUserAllowedContainers(r) + if ok { + return legacy, true + } + } + return allowed, true + } + } + return subUserAllowedContainers(r) +} + +func isAccessRestrictedRequest(r *http.Request) bool { + _, restricted := requestAllowedContainers(r) + return restricted +} + func containerByIdentifier(identifier string) *config.Container { return config.FindContainerByIdentifier(identifier) } func isContainerAllowedForRequest(r *http.Request, identifier string) bool { - allowed, isSubUser := subUserAllowedContainers(r) - if !isSubUser { + allowed, restricted := requestAllowedContainers(r) + if !restricted { return true } c := containerByIdentifier(identifier) @@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "audit:read") { + return + } logs := config.AppConfig.AuditLogs if logs == nil { @@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { } path := r.URL.Path - if path == "/api/tasks" && r.Method == http.MethodGet { + containerPrefix := "/api/containers/" + containerListPath := "/api/containers" + tasksPath := "/api/tasks" + if strings.HasPrefix(path, "/api/v1/") { + containerPrefix = "/api/v1/containers/" + containerListPath = "/api/v1/containers" + tasksPath = "/api/v1/tasks" + } + if path == tasksPath && r.Method == http.MethodGet { next(w, r) return } - if path == "/api/containers" { + if path == containerListPath { if r.Method != http.MethodGet { jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"}) return @@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { return } - if len(path) > len("/api/containers/") { - rest := path[len("/api/containers/"):] + if strings.HasPrefix(path, containerPrefix) { + rest := path[len(containerPrefix):] parts := splitPath(rest) if len(parts) > 0 && parts[0] != "" { c := containerByIdentifier(parts[0]) @@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc { } func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container { - allowed, isSubUser := subUserAllowedContainers(r) - if !isSubUser { + allowed, restricted := requestAllowedContainers(r) + if !restricted { return containers } filtered := make([]config.Container, 0, len(containers)) @@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container) } func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task { - allowed, isSubUser := subUserAllowedContainers(r) - if !isSubUser { - return tasks - } filtered := make([]*Task, 0, len(tasks)) for _, task := range tasks { - if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) { + if isTaskAllowedForRequest(r, task) { filtered = append(filtered, task) - continue - } - if task.ContainerName != "" { - if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) { - filtered = append(filtered, task) - continue - } - } - if task.Config.Name != "" { - if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) { - filtered = append(filtered, task) - } } } return filtered } +func isTaskAllowedForRequest(r *http.Request, task *Task) bool { + allowed, restricted := requestAllowedContainers(r) + if !restricted { + return true + } + if task == nil { + return false + } + if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) { + return true + } + if task.ContainerName != "" { + if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) { + return true + } + } + if task.Config.Name != "" { + if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) { + return true + } + } + return false +} + func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { - return c != nil && c.UUID != "" && allowed.uuids[c.UUID] + if c == nil { + return false + } + if c.UUID != "" && allowed.uuids[c.UUID] { + return true + } + return c.Name != "" && allowed.names[c.Name] } func isSubUserBlockedAction(action string, method string) bool { @@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "subuser:read") { + return + } result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers)) for _, su := range config.AppConfig.SubUsers { @@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) { // HandleSubUserAction handles actions on a specific sub-user func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/") + path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/") + path = strings.TrimPrefix(path, "/api/sub-users/") parts := strings.SplitN(path, "/", 2) subUserID := parts[0] action := "" @@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { switch { case action == "rotate-password" && r.Method == http.MethodPost: + if !requireScope(w, r, "subuser:update") { + return + } password := generateRandomStr(16) if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { target.PassHash = string(hash) @@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"}) case action == "audit-logs" && r.Method == http.MethodGet: + if !requireScope(w, r, "audit:read") { + return + } // Filter audit logs for this sub-user logs := filterSubUserAuditLogs(target.Username) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) case action == "login-logs" && r.Method == http.MethodGet: + if !requireScope(w, r, "loginlog:read") { + return + } // Filter login logs for this sub-user logs := filterSubUserLoginLogs(target.Username) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) diff --git a/backend/internal/api/swap.go b/backend/internal/api/swap.go index 13ac209..b623ad1 100644 --- a/backend/internal/api/swap.go +++ b/backend/internal/api/swap.go @@ -11,19 +11,27 @@ import ( ) type SwapInfo struct { - TotalMB int64 `json:"total_mb"` - UsedMB int64 `json:"used_mb"` - FreeMB int64 `json:"free_mb"` - Enabled bool `json:"enabled"` - SwapFile string `json:"swap_file"` + TotalMB int64 `json:"total_mb"` + UsedMB int64 `json:"used_mb"` + FreeMB int64 `json:"free_mb"` + Enabled bool `json:"enabled"` + SwapFile string `json:"swap_file"` } +const ( + minSwapSizeMB = 128 + maxSwapSizeMB = 262144 +) + // HandleSwapInfo returns current swap status func HandleSwapInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "swap:read") { + return + } info := getSwapInfo() jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) @@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "swap:manage") { + return + } var req struct { - Action string `json:"action"` // create, enable, disable, resize + Action string `json:"action"` // create, enable, disable, resize SizeMB int `json:"size_mb"` // for create/resize } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) { } var msg string + var err error switch req.Action { case "create": if req.SizeMB <= 0 { req.SizeMB = 2048 } - err := createSwap(req.SizeMB) - if err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) - return + if err = validateSwapSize(req.SizeMB); err == nil { + err = createSwap(req.SizeMB) } msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB) case "enable": - err := enableSwap() - if err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) - return - } + err = enableSwap() msg = "SWAP 已启用" case "disable": - err := disableSwap() - if err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) - return - } + err = disableSwap() msg = "SWAP 已禁用" case "resize": - if req.SizeMB <= 0 { - jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"}) - return + if err = validateSwapSize(req.SizeMB); err == nil { + err = disableSwap() + } + if err == nil { + err = createSwap(req.SizeMB) + } + if err == nil { + err = enableSwap() } - disableSwap() - createSwap(req.SizeMB) - enableSwap() msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB) default: jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action}) return } + if err != nil { + auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error()) + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } info := getSwapInfo() + auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "") jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info}) } +func validateSwapSize(sizeMB int) error { + if sizeMB < minSwapSizeMB { + return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB) + } + if sizeMB > maxSwapSizeMB { + return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB) + } + return nil +} + func getSwapInfo() SwapInfo { info := SwapInfo{SwapFile: "/swapfile"} @@ -160,6 +180,9 @@ func createSwap(sizeMB int) error { func enableSwap() error { swapFile := "/swapfile" if _, err := os.Stat(swapFile); os.IsNotExist(err) { + if getSwapInfo().Enabled { + return nil + } return fmt.Errorf("swap 文件不存在,请先创建") } @@ -180,7 +203,7 @@ func disableSwap() error { cmd := exec.Command("swapoff", swapFile) output, err := cmd.CombinedOutput() if err != nil { - if strings.Contains(string(output), "No such") { + if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") { return nil } return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output)) diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index 5694c1a..fc5e086 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template } func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string { + return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "") +} + +func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string { q.mu.Lock() defer q.mu.Unlock() - return q.enqueueBatchCreateList(configs) + return q.enqueueBatchCreateList(configs, user, ip, userAgent) } func (q *TaskQueue) ActiveCreateNames() map[string]bool { @@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool { return names } -func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string { +func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string { var result []string for _, cfg := range configs { cfgCopy := cfg @@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri Status: "pending", CreatedAt: time.Now().Format("2006-01-02 15:04:05"), Config: cfgCopy, + User: user, + IP: ip, + UserAgent: userAgent, } q.enqueueTask(task) result = append(result, task.ID) @@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() { TemplateID: t.TemplateID, Config: string(cfgJSON), User: t.User, + IP: t.IP, + UserAgent: t.UserAgent, }) } config.SaveTasks(saved) @@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti name = c.Name } - // Determine user from JWT claims - user := "admin" - if claims, ok := claimsFromRequest(r); ok { - if subUser, _ := claims["sub_user"].(string); subUser != "" { - user = "user:" + subUser - } - } + // Determine user from authenticated request context. + user := requestActor(r) ip := clientIP(r) userAgent := r.Header.Get("User-Agent") @@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "container:create") { + return + } + if isAccessRestrictedRequest(r) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"}) + return + } var req struct { Containers []lxc.ContainerConfig `json:"containers"` } @@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { } requestNames[name] = true } - ids := globalQueue.EnqueueBatchCreate(req.Containers) + ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent()) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) } @@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"}) + return + } var req struct { Action string `json:"action"` Containers []int `json:"containers"` @@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) { } var taskType TaskType + var requiredScope string switch req.Action { case "start": taskType = TaskStart + requiredScope = "container:power" case "stop": taskType = TaskStop + requiredScope = "container:power" case "restart": taskType = TaskRestart + requiredScope = "container:power" case "delete": taskType = TaskDelete + requiredScope = "container:delete" + case "reinstall": + if req.TemplateID == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"}) + return + } + if !isTemplateEnabledAndDownloaded(req.TemplateID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) + return + } + taskType = TaskReinstall + requiredScope = "container:reinstall" default: jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) return } + if !requireScope(w, r, requiredScope) { + return + } + for _, id := range req.Containers { + c := config.FindContainer(id) + if c == nil || !isContainerAllowedForRequest(r, c.UUID) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"}) + return + } + } - ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID) + ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent()) jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids}) } @@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } - // URL: /api/tasks/{id} - taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/") + if !requireScope(w, r, "task:delete") { + return + } + // URL: /api/tasks/{id} or /api/v1/tasks/{id} + taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/") + taskID = strings.TrimPrefix(taskID, "/api/tasks/") if taskID == "" { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"}) return } globalQueue.mu.Lock() + if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) { + globalQueue.mu.Unlock() + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"}) + return + } delete(globalQueue.tasks, taskID) // Also remove from both queues if pending newCreate := make([]*Task, 0, len(globalQueue.createQueue)) @@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) return } + if !requireScope(w, r, "task:read") { + return + } tasks := globalQueue.GetTasks() tasks = filterTasksForRequest(r, tasks) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks}) @@ -691,6 +744,8 @@ func RestoreTasks() { TemplateID: st.TemplateID, Config: cfg, User: st.User, + IP: st.IP, + UserAgent: st.UserAgent, } if st.Status == "pending" || st.Status == "running" { // Reset running tasks back to pending so they get retried diff --git a/backend/internal/api/vnc.go b/backend/internal/api/vnc.go index ebbbc11..61d6716 100644 --- a/backend/internal/api/vnc.go +++ b/backend/internal/api/vnc.go @@ -36,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) { return } + if !requireScope(w, r, "terminal:vnc") { + return + } var req struct { ContainerName string `json:"container_name"` } @@ -158,6 +161,16 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { } 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 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index dac7399..61597d5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,8 @@ type SavedTask struct { TemplateID string `json:"template_id,omitempty"` Config string `json:"config,omitempty"` User string `json:"user,omitempty"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` } // SavedLoginLog for persisting login logs @@ -152,13 +154,18 @@ func (c *Container) VirshName() string { // SubUser represents a sub-user with access to specific containers type ApiKeyConfig struct { - ID string `json:"id"` - Name string `json:"name"` - KeyHash string `json:"key_hash"` - Prefix string `json:"prefix"` - IPWhitelist string `json:"ip_whitelist"` - CreatedAt string `json:"created_at"` - LastUsed string `json:"last_used"` + ID string `json:"id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + Prefix string `json:"prefix"` + IPWhitelist string `json:"ip_whitelist"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Disabled bool `json:"disabled,omitempty"` + ContainerUUIDs []string `json:"container_uuids,omitempty"` + LastUsedIP string `json:"last_used_ip,omitempty"` } // DeleteApiKey removes an API key by ID @@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) { } if AppConfig.ApiKeys == nil { AppConfig.ApiKeys = make([]ApiKeyConfig, 0) + } else { + for i := range AppConfig.ApiKeys { + if len(AppConfig.ApiKeys[i].Scopes) == 0 { + AppConfig.ApiKeys[i].Scopes = []string{"*"} + } + } } if AppConfig.AuditLogs == nil { AppConfig.AuditLogs = make([]AuditLog, 0) diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index f15c617..0721f18 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string { return string(data) } +func encodeStringSlice(values []string) string { + if len(values) == 0 { + return "" + } + data, err := json.Marshal(values) + if err != nil { + return "" + } + return string(data) +} + +func decodeStringSlice(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil + } + return values +} + func getDBPath() string { cfgPath := getConfigPath() ext := filepath.Ext(cfgPath) @@ -185,7 +207,12 @@ func ensureSchema() error { prefix TEXT, ip_whitelist TEXT, created_at TEXT, - last_used TEXT + last_used TEXT, + scopes TEXT, + expires_at TEXT, + disabled INTEGER, + container_uuids TEXT, + last_used_ip TEXT )`, `CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -210,6 +237,8 @@ func ensureSchema() error { created_at TEXT, template_id TEXT, user TEXT, + ip TEXT, + user_agent TEXT, cfg_name TEXT, cfg_virtualization TEXT, cfg_template_id TEXT, @@ -263,9 +292,55 @@ func ensureSchema() error { return fmt.Errorf("failed to create sqlite schema: %v", err) } } + return ensureSchemaMigrations() +} + +func ensureSchemaMigrations() error { + for _, column := range []struct { + table string + name string + def string + }{ + {"api_keys", "scopes", "TEXT"}, + {"api_keys", "expires_at", "TEXT"}, + {"api_keys", "disabled", "INTEGER"}, + {"api_keys", "container_uuids", "TEXT"}, + {"api_keys", "last_used_ip", "TEXT"}, + {"tasks", "ip", "TEXT"}, + {"tasks", "user_agent", "TEXT"}, + } { + if err := ensureColumn(column.table, column.name, column.def); err != nil { + return err + } + } return nil } +func ensureColumn(table, name, def string) error { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cid int + var columnName, columnType string + var notNull, pk int + var defaultValue interface{} + if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil { + return err + } + if columnName == name { + return nil + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def) + return err +} + func loadConfigFromDB() (*ClicdConfig, bool, error) { meta := map[string]string{} rows, err := db.Query("SELECT key, value FROM app_meta") @@ -468,8 +543,10 @@ func saveSubUsers(tx *sql.Tx) error { func saveAPIKeys(tx *sql.Tx) error { for _, k := range AppConfig.ApiKeys { - if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used) - VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil { + scopes := encodeStringSlice(k.Scopes) + containerUUIDs := encodeStringSlice(k.ContainerUUIDs) + if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil { return err } } @@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error { for _, task := range AppConfig.Tasks { cfg := parseSavedTaskConfig(task.Config) if _, err := tx.Exec(`INSERT INTO tasks( - id, type, container_id, container_name, status, error, created_at, template_id, user, + id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, cfg_assign_ipv6, cfg_expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit, @@ -669,7 +746,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) } func loadAPIKeys() ([]ApiKeyConfig, error) { - rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`) + rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`) if err != nil { return nil, err } @@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) { result := []ApiKeyConfig{} for rows.Next() { var k ApiKeyConfig - if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil { + var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString + var disabled sql.NullInt64 + if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil { return nil, err } + k.Scopes = decodeStringSlice(scopes.String) + k.ExpiresAt = expiresAt.String + k.Disabled = disabled.Valid && disabled.Int64 != 0 + k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String) + k.LastUsedIP = lastUsedIP.String result = append(result, k) } return result, rows.Err() @@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) { func loadTasks() ([]SavedTask, error) { rows, err := db.Query(`SELECT - id, type, container_id, container_name, status, error, created_at, template_id, user, + id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit, @@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) { var t SavedTask var cfg savedTaskConfig var assignIPv6 int + var ip, userAgent sql.NullString if err := rows.Scan( - &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, + &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, &cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit, @@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) { ); err != nil { return nil, err } + t.IP = ip.String + t.UserAgent = userAgent.String cfg.AssignIPv6 = assignIPv6 != 0 result = append(result, t) configs = append(configs, cfg) diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 2fa7af7..d32c8e7 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -2258,13 +2258,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error { // Clean port mappings temporarily m.CleanPortMappings(id) - // Destroy old LXC but keep config + // Destroy old LXC but keep config. lxc-destroy can leave the config + // directory behind when rootfs mounts are still present, which makes the + // following lxc-create fail with "Container already exists". exec.Command("lxc-stop", "-n", lxcName, "-k").Run() - exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs") exec.Command("umount", "-R", "-l", rootfs).Run() - os.RemoveAll(rootfs) - os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img")) + exec.Command("lxc-destroy", "-n", lxcName, "-f").Run() + exec.Command("umount", "-R", "-l", rootfs).Run() + os.RemoveAll(filepath.Join(m.LxcPath, lxcName)) // Create new container with same LXC name (preserves ID) cmd := exec.Command("lxc-create", diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index ec6dd31..6d2349b 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") } - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") if r.Method == http.MethodOptions { @@ -113,6 +113,47 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) + // Versioned external API routes + mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard))) + mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) + mux.HandleFunc("/api/v1/containers/", 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/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots)))) + mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting))) + mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status))) + mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) + mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete))) + mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate))) + mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction))) + mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate))) + mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList))) + mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction))) + mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs))) + mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs))) + mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts)))) + mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck)))) + mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs)))) + mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary)))) + mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings))) + mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) + mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket))) + mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys))) + mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete))) + mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + api.HandleSwapInfo(w, r) + return + } + api.HandleSwapManage(w, r) + }))) + // Version (public) mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion)) diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index e69de29..30259b2 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/pages/ApiIntegration.tsx b/frontend/src/pages/ApiIntegration.tsx index fe87106..fb77f30 100644 --- a/frontend/src/pages/ApiIntegration.tsx +++ b/frontend/src/pages/ApiIntegration.tsx @@ -1,6 +1,18 @@ -import { useState, useEffect, useCallback } from 'react' -import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react' -import api, { APIResponse } from '../services/api' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Check, + ChevronDown, + ChevronUp, + Copy, + Edit3, + Key, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + X, +} from 'lucide-react' +import api, { APIResponse, Container } from '../services/api' import { copyToClipboard } from '../utils/clipboard' interface ApiKeyItem { @@ -11,132 +23,431 @@ interface ApiKeyItem { ip_whitelist: string created_at: string last_used: string + scopes?: string[] + expires_at?: string + disabled?: boolean + container_uuids?: string[] + last_used_ip?: string +} + +interface ApiKeyForm { + name: string + ipWhitelist: string + scopes: string[] + expiresAt: string + disabled: boolean + containerUUIDs: string[] } const BASE_URL = window.location.origin +const scopeGroups = [ + { + title: '总览与只读', + scopes: [ + ['dashboard:read', '控制面板'], + ['host:read', '主机资源'], + ['routing:read', '路由信息'], + ['ipv6:read', 'IPv6 状态'], + ['task:read', '任务列表'], + ['image:read', '镜像列表'], + ], + }, + { + title: '容器', + scopes: [ + ['container:read', '查看容器'], + ['container:create', '创建容器'], + ['container:power', '开关机/重启'], + ['container:reinstall', '重装系统'], + ['container:delete', '删除容器'], + ['container:resize', '资源/到期'], + ['container:traffic', '流量管理'], + ['container:network', '端口映射'], + ['container:password', '重置密码'], + ['ipv6:assign', '分配 IPv6'], + ], + }, + { + title: '快照与终端', + scopes: [ + ['snapshot:read', '查看快照'], + ['snapshot:create', '创建快照'], + ['snapshot:delete', '删除快照'], + ['snapshot:restore', '恢复快照'], + ['snapshot:schedule', '计划/配额'], + ['terminal:ssh', 'WebSSH 票据'], + ['terminal:vnc', 'WebVNC 票据'], + ], + }, + { + title: '平台管理', + scopes: [ + ['image:download', '下载镜像'], + ['image:delete', '删除镜像'], + ['image:toggle', '启停镜像'], + ['security:read', '安全数据'], + ['security:check', '安全扫描'], + ['security:settings', '安全设置'], + ['swap:read', 'Swap 信息'], + ['swap:manage', 'Swap 管理'], + ['subuser:read', '子用户列表'], + ['subuser:create', '创建子用户'], + ['subuser:update', '更新子用户'], + ['audit:read', '操作日志'], + ['loginlog:read', '登录日志'], + ['apikey:read', 'Key 列表'], + ['apikey:create', '创建 Key'], + ['apikey:update', '更新 Key'], + ['apikey:delete', '删除 Key'], + ['admin:access', '管理员接口'], + ], + }, +] + +const defaultReadScopes = [ + 'dashboard:read', + 'container:read', + 'task:read', + 'image:read', + 'snapshot:read', + 'routing:read', + 'ipv6:read', + 'host:read', +] + +const endpointGroups = [ + { + title: '总览', + endpoints: [ + ['GET', '/api/v1/dashboard', '控制面板统计'], + ['GET', '/api/v1/host-info', '主机资源'], + ['GET', '/api/v1/routing', 'NAT/IPv6 路由'], + ['GET', '/api/v1/ipv6/status', 'IPv6 状态'], + ['GET', '/api/v1/tasks', '任务队列'], + ['DELETE', '/api/v1/tasks/{task_id}', '删除任务'], + ], + }, + { + title: '容器', + endpoints: [ + ['GET', '/api/v1/containers', '容器列表'], + ['POST', '/api/v1/containers', '创建容器'], + ['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'], + ['POST', '/api/v1/containers/{id}/start', '开机'], + ['POST', '/api/v1/containers/{id}/stop', '关机'], + ['POST', '/api/v1/containers/{id}/restart', '重启'], + ['POST', '/api/v1/containers/{id}/reinstall', '重装'], + ['DELETE', '/api/v1/containers/{id}/delete', '删除'], + ['GET', '/api/v1/containers/{id}/usage', '资源用量'], + ['GET', '/api/v1/containers/{id}/traffic', '流量统计'], + ['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'], + ['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'], + ['PUT', '/api/v1/containers/{id}/resource-limit', '调整资源限制'], + ['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'], + ['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'], + ['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'], + ], + }, + { + title: '端口与快照', + endpoints: [ + ['GET', '/api/v1/containers/{id}/random-port', '随机可用端口'], + ['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'], + ['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'], + ['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'], + ['GET', '/api/v1/snapshots', '快照总览'], + ['GET', '/api/v1/containers/{id}/snapshots', '容器快照'], + ['POST', '/api/v1/containers/{id}/snapshots', '创建快照'], + ['DELETE', '/api/v1/containers/{id}/snapshots/{snapshot_id}', '删除快照'], + ['POST', '/api/v1/containers/{id}/snapshots/{snapshot_id}/restore', '恢复快照'], + ['POST', '/api/v1/containers/{id}/snapshots/schedule', '计划快照'], + ['PUT', '/api/v1/containers/{id}/snapshots/quota', '快照配额'], + ], + }, + { + title: '平台管理', + endpoints: [ + ['GET', '/api/v1/templates', '模板列表'], + ['GET', '/api/v1/images', '镜像管理列表'], + ['POST', '/api/v1/images/download', '下载镜像'], + ['POST', '/api/v1/images/cancel', '取消镜像下载'], + ['DELETE', '/api/v1/images/delete', '删除镜像缓存'], + ['PUT', '/api/v1/images/toggle', '启用/禁用镜像'], + ['GET', '/api/v1/security/alerts', '安全告警'], + ['POST', '/api/v1/security/check', '立即安全检查'], + ['GET', '/api/v1/security/logs?container={name}', '安全连接日志'], + ['GET', '/api/v1/security/summary', '安全汇总'], + ['GET', '/api/v1/security/settings', '安全设置'], + ['PUT', '/api/v1/security/settings', '更新安全设置'], + ['GET', '/api/v1/swap', 'Swap 信息'], + ['POST', '/api/v1/swap', '调整 Swap'], + ['POST', '/api/v1/batch-create', '批量创建容器'], + ['POST', '/api/v1/batch-action', '批量开关机/删除/重装'], + ['POST', '/api/v1/ssh-ticket', '创建 WebSSH 票据'], + ['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'], + ], + }, + { + title: '账号与日志', + endpoints: [ + ['POST', '/api/v1/sub-user/create', '创建子用户链接'], + ['GET', '/api/v1/sub-users', '子用户列表'], + ['POST', '/api/v1/sub-users/{id}/rotate-password', '轮换子用户密码'], + ['GET', '/api/v1/sub-users/{id}/audit-logs', '子用户操作日志'], + ['GET', '/api/v1/sub-users/{id}/login-logs', '子用户登录日志'], + ['GET', '/api/v1/audit-logs', '操作日志'], + ['GET', '/api/v1/login-logs', '登录日志'], + ['GET', '/api/v1/api-keys', 'API Key 列表'], + ['POST', '/api/v1/api-keys', '创建 API Key'], + ['PATCH', '/api/v1/api-keys/{id}', '更新 API Key'], + ['DELETE', '/api/v1/api-keys/{id}', '删除 API Key'], + ], + }, +] + +const emptyForm = (): ApiKeyForm => ({ + name: '', + ipWhitelist: '', + scopes: [...defaultReadScopes], + expiresAt: '', + disabled: false, + containerUUIDs: [], +}) + export default function ApiIntegration() { const [keys, setKeys] = useState([]) + const [containers, setContainers] = useState([]) const [loading, setLoading] = useState(true) - const [showCreate, setShowCreate] = useState(false) - const [newName, setNewName] = useState('') - const [newIPs, setNewIPs] = useState('') - const [creating, setCreating] = useState(false) + const [showForm, setShowForm] = useState(false) + const [editingKey, setEditingKey] = useState(null) + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) const [newKey, setNewKey] = useState('') - const [showDocs, setShowDocs] = useState(true) const [copiedKey, setCopiedKey] = useState(false) + const [showDocs, setShowDocs] = useState(true) - const fetchKeys = useCallback(async () => { + const containerNameByUUID = useMemo(() => { + const map = new Map() + containers.forEach(c => map.set(c.uuid, c.name)) + return map + }, [containers]) + + const fetchData = useCallback(async () => { + setLoading(true) try { - const res = await api.get>('/api-keys') - setKeys(res.data.data || []) - } catch { /* ignore */ } - finally { setLoading(false) } + const [keyRes, containerRes] = await Promise.all([ + api.get>('/api-keys'), + api.get>('/containers'), + ]) + setKeys(keyRes.data.data || []) + setContainers(containerRes.data.data || []) + } catch { + // keep the page usable if one request fails + } finally { + setLoading(false) + } }, []) - useEffect(() => { fetchKeys() }, [fetchKeys]) + useEffect(() => { + fetchData() + }, [fetchData]) - const createKey = async () => { - if (!newName.trim()) return - setCreating(true) + const openCreate = () => { + setEditingKey(null) + setForm(emptyForm()) + setShowForm(true) + } + + const openEdit = (item: ApiKeyItem) => { + setEditingKey(item) + setForm({ + name: item.name, + ipWhitelist: item.ip_whitelist || '', + scopes: item.scopes?.length ? item.scopes : ['*'], + expiresAt: toDateTimeLocal(item.expires_at || ''), + disabled: Boolean(item.disabled), + containerUUIDs: item.container_uuids || [], + }) + setShowForm(true) + } + + const saveKey = async () => { + if (!form.name.trim()) return + setSaving(true) + const payload = { + name: form.name.trim(), + ip_whitelist: form.ipWhitelist.trim(), + scopes: form.scopes, + expires_at: fromDateTimeLocal(form.expiresAt), + disabled: form.disabled, + container_uuids: form.containerUUIDs, + } try { - const res = await api.post>('/api-keys', { - name: newName.trim(), - ip_whitelist: newIPs.trim(), - }) - if (res.data.data?.key) { - setNewKey(res.data.data.key) - setKeys(prev => [res.data.data!, ...prev]) + if (editingKey) { + const res = await api.patch>(`/api-keys/${editingKey.id}`, payload) + if (res.data.data) { + setKeys(prev => prev.map(k => (k.id === editingKey.id ? res.data.data! : k))) + } + } else { + const res = await api.post>('/api-keys', payload) + if (res.data.data) { + setKeys(prev => [res.data.data!, ...prev]) + if (res.data.data.key) setNewKey(res.data.data.key) + } } - setNewName('') - setNewIPs('') - setShowCreate(false) - } catch { /* ignore */ } - finally { setCreating(false) } + setShowForm(false) + } catch { + // axios interceptor handles auth; form stays open + } finally { + setSaving(false) + } } const deleteKey = async (id: string) => { - if (!window.confirm('确定要删除此 API Key 吗?')) return + if (!window.confirm('确定删除这个 API Key 吗?')) return try { await api.delete(`/api-keys/${id}`) setKeys(prev => prev.filter(k => k.id !== id)) - } catch { /* ignore */ } + } catch { + // ignore + } } const copyKey = async () => { const copied = await copyToClipboard(newKey) if (copied) { setCopiedKey(true) - setTimeout(() => setCopiedKey(false), 2000) + setTimeout(() => setCopiedKey(false), 1600) } } + const toggleScope = (scope: string) => { + setForm(prev => { + if (scope === '*') { + return { ...prev, scopes: prev.scopes.includes('*') ? [...defaultReadScopes] : ['*'] } + } + const withoutAll = prev.scopes.filter(s => s !== '*') + const scopes = withoutAll.includes(scope) + ? withoutAll.filter(s => s !== scope) + : [...withoutAll, scope] + return { ...prev, scopes: scopes.length ? scopes : [...defaultReadScopes] } + }) + } + + const toggleContainer = (uuid: string) => { + setForm(prev => ({ + ...prev, + containerUUIDs: prev.containerUUIDs.includes(uuid) + ? prev.containerUUIDs.filter(item => item !== uuid) + : [...prev.containerUUIDs, uuid], + })) + } + return (
-
-

API 集成

-

管理 API Key 与查看接口文档

+
+
+

API 集成

+

管理外部调用凭据、权限范围与平台 API 文档

+
+
- {/* API Keys */} -
-
-

- API Keys -

-
- - +
+
+ + {newKey} + +
+ )} - {newKey && ( -
-
- 新 API Key 已生成 - -
-

此 Key 仅显示一次,请立即复制保存。

-
- {newKey} - -
-
- )} +
+
+

+ + API Keys +

+ +
{loading ? ( -
加载中...
+
加载中...
) : keys.length === 0 ? ( -
暂无 API Key,点击"创建 Key"开始
+
暂无 API Key
) : (
- - - - - - + + + + + + - {keys.map(k => ( - - - - - - - + + + + + + ))} @@ -146,150 +457,201 @@ export default function ApiIntegration() { )} - {/* Create Key Modal */} - {showCreate && ( -
-
setShowCreate(false)} /> -
-
-

创建 API Key

- -
-
-
- - setNewName(e.target.value)} - placeholder="例如:自动化脚本、CI/CD" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" - onKeyDown={e => e.key === 'Enter' && createKey()} - autoFocus - /> -
-
- -
名称Key 前缀IP 白名单创建时间最后使用操作名称权限绑定容器限制最后使用操作
{k.name}{k.prefix}{k.ip_whitelist || '不限制'}{k.created_at}{k.last_used || '未使用'} - + {keys.map(item => ( +
+
+ {item.name} + {item.disabled && ( + 已禁用 + )} +
+
{item.prefix}
+
+ + + {item.container_uuids?.length + ? item.container_uuids.map(uuid => containerNameByUUID.get(uuid) || uuid).join('、') + : '全部容器'} + +
{item.ip_whitelist ? 'IP 白名单' : '不限 IP'}
+
{item.expires_at ? `到期 ${item.expires_at}` : '长期有效'}
+
+
{item.last_used || '从未使用'}
+ {item.last_used_ip &&
{item.last_used_ip}
} +
+
+ + +