mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b13af91c | |||
| e79609281f | |||
| 2fa130a2b6 | |||
| 14d2192b05 | |||
| 9f5ad94a83 | |||
| ac6587f2bc | |||
| 6fad37b844 | |||
| d0eb92eaab | |||
| 5207082cd1 | |||
| 608b50f18a | |||
| b58a6b1030 | |||
| 366f889a8c |
@@ -58,6 +58,7 @@ backend/tmp/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
*.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+272
-106
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,66 +17,100 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ApiKey struct {
|
type ApiKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
LastUsed string `json:"last_used"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultApiKeyScopes = []string{
|
||||||
|
"dashboard:read",
|
||||||
|
"container:read",
|
||||||
|
"task:read",
|
||||||
|
"image:read",
|
||||||
|
"snapshot:read",
|
||||||
|
"routing:read",
|
||||||
|
"ipv6:read",
|
||||||
|
"host:read",
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
||||||
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "apikey:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listApiKeys(w, r)
|
listApiKeys(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "apikey:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createApiKey(w, r)
|
createApiKey(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleApiKeyDelete handles DELETE for a specific API key
|
// HandleApiKeyDelete handles PATCH and DELETE for a specific API key
|
||||||
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
switch r.Method {
|
||||||
|
case http.MethodPatch:
|
||||||
|
if !requireScope(w, r, "apikey:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateApiKey(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "apikey:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteApiKey(w, r)
|
||||||
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
|
}
|
||||||
if keyID == "" {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
func apiKeyIDFromPath(path string) string {
|
||||||
return
|
path = strings.TrimPrefix(path, "/api/api-keys/")
|
||||||
}
|
path = strings.TrimPrefix(path, "/api/v1/api-keys/")
|
||||||
config.DeleteApiKey(keyID)
|
return strings.Trim(path, "/")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
keys := make([]ApiKey, 0)
|
keys := make([]ApiKey, 0)
|
||||||
for _, k := range config.AppConfig.ApiKeys {
|
for _, k := range config.AppConfig.ApiKeys {
|
||||||
keys = append(keys, ApiKey{
|
keys = append(keys, apiKeyResponse(k))
|
||||||
ID: k.ID,
|
|
||||||
Name: k.Name,
|
|
||||||
Prefix: k.Prefix,
|
|
||||||
IPWhitelist: k.IPWhitelist,
|
|
||||||
CreatedAt: k.CreatedAt,
|
|
||||||
LastUsed: k.LastUsed,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req apiKeyRequest
|
||||||
Name string `json:"name"`
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate key: clicd_sk_ + 32 hex chars
|
// Generate key: clicd_sk_ + 32 hex chars
|
||||||
rawBytes := make([]byte, 16)
|
rawBytes := make([]byte, 16)
|
||||||
@@ -94,31 +127,109 @@ func createApiKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
scopes := normalizeRequestedScopes(req.Scopes, defaultApiKeyScopes)
|
||||||
key := config.ApiKeyConfig{
|
key := config.ApiKeyConfig{
|
||||||
ID: generateShortID(),
|
ID: generateShortID(),
|
||||||
Name: req.Name,
|
Name: strings.TrimSpace(req.Name),
|
||||||
KeyHash: keyHash,
|
KeyHash: keyHash,
|
||||||
Prefix: rawKey[:13] + "...",
|
Prefix: rawKey[:13] + "...",
|
||||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
Scopes: scopes,
|
||||||
|
ExpiresAt: strings.TrimSpace(req.ExpiresAt),
|
||||||
|
Disabled: req.Disabled,
|
||||||
|
ContainerUUIDs: normalizeStringSlice(req.ContainerUUIDs),
|
||||||
}
|
}
|
||||||
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
||||||
config.SaveConfig()
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRequest(r, "apikey.create", key.Name, "scopes="+strings.Join(key.Scopes, ","), true, "")
|
||||||
|
|
||||||
|
resp := apiKeyResponse(key)
|
||||||
|
resp.Key = rawKey
|
||||||
jsonResponse(w, http.StatusCreated, APIResponse{
|
jsonResponse(w, http.StatusCreated, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "API key created. Save this key now - it won't be shown again.",
|
Message: "API key created. Save this key now - it won't be shown again.",
|
||||||
Data: ApiKey{
|
Data: resp,
|
||||||
ID: key.ID,
|
|
||||||
Name: key.Name,
|
|
||||||
Key: rawKey,
|
|
||||||
Prefix: key.Prefix,
|
|
||||||
IPWhitelist: key.IPWhitelist,
|
|
||||||
CreatedAt: key.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||||
|
if keyID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apiKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != "" && !validApiKeyTime(req.ExpiresAt) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range config.AppConfig.ApiKeys {
|
||||||
|
if config.AppConfig.ApiKeys[i].ID != keyID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Name) != "" {
|
||||||
|
config.AppConfig.ApiKeys[i].Name = strings.TrimSpace(req.Name)
|
||||||
|
}
|
||||||
|
config.AppConfig.ApiKeys[i].IPWhitelist = strings.TrimSpace(req.IPWhitelist)
|
||||||
|
if len(req.Scopes) > 0 {
|
||||||
|
config.AppConfig.ApiKeys[i].Scopes = normalizeStringSlice(req.Scopes)
|
||||||
|
}
|
||||||
|
config.AppConfig.ApiKeys[i].ExpiresAt = strings.TrimSpace(req.ExpiresAt)
|
||||||
|
config.AppConfig.ApiKeys[i].Disabled = req.Disabled
|
||||||
|
config.AppConfig.ApiKeys[i].ContainerUUIDs = normalizeStringSlice(req.ContainerUUIDs)
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRequest(r, "apikey.update", config.AppConfig.ApiKeys[i].Name, "scopes="+strings.Join(config.AppConfig.ApiKeys[i].Scopes, ","), true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: apiKeyResponse(config.AppConfig.ApiKeys[i])})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "API key not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keyID := apiKeyIDFromPath(r.URL.Path)
|
||||||
|
if keyID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := keyID
|
||||||
|
for _, k := range config.AppConfig.ApiKeys {
|
||||||
|
if k.ID == keyID {
|
||||||
|
name = k.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.DeleteApiKey(keyID)
|
||||||
|
auditRequest(r, "apikey.delete", name, "", true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyResponse(k config.ApiKeyConfig) ApiKey {
|
||||||
|
return ApiKey{
|
||||||
|
ID: k.ID,
|
||||||
|
Name: k.Name,
|
||||||
|
Prefix: k.Prefix,
|
||||||
|
IPWhitelist: k.IPWhitelist,
|
||||||
|
CreatedAt: k.CreatedAt,
|
||||||
|
LastUsed: k.LastUsed,
|
||||||
|
Scopes: normalizeApiKeyScopes(k.Scopes),
|
||||||
|
ExpiresAt: k.ExpiresAt,
|
||||||
|
Disabled: k.Disabled,
|
||||||
|
ContainerUUIDs: k.ContainerUUIDs,
|
||||||
|
LastUsedIP: k.LastUsedIP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func generateShortID() string {
|
func generateShortID() string {
|
||||||
b := make([]byte, 4)
|
b := make([]byte, 4)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
@@ -203,13 +314,21 @@ func matchApiKey(rawKey string) (idx int, needsRehash bool) {
|
|||||||
|
|
||||||
// validateApiKey checks if the given key is valid and IP is allowed.
|
// validateApiKey checks if the given key is valid and IP is allowed.
|
||||||
func validateApiKey(rawKey, clientIP string) bool {
|
func validateApiKey(rawKey, clientIP string) bool {
|
||||||
|
_, ok := validateApiKeyDetails(rawKey, clientIP)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyDetails(rawKey, clientIP string) (*config.ApiKeyConfig, bool) {
|
||||||
idx, needsRehash := matchApiKey(rawKey)
|
idx, needsRehash := matchApiKey(rawKey)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return false
|
return nil, false
|
||||||
}
|
}
|
||||||
k := config.AppConfig.ApiKeys[idx]
|
k := &config.AppConfig.ApiKeys[idx]
|
||||||
if k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
if k.Disabled || apiKeyExpired(k.ExpiresAt) {
|
||||||
return false
|
return nil, false
|
||||||
|
}
|
||||||
|
if clientIP != "" && k.IPWhitelist != "" && !isIPAllowed(clientIP, k.IPWhitelist) {
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
if needsRehash {
|
if needsRehash {
|
||||||
if newHash, err := hashAPIKey(rawKey); err == nil {
|
if newHash, err := hashAPIKey(rawKey); err == nil {
|
||||||
@@ -217,7 +336,38 @@ func validateApiKey(rawKey, clientIP string) bool {
|
|||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
if len(k.Scopes) == 0 {
|
||||||
|
k.Scopes = []string{"*"}
|
||||||
|
}
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateApiKeyRequest(r *http.Request) (*config.ApiKeyConfig, bool) {
|
||||||
|
apiKey := apiKeyFromRequest(r)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
key, ok := validateApiKeyDetails(apiKey, clientIP(r))
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
updateApiKeyLastUsedForKey(key, clientIP(r))
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContextFromAPIKey(key *config.ApiKeyConfig) AuthContext {
|
||||||
|
actor := "api:" + key.ID
|
||||||
|
if key.Name != "" {
|
||||||
|
actor = "api:" + key.Name
|
||||||
|
}
|
||||||
|
return AuthContext{
|
||||||
|
Type: authTypeAPIKey,
|
||||||
|
ApiKeyID: key.ID,
|
||||||
|
ApiKeyName: key.Name,
|
||||||
|
Actor: actor,
|
||||||
|
Scopes: normalizeApiKeyScopes(key.Scopes),
|
||||||
|
ContainerUUIDs: key.ContainerUUIDs,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyFromRequest(r *http.Request) string {
|
func apiKeyFromRequest(r *http.Request) string {
|
||||||
@@ -232,23 +382,16 @@ func apiKeyFromRequest(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isValidApiKeyRequest(r *http.Request) bool {
|
func isValidApiKeyRequest(r *http.Request) bool {
|
||||||
apiKey := apiKeyFromRequest(r)
|
_, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" {
|
return ok
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !validateApiKey(apiKey, clientIP(r)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||||
func isIPAllowed(clientIP, whitelist string) bool {
|
func isIPAllowed(clientIP, whitelist string) bool {
|
||||||
clientIP = strings.TrimSpace(clientIP)
|
clientIP = normalizeIPString(clientIP)
|
||||||
// Strip port if present
|
client := net.ParseIP(clientIP)
|
||||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
if client == nil {
|
||||||
clientIP = clientIP[:idx]
|
return false
|
||||||
}
|
}
|
||||||
for _, entry := range strings.Split(whitelist, "\n") {
|
for _, entry := range strings.Split(whitelist, "\n") {
|
||||||
entry = strings.TrimSpace(entry)
|
entry = strings.TrimSpace(entry)
|
||||||
@@ -256,74 +399,97 @@ func isIPAllowed(clientIP, whitelist string) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(entry, "/") {
|
if strings.Contains(entry, "/") {
|
||||||
// CIDR match
|
_, network, err := net.ParseCIDR(entry)
|
||||||
if ipInCIDR(clientIP, entry) {
|
if err == nil && network.Contains(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if entry == clientIP {
|
continue
|
||||||
|
}
|
||||||
|
if allowed := net.ParseIP(normalizeIPString(entry)); allowed != nil && allowed.Equal(client) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipInCIDR(ipStr, cidr string) bool {
|
func normalizeIPString(s string) string {
|
||||||
parts := strings.Split(cidr, "/")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Simple prefix match for IPv4
|
|
||||||
ip := netParseIP(ipStr)
|
|
||||||
cidrIP := netParseIP(parts[0])
|
|
||||||
if ip == nil || cidrIP == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
bits, err := strconv.Atoi(parts[1])
|
|
||||||
if err != nil || bits < 0 || bits > 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
mask := uint32(0xFFFFFFFF) << (32 - bits)
|
|
||||||
ipVal := ip4ToUint32(ip)
|
|
||||||
cidrVal := ip4ToUint32(cidrIP)
|
|
||||||
return (ipVal & mask) == (cidrVal & mask)
|
|
||||||
}
|
|
||||||
|
|
||||||
func netParseIP(s string) net.IP {
|
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||||
s = s[:idx]
|
return strings.Trim(host, "[]")
|
||||||
}
|
}
|
||||||
return net.ParseIP(s)
|
return strings.Trim(s, "[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ip4ToUint32(ip net.IP) uint32 {
|
func ipInCIDR(ipStr, cidr string) bool {
|
||||||
ip = ip.To4()
|
ip := net.ParseIP(normalizeIPString(ipStr))
|
||||||
if ip == nil {
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
return 0
|
return err == nil && ip != nil && network.Contains(ip)
|
||||||
}
|
|
||||||
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateApiKeyLastUsed marks the key as recently used.
|
// updateApiKeyLastUsed marks the key as recently used.
|
||||||
func updateApiKeyLastUsed(rawKey string) {
|
func updateApiKeyLastUsed(rawKey string) {
|
||||||
idx, _ := matchApiKey(rawKey)
|
key, ok := validateApiKeyDetails(rawKey, "")
|
||||||
if idx < 0 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
config.AppConfig.ApiKeys[idx].LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
updateApiKeyLastUsedForKey(key, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateApiKeyLastUsedForKey(key *config.ApiKeyConfig, ip string) {
|
||||||
|
key.LastUsed = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if ip != "" {
|
||||||
|
key.LastUsedIP = ip
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
apiKey := apiKeyFromRequest(r)
|
key, ok := validateApiKeyRequest(r)
|
||||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
if !ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||||
updateApiKeyLastUsed(apiKey)
|
|
||||||
next(w, r)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeApiKeyScopes(scopes []string) []string {
|
||||||
|
return normalizeRequestedScopes(scopes, []string{"*"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRequestedScopes(scopes []string, fallback []string) []string {
|
||||||
|
result := normalizeStringSlice(scopes)
|
||||||
|
if len(result) == 0 {
|
||||||
|
return append([]string(nil), fallback...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringSlice(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func validApiKeyTime(value string) bool {
|
||||||
|
_, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyExpired(value string) bool {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
return err == nil && !time.Now().Before(expiresAt)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,6 +29,132 @@ type APIResponse struct {
|
|||||||
Data interface{} `json:"data,omitempty"`
|
Data interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authContextKey struct{}
|
||||||
|
|
||||||
|
type AuthContext struct {
|
||||||
|
Type string
|
||||||
|
Username string
|
||||||
|
ApiKeyID string
|
||||||
|
ApiKeyName string
|
||||||
|
Actor string
|
||||||
|
Scopes []string
|
||||||
|
ContainerUUIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
authTypeAdmin = "admin"
|
||||||
|
authTypeSubUser = "sub_user"
|
||||||
|
authTypeAPIKey = "api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withAuthContext(r *http.Request, auth AuthContext) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), authContextKey{}, auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContextFromRequest(r *http.Request) (AuthContext, bool) {
|
||||||
|
ctx, ok := r.Context().Value(authContextKey{}).(AuthContext)
|
||||||
|
return ctx, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestActor(r *http.Request) string {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok && ctx.Actor != "" {
|
||||||
|
return ctx.Actor
|
||||||
|
}
|
||||||
|
if claims, ok := claimsFromRequest(r); ok {
|
||||||
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
|
return "user:" + subUser
|
||||||
|
}
|
||||||
|
if username, _ := claims["username"].(string); username != "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(r *http.Request, scope string) bool {
|
||||||
|
ctx, ok := authContextFromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch ctx.Type {
|
||||||
|
case authTypeAdmin:
|
||||||
|
return true
|
||||||
|
case authTypeSubUser:
|
||||||
|
return subUserScopeAllowed(scope)
|
||||||
|
case authTypeAPIKey:
|
||||||
|
return scopeAllowed(ctx.Scopes, scope)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subUserScopeAllowed(scope string) bool {
|
||||||
|
switch scope {
|
||||||
|
case "container:read", "container:power", "container:reinstall", "container:network",
|
||||||
|
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
|
||||||
|
"terminal:ssh", "terminal:vnc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyScope(r *http.Request, scopes ...string) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if hasScope(r, scope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeAllowed(scopes []string, required string) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
scope = strings.TrimSpace(scope)
|
||||||
|
if scope == "*" || scope == "admin:*" || scope == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(scope, ":*") {
|
||||||
|
prefix := strings.TrimSuffix(scope, "*")
|
||||||
|
if strings.HasPrefix(required, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireScope(w http.ResponseWriter, r *http.Request, scope string) bool {
|
||||||
|
if hasScope(r, scope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeMiddleware(scope string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireScope(w, r, scope) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnyScopeMiddleware(scopes []string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if hasAnyScope(r, scopes...) {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditRequest(r *http.Request, action, target, detail string, success bool, errMsg string) {
|
||||||
|
config.AddAuditLogFull(action, target, detail, requestActor(r), clientIP(r), r.UserAgent(), success, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -75,8 +202,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||||
tokenVersion := int(tokenVersionFloat)
|
tokenVersion := int(tokenVersionFloat)
|
||||||
|
foundSubUser := false
|
||||||
for i := range config.AppConfig.SubUsers {
|
for i := range config.AppConfig.SubUsers {
|
||||||
if config.AppConfig.SubUsers[i].Username == subUser {
|
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||||
|
foundSubUser = true
|
||||||
stored := config.AppConfig.SubUsers[i].TokenVersion
|
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||||
// If stored version > 0, require token_version to match exactly.
|
// If stored version > 0, require token_version to match exactly.
|
||||||
// This also rejects legacy tokens that lack token_version entirely.
|
// This also rejects legacy tokens that lack token_version entirely.
|
||||||
@@ -86,6 +215,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !foundSubUser {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, ok
|
return claims, ok
|
||||||
@@ -96,6 +228,9 @@ func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isSubUserRequest(r *http.Request) bool {
|
func isSubUserRequest(r *http.Request) bool {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
return ctx.Type == authTypeSubUser
|
||||||
|
}
|
||||||
claims, ok := claimsFromRequest(r)
|
claims, ok := claimsFromRequest(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -210,19 +345,45 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenString := tokenFromRequest(r)
|
tokenString := tokenFromRequest(r)
|
||||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
if claims, ok := claimsFromToken(tokenString); ok {
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
|
auth := AuthContext{Type: authTypeSubUser, Username: subUser, Actor: "user:" + subUser}
|
||||||
|
if values, ok := claims["container_uuids"].([]interface{}); ok {
|
||||||
|
for _, value := range values {
|
||||||
|
if uuid, ok := value.(string); ok {
|
||||||
|
auth.ContainerUUIDs = append(auth.ContainerUUIDs, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, auth))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, _ := claims["username"].(string)
|
||||||
|
if username == "" {
|
||||||
|
username = config.AppConfig.AdminUser
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, AuthContext{Type: authTypeAdmin, Username: username, Actor: username}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
next(w, r)
|
if key, ok := validateApiKeyRequest(r); ok {
|
||||||
|
next(w, withAuthContext(r, authContextFromAPIKey(key)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if isSubUserRequest(r) {
|
ctx, _ := authContextFromRequest(r)
|
||||||
|
if ctx.Type == authTypeSubUser {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeAPIKey && !scopeAllowed(ctx.Scopes, "admin:access") {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
func getRootDiskInfo() (DiskInfo, bool) {
|
||||||
|
return DiskInfo{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
func getRootDiskInfo() (DiskInfo, bool) {
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err := unix.Statfs("/", &stat); err != nil {
|
||||||
|
return DiskInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||||
|
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
return DiskInfo{
|
||||||
|
TotalGB: total,
|
||||||
|
UsedGB: total - free,
|
||||||
|
FreeGB: free,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -20,8 +20,18 @@ var lxcManager = lxc.NewManager()
|
|||||||
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainers(w, r)
|
listContainers(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAccessRestrictedRequest(r) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
createContainer(w, r)
|
createContainer(w, r)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
@@ -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}/...
|
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
||||||
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/containers/")
|
||||||
|
path = strings.TrimPrefix(path, "/api/containers/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
id := 0
|
id := 0
|
||||||
@@ -50,6 +61,10 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isSnapshotAction && !isContainerAllowedForRequest(r, parts[0]) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSnapshotAction && id == 0 {
|
if isSnapshotAction && id == 0 {
|
||||||
// For orphaned snapshots, resolve containerID from the snapshot itself
|
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
@@ -61,45 +76,105 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id = snapshot.ContainerID
|
id = snapshot.ContainerID
|
||||||
}
|
}
|
||||||
|
if isSnapshotAction {
|
||||||
|
if c := config.FindContainer(id); c != nil && !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "start" && r.Method == http.MethodPost:
|
case action == "start" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "start")
|
HandleSingleTaskAction(w, r, id, "start")
|
||||||
case action == "stop" && r.Method == http.MethodPost:
|
case action == "stop" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "stop")
|
HandleSingleTaskAction(w, r, id, "stop")
|
||||||
case action == "restart" && r.Method == http.MethodPost:
|
case action == "restart" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:power") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "restart")
|
HandleSingleTaskAction(w, r, id, "restart")
|
||||||
case action == "reinstall" && r.Method == http.MethodPost:
|
case action == "reinstall" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:reinstall") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "reinstall")
|
HandleSingleTaskAction(w, r, id, "reinstall")
|
||||||
case action == "delete" && r.Method == http.MethodDelete:
|
case action == "delete" && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "container:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
HandleSingleTaskAction(w, r, id, "delete")
|
HandleSingleTaskAction(w, r, id, "delete")
|
||||||
case action == "reset-password" && r.Method == http.MethodPost:
|
case action == "reset-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:password") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetSSHPassword(w, r, id)
|
resetSSHPassword(w, r, id)
|
||||||
case action == "usage" && r.Method == http.MethodGet:
|
case action == "usage" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getUsage(w, r, id)
|
getUsage(w, r, id)
|
||||||
case action == "traffic" && r.Method == http.MethodGet:
|
case action == "traffic" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getTraffic(w, r, id)
|
getTraffic(w, r, id)
|
||||||
case action == "traffic-reset" && r.Method == http.MethodPost:
|
case action == "traffic-reset" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
resetTraffic(w, r, id)
|
resetTraffic(w, r, id)
|
||||||
case action == "traffic-limit" && r.Method == http.MethodPut:
|
case action == "traffic-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:traffic") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateTrafficLimit(w, r, id)
|
updateTrafficLimit(w, r, id)
|
||||||
case action == "resource-limit" && r.Method == http.MethodPut:
|
case action == "resource-limit" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateResourceLimit(w, r, id)
|
updateResourceLimit(w, r, id)
|
||||||
case action == "random-port" && r.Method == http.MethodGet:
|
case action == "random-port" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getRandomPort(w, r, id)
|
getRandomPort(w, r, id)
|
||||||
case action == "expiry" && r.Method == http.MethodPut:
|
case action == "expiry" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:resize") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateExpiry(w, r, id)
|
updateExpiry(w, r, id)
|
||||||
case action == "ipv6" && r.Method == http.MethodPost:
|
case action == "ipv6" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "ipv6:assign") {
|
||||||
|
return
|
||||||
|
}
|
||||||
assignIPv6(w, r, id)
|
assignIPv6(w, r, id)
|
||||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
handleContainerSnapshots(w, r, id, action)
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
addPortMapping(w, r, id)
|
addPortMapping(w, r, id)
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||||
case r.Method == http.MethodGet:
|
case r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
getContainer(w, r, id)
|
getContainer(w, r, id)
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
@@ -348,6 +423,9 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSubUserRequest(r) {
|
if isSubUserRequest(r) {
|
||||||
HandleEnabledImages(w, r)
|
HandleEnabledImages(w, r)
|
||||||
return
|
return
|
||||||
@@ -362,7 +440,11 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "dashboard:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
containers, _ := listByRuntime()
|
containers, _ := listByRuntime()
|
||||||
|
containers = filterContainersForRequest(r, containers)
|
||||||
running := 0
|
running := 0
|
||||||
stopped := 0
|
stopped := 0
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
@@ -386,6 +468,9 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "host:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
info := getHostInfo()
|
info := getHostInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
}
|
}
|
||||||
|
|||||||
+1198
-14
File diff suppressed because it is too large
Load Diff
@@ -221,6 +221,9 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
cleanupOldImageDownloadErrors()
|
cleanupOldImageDownloadErrors()
|
||||||
@@ -287,6 +290,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -397,6 +403,9 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
}
|
}
|
||||||
@@ -434,6 +443,9 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -484,6 +496,9 @@ func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:toggle") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
@@ -510,6 +525,9 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "image:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "ipv6:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
status := lxcManager.DetectIPv6Status()
|
status := lxcManager.DetectIPv6Status()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "routing:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
nat4Mappings := make([]nat4Route, 0)
|
nat4Mappings := make([]nat4Route, 0)
|
||||||
usedPorts := map[int]bool{}
|
usedPorts := map[int]bool{}
|
||||||
|
|||||||
@@ -654,18 +654,27 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: filterSecurityAlertsForRequest(r, mergedSecurityAlerts())})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSecuritySettings returns or updates security automation settings.
|
// HandleSecuritySettings returns or updates security automation settings.
|
||||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
}})
|
}})
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
|
if !requireScope(w, r, "security:settings") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
AutoShutdown bool `json:"auto_shutdown"`
|
AutoShutdown bool `json:"auto_shutdown"`
|
||||||
}
|
}
|
||||||
@@ -678,6 +687,7 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
}})
|
}})
|
||||||
@@ -692,6 +702,9 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:check") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -706,6 +719,10 @@ func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ensureScanner().checkContainer(c.Name, c.IP)
|
ensureScanner().checkContainer(c.Name, c.IP)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
||||||
@@ -717,6 +734,9 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
containerName := r.URL.Query().Get("container")
|
containerName := r.URL.Query().Get("container")
|
||||||
if containerName == "" {
|
if containerName == "" {
|
||||||
@@ -729,6 +749,10 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
||||||
}
|
}
|
||||||
@@ -781,12 +805,15 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "security:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
critical := 0
|
critical := 0
|
||||||
high := 0
|
high := 0
|
||||||
medium := 0
|
medium := 0
|
||||||
low := 0
|
low := 0
|
||||||
alerts := mergedSecurityAlerts()
|
alerts := filterSecurityAlertsForRequest(r, mergedSecurityAlerts())
|
||||||
for _, a := range alerts {
|
for _, a := range alerts {
|
||||||
switch a.Severity {
|
switch a.Severity {
|
||||||
case "critical":
|
case "critical":
|
||||||
@@ -812,6 +839,20 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterSecurityAlertsForRequest(r *http.Request, alerts []SecurityAlert) []SecurityAlert {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
filtered := make([]SecurityAlert, 0, len(alerts))
|
||||||
|
for _, alert := range alerts {
|
||||||
|
if c := config.FindContainerByName(alert.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
filtered = append(filtered, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func mergedSecurityAlerts() []SecurityAlert {
|
func mergedSecurityAlerts() []SecurityAlert {
|
||||||
ss := ensureScanner()
|
ss := ensureScanner()
|
||||||
ss.mu.Lock()
|
ss.mu.Lock()
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Return in reverse (newest first)
|
// Return in reverse (newest first)
|
||||||
reversed := make([]LoginLog, len(loginLogs))
|
reversed := make([]LoginLog, len(loginLogs))
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||||
|
snapshots = filterSnapshotsForRequest(r, snapshots)
|
||||||
sortSnapshotsNewestFirst(snapshots)
|
sortSnapshotsNewestFirst(snapshots)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
||||||
}
|
}
|
||||||
@@ -24,17 +28,35 @@ func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||||
switch {
|
switch {
|
||||||
case action == "snapshots" && r.Method == http.MethodGet:
|
case action == "snapshots" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "snapshot:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
listContainerSnapshots(w, r, containerID)
|
listContainerSnapshots(w, r, containerID)
|
||||||
case action == "snapshots" && r.Method == http.MethodPost:
|
case action == "snapshots" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
createContainerSnapshot(w, r, containerID)
|
createContainerSnapshot(w, r, containerID)
|
||||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotSchedule(w, r, containerID)
|
updateSnapshotSchedule(w, r, containerID)
|
||||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "snapshot:schedule") {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSnapshotQuota(w, r, containerID)
|
updateSnapshotQuota(w, r, containerID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "snapshot:restore") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "snapshot:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||||
default:
|
default:
|
||||||
@@ -186,15 +208,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requestUser(r *http.Request) string {
|
func requestUser(r *http.Request) string {
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
return requestActor(r)
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
return "user:" + subUser
|
|
||||||
}
|
|
||||||
if username, _ := claims["username"].(string); username != "" {
|
|
||||||
return username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "admin"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||||
@@ -204,3 +218,17 @@ func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
|||||||
return tj.Before(ti)
|
return tj.Before(ti)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterSnapshotsForRequest(r *http.Request, snapshots []config.Snapshot) []config.Snapshot {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
filtered := make([]config.Snapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if c := config.FindContainer(snapshot.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
filtered = append(filtered, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:ssh") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -281,13 +284,40 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
|||||||
return allowed, true
|
return allowed, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
if ctx.Type == authTypeAPIKey && len(ctx.ContainerUUIDs) == 0 {
|
||||||
|
return subUserAccess{}, false
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeSubUser || ctx.Type == authTypeAPIKey {
|
||||||
|
allowed := subUserAccess{names: make(map[string]bool), uuids: make(map[string]bool)}
|
||||||
|
for _, uuid := range ctx.ContainerUUIDs {
|
||||||
|
allowed.uuids[uuid] = true
|
||||||
|
}
|
||||||
|
if ctx.Type == authTypeSubUser && len(ctx.ContainerUUIDs) == 0 {
|
||||||
|
legacy, ok := subUserAllowedContainers(r)
|
||||||
|
if ok {
|
||||||
|
return legacy, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subUserAllowedContainers(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||||
|
_, restricted := requestAllowedContainers(r)
|
||||||
|
return restricted
|
||||||
|
}
|
||||||
|
|
||||||
func containerByIdentifier(identifier string) *config.Container {
|
func containerByIdentifier(identifier string) *config.Container {
|
||||||
return config.FindContainerByIdentifier(identifier)
|
return config.FindContainerByIdentifier(identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c := containerByIdentifier(identifier)
|
c := containerByIdentifier(identifier)
|
||||||
@@ -303,6 +333,9 @@ func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logs := config.AppConfig.AuditLogs
|
logs := config.AppConfig.AuditLogs
|
||||||
if logs == nil {
|
if logs == nil {
|
||||||
@@ -327,12 +360,20 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
if path == "/api/tasks" && r.Method == http.MethodGet {
|
containerPrefix := "/api/containers/"
|
||||||
|
containerListPath := "/api/containers"
|
||||||
|
tasksPath := "/api/tasks"
|
||||||
|
if strings.HasPrefix(path, "/api/v1/") {
|
||||||
|
containerPrefix = "/api/v1/containers/"
|
||||||
|
containerListPath = "/api/v1/containers"
|
||||||
|
tasksPath = "/api/v1/tasks"
|
||||||
|
}
|
||||||
|
if path == tasksPath && r.Method == http.MethodGet {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "/api/containers" {
|
if path == containerListPath {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||||
return
|
return
|
||||||
@@ -341,8 +382,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(path) > len("/api/containers/") {
|
if strings.HasPrefix(path, containerPrefix) {
|
||||||
rest := path[len("/api/containers/"):]
|
rest := path[len(containerPrefix):]
|
||||||
parts := splitPath(rest)
|
parts := splitPath(rest)
|
||||||
if len(parts) > 0 && parts[0] != "" {
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
@@ -373,8 +414,8 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
if !isSubUser {
|
if !restricted {
|
||||||
return containers
|
return containers
|
||||||
}
|
}
|
||||||
filtered := make([]config.Container, 0, len(containers))
|
filtered := make([]config.Container, 0, len(containers))
|
||||||
@@ -387,33 +428,47 @@ func filterContainersForRequest(r *http.Request, containers []config.Container)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||||
allowed, isSubUser := subUserAllowedContainers(r)
|
|
||||||
if !isSubUser {
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
filtered := make([]*Task, 0, len(tasks))
|
filtered := make([]*Task, 0, len(tasks))
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
if isTaskAllowedForRequest(r, task) {
|
||||||
filtered = append(filtered, task)
|
filtered = append(filtered, task)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.ContainerName != "" {
|
|
||||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
|
||||||
filtered = append(filtered, task)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if task.Config.Name != "" {
|
|
||||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
|
||||||
filtered = append(filtered, task)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTaskAllowedForRequest(r *http.Request, task *Task) bool {
|
||||||
|
allowed, restricted := requestAllowedContainers(r)
|
||||||
|
if !restricted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if task.ContainerName != "" {
|
||||||
|
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.Config.Name != "" {
|
||||||
|
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.UUID != "" && allowed.uuids[c.UUID] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.Name != "" && allowed.names[c.Name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSubUserBlockedAction(action string, method string) bool {
|
func isSubUserBlockedAction(action string, method string) bool {
|
||||||
@@ -536,6 +591,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "subuser:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
@@ -585,7 +643,8 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// HandleSubUserAction handles actions on a specific sub-user
|
// HandleSubUserAction handles actions on a specific sub-user
|
||||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/sub-users/")
|
||||||
|
path = strings.TrimPrefix(path, "/api/sub-users/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
subUserID := parts[0]
|
subUserID := parts[0]
|
||||||
action := ""
|
action := ""
|
||||||
@@ -608,6 +667,9 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "rotate-password" && r.Method == http.MethodPost:
|
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||||
|
if !requireScope(w, r, "subuser:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
password := generateRandomStr(16)
|
password := generateRandomStr(16)
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||||
target.PassHash = string(hash)
|
target.PassHash = string(hash)
|
||||||
@@ -625,11 +687,17 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||||
|
|
||||||
case action == "audit-logs" && r.Method == http.MethodGet:
|
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "audit:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter audit logs for this sub-user
|
// Filter audit logs for this sub-user
|
||||||
logs := filterSubUserAuditLogs(target.Username)
|
logs := filterSubUserAuditLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
case action == "login-logs" && r.Method == http.MethodGet:
|
case action == "login-logs" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "loginlog:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Filter login logs for this sub-user
|
// Filter login logs for this sub-user
|
||||||
logs := filterSubUserLoginLogs(target.Username)
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|||||||
@@ -11,19 +11,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SwapInfo struct {
|
type SwapInfo struct {
|
||||||
TotalMB int64 `json:"total_mb"`
|
TotalMB int64 `json:"total_mb"`
|
||||||
UsedMB int64 `json:"used_mb"`
|
UsedMB int64 `json:"used_mb"`
|
||||||
FreeMB int64 `json:"free_mb"`
|
FreeMB int64 `json:"free_mb"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SwapFile string `json:"swap_file"`
|
SwapFile string `json:"swap_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minSwapSizeMB = 128
|
||||||
|
maxSwapSizeMB = 262144
|
||||||
|
)
|
||||||
|
|
||||||
// HandleSwapInfo returns current swap status
|
// HandleSwapInfo returns current swap status
|
||||||
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info := getSwapInfo()
|
info := getSwapInfo()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
@@ -35,9 +43,12 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "swap:manage") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"` // create, enable, disable, resize
|
Action string `json:"action"` // create, enable, disable, resize
|
||||||
SizeMB int `json:"size_mb"` // for create/resize
|
SizeMB int `json:"size_mb"` // for create/resize
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -46,54 +57,63 @@ func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var msg string
|
var msg string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "create":
|
case "create":
|
||||||
if req.SizeMB <= 0 {
|
if req.SizeMB <= 0 {
|
||||||
req.SizeMB = 2048
|
req.SizeMB = 2048
|
||||||
}
|
}
|
||||||
err := createSwap(req.SizeMB)
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
if err != nil {
|
err = createSwap(req.SizeMB)
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
||||||
|
|
||||||
case "enable":
|
case "enable":
|
||||||
err := enableSwap()
|
err = enableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已启用"
|
msg = "SWAP 已启用"
|
||||||
|
|
||||||
case "disable":
|
case "disable":
|
||||||
err := disableSwap()
|
err = disableSwap()
|
||||||
if err != nil {
|
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "SWAP 已禁用"
|
msg = "SWAP 已禁用"
|
||||||
|
|
||||||
case "resize":
|
case "resize":
|
||||||
if req.SizeMB <= 0 {
|
if err = validateSwapSize(req.SizeMB); err == nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
|
err = disableSwap()
|
||||||
return
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = createSwap(req.SizeMB)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = enableSwap()
|
||||||
}
|
}
|
||||||
disableSwap()
|
|
||||||
createSwap(req.SizeMB)
|
|
||||||
enableSwap()
|
|
||||||
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), false, err.Error())
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info := getSwapInfo()
|
info := getSwapInfo()
|
||||||
|
auditRequest(r, "swap."+req.Action, "/swapfile", fmt.Sprintf("size_mb=%d", req.SizeMB), true, "")
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSwapSize(sizeMB int) error {
|
||||||
|
if sizeMB < minSwapSizeMB {
|
||||||
|
return fmt.Errorf("swap size must be at least %d MB", minSwapSizeMB)
|
||||||
|
}
|
||||||
|
if sizeMB > maxSwapSizeMB {
|
||||||
|
return fmt.Errorf("swap size cannot exceed %d MB", maxSwapSizeMB)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getSwapInfo() SwapInfo {
|
func getSwapInfo() SwapInfo {
|
||||||
info := SwapInfo{SwapFile: "/swapfile"}
|
info := SwapInfo{SwapFile: "/swapfile"}
|
||||||
|
|
||||||
@@ -160,6 +180,9 @@ func createSwap(sizeMB int) error {
|
|||||||
func enableSwap() error {
|
func enableSwap() error {
|
||||||
swapFile := "/swapfile"
|
swapFile := "/swapfile"
|
||||||
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
||||||
|
if getSwapInfo().Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("swap 文件不存在,请先创建")
|
return fmt.Errorf("swap 文件不存在,请先创建")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +203,7 @@ func disableSwap() error {
|
|||||||
cmd := exec.Command("swapoff", swapFile)
|
cmd := exec.Command("swapoff", swapFile)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(string(output), "No such") {
|
if strings.Contains(string(output), "No such") || strings.Contains(string(output), "Invalid argument") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, template
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
||||||
|
return q.EnqueueBatchCreateWithAudit(configs, "admin", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) EnqueueBatchCreateWithAudit(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
return q.enqueueBatchCreateList(configs)
|
return q.enqueueBatchCreateList(configs, user, ip, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||||
@@ -147,7 +151,7 @@ func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
|
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||||
var result []string
|
var result []string
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
cfgCopy := cfg
|
cfgCopy := cfg
|
||||||
@@ -161,6 +165,9 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []stri
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
Config: cfgCopy,
|
Config: cfgCopy,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
q.enqueueTask(task)
|
q.enqueueTask(task)
|
||||||
result = append(result, task.ID)
|
result = append(result, task.ID)
|
||||||
@@ -424,6 +431,8 @@ func (q *TaskQueue) persistTasks() {
|
|||||||
TemplateID: t.TemplateID,
|
TemplateID: t.TemplateID,
|
||||||
Config: string(cfgJSON),
|
Config: string(cfgJSON),
|
||||||
User: t.User,
|
User: t.User,
|
||||||
|
IP: t.IP,
|
||||||
|
UserAgent: t.UserAgent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
config.SaveTasks(saved)
|
config.SaveTasks(saved)
|
||||||
@@ -456,13 +465,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
name = c.Name
|
name = c.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine user from JWT claims
|
// Determine user from authenticated request context.
|
||||||
user := "admin"
|
user := requestActor(r)
|
||||||
if claims, ok := claimsFromRequest(r); ok {
|
|
||||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
|
||||||
user = "user:" + subUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
userAgent := r.Header.Get("User-Agent")
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
@@ -517,6 +521,13 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "container:create") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isAccessRestrictedRequest(r) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Container-bound API keys cannot create containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Containers []lxc.ContainerConfig `json:"containers"`
|
Containers []lxc.ContainerConfig `json:"containers"`
|
||||||
}
|
}
|
||||||
@@ -576,7 +587,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
requestNames[name] = true
|
requestNames[name] = true
|
||||||
}
|
}
|
||||||
ids := globalQueue.EnqueueBatchCreate(req.Containers)
|
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +597,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !hasAnyScope(r, "container:power", "container:delete", "container:reinstall") {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Containers []int `json:"containers"`
|
Containers []int `json:"containers"`
|
||||||
@@ -597,21 +612,47 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
|
var requiredScope string
|
||||||
switch req.Action {
|
switch req.Action {
|
||||||
case "start":
|
case "start":
|
||||||
taskType = TaskStart
|
taskType = TaskStart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "stop":
|
case "stop":
|
||||||
taskType = TaskStop
|
taskType = TaskStop
|
||||||
|
requiredScope = "container:power"
|
||||||
case "restart":
|
case "restart":
|
||||||
taskType = TaskRestart
|
taskType = TaskRestart
|
||||||
|
requiredScope = "container:power"
|
||||||
case "delete":
|
case "delete":
|
||||||
taskType = TaskDelete
|
taskType = TaskDelete
|
||||||
|
requiredScope = "container:delete"
|
||||||
|
case "reinstall":
|
||||||
|
if req.TemplateID == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isTemplateEnabledAndDownloaded(req.TemplateID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskType = TaskReinstall
|
||||||
|
requiredScope = "container:reinstall"
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, requiredScope) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range req.Containers {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil || !isContainerAllowedForRequest(r, c.UUID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,13 +662,22 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// URL: /api/tasks/{id}
|
if !requireScope(w, r, "task:delete") {
|
||||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
return
|
||||||
|
}
|
||||||
|
// URL: /api/tasks/{id} or /api/v1/tasks/{id}
|
||||||
|
taskID := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||||
|
taskID = strings.TrimPrefix(taskID, "/api/tasks/")
|
||||||
if taskID == "" {
|
if taskID == "" {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalQueue.mu.Lock()
|
globalQueue.mu.Lock()
|
||||||
|
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
delete(globalQueue.tasks, taskID)
|
delete(globalQueue.tasks, taskID)
|
||||||
// Also remove from both queues if pending
|
// Also remove from both queues if pending
|
||||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||||
@@ -655,6 +705,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireScope(w, r, "task:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
tasks := globalQueue.GetTasks()
|
tasks := globalQueue.GetTasks()
|
||||||
tasks = filterTasksForRequest(r, tasks)
|
tasks = filterTasksForRequest(r, tasks)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||||
@@ -691,6 +744,8 @@ func RestoreTasks() {
|
|||||||
TemplateID: st.TemplateID,
|
TemplateID: st.TemplateID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
User: st.User,
|
User: st.User,
|
||||||
|
IP: st.IP,
|
||||||
|
UserAgent: st.UserAgent,
|
||||||
}
|
}
|
||||||
if st.Status == "pending" || st.Status == "running" {
|
if st.Status == "pending" || st.Status == "running" {
|
||||||
// Reset running tasks back to pending so they get retried
|
// Reset running tasks back to pending so they get retried
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
type webVNCTicket struct {
|
type webVNCTicket struct {
|
||||||
ContainerName string
|
ContainerName string
|
||||||
ContainerUUID string
|
ContainerUUID string
|
||||||
|
Username string
|
||||||
SubUser bool
|
SubUser bool
|
||||||
|
ClientIP string
|
||||||
|
UserAgent string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireScope(w, r, "terminal:vnc") {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
}
|
}
|
||||||
@@ -58,13 +64,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username, isSubUser := vncRequesterIdentity(r)
|
||||||
ticket := randomHex(32)
|
ticket := randomHex(32)
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||||
webVNCTickets.items[ticket] = webVNCTicket{
|
webVNCTickets.items[ticket] = webVNCTicket{
|
||||||
ContainerName: c.Name,
|
ContainerName: c.Name,
|
||||||
ContainerUUID: c.UUID,
|
ContainerUUID: c.UUID,
|
||||||
SubUser: isSubUserRequest(r),
|
Username: username,
|
||||||
|
SubUser: isSubUser,
|
||||||
|
ClientIP: clientIP(r),
|
||||||
|
UserAgent: r.UserAgent(),
|
||||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
}
|
}
|
||||||
webVNCTickets.Unlock()
|
webVNCTickets.Unlock()
|
||||||
@@ -89,7 +99,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
item, ok := consumeWebVNCTicket(ticket, containerName, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -137,7 +147,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
|
||||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort)
|
||||||
|
|
||||||
done := make(chan string, 2)
|
done := make(chan string, 2)
|
||||||
var writeMu sync.Mutex
|
var writeMu sync.Mutex
|
||||||
@@ -147,7 +157,31 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
reason := <-done
|
reason := <-done
|
||||||
_ = vncConn.Close()
|
_ = vncConn.Close()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vncRequesterIdentity(r *http.Request) (string, bool) {
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok {
|
||||||
|
switch ctx.Type {
|
||||||
|
case authTypeSubUser:
|
||||||
|
return ctx.Username, true
|
||||||
|
case authTypeAPIKey:
|
||||||
|
return ctx.Actor, false
|
||||||
|
case authTypeAdmin:
|
||||||
|
return ctx.Username, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claims, ok := claimsFromRequest(r)
|
||||||
|
if !ok {
|
||||||
|
return "api-key", false
|
||||||
|
}
|
||||||
|
if subUser, ok := claims["sub_user"].(string); ok && subUser != "" {
|
||||||
|
return subUser, true
|
||||||
|
}
|
||||||
|
if username, ok := claims["username"].(string); ok && username != "" {
|
||||||
|
return username, false
|
||||||
|
}
|
||||||
|
return "unknown", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func webVNCTicketFromRequest(r *http.Request) string {
|
func webVNCTicketFromRequest(r *http.Request) string {
|
||||||
@@ -175,7 +209,7 @@ func webVNCResponseProtocol(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
webVNCTickets.Lock()
|
webVNCTickets.Lock()
|
||||||
defer webVNCTickets.Unlock()
|
defer webVNCTickets.Unlock()
|
||||||
@@ -185,7 +219,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
|||||||
return webVNCTicket{}, false
|
return webVNCTicket{}, false
|
||||||
}
|
}
|
||||||
delete(webVNCTickets.items, ticket)
|
delete(webVNCTickets.items, ticket)
|
||||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
return item, item.ContainerName == containerName &&
|
||||||
|
item.ClientIP == clientIP(r) &&
|
||||||
|
item.UserAgent == r.UserAgent() &&
|
||||||
|
now.Before(item.ExpiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||||
|
|||||||
+179
-2
@@ -21,8 +21,9 @@ import (
|
|||||||
var manager = lxc.NewManager()
|
var manager = lxc.NewManager()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
clicdBackupDir = "/root/clicd-backups"
|
clicdBackupDir = "/root/clicd-backups"
|
||||||
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
|
clicdNewBinaryPath = "/usr/local/bin/clicd.new"
|
||||||
|
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run starts the CLI interface.
|
// Run starts the CLI interface.
|
||||||
@@ -754,6 +755,7 @@ func cliUninstall(reader *bufio.Reader) {
|
|||||||
|
|
||||||
destroyAllLXCContainers()
|
destroyAllLXCContainers()
|
||||||
destroyAllKVMDomains()
|
destroyAllKVMDomains()
|
||||||
|
removeCLICDLibvirtDefaultNetwork()
|
||||||
cleanupCLICDNetworking()
|
cleanupCLICDNetworking()
|
||||||
removeCLICDHostHooks()
|
removeCLICDHostHooks()
|
||||||
removeCLICDQuotaRecords()
|
removeCLICDQuotaRecords()
|
||||||
@@ -840,8 +842,60 @@ func removeKVMDomain(name string) {
|
|||||||
runQuiet("virsh", "undefine", name)
|
runQuiet("virsh", "undefine", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeCLICDLibvirtDefaultNetwork() {
|
||||||
|
if !commandExists("virsh") || !fileExists(libvirtDefaultNetworkMarker) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if libvirtDefaultUsedByNonCLICDDomain() {
|
||||||
|
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Removing CLICD-created libvirt default network...")
|
||||||
|
runQuiet("virsh", "net-destroy", "default")
|
||||||
|
runQuiet("virsh", "net-undefine", "default")
|
||||||
|
removePath(libvirtDefaultNetworkMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func libvirtDefaultUsedByNonCLICDDomain() bool {
|
||||||
|
if !commandExists("virsh") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
name := strings.TrimSpace(line)
|
||||||
|
if name == "" || isCLICDKVMDomain(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usesLibvirtDefaultNetwork(name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func usesLibvirtDefaultNetwork(domain string) bool {
|
||||||
|
out, err := exec.Command("virsh", "domiflist", domain).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
for _, field := range fields {
|
||||||
|
if field == "default" || field == "virbr0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupCLICDNetworking() {
|
func cleanupCLICDNetworking() {
|
||||||
removeCLICDNATRules()
|
removeCLICDNATRules()
|
||||||
|
cleanupCLICDIPv6Runtime()
|
||||||
|
cleanupCLICDIPv6BridgeRoutes()
|
||||||
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||||
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
||||||
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
||||||
@@ -850,6 +904,123 @@ func cleanupCLICDNetworking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDIPv6Runtime() {
|
||||||
|
if config.AppConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
cleanupCLICDContainerIPv6(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDContainerIPv6(c config.Container) {
|
||||||
|
bridge := "lxcbr0"
|
||||||
|
if c.IsKVM() {
|
||||||
|
bridge = "virbr0"
|
||||||
|
}
|
||||||
|
mac := strings.ToLower(strings.TrimSpace(c.MACAddress))
|
||||||
|
if mac != "" && bridge == "virbr0" {
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.IPv6) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := strings.TrimSpace(c.IPv6)
|
||||||
|
if slash := strings.Index(addr, "/"); slash >= 0 {
|
||||||
|
addr = addr[:slash]
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(c.IPv6)
|
||||||
|
if !strings.Contains(source, "/") {
|
||||||
|
source += "/128"
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIP6NATSource(source)
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||||
|
if mac != "" && bridge == "virbr0" {
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-m", "mac", "--mac-source", mac, "-j", "DROP")
|
||||||
|
}
|
||||||
|
|
||||||
|
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||||
|
if strings.TrimSpace(c.IPv6Interface) != "" {
|
||||||
|
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", c.IPv6Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupCLICDIPv6BridgeRoutes() {
|
||||||
|
if !commandExists("ip") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||||
|
out, err := exec.Command("ip", "-6", "route", "show", "dev", bridge).Output()
|
||||||
|
if err == nil {
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || !strings.HasSuffix(fields[0], "/128") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := fields[0]
|
||||||
|
addr := strings.TrimSuffix(source, "/128")
|
||||||
|
deleteIP6NATSource(source)
|
||||||
|
deleteIP6FilterRule("FORWARD", "-i", bridge, "-s", source, "-j", "ACCEPT")
|
||||||
|
deleteIP6FilterRule("FORWARD", "-o", bridge, "-d", source, "-j", "ACCEPT")
|
||||||
|
removeProxyNDPForAddress(addr)
|
||||||
|
runQuiet("ip", "-6", "route", "del", source, "dev", bridge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runQuiet("ip", "-6", "addr", "del", "fe80::1/64", "dev", bridge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeProxyNDPForAddress(addr string) {
|
||||||
|
out, err := exec.Command("ip", "-6", "neigh", "show", "proxy").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || fields[0] != addr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := 0; i+1 < len(fields); i++ {
|
||||||
|
if fields[i] == "dev" {
|
||||||
|
runQuiet("ip", "-6", "neigh", "del", "proxy", addr, "dev", fields[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteIP6NATSource(source string) {
|
||||||
|
if !commandExists("ip6tables") || strings.TrimSpace(source) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
out, err := exec.Command("ip6tables", "-t", "nat", "-S", "POSTROUTING").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := false
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if !strings.Contains(line, "-s "+source) || !strings.Contains(line, " -j MASQUERADE") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 || fields[0] != "-A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[0] = "-D"
|
||||||
|
args := append([]string{"-t", "nat"}, fields...)
|
||||||
|
deleted = runCommandOK("ip6tables", args...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !deleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func removeCLICDNATRules() {
|
func removeCLICDNATRules() {
|
||||||
if commandExists("iptables") {
|
if commandExists("iptables") {
|
||||||
for {
|
for {
|
||||||
@@ -879,6 +1050,12 @@ func deleteFilterRule(args ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteIP6FilterRule(args ...string) {
|
||||||
|
fullArgs := append([]string{"-D"}, args...)
|
||||||
|
for runCommandOK("ip6tables", fullArgs...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteIP6TablesBridgeRules(bridge string) {
|
func deleteIP6TablesBridgeRules(bridge string) {
|
||||||
if !commandExists("ip6tables") {
|
if !commandExists("ip6tables") {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type SavedTask struct {
|
|||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
Config string `json:"config,omitempty"`
|
Config string `json:"config,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SavedLoginLog for persisting login logs
|
// SavedLoginLog for persisting login logs
|
||||||
@@ -152,13 +154,18 @@ func (c *Container) VirshName() string {
|
|||||||
|
|
||||||
// SubUser represents a sub-user with access to specific containers
|
// SubUser represents a sub-user with access to specific containers
|
||||||
type ApiKeyConfig struct {
|
type ApiKeyConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
KeyHash string `json:"key_hash"`
|
KeyHash string `json:"key_hash"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
IPWhitelist string `json:"ip_whitelist"`
|
IPWhitelist string `json:"ip_whitelist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsed string `json:"last_used"`
|
LastUsed string `json:"last_used"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteApiKey removes an API key by ID
|
// DeleteApiKey removes an API key by ID
|
||||||
@@ -391,6 +398,12 @@ func normalizeConfigDefaults(dataDir string) {
|
|||||||
}
|
}
|
||||||
if AppConfig.ApiKeys == nil {
|
if AppConfig.ApiKeys == nil {
|
||||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||||
|
} else {
|
||||||
|
for i := range AppConfig.ApiKeys {
|
||||||
|
if len(AppConfig.ApiKeys[i].Scopes) == 0 {
|
||||||
|
AppConfig.ApiKeys[i].Scopes = []string{"*"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if AppConfig.AuditLogs == nil {
|
if AppConfig.AuditLogs == nil {
|
||||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encodeStringSlice(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringSlice(raw string) []string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
func getDBPath() string {
|
func getDBPath() string {
|
||||||
cfgPath := getConfigPath()
|
cfgPath := getConfigPath()
|
||||||
ext := filepath.Ext(cfgPath)
|
ext := filepath.Ext(cfgPath)
|
||||||
@@ -185,7 +207,12 @@ func ensureSchema() error {
|
|||||||
prefix TEXT,
|
prefix TEXT,
|
||||||
ip_whitelist TEXT,
|
ip_whitelist TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
last_used TEXT
|
last_used TEXT,
|
||||||
|
scopes TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
disabled INTEGER,
|
||||||
|
container_uuids TEXT,
|
||||||
|
last_used_ip TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -210,6 +237,8 @@ func ensureSchema() error {
|
|||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
template_id TEXT,
|
template_id TEXT,
|
||||||
user TEXT,
|
user TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
cfg_name TEXT,
|
cfg_name TEXT,
|
||||||
cfg_virtualization TEXT,
|
cfg_virtualization TEXT,
|
||||||
cfg_template_id TEXT,
|
cfg_template_id TEXT,
|
||||||
@@ -263,9 +292,55 @@ func ensureSchema() error {
|
|||||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ensureSchemaMigrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSchemaMigrations() error {
|
||||||
|
for _, column := range []struct {
|
||||||
|
table string
|
||||||
|
name string
|
||||||
|
def string
|
||||||
|
}{
|
||||||
|
{"api_keys", "scopes", "TEXT"},
|
||||||
|
{"api_keys", "expires_at", "TEXT"},
|
||||||
|
{"api_keys", "disabled", "INTEGER"},
|
||||||
|
{"api_keys", "container_uuids", "TEXT"},
|
||||||
|
{"api_keys", "last_used_ip", "TEXT"},
|
||||||
|
{"tasks", "ip", "TEXT"},
|
||||||
|
{"tasks", "user_agent", "TEXT"},
|
||||||
|
} {
|
||||||
|
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureColumn(table, name, def string) error {
|
||||||
|
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var columnName, columnType string
|
||||||
|
var notNull, pk int
|
||||||
|
var defaultValue interface{}
|
||||||
|
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if columnName == name {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||||
meta := map[string]string{}
|
meta := map[string]string{}
|
||||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||||
@@ -468,8 +543,10 @@ func saveSubUsers(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func saveAPIKeys(tx *sql.Tx) error {
|
func saveAPIKeys(tx *sql.Tx) error {
|
||||||
for _, k := range AppConfig.ApiKeys {
|
for _, k := range AppConfig.ApiKeys {
|
||||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
scopes := encodeStringSlice(k.Scopes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
containerUUIDs := encodeStringSlice(k.ContainerUUIDs)
|
||||||
|
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed, scopes, k.ExpiresAt, boolInt(k.Disabled), containerUUIDs, k.LastUsedIP); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,13 +575,13 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
for _, task := range AppConfig.Tasks {
|
for _, task := range AppConfig.Tasks {
|
||||||
cfg := parseSavedTaskConfig(task.Config)
|
cfg := parseSavedTaskConfig(task.Config)
|
||||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv6, cfg_expires_at
|
cfg_assign_ipv6, cfg_expires_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||||
@@ -669,7 +746,7 @@ func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used, scopes, expires_at, disabled, container_uuids, last_used_ip FROM api_keys ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -677,9 +754,16 @@ func loadAPIKeys() ([]ApiKeyConfig, error) {
|
|||||||
result := []ApiKeyConfig{}
|
result := []ApiKeyConfig{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k ApiKeyConfig
|
var k ApiKeyConfig
|
||||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
var scopes, expiresAt, containerUUIDs, lastUsedIP sql.NullString
|
||||||
|
var disabled sql.NullInt64
|
||||||
|
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed, &scopes, &expiresAt, &disabled, &containerUUIDs, &lastUsedIP); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
k.Scopes = decodeStringSlice(scopes.String)
|
||||||
|
k.ExpiresAt = expiresAt.String
|
||||||
|
k.Disabled = disabled.Valid && disabled.Int64 != 0
|
||||||
|
k.ContainerUUIDs = decodeStringSlice(containerUUIDs.String)
|
||||||
|
k.LastUsedIP = lastUsedIP.String
|
||||||
result = append(result, k)
|
result = append(result, k)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
@@ -709,7 +793,7 @@ func loadAuditLogs() ([]AuditLog, error) {
|
|||||||
|
|
||||||
func loadTasks() ([]SavedTask, error) {
|
func loadTasks() ([]SavedTask, error) {
|
||||||
rows, err := db.Query(`SELECT
|
rows, err := db.Query(`SELECT
|
||||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||||
@@ -725,8 +809,9 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv6 int
|
var assignIPv6 int
|
||||||
|
var ip, userAgent sql.NullString
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||||
@@ -734,6 +819,8 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.IP = ip.String
|
||||||
|
t.UserAgent = userAgent.String
|
||||||
cfg.AssignIPv6 = assignIPv6 != 0
|
cfg.AssignIPv6 = assignIPv6 != 0
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ipv6GatewayLinkLocal = "fe80::1"
|
const ipv6GatewayLinkLocal = "fe80::1"
|
||||||
|
const libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
type usageSample struct {
|
type usageSample struct {
|
||||||
CPUUsec uint64
|
CPUUsec uint64
|
||||||
@@ -1476,6 +1477,9 @@ func ensureDefaultNetwork() error {
|
|||||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||||
|
_ = os.WriteFile(libvirtDefaultNetworkMarker, []byte("created-by-clicd\n"), 0644)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Start and autostart the default network
|
// Start and autostart the default network
|
||||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||||
|
|||||||
@@ -993,6 +993,27 @@ func parseSubIDRange(path, user string) (int, error) {
|
|||||||
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error {
|
||||||
|
// Unprivileged container root maps to a subordinate host UID, so it needs
|
||||||
|
// execute permission on the LXC parent and container directories to reach
|
||||||
|
// rootfs. Some distributions create /var/lib/lxc as 750/700, which causes
|
||||||
|
// lxc-start to abort with "Could not access /var/lib/lxc".
|
||||||
|
for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if mode&0001 != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, mode|0001); err != nil {
|
||||||
|
return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||||
uidBase, gidBase, err := unprivilegedIDMap()
|
uidBase, gidBase, err := unprivilegedIDMap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1000,6 +1021,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
}
|
}
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
|
||||||
|
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := os.Stat(marker); err == nil {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2234,13 +2258,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
|||||||
// Clean port mappings temporarily
|
// Clean port mappings temporarily
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
|
||||||
// Destroy old LXC but keep config
|
// 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-stop", "-n", lxcName, "-k").Run()
|
||||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
|
||||||
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfs := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||||
os.RemoveAll(rootfs)
|
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||||
os.Remove(filepath.Join(m.LxcPath, lxcName, "rootfs.img"))
|
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||||
|
os.RemoveAll(filepath.Join(m.LxcPath, lxcName))
|
||||||
|
|
||||||
// Create new container with same LXC name (preserves ID)
|
// Create new container with same LXC name (preserves ID)
|
||||||
cmd := exec.Command("lxc-create",
|
cmd := exec.Command("lxc-create",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
@@ -86,6 +86,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
@@ -113,6 +114,48 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
|
||||||
|
// Versioned external API routes
|
||||||
|
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||||
|
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||||
|
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
|
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
|
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||||
|
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||||
|
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||||
|
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||||
|
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
|
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||||
|
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||||
|
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||||
|
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||||
|
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
|
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users", corsMiddleware(api.AuthMiddleware(api.HandleSubUserList)))
|
||||||
|
mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction)))
|
||||||
|
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||||
|
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||||
|
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||||
|
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||||
|
mux.HandleFunc("/api/v1/security/summary", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleContainerSecuritySummary))))
|
||||||
|
mux.HandleFunc("/api/v1/security/settings", corsMiddleware(api.AuthMiddleware(api.HandleSecuritySettings)))
|
||||||
|
mux.HandleFunc("/api/v1/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||||
|
mux.HandleFunc("/api/v1/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||||
|
mux.HandleFunc("/api/v1/api-keys", corsMiddleware(api.AuthMiddleware(api.HandleApiKeys)))
|
||||||
|
mux.HandleFunc("/api/v1/api-keys/", corsMiddleware(api.AuthMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/swap", corsMiddleware(api.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
api.HandleSwapInfo(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.HandleSwapManage(w, r)
|
||||||
|
})))
|
||||||
|
|
||||||
// Version (public)
|
// Version (public)
|
||||||
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
|
?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.2"
|
Version = "1.1.5"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail'
|
|||||||
import Security from './pages/Security'
|
import Security from './pages/Security'
|
||||||
import AuditLogs from './pages/AuditLogs'
|
import AuditLogs from './pages/AuditLogs'
|
||||||
import ApiIntegration from './pages/ApiIntegration'
|
import ApiIntegration from './pages/ApiIntegration'
|
||||||
|
import HostReport from './pages/HostReport'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
import Snapshots from './pages/Snapshots'
|
import Snapshots from './pages/Snapshots'
|
||||||
@@ -64,6 +65,7 @@ function App() {
|
|||||||
<Route path="routing" element={<Routing />} />
|
<Route path="routing" element={<Routing />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
|
<Route path="host-report" element={<HostReport />} />
|
||||||
<Route path="sub-users" element={<SubUserManagement />} />
|
<Route path="sub-users" element={<SubUserManagement />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
Code2,
|
||||||
|
Cpu,
|
||||||
Camera,
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||||
|
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||||
const isSecurityPage = location.pathname.startsWith('/security')
|
const isSecurityPage = location.pathname.startsWith('/security')
|
||||||
const isSettingsPage = location.pathname.startsWith('/settings')
|
const isSettingsPage = location.pathname.startsWith('/settings')
|
||||||
|
|
||||||
@@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>API 集成</span>}
|
{!collapsed && <span>API 集成</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/host-report')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
isHostReportPage
|
||||||
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Cpu className="w-4 h-4" />
|
||||||
|
{!collapsed && <span>宿主机信息</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -21,6 +21,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureResizeObserver = () => {
|
||||||
|
if ('ResizeObserver' in window) return
|
||||||
|
|
||||||
|
class FallbackResizeObserver {
|
||||||
|
private target: Element | null = null
|
||||||
|
private timer = 0
|
||||||
|
private lastWidth = -1
|
||||||
|
private lastHeight = -1
|
||||||
|
|
||||||
|
constructor(private callback: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
observe = (target: Element) => {
|
||||||
|
this.target = target
|
||||||
|
this.check()
|
||||||
|
this.timer = window.setInterval(this.check, 250)
|
||||||
|
window.addEventListener('resize', this.check)
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve = () => this.disconnect()
|
||||||
|
|
||||||
|
disconnect = () => {
|
||||||
|
if (this.timer) window.clearInterval(this.timer)
|
||||||
|
this.timer = 0
|
||||||
|
window.removeEventListener('resize', this.check)
|
||||||
|
this.target = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private check = () => {
|
||||||
|
if (!this.target) return
|
||||||
|
const contentRect = this.target.getBoundingClientRect()
|
||||||
|
if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return
|
||||||
|
this.lastWidth = contentRect.width
|
||||||
|
this.lastHeight = contentRect.height
|
||||||
|
this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver
|
||||||
|
}
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
const target = screenRef.current
|
const target = screenRef.current
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
ensureResizeObserver()
|
||||||
|
const rfb = new RFB(target, getWebVNCUrl(containerName), {
|
||||||
|
wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`],
|
||||||
|
})
|
||||||
rfb.scaleViewport = true
|
rfb.scaleViewport = true
|
||||||
rfb.resizeSession = false
|
rfb.resizeSession = false
|
||||||
rfb.focusOnClick = true
|
rfb.focusOnClick = true
|
||||||
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setErrorMsg('WebVNC 初始化失败')
|
const message = err instanceof Error && err.message ? `:${err.message}` : ''
|
||||||
|
setErrorMsg(`WebVNC 初始化失败${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
|
import {
|
||||||
import api, { APIResponse } from '../services/api'
|
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'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
interface ApiKeyItem {
|
interface ApiKeyItem {
|
||||||
@@ -11,132 +23,431 @@ interface ApiKeyItem {
|
|||||||
ip_whitelist: string
|
ip_whitelist: string
|
||||||
created_at: string
|
created_at: string
|
||||||
last_used: 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 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() {
|
export default function ApiIntegration() {
|
||||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||||
|
const [containers, setContainers] = useState<Container[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [editingKey, setEditingKey] = useState<ApiKeyItem | null>(null)
|
||||||
const [newIPs, setNewIPs] = useState('')
|
const [form, setForm] = useState<ApiKeyForm>(emptyForm)
|
||||||
const [creating, setCreating] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [newKey, setNewKey] = useState('')
|
const [newKey, setNewKey] = useState('')
|
||||||
const [showDocs, setShowDocs] = useState(true)
|
|
||||||
const [copiedKey, setCopiedKey] = useState(false)
|
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 {
|
try {
|
||||||
const res = await api.get<APIResponse<ApiKeyItem[]>>('/api-keys')
|
const [keyRes, containerRes] = await Promise.all([
|
||||||
setKeys(res.data.data || [])
|
api.get<APIResponse<ApiKeyItem[]>>('/api-keys'),
|
||||||
} catch { /* ignore */ }
|
api.get<APIResponse<Container[]>>('/containers'),
|
||||||
finally { setLoading(false) }
|
])
|
||||||
|
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 () => {
|
const openCreate = () => {
|
||||||
if (!newName.trim()) return
|
setEditingKey(null)
|
||||||
setCreating(true)
|
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 {
|
try {
|
||||||
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', {
|
if (editingKey) {
|
||||||
name: newName.trim(),
|
const res = await api.patch<APIResponse<ApiKeyItem>>(`/api-keys/${editingKey.id}`, payload)
|
||||||
ip_whitelist: newIPs.trim(),
|
if (res.data.data) {
|
||||||
})
|
setKeys(prev => prev.map(k => (k.id === editingKey.id ? res.data.data! : k)))
|
||||||
if (res.data.data?.key) {
|
}
|
||||||
setNewKey(res.data.data.key)
|
} else {
|
||||||
setKeys(prev => [res.data.data!, ...prev])
|
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('')
|
setShowForm(false)
|
||||||
setNewIPs('')
|
} catch {
|
||||||
setShowCreate(false)
|
// axios interceptor handles auth; form stays open
|
||||||
} catch { /* ignore */ }
|
} finally {
|
||||||
finally { setCreating(false) }
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteKey = async (id: string) => {
|
const deleteKey = async (id: string) => {
|
||||||
if (!window.confirm('确定要删除此 API Key 吗?')) return
|
if (!window.confirm('确定删除这个 API Key 吗?')) return
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api-keys/${id}`)
|
await api.delete(`/api-keys/${id}`)
|
||||||
setKeys(prev => prev.filter(k => k.id !== id))
|
setKeys(prev => prev.filter(k => k.id !== id))
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyKey = async () => {
|
const copyKey = async () => {
|
||||||
const copied = await copyToClipboard(newKey)
|
const copied = await copyToClipboard(newKey)
|
||||||
if (copied) {
|
if (copied) {
|
||||||
setCopiedKey(true)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold text-black">API 集成</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mt-1">管理 API Key 与查看接口文档</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* API Keys */}
|
{newKey && (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
|
<div className="text-sm font-semibold text-amber-800">新的 API Key 已生成</div>
|
||||||
<Key className="w-4 h-4" />API Keys
|
<button onClick={() => setNewKey('')} className="rounded p-1 text-amber-700 hover:bg-amber-100" title="关闭">
|
||||||
</h2>
|
<X className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-2">
|
</button>
|
||||||
<button onClick={fetchKeys} className="p-1.5 text-gray-400 hover:text-black rounded" title="刷新"><RefreshCw className="w-3.5 h-3.5" /></button>
|
</div>
|
||||||
<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">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Plus className="w-3.5 h-3.5" />创建 Key
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{newKey && (
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<span className="text-sm font-semibold text-amber-800">新 API Key 已生成</span>
|
<Key className="h-4 w-4" />
|
||||||
<button onClick={() => setNewKey('')} className="text-amber-600 hover:text-amber-800 text-xs">关闭</button>
|
API Keys
|
||||||
</div>
|
</h2>
|
||||||
<p className="text-xs text-amber-700 mb-2">此 Key 仅显示一次,请立即复制保存。</p>
|
<button onClick={fetchData} className="rounded p-1.5 text-gray-400 hover:text-black" title="刷新">
|
||||||
<div className="flex items-center gap-2">
|
<RefreshCw className="h-4 w-4" />
|
||||||
<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>
|
||||||
<button onClick={copyKey} className="px-3 py-2 bg-amber-600 text-white rounded-md text-xs hover:bg-amber-700 whitespace-nowrap">
|
</div>
|
||||||
{copiedKey ? '已复制' : '复制'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{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 ? (
|
) : 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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
|
<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-4 py-3">名称</th>
|
||||||
<th className="px-3 py-2">Key 前缀</th>
|
<th className="px-4 py-3">权限</th>
|
||||||
<th className="px-3 py-2">IP 白名单</th>
|
<th className="px-4 py-3">绑定容器</th>
|
||||||
<th className="px-3 py-2">创建时间</th>
|
<th className="px-4 py-3">限制</th>
|
||||||
<th className="px-3 py-2">最后使用</th>
|
<th className="px-4 py-3">最后使用</th>
|
||||||
<th className="px-3 py-2 text-right">操作</th>
|
<th className="px-4 py-3 text-right">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{keys.map(k => (
|
{keys.map(item => (
|
||||||
<tr key={k.id} className="hover:bg-gray-50">
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
<td className="px-3 py-2.5 font-medium text-gray-800">{k.name}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{k.prefix}</td>
|
<div className="flex items-center gap-2">
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.ip_whitelist || '不限制'}</td>
|
<span className="font-medium text-gray-900">{item.name}</span>
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.created_at}</td>
|
{item.disabled && (
|
||||||
<td className="px-3 py-2.5 text-xs text-gray-500">{k.last_used || '未使用'}</td>
|
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">已禁用</span>
|
||||||
<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="删除">
|
</div>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<div className="mt-1 font-mono text-xs text-gray-400">{item.prefix}</div>
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -146,150 +457,201 @@ export default function ApiIntegration() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Key Modal */}
|
<div className="rounded-lg border border-gray-200 bg-white">
|
||||||
{showCreate && (
|
<button
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
onClick={() => setShowDocs(value => !value)}
|
||||||
<div className="absolute inset-0 bg-black/30" onClick={() => setShowCreate(false)} />
|
className="flex w-full items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 text-left"
|
||||||
<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">
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h3 className="text-base font-semibold text-black">创建 API Key</h3>
|
<ShieldCheck className="h-4 w-4" />
|
||||||
<button onClick={() => setShowCreate(false)} className="p-1 text-gray-400 hover:text-black rounded"><X className="w-4 h-4" /></button>
|
API 文档
|
||||||
</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 文档
|
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={() => setShowDocs(!showDocs)} className="text-xs text-gray-500 hover:text-black">
|
{showDocs ? <ChevronUp className="h-4 w-4 text-gray-400" /> : <ChevronDown className="h-4 w-4 text-gray-400" />}
|
||||||
{showDocs ? '收起' : '展开'}
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDocs && (
|
{showDocs && (
|
||||||
<div className="space-y-6 text-sm">
|
<div className="space-y-6 p-5">
|
||||||
<section>
|
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
|
||||||
<h3 className="font-semibold text-black mb-2">认证方式</h3>
|
<div>curl -H "X-API-Key: clicd_sk_xxxx" {BASE_URL}/api/v1/containers</div>
|
||||||
<p className="text-gray-600 mb-3">所有 API 使用 <strong>POST</strong> 方法,在请求头中携带 API Key:</p>
|
<div className="mt-2 text-gray-400">curl -H "Authorization: Bearer clicd_sk_xxxx" {BASE_URL}/api/v1/dashboard</div>
|
||||||
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs space-y-2">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section>
|
{endpointGroups.map(group => (
|
||||||
<h3 className="font-semibold text-black mb-2">容器管理</h3>
|
<section key={group.title}>
|
||||||
<Endpoint method="POST" path="/api/containers/list" desc="获取容器列表" />
|
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
|
||||||
<Endpoint method="POST" path="/api/containers/detail" desc="获取容器详情" body='{"id": 1}' />
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
<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}`} />
|
{group.endpoints.map(([method, path, desc]) => (
|
||||||
<Endpoint method="POST" path="/api/containers/start" desc="启动容器" body='{"id": 1}' />
|
<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]">
|
||||||
<Endpoint method="POST" path="/api/containers/stop" desc="停止容器" body='{"id": 1}' />
|
<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>
|
||||||
<Endpoint method="POST" path="/api/containers/restart" desc="重启容器" body='{"id": 1}' />
|
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
|
||||||
<Endpoint method="POST" path="/api/containers/delete" desc="删除容器" body='{"id": 1}' />
|
<span className="text-gray-500">{desc}</span>
|
||||||
<Endpoint method="POST" path="/api/containers/reinstall" desc="重装系统" body='{"id": 1, "template_id": "debian-bookworm"}' />
|
</div>
|
||||||
<Endpoint method="POST" path="/api/containers/usage" desc="获取资源用量" body='{"id": 1}' />
|
))}
|
||||||
<Endpoint method="POST" path="/api/containers/traffic" desc="获取流量统计" body='{"id": 1}' />
|
</div>
|
||||||
<Endpoint method="POST" path="/api/containers/traffic-reset" desc="重置流量" body='{"id": 1}' />
|
</section>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Endpoint({ method, path, desc, body }: { method: string; path: string; desc: string; body?: string }) {
|
{showForm && (
|
||||||
return (
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="flex items-start gap-3 py-2 border-b border-gray-50">
|
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
|
||||||
<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>
|
<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">
|
||||||
<code className="shrink-0 text-xs text-gray-800 font-mono">{path}</code>
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
|
||||||
<span className="text-xs text-gray-500 min-w-0">{desc}</span>
|
<h3 className="text-base font-semibold text-black">{editingKey ? '编辑 API Key' : '创建 API Key'}</h3>
|
||||||
{body && (
|
<button onClick={() => setShowForm(false)} className="rounded p-1 text-gray-400 hover:text-black" title="关闭">
|
||||||
<details className="text-xs">
|
<X className="h-4 w-4" />
|
||||||
<summary className="text-gray-400 cursor-pointer hover:text-gray-600">Body</summary>
|
</button>
|
||||||
<pre className="mt-1 p-2 bg-gray-50 rounded text-xs text-gray-600 overflow-x-auto">{body}</pre>
|
</div>
|
||||||
</details>
|
<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>
|
</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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { ReactNode, useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
RefreshCw,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { getHostReport, HostProbeReport } from '../services/api'
|
||||||
|
|
||||||
|
export default function HostReport() {
|
||||||
|
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchReport = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getHostReport()
|
||||||
|
setReport(res.data.data || null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport()
|
||||||
|
}, [fetchReport])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !report ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
||||||
|
) : !report ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
||||||
|
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
||||||
|
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||||
|
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProbeSection title="系统概览">
|
||||||
|
<ProbeRows rows={[
|
||||||
|
['主机名', report.hostname],
|
||||||
|
['操作系统', report.os],
|
||||||
|
['内核', report.kernel],
|
||||||
|
['生成时间', report.generated_at],
|
||||||
|
['CPU 架构', report.cpu.architecture],
|
||||||
|
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
||||||
|
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
||||||
|
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
||||||
|
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
||||||
|
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
||||||
|
]} />
|
||||||
|
</ProbeSection>
|
||||||
|
|
||||||
|
<ProbeSection title="公网与路由">
|
||||||
|
<ProbeRows rows={[
|
||||||
|
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
||||||
|
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
||||||
|
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
||||||
|
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
||||||
|
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
||||||
|
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
||||||
|
]} />
|
||||||
|
</ProbeSection>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="内存条"
|
||||||
|
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
||||||
|
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
||||||
|
rows={(report.memory.modules || []).map(m => [
|
||||||
|
m.locator || '-',
|
||||||
|
m.size || '-',
|
||||||
|
m.type || '-',
|
||||||
|
m.speed || '-',
|
||||||
|
m.manufacturer || '-',
|
||||||
|
[m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="硬盘与健康"
|
||||||
|
empty="未检测到硬盘"
|
||||||
|
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
||||||
|
rows={report.disks.map(d => [
|
||||||
|
`${d.path || d.name}\n${d.serial || ''}`,
|
||||||
|
d.model || '-',
|
||||||
|
formatBytes(d.size_bytes),
|
||||||
|
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
||||||
|
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||||
|
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
||||||
|
formatLifeUsed(d.smart?.life_used_percent),
|
||||||
|
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
||||||
|
formatBytes(d.smart?.read_data_bytes || 0),
|
||||||
|
formatBytes(d.smart?.written_data_bytes || 0),
|
||||||
|
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
||||||
|
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="网卡"
|
||||||
|
empty="未检测到网卡"
|
||||||
|
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
||||||
|
rows={report.network_interfaces.map(n => [
|
||||||
|
`${n.name}\n${n.model || ''}`,
|
||||||
|
n.state || '-',
|
||||||
|
`${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`,
|
||||||
|
n.mac || '-',
|
||||||
|
n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-',
|
||||||
|
n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeTable
|
||||||
|
title="显卡"
|
||||||
|
empty="未检测到显卡"
|
||||||
|
headers={['名称', '厂商', '类型', '驱动']}
|
||||||
|
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProbeSection title="环境支持">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{report.environment.map(item => (
|
||||||
|
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||||
|
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||||
|
{item.required ? '必要' : '可选'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ProbeSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-gray-500">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="line-clamp-2 break-words text-sm font-semibold text-gray-900" title={value}>{value}</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-gray-500" title={sub}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeRows({ rows }: { rows: Array<[string, string]> }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div key={label} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[160px_1fr]">
|
||||||
|
<div className="font-medium text-gray-500">{label}</div>
|
||||||
|
<div className="whitespace-pre-wrap break-words font-mono text-gray-800">{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-black">{title}</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-3 text-xs text-gray-400">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500">
|
||||||
|
{headers.map(header => <th key={header} className="px-3 py-2 font-medium">{header}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="align-top">
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="max-w-[280px] whitespace-pre-wrap break-words px-3 py-2 text-gray-700">
|
||||||
|
{cell || '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) {
|
||||||
|
return `${ip.address}/${ip.prefix_len} (${ip.interface})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) {
|
||||||
|
const parts = [
|
||||||
|
prefix.prefix || '-',
|
||||||
|
prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '',
|
||||||
|
prefix.gateway ? `via ${prefix.gateway}` : '',
|
||||||
|
prefix.interface ? `dev ${prefix.interface}` : '',
|
||||||
|
prefix.source ? `[${prefix.source}]` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) {
|
||||||
|
const value = prefix.prefix || prefix.address || '-'
|
||||||
|
const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}`
|
||||||
|
return `${cidr} via ${prefix.gateway || '-'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMB(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`
|
||||||
|
return `${value} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
let next = value
|
||||||
|
let index = 0
|
||||||
|
while (next >= 1024 && index < units.length - 1) {
|
||||||
|
next /= 1024
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLifeUsed(value?: number) {
|
||||||
|
if (value === undefined || value === null) return '-'
|
||||||
|
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPowerOnDays(hours: number) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const rest = hours % 24
|
||||||
|
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommands(read?: number, write?: number) {
|
||||||
|
if (!read && !write) return '-'
|
||||||
|
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value: number) {
|
||||||
|
if (!value) return '-'
|
||||||
|
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||||
|
return `${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
||||||
|
const rows: string[] = []
|
||||||
|
if (wear) rows.push(`磨损 ${wear}`)
|
||||||
|
if (erase) rows.push(`擦写 ${erase}`)
|
||||||
|
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
||||||
|
return rows.length ? rows.join('\n') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeModeLabel(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case 'kvm_lxc':
|
||||||
|
return '支持 KVM + LXC'
|
||||||
|
case 'lxc_only':
|
||||||
|
return '仅支持 LXC'
|
||||||
|
default:
|
||||||
|
return '未满足运行环境'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diskHealthLabel(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case 'ok':
|
||||||
|
return '健康'
|
||||||
|
case 'failed':
|
||||||
|
return '异常'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gpuTypeLabel(value: string) {
|
||||||
|
if (value === 'integrated') return '核显'
|
||||||
|
if (value === 'discrete') return '独显'
|
||||||
|
return value || '-'
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.2</p>
|
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.5</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
|
import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
@@ -20,7 +20,6 @@ export default function Settings() {
|
|||||||
const [oldPwd, setOldPwd] = useState('')
|
const [oldPwd, setOldPwd] = useState('')
|
||||||
const [newPwd, setNewPwd] = useState('')
|
const [newPwd, setNewPwd] = useState('')
|
||||||
const [newUsername, setNewUsername] = useState('')
|
const [newUsername, setNewUsername] = useState('')
|
||||||
const [pwdForUser, setPwdForUser] = useState('')
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,30 +32,45 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
|
useEffect(() => {
|
||||||
|
fetchLogs()
|
||||||
|
const timer = setInterval(fetchLogs, 15000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [fetchLogs])
|
||||||
|
|
||||||
const handleSaveAccount = async () => {
|
const handleSaveAccount = async () => {
|
||||||
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
|
if (!oldPwd) {
|
||||||
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
|
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||||
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
|
return
|
||||||
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
|
}
|
||||||
|
if (!newPwd && !newUsername) {
|
||||||
|
dialog.alert('提示', '至少填写新密码或新用户名中的一项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPwd && newPwd.length < 6) {
|
||||||
|
dialog.alert('提示', '新密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newUsername && newUsername.length < 3) {
|
||||||
|
dialog.alert('提示', '用户名至少 3 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let results: string[] = []
|
const results: string[] = []
|
||||||
try {
|
try {
|
||||||
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
|
|
||||||
if (newUsername) {
|
if (newUsername) {
|
||||||
const res = await changeUsername(newUsername, oldPwd)
|
const res = await changeUsername(newUsername, oldPwd)
|
||||||
if (res.data.success) results.push('用户名已修改')
|
results.push(res.data.success ? '用户名已修改' : '用户名修改失败')
|
||||||
else results.push('用户名修改失败')
|
|
||||||
}
|
}
|
||||||
if (newPwd) {
|
if (newPwd) {
|
||||||
const res = await changePassword(oldPwd, newPwd)
|
const res = await changePassword(oldPwd, newPwd)
|
||||||
if (res.data.success) results.push('密码已修改')
|
results.push(res.data.success ? '密码已修改' : '密码修改失败')
|
||||||
else results.push('密码修改失败')
|
|
||||||
}
|
}
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
dialog.alert('完成', results.join(',') + '。下次登录生效')
|
dialog.alert('完成', `${results.join(',')}。下次登录生效`)
|
||||||
setOldPwd(''); setNewPwd(''); setNewUsername('')
|
setOldPwd('')
|
||||||
|
setNewPwd('')
|
||||||
|
setNewUsername('')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { message?: string } } }
|
const e = err as { response?: { data?: { message?: string } } }
|
||||||
@@ -67,48 +81,48 @@ export default function Settings() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(logs.length / pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">账号管理与登录日志</p>
|
<p className="mt-1 text-sm text-gray-500">账号管理与登录日志</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Settings */}
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
<UserCog className="h-4 w-4" />账号设置
|
||||||
<UserCog className="w-4 h-4" />账号设置
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">当前用户名</label>
|
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||||
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
|
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">新用户名(留空则不修改)</label>
|
<label className="mb-1 block text-xs text-gray-500">新用户名(留空则不修改)</label>
|
||||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
|
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-100 pt-3">
|
<div className="border-t border-gray-100 pt-3">
|
||||||
<label className="block text-xs text-gray-500 mb-1">新密码(留空则不修改)</label>
|
<label className="mb-1 block text-xs text-gray-500">新密码(留空则不修改)</label>
|
||||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
|
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">当前密码(验证身份)</label>
|
<label className="mb-1 block text-xs text-gray-500">当前密码(验证身份)</label>
|
||||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
|
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800">保存修改</button>
|
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Logs */}
|
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
|
<LogIn className="h-4 w-4" />登录日志
|
||||||
<LogIn className="w-4 h-4" />登录日志
|
|
||||||
</h2>
|
</h2>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">暂无登录记录</p>
|
<p className="text-sm text-gray-400">暂无登录记录</p>
|
||||||
@@ -117,23 +131,23 @@ export default function Settings() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-gray-400 border-b border-gray-100">
|
<tr className="border-b border-gray-100 text-gray-400">
|
||||||
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" />时间</span></th>
|
<th className="w-40 py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Clock className="h-3 w-3" />时间</span></th>
|
||||||
<th className="text-left py-2 font-medium">用户名</th>
|
<th className="py-2 text-left font-medium">用户名</th>
|
||||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
|
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Globe className="h-3 w-3" />IP</span></th>
|
||||||
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" />设备</span></th>
|
<th className="py-2 text-left font-medium"><span className="inline-flex items-center gap-1"><Monitor className="h-3 w-3" />设备</span></th>
|
||||||
<th className="text-left py-2 font-medium">结果</th>
|
<th className="py-2 text-left font-medium">结果</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
|
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => (
|
||||||
<tr key={i}>
|
<tr key={`${log.time}-${index}`}>
|
||||||
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
|
<td className="whitespace-nowrap py-1.5 font-mono text-gray-500">{log.time}</td>
|
||||||
<td className="py-1.5 text-gray-700">{log.username}</td>
|
<td className="py-1.5 text-gray-700">{log.username}</td>
|
||||||
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
|
<td className="py-1.5 font-mono text-gray-500">{log.ip}</td>
|
||||||
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
<td className="max-w-[180px] truncate py-1.5 text-gray-500" title={log.user_agent}>{formatUA(log.user_agent)}</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
|
||||||
{log.success ? '成功' : '失败'}
|
{log.success ? '成功' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -143,23 +157,22 @@ export default function Settings() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{logs.length > pageSize && (
|
{logs.length > pageSize && (
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
|
||||||
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{Math.ceil(logs.length / pageSize)} 页</span>
|
<span className="text-xs text-gray-400">共 {logs.length} 条,第 {logPage}/{totalPages} 页</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">首页</button>
|
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">首页</button>
|
||||||
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">上一页</button>
|
||||||
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
const totalPages = Math.ceil(logs.length / pageSize)
|
|
||||||
let start = Math.max(1, logPage - 2)
|
let start = Math.max(1, logPage - 2)
|
||||||
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
|
||||||
const page = start + i
|
const page = start + i
|
||||||
if (page > totalPages) return null
|
if (page > totalPages) return null
|
||||||
return (
|
return (
|
||||||
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
<button key={page} onClick={() => setLogPage(page)} className={`h-7 w-7 rounded text-xs ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
<button onClick={() => setLogPage(p => Math.min(totalPages, p + 1))} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">下一页</button>
|
||||||
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30">末页</button>
|
<button onClick={() => setLogPage(totalPages)} disabled={logPage >= totalPages} className="rounded border border-gray-200 px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-30">末页</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,7 +184,6 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatUA(ua: string): string {
|
function formatUA(ua: string): string {
|
||||||
// Extract browser/OS info from UA string
|
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (ua.includes('Windows NT')) parts.push('Windows')
|
if (ua.includes('Windows NT')) parts.push('Windows')
|
||||||
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
else if (ua.includes('Mac OS X')) parts.push('macOS')
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ export interface IPv6Status {
|
|||||||
prefixes: IPv6PrefixInfo[]
|
prefixes: IPv6PrefixInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPv4PrefixInfo {
|
||||||
|
interface: string
|
||||||
|
address: string
|
||||||
|
prefix: string
|
||||||
|
prefix_len: number
|
||||||
|
subnet_mask: string
|
||||||
|
gateway: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
total_containers: number
|
total_containers: number
|
||||||
running: number
|
running: number
|
||||||
@@ -161,6 +171,93 @@ export interface HostInfo {
|
|||||||
load: { load1: number; load5: number; load15: number }
|
load: { load1: number; load5: number; load15: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostProbeReport {
|
||||||
|
generated_at: string
|
||||||
|
hostname: string
|
||||||
|
kernel: string
|
||||||
|
os: string
|
||||||
|
cpu: {
|
||||||
|
model: string
|
||||||
|
cores: number
|
||||||
|
threads: number
|
||||||
|
architecture: string
|
||||||
|
flags: string[]
|
||||||
|
has_integrated_gpu: boolean
|
||||||
|
virtualization: boolean
|
||||||
|
virtualization_key: string
|
||||||
|
}
|
||||||
|
memory: {
|
||||||
|
total_mb: number
|
||||||
|
used_mb: number
|
||||||
|
free_mb: number
|
||||||
|
modules: Array<{
|
||||||
|
locator: string
|
||||||
|
size: string
|
||||||
|
type: string
|
||||||
|
speed: string
|
||||||
|
manufacturer: string
|
||||||
|
part_number: string
|
||||||
|
serial_number: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
disks: Array<{
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
model: string
|
||||||
|
serial: string
|
||||||
|
size_bytes: number
|
||||||
|
type: string
|
||||||
|
rotational: boolean
|
||||||
|
mountpoints: string[]
|
||||||
|
health: string
|
||||||
|
health_detail: string
|
||||||
|
smart?: {
|
||||||
|
available: boolean
|
||||||
|
life_used_percent?: number
|
||||||
|
power_on_hours?: number
|
||||||
|
power_cycle_count?: number
|
||||||
|
read_data_bytes?: number
|
||||||
|
written_data_bytes?: number
|
||||||
|
read_commands?: number
|
||||||
|
write_commands?: number
|
||||||
|
wear_leveling_count?: string
|
||||||
|
erase_count?: string
|
||||||
|
media_errors?: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
network_interfaces: Array<{
|
||||||
|
name: string
|
||||||
|
mac: string
|
||||||
|
state: string
|
||||||
|
speed_mbps: number
|
||||||
|
driver: string
|
||||||
|
model: string
|
||||||
|
ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
}>
|
||||||
|
public_ipv4: string[]
|
||||||
|
ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv4_prefixes: IPv4PrefixInfo[]
|
||||||
|
ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }>
|
||||||
|
ipv6_prefixes: IPv6PrefixInfo[]
|
||||||
|
gateways: Array<{ family: string; interface: string; gateway: string }>
|
||||||
|
gpus: Array<{ name: string; vendor: string; driver: string; type: string }>
|
||||||
|
runtime: {
|
||||||
|
lxc_available: boolean
|
||||||
|
kvm_available: boolean
|
||||||
|
dev_kvm: boolean
|
||||||
|
nested_virtualization: boolean
|
||||||
|
nested_detail: string
|
||||||
|
support_mode: string
|
||||||
|
}
|
||||||
|
system: {
|
||||||
|
uptime_seconds: number
|
||||||
|
uptime_text: string
|
||||||
|
process_count: number
|
||||||
|
}
|
||||||
|
environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContainerUsage {
|
export interface ContainerUsage {
|
||||||
memory_usage_bytes: number
|
memory_usage_bytes: number
|
||||||
memory_total_bytes?: number
|
memory_total_bytes?: number
|
||||||
@@ -393,6 +490,9 @@ export const getDashboard = () =>
|
|||||||
export const getHostInfo = () =>
|
export const getHostInfo = () =>
|
||||||
api.get<APIResponse<HostInfo>>('/host-info')
|
api.get<APIResponse<HostInfo>>('/host-info')
|
||||||
|
|
||||||
|
export const getHostReport = () =>
|
||||||
|
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||||
|
|
||||||
// Snapshots
|
// Snapshots
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
id: string
|
id: string
|
||||||
@@ -456,10 +556,9 @@ export const getWebSSHUrl = (containerName: string) => {
|
|||||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
export const getWebVNCUrl = (containerName: string) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const params = new URLSearchParams({ container: containerName })
|
const params = new URLSearchParams({ container: containerName })
|
||||||
if (ticket) params.set('ticket', ticket)
|
|
||||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+430
-32
@@ -8,6 +8,8 @@ ACTION="${1:-install}"
|
|||||||
ACTION_CONFIRM="${2:-}"
|
ACTION_CONFIRM="${2:-}"
|
||||||
ISSUE_URL="https://github.com/${REPO}/issues"
|
ISSUE_URL="https://github.com/${REPO}/issues"
|
||||||
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
||||||
|
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||||
|
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " CLICD 中文安装/卸载脚本"
|
echo " CLICD 中文安装/卸载脚本"
|
||||||
@@ -54,7 +56,7 @@ run_step() {
|
|||||||
step_name="$1"
|
step_name="$1"
|
||||||
shift
|
shift
|
||||||
log "开始:$step_name"
|
log "开始:$step_name"
|
||||||
if "$@" >> "$LOG_FILE" 2>&1; then
|
if ( "$@" ) >> "$LOG_FILE" 2>&1; then
|
||||||
log "完成:$step_name"
|
log "完成:$step_name"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -136,6 +138,7 @@ usage() {
|
|||||||
示例:
|
示例:
|
||||||
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
|
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
|
||||||
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
|
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
|
||||||
|
|
||||||
日志:${LOG_FILE}
|
日志:${LOG_FILE}
|
||||||
问题反馈:${ISSUE_URL}
|
问题反馈:${ISSUE_URL}
|
||||||
@@ -218,6 +221,39 @@ remove_lxc_container_dir() {
|
|||||||
log "已删除 $container_dir"
|
log "已删除 $container_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove_clicd_lxc_image_cache() {
|
||||||
|
log "正在删除 CLICD 使用的 LXC 镜像缓存..."
|
||||||
|
|
||||||
|
for container_dir in /var/lib/lxc/clicd-img-dl-*; do
|
||||||
|
[ -d "$container_dir" ] || continue
|
||||||
|
remove_lxc_container_dir "$container_dir"
|
||||||
|
done
|
||||||
|
|
||||||
|
for image in \
|
||||||
|
"ubuntu noble amd64" \
|
||||||
|
"ubuntu jammy amd64" \
|
||||||
|
"debian bookworm amd64" \
|
||||||
|
"debian bullseye amd64" \
|
||||||
|
"alpine 3.21 amd64" \
|
||||||
|
"centos 9-Stream amd64" \
|
||||||
|
"archlinux current amd64" \
|
||||||
|
"fedora 44 amd64" \
|
||||||
|
"rockylinux 10 amd64"
|
||||||
|
do
|
||||||
|
set -- $image
|
||||||
|
distro="$1"
|
||||||
|
release="$2"
|
||||||
|
arch="$3"
|
||||||
|
cache_dir="/var/cache/lxc/download/$distro/$release/$arch"
|
||||||
|
remove_path "$cache_dir"
|
||||||
|
rmdir "/var/cache/lxc/download/$distro/$release" >/dev/null 2>&1 || true
|
||||||
|
rmdir "/var/cache/lxc/download/$distro" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
rmdir /var/cache/lxc/download >/dev/null 2>&1 || true
|
||||||
|
rmdir /var/cache/lxc >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
remove_kvm_domain() {
|
remove_kvm_domain() {
|
||||||
domain="$1"
|
domain="$1"
|
||||||
case "$domain" in
|
case "$domain" in
|
||||||
@@ -258,6 +294,48 @@ destroy_clicd_kvm_domains() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
domain_is_clicd_kvm() {
|
||||||
|
domain="$1"
|
||||||
|
case "$domain" in
|
||||||
|
vm-[0-9]*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'
|
||||||
|
}
|
||||||
|
|
||||||
|
libvirt_default_used_by_non_clicd_domain() {
|
||||||
|
if ! has_cmd virsh; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for domain in $(virsh list --all --name 2>/dev/null); do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if domain_is_clicd_kvm "$domain"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if virsh domiflist "$domain" 2>/dev/null | awk '$3 == "default" || $3 == "virbr0" {found = 1} END {exit found ? 0 : 1}'; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_clicd_libvirt_default_network() {
|
||||||
|
if ! has_cmd virsh || [ ! -f "$LIBVIRT_DEFAULT_MARKER" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if libvirt_default_used_by_non_clicd_domain; then
|
||||||
|
warn "检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "正在删除 CLICD 创建的 libvirt default NAT 网络..."
|
||||||
|
virsh net-destroy default >/dev/null 2>&1 || true
|
||||||
|
virsh net-undefine default >/dev/null 2>&1 || true
|
||||||
|
rm -f "$LIBVIRT_DEFAULT_MARKER"
|
||||||
|
}
|
||||||
|
|
||||||
delete_iptables_lines() {
|
delete_iptables_lines() {
|
||||||
table="$1"
|
table="$1"
|
||||||
chain="$2"
|
chain="$2"
|
||||||
@@ -295,6 +373,143 @@ delete_filter_rule() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete_ip6_filter_rule() {
|
||||||
|
if ! has_cmd ip6tables; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while ip6tables -D "$@" >/dev/null 2>&1; do
|
||||||
|
:
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_ip6tables_nat_source() {
|
||||||
|
source="$1"
|
||||||
|
if ! has_cmd ip6tables || [ -z "$source" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
rule="$(
|
||||||
|
ip6tables -t nat -S POSTROUTING 2>/dev/null |
|
||||||
|
grep -F -- "-s $source" |
|
||||||
|
grep -F -- " -j MASQUERADE" |
|
||||||
|
sed 's/^-A /-D /' |
|
||||||
|
head -n 1
|
||||||
|
)"
|
||||||
|
[ -n "$rule" ] || break
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
ip6tables -t nat $rule >/dev/null 2>&1 || break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
read_clicd_network_records() {
|
||||||
|
db="/root/.clicd/config.db"
|
||||||
|
legacy="/root/.clicd/config.json"
|
||||||
|
query="SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> '';"
|
||||||
|
|
||||||
|
if [ -f "$db" ] && has_cmd sqlite3; then
|
||||||
|
sqlite3 -separator '|' "$db" "$query" 2>/dev/null || true
|
||||||
|
elif [ -f "$db" ] && has_cmd python3; then
|
||||||
|
CLICD_DB="$db" python3 - <<'PY' 2>/dev/null || true
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db = os.environ.get("CLICD_DB")
|
||||||
|
for row in sqlite3.connect(db).execute(
|
||||||
|
"SELECT COALESCE(virtualization,''), COALESCE(ipv6,''), COALESCE(ipv6_interface,''), COALESCE(mac_address,'') "
|
||||||
|
"FROM containers WHERE COALESCE(ipv6,'') <> '' OR COALESCE(mac_address,'') <> ''"
|
||||||
|
):
|
||||||
|
print("|".join("" if value is None else str(value) for value in row))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$legacy" ] && has_cmd python3; then
|
||||||
|
CLICD_LEGACY_CONFIG="$legacy" python3 - <<'PY' 2>/dev/null || true
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = os.environ.get("CLICD_LEGACY_CONFIG")
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for item in data.get("containers", []):
|
||||||
|
virt = item.get("virtualization", "")
|
||||||
|
ipv6 = item.get("ipv6", "")
|
||||||
|
uplink = item.get("ipv6_interface", "")
|
||||||
|
mac = item.get("mac_address", "")
|
||||||
|
if ipv6 or mac:
|
||||||
|
print("|".join(str(value or "") for value in (virt, ipv6, uplink, mac)))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_record() {
|
||||||
|
virt="$1"
|
||||||
|
ipv6="$2"
|
||||||
|
uplink="$3"
|
||||||
|
mac="$4"
|
||||||
|
bridge="lxcbr0"
|
||||||
|
if [ "$virt" = "kvm" ]; then
|
||||||
|
bridge="virbr0"
|
||||||
|
fi
|
||||||
|
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$ipv6" ] || return
|
||||||
|
addr="${ipv6%%/*}"
|
||||||
|
source="$ipv6"
|
||||||
|
case "$source" in
|
||||||
|
*/*) ;;
|
||||||
|
*) source="$source/128" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
delete_ip6tables_nat_source "$source"
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
|
||||||
|
if [ -n "$mac" ] && [ "$bridge" = "virbr0" ]; then
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -m mac --mac-source "$mac" -j DROP
|
||||||
|
fi
|
||||||
|
|
||||||
|
if has_cmd ip; then
|
||||||
|
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
if [ -n "$uplink" ]; then
|
||||||
|
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_from_config() {
|
||||||
|
read_clicd_network_records | while IFS='|' read -r virt ipv6 uplink mac; do
|
||||||
|
cleanup_clicd_ipv6_record "$virt" "$ipv6" "$uplink" "$mac"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_clicd_ipv6_bridge_routes() {
|
||||||
|
if ! has_cmd ip; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
for bridge in lxcbr0 virbr0; do
|
||||||
|
ip -6 route show dev "$bridge" 2>/dev/null | awk '$1 ~ /\/128$/ {print $1}' | while IFS= read -r source; do
|
||||||
|
[ -n "$source" ] || continue
|
||||||
|
addr="${source%%/*}"
|
||||||
|
delete_ip6tables_nat_source "$source"
|
||||||
|
delete_ip6_filter_rule FORWARD -i "$bridge" -s "$source" -j ACCEPT
|
||||||
|
delete_ip6_filter_rule FORWARD -o "$bridge" -d "$source" -j ACCEPT
|
||||||
|
ip -6 neigh show proxy 2>/dev/null | awk -v addr="$addr" '$1 == addr {for (i = 1; i < NF; i++) if ($i == "dev") print $(i + 1)}' | while IFS= read -r uplink; do
|
||||||
|
[ -n "$uplink" ] || continue
|
||||||
|
ip -6 neigh del proxy "$addr" dev "$uplink" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
ip -6 route del "$source" dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
ip -6 addr del fe80::1/64 dev "$bridge" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
delete_ip6tables_bridge_rules() {
|
delete_ip6tables_bridge_rules() {
|
||||||
if ! has_cmd ip6tables; then
|
if ! has_cmd ip6tables; then
|
||||||
return
|
return
|
||||||
@@ -315,6 +530,8 @@ cleanup_clicd_networking() {
|
|||||||
delete_iptables_lines nat PREROUTING 'clicd-'
|
delete_iptables_lines nat PREROUTING 'clicd-'
|
||||||
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
|
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
|
||||||
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
|
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
|
||||||
|
cleanup_clicd_ipv6_from_config
|
||||||
|
cleanup_clicd_ipv6_bridge_routes
|
||||||
|
|
||||||
for bridge in lxcbr0 virbr0; do
|
for bridge in lxcbr0 virbr0; do
|
||||||
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
||||||
@@ -354,8 +571,14 @@ remove_clicd_quota_records() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_clicd_tmp_files() {
|
remove_clicd_tmp_files() {
|
||||||
|
current_dir="$(pwd -P 2>/dev/null || pwd)"
|
||||||
for path in /tmp/clicd-* /tmp/clicd.*; do
|
for path in /tmp/clicd-* /tmp/clicd.*; do
|
||||||
[ -e "$path" ] || [ -L "$path" ] || continue
|
[ -e "$path" ] || [ -L "$path" ] || continue
|
||||||
|
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
|
||||||
|
if [ "$abs_path" = "$current_dir" ]; then
|
||||||
|
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
rm -rf "$path"
|
rm -rf "$path"
|
||||||
log "已删除 $path"
|
log "已删除 $path"
|
||||||
done
|
done
|
||||||
@@ -376,10 +599,12 @@ confirm_uninstall() {
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
|
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
|
||||||
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器和 vm-数字 的 KVM 域。" >&2
|
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
|
||||||
echo "如需确认卸载,请输入:YES" >&2
|
echo "如需确认卸载,请输入:YES" >&2
|
||||||
if [ -t 0 ]; then
|
if [ -r /dev/tty ]; then
|
||||||
read answer
|
IFS= read -r answer < /dev/tty
|
||||||
|
elif [ -t 0 ]; then
|
||||||
|
IFS= read -r answer
|
||||||
else
|
else
|
||||||
answer=""
|
answer=""
|
||||||
fi
|
fi
|
||||||
@@ -409,7 +634,9 @@ uninstall_clicd() {
|
|||||||
[ -d "$container_dir" ] || continue
|
[ -d "$container_dir" ] || continue
|
||||||
remove_lxc_container_dir "$container_dir"
|
remove_lxc_container_dir "$container_dir"
|
||||||
done
|
done
|
||||||
|
remove_clicd_lxc_image_cache
|
||||||
destroy_clicd_kvm_domains
|
destroy_clicd_kvm_domains
|
||||||
|
remove_clicd_libvirt_default_network
|
||||||
cleanup_clicd_networking
|
cleanup_clicd_networking
|
||||||
remove_clicd_host_hooks
|
remove_clicd_host_hooks
|
||||||
remove_clicd_quota_records
|
remove_clicd_quota_records
|
||||||
@@ -424,7 +651,7 @@ uninstall_clicd() {
|
|||||||
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
|
# /var/lib/lxc 可能包含非 CLICD 容器,生产环境不整体删除。
|
||||||
unmount_path_tree /var/lib/clicd
|
unmount_path_tree /var/lib/clicd
|
||||||
remove_path /var/lib/clicd
|
remove_path /var/lib/clicd
|
||||||
# /var/cache/lxc 是 LXC 全局镜像缓存,可能被其他工具复用,生产环境不整体删除。
|
# /var/cache/lxc 是 LXC 全局缓存,已按 CLICD 模板精确清理,生产环境不整体删除。
|
||||||
remove_path /var/cache/clicd
|
remove_path /var/cache/clicd
|
||||||
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
|
warn "保留 /root/clicd-backups,避免误删部署/回滚备份。确认不需要后可手动删除。"
|
||||||
remove_clicd_tmp_files
|
remove_clicd_tmp_files
|
||||||
@@ -444,7 +671,7 @@ uninstall_clicd() {
|
|||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
|
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
|
||||||
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
|
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
|
||||||
echo " 已保留 /root/clicd-backups 和 LXC 全局缓存,避免误删生产备份/共享镜像。"
|
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
|
||||||
echo " 日志:$LOG_FILE"
|
echo " 日志:$LOG_FILE"
|
||||||
echo " 问题反馈:$ISSUE_URL"
|
echo " 问题反馈:$ISSUE_URL"
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
@@ -674,17 +901,43 @@ EOF
|
|||||||
sysctl --system >/dev/null 2>&1 || true
|
sysctl --system >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
systemd_unit_exists() {
|
||||||
|
unit="$1"
|
||||||
|
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
systemd_enable_now_if_exists() {
|
||||||
|
unit="$1"
|
||||||
|
if systemd_unit_exists "$unit"; then
|
||||||
|
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "未检测到 systemd 单元 $unit,跳过。"
|
||||||
|
}
|
||||||
|
|
||||||
|
systemd_existing_units() {
|
||||||
|
for unit in "$@"; do
|
||||||
|
if systemd_unit_exists "$unit"; then
|
||||||
|
printf ' %s' "$unit"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
setup_runtime_services() {
|
setup_runtime_services() {
|
||||||
log "正在配置 LXC 和 KVM 服务..."
|
log "正在配置 LXC 和 KVM 服务..."
|
||||||
|
|
||||||
if is_systemd; then
|
if is_systemd; then
|
||||||
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxcfs.service
|
||||||
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxc-net.service
|
||||||
systemctl enable --now lxc >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists lxc.service
|
||||||
systemctl enable --now libvirtd >/dev/null 2>&1 || true
|
if systemd_unit_exists libvirtd.service; then
|
||||||
systemctl enable --now virtqemud >/dev/null 2>&1 || true
|
systemd_enable_now_if_exists libvirtd.service
|
||||||
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true
|
log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
|
||||||
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true
|
else
|
||||||
|
systemd_enable_now_if_exists virtqemud.service
|
||||||
|
systemd_enable_now_if_exists virtqemud.socket
|
||||||
|
fi
|
||||||
|
systemd_enable_now_if_exists virtlogd.socket
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -734,6 +987,8 @@ setup_default_libvirt_network() {
|
|||||||
EOF
|
EOF
|
||||||
virsh net-define "$net_xml"
|
virsh net-define "$net_xml"
|
||||||
rm -f "$net_xml"
|
rm -f "$net_xml"
|
||||||
|
mkdir -p "$(dirname "$LIBVIRT_DEFAULT_MARKER")"
|
||||||
|
touch "$LIBVIRT_DEFAULT_MARKER"
|
||||||
fi
|
fi
|
||||||
if ! libvirt_network_active; then
|
if ! libvirt_network_active; then
|
||||||
virsh net-start default
|
virsh net-start default
|
||||||
@@ -752,17 +1007,36 @@ setup_subids() {
|
|||||||
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
|
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_lxc_storage_access() {
|
||||||
|
log "Configuring LXC storage directory permissions..."
|
||||||
|
mkdir -p /var/lib/lxc
|
||||||
|
chmod 755 /var/lib/lxc
|
||||||
|
}
|
||||||
|
|
||||||
try_enable_project_quota() {
|
try_enable_project_quota() {
|
||||||
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
|
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
|
||||||
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
|
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
|
||||||
|
|
||||||
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
|
case "$root_fs" in
|
||||||
warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。"
|
ext4)
|
||||||
|
;;
|
||||||
|
xfs|btrfs|zfs|overlay|unknown|"")
|
||||||
|
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quota,CLICD 将使用兼容磁盘限制模式。"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
|
||||||
|
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! has_cmd tune2fs; then
|
if ! has_cmd tune2fs; then
|
||||||
warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。"
|
log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -771,7 +1045,60 @@ try_enable_project_quota() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。"
|
log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_file() {
|
||||||
|
url="$1"
|
||||||
|
dest="$2"
|
||||||
|
rm -f "$dest"
|
||||||
|
|
||||||
|
if has_cmd curl; then
|
||||||
|
curl -fL --retry 6 --retry-delay 2 --connect-timeout 20 --max-time 600 "$url" -o "$dest"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if has_cmd wget; then
|
||||||
|
wget --tries=6 --timeout=30 --waitretry=2 -O "$dest" "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
return 127
|
||||||
|
}
|
||||||
|
|
||||||
|
release_api_json() {
|
||||||
|
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
|
if has_cmd curl; then
|
||||||
|
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 120 "$api_url" 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if has_cmd wget; then
|
||||||
|
wget -qO- --tries=3 --timeout=30 "$api_url" 2>/dev/null || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_asset_url() {
|
||||||
|
asset_name="$1"
|
||||||
|
|
||||||
|
if [ "$CLICD_INSTALL_VERSION" != "latest" ]; then
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${asset_name}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_data="$(release_api_json)"
|
||||||
|
url="$(printf '%s\n' "$api_data" | sed -n 's/.*"browser_download_url": *"\([^"]*\/'"$asset_name"'\)".*/\1/p' | head -n 1)"
|
||||||
|
if [ -n "$url" ]; then
|
||||||
|
printf '%s\n' "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag="$(printf '%s\n' "$api_data" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)"
|
||||||
|
if [ -n "$tag" ]; then
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/download/${tag}/${asset_name}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "https://github.com/${REPO}/releases/latest/download/${asset_name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
download_release_if_needed() {
|
download_release_if_needed() {
|
||||||
@@ -789,19 +1116,66 @@ download_release_if_needed() {
|
|||||||
log "正在下载发行版包:${download_url}"
|
log "正在下载发行版包:${download_url}"
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' 0
|
rm -f "$INSTALL_DOWNLOAD_MARKER"
|
||||||
|
printf '%s\n' "$tmp_dir" > "$INSTALL_DOWNLOAD_MARKER" || die "Failed to write install temp marker."
|
||||||
|
|
||||||
if has_cmd curl; then
|
if ! has_cmd curl && ! has_cmd wget; then
|
||||||
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
|
|
||||||
elif has_cmd wget; then
|
|
||||||
wget -O "$tmp_dir/$ASSET" "$download_url"
|
|
||||||
else
|
|
||||||
die "下载发行版包需要 curl 或 wget。"
|
die "下载发行版包需要 curl 或 wget。"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
|
archive_path="$tmp_dir/$ASSET"
|
||||||
cd "$tmp_dir/clicd-linux-amd64"
|
archive_urls="$download_url"
|
||||||
[ -f "./clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
resolved_archive_url="$(release_asset_url "$ASSET")"
|
||||||
|
if [ "$resolved_archive_url" != "$download_url" ]; then
|
||||||
|
archive_urls="$archive_urls $resolved_archive_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive_ok=0
|
||||||
|
for url in $archive_urls; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
log "Trying release archive: $url"
|
||||||
|
if download_file "$url" "$archive_path" && [ -s "$archive_path" ]; then
|
||||||
|
archive_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
warn "Release archive download failed, trying next source: $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$archive_ok" = "1" ]; then
|
||||||
|
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
||||||
|
else
|
||||||
|
binary_asset="clicd-linux-amd64"
|
||||||
|
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
||||||
|
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
||||||
|
else
|
||||||
|
binary_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${binary_asset}"
|
||||||
|
fi
|
||||||
|
binary_urls="$binary_url"
|
||||||
|
resolved_binary_url="$(release_asset_url "$binary_asset")"
|
||||||
|
if [ "$resolved_binary_url" != "$binary_url" ]; then
|
||||||
|
binary_urls="$binary_urls $resolved_binary_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
binary_path="$tmp_dir/$binary_asset"
|
||||||
|
binary_ok=0
|
||||||
|
for url in $binary_urls; do
|
||||||
|
[ -n "$url" ] || continue
|
||||||
|
log "Trying release binary: $url"
|
||||||
|
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
||||||
|
mkdir -p "$tmp_dir/clicd-linux-amd64"
|
||||||
|
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
|
||||||
|
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
|
||||||
|
binary_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
warn "Release binary download failed, trying next source: $url"
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
|
||||||
|
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_binary() {
|
install_binary() {
|
||||||
@@ -812,28 +1186,51 @@ install_binary() {
|
|||||||
rc-service clicd stop >/dev/null 2>&1 || true
|
rc-service clicd stop >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bin_src="./clicd"
|
||||||
|
download_dir=""
|
||||||
|
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
||||||
|
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
||||||
|
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
|
||||||
|
bin_src="$download_dir/clicd-linux-amd64/clicd"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
||||||
|
|
||||||
tmp_bin="/usr/local/bin/clicd.new.$$"
|
tmp_bin="/usr/local/bin/clicd.new.$$"
|
||||||
cp ./clicd "$tmp_bin"
|
cp "$bin_src" "$tmp_bin"
|
||||||
chmod +x "$tmp_bin"
|
chmod +x "$tmp_bin"
|
||||||
mv -f "$tmp_bin" /usr/local/bin/clicd
|
mv -f "$tmp_bin" /usr/local/bin/clicd
|
||||||
chmod +x /usr/local/bin/clicd
|
chmod +x /usr/local/bin/clicd
|
||||||
log "已安装二进制:/usr/local/bin/clicd"
|
log "已安装二进制:/usr/local/bin/clicd"
|
||||||
|
|
||||||
|
if [ -n "$download_dir" ]; then
|
||||||
|
case "$download_dir" in
|
||||||
|
/tmp/*)
|
||||||
|
rm -rf "$download_dir"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$INSTALL_DOWNLOAD_MARKER"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_systemd_service() {
|
install_systemd_service() {
|
||||||
cat > /etc/systemd/system/clicd.service << 'EOF'
|
libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
|
||||||
|
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
|
||||||
|
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/clicd.service << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=CLICD - LXC/KVM Container Manager
|
Description=CLICD - LXC/KVM Container Manager
|
||||||
After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket
|
After=network-online.target${lxc_after}${libvirt_after}
|
||||||
Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket
|
Wants=network-online.target${libvirt_wants}
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=10
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/clicd server
|
ExecStart=/usr/local/bin/clicd server
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=10
|
|
||||||
LimitNOFILE=1048576
|
LimitNOFILE=1048576
|
||||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
@@ -918,6 +1315,7 @@ run_step "配置内核网络参数" configure_kernel_networking
|
|||||||
run_step "配置运行时服务" setup_runtime_services
|
run_step "配置运行时服务" setup_runtime_services
|
||||||
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
||||||
run_step "配置 UID/GID 映射" setup_subids
|
run_step "配置 UID/GID 映射" setup_subids
|
||||||
|
run_step "Configure LXC storage permissions" configure_lxc_storage_access
|
||||||
run_step "检查 project quota" try_enable_project_quota
|
run_step "检查 project quota" try_enable_project_quota
|
||||||
run_step "下载发行版包" download_release_if_needed
|
run_step "下载发行版包" download_release_if_needed
|
||||||
run_step "安装 CLICD 二进制" install_binary
|
run_step "安装 CLICD 二进制" install_binary
|
||||||
|
|||||||
Reference in New Issue
Block a user