增强API集成能力,划分KEY功能权限

This commit is contained in:
MengMengCode
2026-06-08 19:25:55 +08:00
parent 2fa130a2b6
commit e79609281f
20 changed files with 1596 additions and 425 deletions
+272 -106
View File
@@ -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)
}
+160 -4
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -28,6 +29,132 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"`
}
type authContextKey struct{}
type AuthContext struct {
Type string
Username string
ApiKeyID string
ApiKeyName string
Actor string
Scopes []string
ContainerUUIDs []string
}
const (
authTypeAdmin = "admin"
authTypeSubUser = "sub_user"
authTypeAPIKey = "api_key"
)
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
}
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
return ctx, ok
}
func requestActor(r *http.Request) string {
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
return ctx.Actor
}
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func hasScope(r *http.Request, scope string) bool {
ctx, ok := authContextFromRequest(r)
if !ok {
return true
}
switch ctx.Type {
case authTypeAdmin:
return true
case authTypeSubUser:
return subUserScopeAllowed(scope)
case authTypeAPIKey:
return scopeAllowed(ctx.Scopes, scope)
default:
return false
}
}
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
default:
return false
}
}
func hasAnyScope(r *http.Request, scopes ...string) bool {
for _, scope := range scopes {
if hasScope(r, scope) {
return true
}
}
return false
}
func scopeAllowed(scopes []string, required string) bool {
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == "admin:*" || scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasScope(r, scope) {
return true
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return false
}
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, scope) {
return
}
next(w, r)
}
}
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if hasAnyScope(r, scopes...) {
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
}
}
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -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
}
+86 -1
View File
@@ -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})
}
+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"})
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()
+3
View File
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "ipv6:read") {
return
}
status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
+3
View File
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:read") {
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
+43 -2
View File
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
}
// HandleSecuritySettings returns or updates security automation settings.
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireScope(w, r, "security:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
case http.MethodPut:
if !requireScope(w, r, "security:settings") {
return
}
var req struct {
AutoShutdown bool `json:"auto_shutdown"`
}
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
}})
@@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:check") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
return
}
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
ensureScanner().checkContainer(c.Name, c.IP)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
@@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
containerName := r.URL.Query().Get("container")
if containerName == "" {
@@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
return
}
if !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
}
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "security:read") {
return
}
critical := 0
high := 0
medium := 0
low := 0
alerts := mergedSecurityAlerts()
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
for _, a := range alerts {
switch a.Severity {
case "critical":
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
}
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return alerts
}
filtered := make([]SecurityAlert, 0, len(alerts))
for _, alert := range alerts {
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, alert)
}
}
return filtered
}
func mergedSecurityAlerts() []SecurityAlert {
ss := ensureScanner()
ss.mu.Lock()
+3
View File
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "loginlog:read") {
return
}
// Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs))
+37 -9
View File
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "snapshot:read") {
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
snapshots = filterSnapshotsForRequest(r, snapshots)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
}
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
if !requireScope(w, r, "snapshot:read") {
return
}
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:create") {
return
}
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
if !requireScope(w, r, "snapshot:schedule") {
return
}
updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
if !requireScope(w, r, "snapshot:restore") {
return
}
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
if !requireScope(w, r, "snapshot:delete") {
return
}
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
}
func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
return requestActor(r)
}
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
return tj.Before(ti)
})
}
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return snapshots
}
filtered := make([]config.Snapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, snapshot)
}
}
return filtered
}
+3
View File
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
return
}
if !requireScope(w, r, "terminal:ssh") {
return
}
var req struct {
ContainerName string `json:"container_name"`
}
+95 -27
View File
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:create") {
return
}
var req struct {
ContainerName string `json:"container_name"`
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
return allowed, true
}
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
if ctx, ok := authContextFromRequest(r); ok {
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
return subUserAccess{}, false
}
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
for _, uuid := range ctx.ContainerUUIDs {
allowed.uuids[uuid] = true
}
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
legacy, ok := subUserAllowedContainers(r)
if ok {
return legacy, true
}
}
return allowed, true
}
}
return subUserAllowedContainers(r)
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
}
func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier)
}
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
c := containerByIdentifier(identifier)
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "audit:read") {
return
}
logs := config.AppConfig.AuditLogs
if logs == nil {
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet {
containerPrefix := "/api/containers/"
containerListPath := "/api/containers"
tasksPath := "/api/tasks"
if strings.HasPrefix(path, "/api/v1/") {
containerPrefix = "/api/v1/containers/"
containerListPath = "/api/v1/containers"
tasksPath = "/api/v1/tasks"
}
if path == tasksPath && r.Method == http.MethodGet {
next(w, r)
return
}
if path == "/api/containers" {
if path == containerListPath {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}
if len(path) > len("/api/containers/") {
rest := path[len("/api/containers/"):]
if strings.HasPrefix(path, containerPrefix) {
rest := path[len(containerPrefix):]
parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0])
@@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return containers
}
filtered := make([]config.Container, 0, len(containers))
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
}
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
if isTaskAllowedForRequest(r, task) {
filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
}
}
return filtered
}
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
allowed, restricted := requestAllowedContainers(r)
if !restricted {
return true
}
if task == nil {
return false
}
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
return true
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
return true
}
}
return false
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
if c == nil {
return false
}
if c.UUID != "" && allowed.uuids[c.UUID] {
return true
}
return c.Name != "" && allowed.names[c.Name]
}
func isSubUserBlockedAction(action string, method string) bool {
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "subuser:read") {
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
// HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
path = strings.TrimPrefix(path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2)
subUserID := parts[0]
action := ""
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
if !requireScope(w, r, "subuser:update") {
return
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash)
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "audit:read") {
return
}
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
if !requireScope(w, r, "loginlog:read") {
return
}
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
+50 -27
View File
@@ -11,19 +11,27 @@ import (
)
type SwapInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
}
const (
minSwapSizeMB = 128
maxSwapSizeMB = 262144
)
// HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:read") {
return
}
info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "swap:manage") {
return
}
var req struct {
Action string `json:"action"` // create, enable, disable, resize
Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
}
var msg string
var err error
switch req.Action {
case "create":
if req.SizeMB <= 0 {
req.SizeMB = 2048
}
err := createSwap(req.SizeMB)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = createSwap(req.SizeMB)
}
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable":
err := enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = enableSwap()
msg = "SWAP 已启用"
case "disable":
err := disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
err = disableSwap()
msg = "SWAP 已禁用"
case "resize":
if req.SizeMB <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
return
if err = validateSwapSize(req.SizeMB); err == nil {
err = disableSwap()
}
if err == nil {
err = createSwap(req.SizeMB)
}
if err == nil {
err = enableSwap()
}
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return
}
if err != nil {
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
info := getSwapInfo()
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
}
func validateSwapSize(sizeMB int) error {
if sizeMB < minSwapSizeMB {
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
}
if sizeMB > maxSwapSizeMB {
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
}
return nil
}
func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"}
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
func enableSwap() error {
swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
if getSwapInfo().Enabled {
return nil
}
return fmt.Errorf("swap 文件不存在,请先创建")
}
@@ -180,7 +203,7 @@ func disableSwap() error {
cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "No such") {
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
return nil
}
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
+68 -13
View File
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
}
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
}
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock()
defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs)
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
}
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
return names
}
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
var result []string
for _, cfg := range configs {
cfgCopy := cfg
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
IP: ip,
UserAgent: userAgent,
}
q.enqueueTask(task)
result = append(result, task.ID)
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
TemplateID: t.TemplateID,
Config: string(cfgJSON),
User: t.User,
IP: t.IP,
UserAgent: t.UserAgent,
})
}
config.SaveTasks(saved)
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
name = c.Name
}
// Determine user from JWT claims
user := "admin"
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
// Determine user from authenticated request context.
user := requestActor(r)
ip := clientIP(r)
userAgent := r.Header.Get("User-Agent")
@@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "container:create") {
return
}
if isAccessRestrictedRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
return
}
var req struct {
Containers []lxc.ContainerConfig `json:"containers"`
}
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
}
requestNames[name] = true
}
ids := globalQueue.EnqueueBatchCreate(req.Containers)
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return
}
var req struct {
Action string `json:"action"`
Containers []int `json:"containers"`
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
}
var taskType TaskType
var requiredScope string
switch req.Action {
case "start":
taskType = TaskStart
requiredScope = "container:power"
case "stop":
taskType = TaskStop
requiredScope = "container:power"
case "restart":
taskType = TaskRestart
requiredScope = "container:power"
case "delete":
taskType = TaskDelete
requiredScope = "container:delete"
case "reinstall":
if req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall
requiredScope = "container:reinstall"
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return
}
if !requireScope(w, r, requiredScope) {
return
}
for _, id := range req.Containers {
c := config.FindContainer(id)
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
}
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
// URL: /api/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
if !requireScope(w, r, "task:delete") {
return
}
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
if taskID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return
}
globalQueue.mu.Lock()
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
return
}
delete(globalQueue.tasks, taskID)
// Also remove from both queues if pending
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "task:read") {
return
}
tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
@@ -691,6 +744,8 @@ func RestoreTasks() {
TemplateID: st.TemplateID,
Config: cfg,
User: st.User,
IP: st.IP,
UserAgent: st.UserAgent,
}
if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried
+13
View File
@@ -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
+20 -7
View File
@@ -33,6 +33,8 @@ type SavedTask struct {
TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"`
User string `json:"user,omitempty"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// SavedLoginLog for persisting login logs
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
}
// DeleteApiKey removes an API key by ID
@@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) {
}
if AppConfig.ApiKeys == nil {
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
} else {
for i := range AppConfig.ApiKeys {
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
AppConfig.ApiKeys[i].Scopes = []string{"*"}
}
}
}
if AppConfig.AuditLogs == nil {
AppConfig.AuditLogs = make([]AuditLog, 0)
+97 -10
View File
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
return string(data)
}
func encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
}
data, err := json.Marshal(values)
if err != nil {
return ""
}
return string(data)
}
func decodeStringSlice(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func getDBPath() string {
cfgPath := getConfigPath()
ext := filepath.Ext(cfgPath)
@@ -185,7 +207,12 @@ func ensureSchema() error {
prefix TEXT,
ip_whitelist TEXT,
created_at TEXT,
last_used TEXT
last_used TEXT,
scopes TEXT,
expires_at TEXT,
disabled INTEGER,
container_uuids TEXT,
last_used_ip TEXT
)`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -210,6 +237,8 @@ func ensureSchema() error {
created_at TEXT,
template_id TEXT,
user TEXT,
ip TEXT,
user_agent TEXT,
cfg_name TEXT,
cfg_virtualization TEXT,
cfg_template_id TEXT,
@@ -263,9 +292,55 @@ func ensureSchema() error {
return fmt.Errorf("failed to create sqlite schema: %v", err)
}
}
return ensureSchemaMigrations()
}
func ensureSchemaMigrations() error {
for _, column := range []struct {
table string
name string
def string
}{
{"api_keys", "scopes", "TEXT"},
{"api_keys", "expires_at", "TEXT"},
{"api_keys", "disabled", "INTEGER"},
{"api_keys", "container_uuids", "TEXT"},
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
}
}
return nil
}
func ensureColumn(table, name, def string) error {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var columnName, columnType string
var notNull, pk int
var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &defaultValue, &pk); err != nil {
return err
}
if columnName == name {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
return err
}
func loadConfigFromDB() (*ClicdConfig, bool, error) {
meta := map[string]string{}
rows, err := db.Query("SELECT key, value FROM app_meta")
@@ -468,8 +543,10 @@ func saveSubUsers(tx *sql.Tx) error {
func saveAPIKeys(tx *sql.Tx) error {
for _, k := range AppConfig.ApiKeys {
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
scopes := encodeStringSlice(k.Scopes)
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
return err
}
}
@@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error {
for _, task := range AppConfig.Tasks {
cfg := parseSavedTaskConfig(task.Config)
if _, err := tx.Exec(`INSERT INTO tasks(
id, type, container_id, container_name, status, error, created_at, template_id, user,
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
@@ -669,7 +746,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
}
func loadAPIKeys() ([]ApiKeyConfig, error) {
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
if err != nil {
return nil, err
}
@@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
result := []ApiKeyConfig{}
for rows.Next() {
var k ApiKeyConfig
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
var disabled sql.NullInt64
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
return nil, err
}
k.Scopes = decodeStringSlice(scopes.String)
k.ExpiresAt = expiresAt.String
k.Disabled = disabled.Valid && disabled.Int64 != 0
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
k.LastUsedIP = lastUsedIP.String
result = append(result, k)
}
return result, rows.Err()
@@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) {
func loadTasks() ([]SavedTask, error) {
rows, err := db.Query(`SELECT
id, type, container_id, container_name, status, error, created_at, template_id, user,
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
@@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
var ip, userAgent sql.NullString
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
@@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) {
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
cfg.AssignIPv6 = assignIPv6 != 0
result = append(result, t)
configs = append(configs, cfg)
+6 -4
View File
@@ -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",
+42 -1
View File
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
@@ -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))
+1
View File
@@ -0,0 +1 @@
+576 -214
View File
@@ -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<ApiKeyItem[]>([])
const [containers, setContainers] = useState<Container[]>([])
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<ApiKeyItem | null>(null)
const [form, setForm] = useState<ApiKeyForm>(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<string, string>()
containers.forEach(c => map.set(c.uuid, c.name))
return map
}, [containers])
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await api.get<APIResponse<ApiKeyItem[]>>('/api-keys')
setKeys(res.data.data || [])
} catch { /* ignore */ }
finally { setLoading(false) }
const [keyRes, containerRes] = await Promise.all([
api.get<APIResponse<ApiKeyItem[]>>('/api-keys'),
api.get<APIResponse<Container[]>>('/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<APIResponse<ApiKeyItem>>('/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<APIResponse<ApiKeyItem>>(`/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<APIResponse<ApiKeyItem>>('/api-keys', payload)
if (res.data.data) {
setKeys(prev => [res.data.data!, ...prev])
if (res.data.data.key) setNewKey(res.data.data.key)
}
}
setNewName('')
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black">API </h1>
<p className="text-sm text-gray-500 mt-1"> API Key </p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">API </h1>
<p className="mt-1 text-sm text-gray-500"> API </p>
</div>
<button
onClick={openCreate}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800"
>
<Plus className="h-4 w-4" />
Key
</button>
</div>
{/* API Keys */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Key className="w-4 h-4" />API Keys
</h2>
<div className="flex items-center gap-2">
<button onClick={fetchKeys} className="p-1.5 text-gray-400 hover:text-black rounded" title="刷新"><RefreshCw className="w-3.5 h-3.5" /></button>
<button onClick={() => setShowCreate(true)} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" /> Key
{newKey && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-amber-800"> API Key </div>
<button onClick={() => setNewKey('')} className="rounded p-1 text-amber-700 hover:bg-amber-100" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<code className="min-w-0 flex-1 break-all rounded border border-amber-300 bg-white px-3 py-2 font-mono text-xs text-gray-800">
{newKey}
</code>
<button
onClick={copyKey}
className="inline-flex items-center justify-center gap-1.5 rounded-md bg-amber-600 px-3 py-2 text-xs text-white hover:bg-amber-700"
>
{copiedKey ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copiedKey ? '已复制' : '复制'}
</button>
</div>
</div>
)}
{newKey && (
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-amber-800"> API Key </span>
<button onClick={() => setNewKey('')} className="text-amber-600 hover:text-amber-800 text-xs"></button>
</div>
<p className="text-xs text-amber-700 mb-2"> Key </p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-white border border-amber-300 rounded text-xs font-mono text-gray-800 break-all">{newKey}</code>
<button onClick={copyKey} className="px-3 py-2 bg-amber-600 text-white rounded-md text-xs hover:bg-amber-700 whitespace-nowrap">
{copiedKey ? '已复制' : '复制'}
</button>
</div>
</div>
)}
<div className="rounded-lg border border-gray-200 bg-white">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Key className="h-4 w-4" />
API Keys
</h2>
<button onClick={fetchData} className="rounded p-1.5 text-gray-400 hover:text-black" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
{loading ? (
<div className="py-8 text-center text-sm text-gray-400">...</div>
<div className="py-10 text-center text-sm text-gray-400">...</div>
) : keys.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400"> API Key"创建 Key"</div>
<div className="py-10 text-center text-sm text-gray-400"> API Key</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Key </th>
<th className="px-3 py-2">IP </th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">使</th>
<th className="px-3 py-2 text-right"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3">使</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{keys.map(k => (
<tr key={k.id} className="hover:bg-gray-50">
<td className="px-3 py-2.5 font-medium text-gray-800">{k.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{k.prefix}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.ip_whitelist || '不限制'}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.created_at}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.last_used || '未使用'}</td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => deleteKey(k.id)} className="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
{keys.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{item.name}</span>
{item.disabled && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600"></span>
)}
</div>
<div className="mt-1 font-mono text-xs text-gray-400">{item.prefix}</div>
</td>
<td className="px-4 py-3">
<ScopeSummary scopes={item.scopes || ['*']} />
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{item.container_uuids?.length
? item.container_uuids.map(uuid => containerNameByUUID.get(uuid) || uuid).join('、')
: '全部容器'}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
<div>{item.ip_whitelist ? 'IP 白名单' : '不限 IP'}</div>
<div>{item.expires_at ? `到期 ${item.expires_at}` : '长期有效'}</div>
</td>
<td className="px-4 py-3 text-xs text-gray-500">
<div>{item.last_used || '从未使用'}</div>
{item.last_used_ip && <div className="font-mono text-[11px] text-gray-400">{item.last_used_ip}</div>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button onClick={() => openEdit(item)} className="rounded p-1.5 text-gray-400 hover:text-black" title="编辑">
<Edit3 className="h-3.5 w-3.5" />
</button>
<button onClick={() => deleteKey(item.id)} className="rounded p-1.5 text-gray-400 hover:text-red-600" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
@@ -146,150 +457,201 @@ export default function ApiIntegration() {
)}
</div>
{/* Create Key Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowCreate(false)} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-black"> API Key</h3>
<button onClick={() => setShowCreate(false)} className="p-1 text-gray-400 hover:text-black rounded"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="例如:自动化脚本、CI/CD"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
onKeyDown={e => e.key === 'Enter' && createKey()}
autoFocus
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">IP </label>
<textarea
value={newIPs}
onChange={e => setNewIPs(e.target.value)}
placeholder={`1.2.3.4\n10.0.0.0/24`}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono resize-none"
/>
<p className="text-[10px] text-gray-400 mt-1"> IP CIDR IP</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button onClick={createKey} disabled={creating || !newName.trim()} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
{creating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</div>
)}
{/* API Documentation */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Code className="w-4 h-4" />API
<div className="rounded-lg border border-gray-200 bg-white">
<button
onClick={() => setShowDocs(value => !value)}
className="flex w-full items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 text-left"
>
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />
API
</h2>
<button onClick={() => setShowDocs(!showDocs)} className="text-xs text-gray-500 hover:text-black">
{showDocs ? '收起' : '展开'}
</button>
</div>
{showDocs ? <ChevronUp className="h-4 w-4 text-gray-400" /> : <ChevronDown className="h-4 w-4 text-gray-400" />}
</button>
{showDocs && (
<div className="space-y-6 text-sm">
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<p className="text-gray-600 mb-3"> API 使 <strong>POST</strong> API Key</p>
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs space-y-2">
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"X-API-Key: clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
<div className="text-gray-500"># Bearer </div>
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"Authorization: Bearer clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
</div>
</section>
<div className="space-y-6 p-5">
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
<div>curl -H "X-API-Key: clicd_sk_xxxx" {BASE_URL}/api/v1/containers</div>
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</div>
</div>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/list" desc="获取容器列表" />
<Endpoint method="POST" path="/api/containers/detail" desc="获取容器详情" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/create" desc="创建容器" body={`{\n "name": "my-container",\n "template_id": "ubuntu-noble",\n "vcpu": 2,\n "ram_mb": 1024,\n "disk_gb": 20,\n "network_bw_mbps": 100,\n "monthly_traffic_gb": 1000,\n "io_speed_mbps": 500\n}`} />
<Endpoint method="POST" path="/api/containers/start" desc="启动容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/stop" desc="停止容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/restart" desc="重启容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/delete" desc="删除容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/reinstall" desc="重装系统" body='{"id": 1, "template_id": "debian-bookworm"}' />
<Endpoint method="POST" path="/api/containers/usage" desc="获取资源用量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic" desc="获取流量统计" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-reset" desc="重置流量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-limit" desc="修改流量限制" body='{"id": 1, "traffic_mode": "total", "monthly_traffic_gb": 1000}' />
<Endpoint method="POST" path="/api/containers/resource-limit" desc="修改资源限制" body='{"id": 1, "vcpu": 2, "ram_mb": 2048, "io_speed_mbps": 500, "network_bw_mbps": 100}' />
<Endpoint method="POST" path="/api/containers/expiry" desc="修改到期时间" body='{"id": 1, "expires_at": "2026-12-31 23:59:59"}' />
<Endpoint method="POST" path="/api/containers/reset-password" desc="重置 SSH 密码" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/port-mappings/add" desc="添加映射" body='{"id": 1, "container_port": 8080, "host_port": 8080, "protocol": "tcp", "description": "Web"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/update" desc="更新映射" body='{"id": 1, "index": 0, "container_port": 8080, "host_port": 9090, "protocol": "tcp", "description": "API"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/delete" desc="删除映射" body='{"id": 1, "index": 0}' />
<Endpoint method="POST" path="/api/containers/random-port" desc="获取随机空闲端口" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/dashboard" desc="容器统计概览" />
<Endpoint method="POST" path="/api/host-info" desc="宿主机资源信息" />
<Endpoint method="POST" path="/api/templates" desc="可用系统模板列表" />
<Endpoint method="POST" path="/api/tasks" desc="任务队列" />
<Endpoint method="POST" path="/api/tasks/delete" desc="删除任务" body='{"id": "task-1"}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/sub-user/create" desc="创建管理链接" body='{"container_name": "my-container"}' />
<Endpoint method="POST" path="/api/audit-logs" desc="操作日志" />
<Endpoint method="POST" path="/api/login-logs" desc="登录日志" />
<Endpoint method="POST" path="/api/security/alerts" desc="安全告警" />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 font-mono text-xs text-gray-700">
{`{
"success": true,
"message": "操作成功",
"data": { ... }
}`}
</div>
</section>
{endpointGroups.map(group => (
<section key={group.title}>
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
<div className="overflow-hidden rounded-lg border border-gray-200">
{group.endpoints.map(([method, path, desc]) => (
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(280px,1fr)_180px]">
<span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span>
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
<span className="text-gray-500">{desc}</span>
</div>
))}
</div>
</section>
))}
</div>
)}
</div>
</div>
)
}
function Endpoint({ method, path, desc, body }: { method: string; path: string; desc: string; body?: string }) {
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50">
<span className="shrink-0 px-1.5 py-0.5 rounded border text-[10px] font-mono font-bold bg-blue-50 text-blue-700 border-blue-200">{method}</span>
<code className="shrink-0 text-xs text-gray-800 font-mono">{path}</code>
<span className="text-xs text-gray-500 min-w-0">{desc}</span>
{body && (
<details className="text-xs">
<summary className="text-gray-400 cursor-pointer hover:text-gray-600">Body</summary>
<pre className="mt-1 p-2 bg-gray-50 rounded text-xs text-gray-600 overflow-x-auto">{body}</pre>
</details>
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
<div className="relative flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<h3 className="text-base font-semibold text-black">{editingKey ? '编辑 API Key' : '创建 API Key'}</h3>
<button onClick={() => setShowForm(false)} className="rounded p-1 text-gray-400 hover:text-black" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="grid gap-5 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-4">
<label className="block">
<span className="mb-1 block text-xs text-gray-500"></span>
<input
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="CI/CD、计费系统、自动化脚本"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-500">IP </span>
<textarea
value={form.ipWhitelist}
onChange={e => setForm(prev => ({ ...prev, ipWhitelist: e.target.value }))}
rows={4}
className="w-full resize-none rounded-md border border-gray-300 px-3 py-2 font-mono text-sm"
placeholder={`1.2.3.4\n10.0.0.0/24`}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-500"></span>
<input
type="datetime-local"
value={form.expiresAt}
onChange={e => setForm(prev => ({ ...prev, expiresAt: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.disabled}
onChange={e => setForm(prev => ({ ...prev, disabled: e.target.checked }))}
className="h-4 w-4 accent-black"
/>
Key
</label>
<div>
<div className="mb-2 text-xs text-gray-500"></div>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-gray-200 p-2">
<label className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.containerUUIDs.length === 0}
onChange={() => setForm(prev => ({ ...prev, containerUUIDs: [] }))}
className="h-4 w-4 accent-black"
/>
</label>
{containers.map(container => (
<label key={container.uuid} className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.containerUUIDs.includes(container.uuid)}
onChange={() => toggleContainer(container.uuid)}
className="h-4 w-4 accent-black"
/>
<span className="min-w-0 truncate">{container.name}</span>
<span className="shrink-0 font-mono text-[10px] text-gray-400">{container.uuid.slice(0, 8)}</span>
</label>
))}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-gray-500"></div>
<button onClick={() => toggleScope('*')} className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
{form.scopes.includes('*') ? '取消全权限' : '全权限'}
</button>
</div>
<div className="space-y-4">
{scopeGroups.map(group => (
<div key={group.title}>
<div className="mb-2 text-xs font-medium text-gray-700">{group.title}</div>
<div className="grid gap-2 sm:grid-cols-2">
{group.scopes.map(([scope, label]) => (
<label key={scope} className="flex items-center gap-2 rounded border border-gray-200 px-2 py-2 text-xs text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.scopes.includes('*') || form.scopes.includes(scope)}
disabled={form.scopes.includes('*')}
onChange={() => toggleScope(scope)}
className="h-4 w-4 accent-black"
/>
<span className="min-w-0 flex-1 truncate">{label}</span>
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:block">{scope}</code>
</label>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
<button onClick={() => setShowForm(false)} className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50">
</button>
<button
onClick={saveKey}
disabled={saving || !form.name.trim()}
className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function ScopeSummary({ scopes }: { scopes: string[] }) {
if (scopes.includes('*')) {
return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600"></span>
}
const visible = scopes.slice(0, 3)
return (
<div className="flex max-w-xs flex-wrap gap-1">
{visible.map(scope => (
<span key={scope} className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-600">
{scope}
</span>
))}
{scopes.length > visible.length && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">+{scopes.length - visible.length}</span>
)}
</div>
)
}
function toDateTimeLocal(value: string) {
if (!value) return ''
return value.replace(' ', 'T').slice(0, 16)
}
function fromDateTimeLocal(value: string) {
if (!value) return ''
return `${value.replace('T', ' ')}:00`
}