mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
增强API集成能力,划分KEY功能权限
This commit is contained in:
+272
-106
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user