mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
first commit
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsed string `json:"last_used"`
|
||||
}
|
||||
|
||||
// HandleApiKeys handles GET (list) and POST (create) for API keys
|
||||
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
listApiKeys(w, r)
|
||||
case http.MethodPost:
|
||||
createApiKey(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleApiKeyDelete handles DELETE for a specific API key
|
||||
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
|
||||
if keyID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
|
||||
return
|
||||
}
|
||||
config.DeleteApiKey(keyID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
|
||||
}
|
||||
|
||||
func listApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys := make([]ApiKey, 0)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
keys = append(keys, ApiKey{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
IPWhitelist: k.IPWhitelist,
|
||||
CreatedAt: k.CreatedAt,
|
||||
LastUsed: k.LastUsed,
|
||||
})
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
|
||||
}
|
||||
|
||||
func createApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate key: clicd_sk_ + 32 hex chars
|
||||
rawBytes := make([]byte, 16)
|
||||
rand.Read(rawBytes)
|
||||
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
key := config.ApiKeyConfig{
|
||||
ID: generateShortID(),
|
||||
Name: req.Name,
|
||||
KeyHash: hashKey(rawKey),
|
||||
Prefix: rawKey[:13] + "...",
|
||||
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
|
||||
CreatedAt: now,
|
||||
}
|
||||
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
|
||||
config.SaveConfig()
|
||||
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{
|
||||
Success: true,
|
||||
Message: "API key created. Save this key now - it won't be shown again.",
|
||||
Data: ApiKey{
|
||||
ID: key.ID,
|
||||
Name: key.Name,
|
||||
Key: rawKey,
|
||||
Prefix: key.Prefix,
|
||||
IPWhitelist: key.IPWhitelist,
|
||||
CreatedAt: key.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func generateShortID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hashKey creates a simple hash for storage (not reversible)
|
||||
func hashKey(key string) string {
|
||||
b := make([]byte, 32)
|
||||
for i := range key {
|
||||
b[i%32] ^= key[i]
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// validateApiKey checks if the given key is valid and IP is allowed
|
||||
func validateApiKey(rawKey, clientIP string) bool {
|
||||
hashed := hashKey(rawKey)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if k.KeyHash == hashed {
|
||||
if k.IPWhitelist == "" {
|
||||
return true
|
||||
}
|
||||
return isIPAllowed(clientIP, k.IPWhitelist)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||
func isIPAllowed(clientIP, whitelist string) bool {
|
||||
clientIP = strings.TrimSpace(clientIP)
|
||||
// Strip port if present
|
||||
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
|
||||
clientIP = clientIP[:idx]
|
||||
}
|
||||
for _, entry := range strings.Split(whitelist, "\n") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
// CIDR match
|
||||
if ipInCIDR(clientIP, entry) {
|
||||
return true
|
||||
}
|
||||
} else if entry == clientIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ipInCIDR(ipStr, cidr string) bool {
|
||||
parts := strings.Split(cidr, "/")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
// Simple prefix match for IPv4
|
||||
ip := netParseIP(ipStr)
|
||||
cidrIP := netParseIP(parts[0])
|
||||
if ip == nil || cidrIP == nil {
|
||||
return false
|
||||
}
|
||||
bits, err := strconv.Atoi(parts[1])
|
||||
if err != nil || bits < 0 || bits > 32 {
|
||||
return false
|
||||
}
|
||||
mask := uint32(0xFFFFFFFF) << (32 - bits)
|
||||
ipVal := ip4ToUint32(ip)
|
||||
cidrVal := ip4ToUint32(cidrIP)
|
||||
return (ipVal & mask) == (cidrVal & mask)
|
||||
}
|
||||
|
||||
func netParseIP(s string) net.IP {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
|
||||
s = s[:idx]
|
||||
}
|
||||
return net.ParseIP(s)
|
||||
}
|
||||
|
||||
func ip4ToUint32(ip net.IP) uint32 {
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return 0
|
||||
}
|
||||
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
|
||||
}
|
||||
|
||||
// updateApiKeyLastUsed marks the key as recently used
|
||||
func updateApiKeyLastUsed(rawKey string) {
|
||||
hashed := hashKey(rawKey)
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
for i := range config.AppConfig.ApiKeys {
|
||||
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
|
||||
config.AppConfig.ApiKeys[i].LastUsed = now
|
||||
config.SaveConfig()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
|
||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check header
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
// Check query param
|
||||
apiKey = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if apiKey == "" {
|
||||
// Check Bearer token (some clients use this)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
apiKey = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
clientIP := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
clientIP = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a short-lived JWT so downstream admin middleware passes
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": config.AppConfig.AdminUser,
|
||||
"api_key": true,
|
||||
"exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
|
||||
// Set cookie for subsequent requests
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clicd_token",
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 300,
|
||||
})
|
||||
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func tokenFromRequest(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie("clicd_token")
|
||||
if err == nil {
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidToken(tokenString string) bool {
|
||||
_, ok := claimsFromToken(tokenString)
|
||||
return ok
|
||||
}
|
||||
|
||||
func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
if tokenString == "" {
|
||||
return nil, false
|
||||
}
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(config.AppConfig.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
|
||||
return claimsFromToken(tokenFromRequest(r))
|
||||
}
|
||||
|
||||
func isSubUserRequest(r *http.Request) bool {
|
||||
claims, ok := claimsFromRequest(r)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = claims["sub_user"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isAuthenticatedRequest(r *http.Request) bool {
|
||||
return isValidToken(tokenFromRequest(r))
|
||||
}
|
||||
|
||||
// HandleLogin processes login requests
|
||||
func HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = forwarded
|
||||
}
|
||||
ua := r.Header.Get("User-Agent")
|
||||
|
||||
if req.Username != config.AppConfig.AdminUser {
|
||||
RecordLoginLog(req.Username, ip, ua, false)
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil {
|
||||
RecordLoginLog(req.Username, ip, ua, false)
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
RecordLoginLog(req.Username, ip, ua, true)
|
||||
|
||||
// Generate JWT token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": req.Username,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: LoginResponse{
|
||||
Token: tokenString,
|
||||
Username: req.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleChangePassword processes password change requests
|
||||
func HandleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 8 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "New password must be at least 8 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Current password is incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
config.AppConfig.AdminPassHash = string(hash)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Password changed successfully"})
|
||||
}
|
||||
|
||||
// HandleCheckAuth checks if the user is authenticated
|
||||
func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Authenticated"})
|
||||
}
|
||||
|
||||
// AuthMiddleware extracts JWT from cookies or Authorization header
|
||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString := tokenFromRequest(r)
|
||||
if !isValidToken(tokenString) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
|
||||
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isSubUserRequest(r) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
var lxcManager = lxc.NewManager()
|
||||
|
||||
// HandleContainers handles container list and creation
|
||||
func HandleContainers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
listContainers(w, r)
|
||||
case http.MethodPost:
|
||||
createContainer(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
|
||||
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
c := containerByIdentifier(parts[0])
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
id := c.ID
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch {
|
||||
case action == "start" && r.Method == http.MethodPost:
|
||||
HandleSingleTaskAction(w, r, id, "start")
|
||||
case action == "stop" && r.Method == http.MethodPost:
|
||||
HandleSingleTaskAction(w, r, id, "stop")
|
||||
case action == "restart" && r.Method == http.MethodPost:
|
||||
HandleSingleTaskAction(w, r, id, "restart")
|
||||
case action == "reinstall" && r.Method == http.MethodPost:
|
||||
HandleSingleTaskAction(w, r, id, "reinstall")
|
||||
case action == "delete" && r.Method == http.MethodDelete:
|
||||
HandleSingleTaskAction(w, r, id, "delete")
|
||||
case action == "reset-password" && r.Method == http.MethodPost:
|
||||
resetSSHPassword(w, r, id)
|
||||
case action == "usage" && r.Method == http.MethodGet:
|
||||
getUsage(w, r, id)
|
||||
case action == "traffic" && r.Method == http.MethodGet:
|
||||
getTraffic(w, r, id)
|
||||
case action == "traffic-reset" && r.Method == http.MethodPost:
|
||||
resetTraffic(w, r, id)
|
||||
case action == "traffic-limit" && r.Method == http.MethodPut:
|
||||
updateTrafficLimit(w, r, id)
|
||||
case action == "resource-limit" && r.Method == http.MethodPut:
|
||||
updateResourceLimit(w, r, id)
|
||||
case action == "random-port" && r.Method == http.MethodGet:
|
||||
getRandomPort(w, r, id)
|
||||
case action == "expiry" && r.Method == http.MethodPut:
|
||||
updateExpiry(w, r, id)
|
||||
case action == "ipv6" && r.Method == http.MethodPost:
|
||||
assignIPv6(w, r, id)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
addPortMapping(w, r, id)
|
||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
||||
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
|
||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case r.Method == http.MethodGet:
|
||||
getContainer(w, r, id)
|
||||
default:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers = filterContainersForRequest(r, containers)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
|
||||
}
|
||||
|
||||
func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg lxc.ContainerConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||
return
|
||||
}
|
||||
if cfg.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
||||
return
|
||||
}
|
||||
if cfg.VCPU <= 0 {
|
||||
cfg.VCPU = 1
|
||||
}
|
||||
if cfg.RAMMB < 128 {
|
||||
cfg.RAMMB = 512
|
||||
}
|
||||
if cfg.DiskGB < 1 {
|
||||
cfg.DiskGB = 5
|
||||
}
|
||||
if cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
}
|
||||
if cfg.PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.ExpiresAt != "" {
|
||||
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
|
||||
return
|
||||
}
|
||||
if !time.Now().Before(expiresAt) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Expiration date must be in the future"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := lxcManager.CreateContainer(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Container created successfully"})
|
||||
}
|
||||
|
||||
func getContainer(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
|
||||
}
|
||||
|
||||
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||
usage, err := lxcManager.GetResourceUsage(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: usage})
|
||||
}
|
||||
|
||||
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
||||
info := lxcManager.GetTrafficInfo(id)
|
||||
if info == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
|
||||
func updateExpiry(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
c.ExpiresAt = req.ExpiresAt
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Expiry updated"})
|
||||
}
|
||||
|
||||
func resetTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
c.TrafficUsedRX = 0
|
||||
c.TrafficUsedTX = 0
|
||||
c.TrafficResetDate = time.Now().Format("2006-01")
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic reset"})
|
||||
}
|
||||
|
||||
func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req struct {
|
||||
Mode string `json:"traffic_mode"`
|
||||
MonthlyGB int `json:"monthly_traffic_gb"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
c.TrafficMode = req.Mode
|
||||
c.MonthlyTrafficGB = req.MonthlyGB
|
||||
c.TrafficInGB = req.TrafficInGB
|
||||
c.TrafficOutGB = req.TrafficOutGB
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic limit updated"})
|
||||
}
|
||||
|
||||
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req struct {
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
IOMBps int `json:"io_speed_mbps"`
|
||||
BWMbps int `json:"network_bw_mbps"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update config
|
||||
nextVCPU := c.VCPU
|
||||
nextRAMMB := c.RAMMB
|
||||
if req.VCPU > 0 {
|
||||
nextVCPU = req.VCPU
|
||||
}
|
||||
if req.RAMMB > 0 {
|
||||
nextRAMMB = req.RAMMB
|
||||
}
|
||||
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.VCPU = nextVCPU
|
||||
c.RAMMB = nextRAMMB
|
||||
c.IOSpeedMBps = req.IOMBps
|
||||
c.NetworkBWMbps = req.BWMbps
|
||||
config.SaveConfig()
|
||||
|
||||
// Re-apply resource limits to running container
|
||||
if c.Status == "running" {
|
||||
if err := lxcManager.ApplyContainerLimits(c); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"})
|
||||
}
|
||||
|
||||
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
// Find a random unused port between 10000-65535
|
||||
used := map[int]bool{}
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
// Also check all containers
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == id {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
// Try random ports
|
||||
for tries := 0; tries < 100; tries++ {
|
||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||
if !used[port] {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": 0}})
|
||||
}
|
||||
|
||||
// HandleTemplates returns available LXC templates
|
||||
func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
templates := lxc.GetTemplates()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
|
||||
}
|
||||
|
||||
// HandleDashboard returns dashboard stats
|
||||
func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
if c.Status == "running" {
|
||||
running++
|
||||
} else {
|
||||
stopped++
|
||||
}
|
||||
}
|
||||
stats := map[string]interface{}{
|
||||
"total_containers": len(containers),
|
||||
"running": running,
|
||||
"stopped": stopped,
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: stats})
|
||||
}
|
||||
|
||||
// HandleHostInfo returns host machine resource info
|
||||
func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
info := getHostInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
|
||||
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && lxc.IsExpired(*c) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
||||
return
|
||||
}
|
||||
newPassword, err := lxcManager.ResetSSHPassword(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "SSH password reset successfully",
|
||||
Data: map[string]string{"password": newPassword},
|
||||
})
|
||||
}
|
||||
|
||||
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var pm config.PortMapping
|
||||
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
mappings, err := lxcManager.AddPortMapping(id, pm)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||
}
|
||||
|
||||
func updatePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) {
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
|
||||
return
|
||||
}
|
||||
var pm config.PortMapping
|
||||
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
|
||||
return
|
||||
}
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "container port must be 1-65535"})
|
||||
return
|
||||
}
|
||||
existing := c.PortMappings[index]
|
||||
pm = config.PortMapping{
|
||||
ContainerPort: pm.ContainerPort,
|
||||
HostPort: existing.HostPort,
|
||||
Protocol: existing.Protocol,
|
||||
Description: existing.Description,
|
||||
}
|
||||
}
|
||||
mappings, err := lxcManager.UpdatePortMapping(id, index, pm)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||
}
|
||||
|
||||
func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) {
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
|
||||
return
|
||||
}
|
||||
mappings, err := lxcManager.DeletePortMapping(id, index)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
type HostInfo struct {
|
||||
CPU CpuInfo `json:"cpu"`
|
||||
RAM MemoryInfo `json:"ram"`
|
||||
Disk DiskInfo `json:"disk"`
|
||||
Network NetworkInfo `json:"network"`
|
||||
DiskIO DiskIOInfo `json:"disk_io"`
|
||||
Load LoadInfo `json:"load"`
|
||||
}
|
||||
|
||||
type LoadInfo struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type CpuInfo struct {
|
||||
Cores int `json:"cores"`
|
||||
Usage float64 `json:"usage_pct"`
|
||||
}
|
||||
|
||||
type MemoryInfo struct {
|
||||
TotalMB int64 `json:"total_mb"`
|
||||
UsedMB int64 `json:"used_mb"`
|
||||
FreeMB int64 `json:"free_mb"`
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
TotalGB float64 `json:"total_gb"`
|
||||
UsedGB float64 `json:"used_gb"`
|
||||
FreeGB float64 `json:"free_gb"`
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
RXBytes uint64 `json:"rx_bytes"`
|
||||
TXBytes uint64 `json:"tx_bytes"`
|
||||
RXBps float64 `json:"rx_bps"`
|
||||
TXBps float64 `json:"tx_bps"`
|
||||
PublicIPv4 string `json:"public_ipv4"`
|
||||
PublicIPv4Interface string `json:"public_ipv4_interface"`
|
||||
PublicIPv6 string `json:"public_ipv6"`
|
||||
PublicIPv6Interface string `json:"public_ipv6_interface"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
type DiskIOInfo struct {
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
WriteBytes uint64 `json:"write_bytes"`
|
||||
ReadBps float64 `json:"read_bps"`
|
||||
WriteBps float64 `json:"write_bps"`
|
||||
}
|
||||
|
||||
var hostCPUMu sync.Mutex
|
||||
var lastHostCPU cpuTimes
|
||||
var hostIOMu sync.Mutex
|
||||
var lastHostIO hostIOSample
|
||||
|
||||
type cpuTimes struct {
|
||||
Total uint64
|
||||
Idle uint64
|
||||
}
|
||||
|
||||
type hostIOSample struct {
|
||||
RXBytes uint64
|
||||
TXBytes uint64
|
||||
ReadBytes uint64
|
||||
WriteBytes uint64
|
||||
At int64
|
||||
}
|
||||
|
||||
func getHostInfo() HostInfo {
|
||||
info := HostInfo{
|
||||
CPU: CpuInfo{Cores: runtime.NumCPU()},
|
||||
}
|
||||
|
||||
info.RAM = getMemoryInfo()
|
||||
info.Disk = getDiskInfo()
|
||||
info.CPU.Usage = getCPUUsage()
|
||||
info.Network, info.DiskIO = getHostRates()
|
||||
info.Load = getLoadInfo()
|
||||
return info
|
||||
}
|
||||
|
||||
func getMemoryInfo() MemoryInfo {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return MemoryInfo{TotalMB: 0, UsedMB: 0, FreeMB: 0}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var total, available, free int64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
val, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
total = val / 1024
|
||||
case "MemAvailable:":
|
||||
available = val / 1024
|
||||
case "MemFree:":
|
||||
free = val / 1024
|
||||
}
|
||||
}
|
||||
|
||||
used := total - available
|
||||
if available == 0 {
|
||||
used = total - free
|
||||
}
|
||||
|
||||
return MemoryInfo{
|
||||
TotalMB: total,
|
||||
UsedMB: used,
|
||||
FreeMB: available,
|
||||
}
|
||||
}
|
||||
|
||||
func getDiskInfo() DiskInfo {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err != nil {
|
||||
// Try command-based fallback
|
||||
cmd := exec.Command("df", "-BG", "/")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
if len(lines) >= 2 {
|
||||
fields := strings.Fields(lines[1])
|
||||
if len(fields) >= 4 {
|
||||
total, _ := parseSizeGBf(fields[1])
|
||||
used, _ := parseSizeGBf(fields[2])
|
||||
free, _ := parseSizeGBf(fields[3])
|
||||
return DiskInfo{TotalGB: total, UsedGB: used, FreeGB: free}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DiskInfo{}
|
||||
}
|
||||
|
||||
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
|
||||
used := total - free
|
||||
|
||||
return DiskInfo{
|
||||
TotalGB: total,
|
||||
UsedGB: used,
|
||||
FreeGB: free,
|
||||
}
|
||||
}
|
||||
|
||||
func getCPUUsage() float64 {
|
||||
current, err := readCPUTimes()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
hostCPUMu.Lock()
|
||||
defer hostCPUMu.Unlock()
|
||||
|
||||
if lastHostCPU.Total == 0 {
|
||||
lastHostCPU = current
|
||||
return 0
|
||||
}
|
||||
|
||||
totalDelta := current.Total - lastHostCPU.Total
|
||||
idleDelta := current.Idle - lastHostCPU.Idle
|
||||
lastHostCPU = current
|
||||
|
||||
if totalDelta == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
usage := (1 - float64(idleDelta)/float64(totalDelta)) * 100
|
||||
if usage < 0 {
|
||||
return 0
|
||||
}
|
||||
if usage > 100 {
|
||||
return 100
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func readCPUTimes() (cpuTimes, error) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
if !scanner.Scan() {
|
||||
return cpuTimes{}, scanner.Err()
|
||||
}
|
||||
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 8 || fields[0] != "cpu" {
|
||||
return cpuTimes{}, nil
|
||||
}
|
||||
|
||||
var values []uint64
|
||||
for _, field := range fields[1:] {
|
||||
value, _ := strconv.ParseUint(field, 10, 64)
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
var total uint64
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
|
||||
idle := values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
|
||||
return cpuTimes{Total: total, Idle: idle}, nil
|
||||
}
|
||||
|
||||
func parseSizeGB(s string) (int64, error) {
|
||||
s = strings.TrimSuffix(s, "G")
|
||||
s = strings.TrimSpace(s)
|
||||
val, err := strconv.ParseInt(s, 10, 64)
|
||||
return val, err
|
||||
}
|
||||
|
||||
func parseSizeGBf(s string) (float64, error) {
|
||||
s = strings.TrimSuffix(s, "G")
|
||||
s = strings.TrimSpace(s)
|
||||
val, err := strconv.ParseFloat(s, 64)
|
||||
return val, err
|
||||
}
|
||||
|
||||
func getHostRates() (NetworkInfo, DiskIOInfo) {
|
||||
rx, tx := readHostNetworkBytes()
|
||||
readBytes, writeBytes := readHostDiskBytes()
|
||||
now := unixNano()
|
||||
|
||||
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
||||
publicIPv4 := lxc.DetectPublicIPv4()
|
||||
network.PublicIPv4 = publicIPv4.Address
|
||||
network.PublicIPv4Interface = publicIPv4.Interface
|
||||
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
|
||||
if len(network.IPv6Prefixes) > 0 {
|
||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||
}
|
||||
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
||||
|
||||
hostIOMu.Lock()
|
||||
defer hostIOMu.Unlock()
|
||||
|
||||
if lastHostIO.At == 0 {
|
||||
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
|
||||
return network, diskIO
|
||||
}
|
||||
|
||||
elapsed := float64(now-lastHostIO.At) / 1_000_000_000
|
||||
if elapsed > 0 {
|
||||
if rx >= lastHostIO.RXBytes {
|
||||
network.RXBps = float64(rx-lastHostIO.RXBytes) / elapsed
|
||||
}
|
||||
if tx >= lastHostIO.TXBytes {
|
||||
network.TXBps = float64(tx-lastHostIO.TXBytes) / elapsed
|
||||
}
|
||||
if readBytes >= lastHostIO.ReadBytes {
|
||||
diskIO.ReadBps = float64(readBytes-lastHostIO.ReadBytes) / elapsed
|
||||
}
|
||||
if writeBytes >= lastHostIO.WriteBytes {
|
||||
diskIO.WriteBps = float64(writeBytes-lastHostIO.WriteBytes) / elapsed
|
||||
}
|
||||
}
|
||||
|
||||
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
|
||||
return network, diskIO
|
||||
}
|
||||
|
||||
func readHostNetworkBytes() (uint64, uint64) {
|
||||
entries, err := os.ReadDir("/sys/class/net")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var rx, tx uint64
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == "lo" {
|
||||
continue
|
||||
}
|
||||
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
||||
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func readHostDiskBytes() (uint64, uint64) {
|
||||
f, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var readSectors, writeSectors uint64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
device := fields[2]
|
||||
if strings.HasPrefix(device, "loop") ||
|
||||
strings.HasPrefix(device, "ram") ||
|
||||
strings.HasPrefix(device, "fd") ||
|
||||
strings.HasPrefix(device, "sr") {
|
||||
continue
|
||||
}
|
||||
read, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
write, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
readSectors += read
|
||||
writeSectors += write
|
||||
}
|
||||
return readSectors * 512, writeSectors * 512
|
||||
}
|
||||
|
||||
func readUintFile(path string) uint64 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
value, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func unixNano() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
func getLoadInfo() LoadInfo {
|
||||
f, err := os.Open("/proc/loadavg")
|
||||
if err != nil {
|
||||
return LoadInfo{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
if !scanner.Scan() {
|
||||
return LoadInfo{}
|
||||
}
|
||||
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 3 {
|
||||
return LoadInfo{}
|
||||
}
|
||||
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return LoadInfo{Load1: load1, Load5: load5, Load15: load15}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
// ImageInfo represents a template image with its download/enable status.
|
||||
type ImageInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Downloading bool `json:"downloading"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
var imageDownloads = map[string]bool{}
|
||||
|
||||
// isImageDownloaded checks if the LXC download cache exists for a template.
|
||||
func isImageDownloaded(distro, release, arch string) bool {
|
||||
downloaded, _ := imageDownloadedInfo(distro, release, arch)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
|
||||
func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
|
||||
cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch)
|
||||
info, err := os.Stat(cachePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false, 0
|
||||
}
|
||||
// Check directly for rootfs.tar.xz (some LXC versions store it here)
|
||||
if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
// Check one level deeper (LXC uses variant subdirectories like "default")
|
||||
entries, err := os.ReadDir(cachePath)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
subPath := filepath.Join(cachePath, entry.Name())
|
||||
if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// getEnabledImageSet returns the set of enabled image IDs.
|
||||
// If none have been explicitly set, all templates are enabled by default.
|
||||
func getEnabledImageSet() map[string]bool {
|
||||
set := make(map[string]bool)
|
||||
if len(config.AppConfig.EnabledImages) == 0 {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
set[t.ID] = true
|
||||
}
|
||||
} else {
|
||||
for _, id := range config.AppConfig.EnabledImages {
|
||||
set[id] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// HandleImages returns the list of templates with download/enable status.
|
||||
func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
images := make([]ImageInfo, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||
}
|
||||
|
||||
// HandleImageDownload downloads a template image from the LXC image server.
|
||||
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloaded? Just enable if needed.
|
||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloading?
|
||||
imageDownloadsMu.Lock()
|
||||
if imageDownloads[req.TemplateID] {
|
||||
imageDownloadsMu.Unlock()
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
imageDownloads[req.TemplateID] = true
|
||||
imageDownloadsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, req.TemplateID)
|
||||
imageDownloadsMu.Unlock()
|
||||
}()
|
||||
|
||||
// Auto-enable on download
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
|
||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the temp container unconditionally.
|
||||
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
|
||||
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
|
||||
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
}
|
||||
|
||||
// HandleImageDelete deletes a cached template image from disk.
|
||||
func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove cache directory
|
||||
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
if err := os.RemoveAll(cachePath); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Failed to delete image cache: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from enabled list
|
||||
removeImageEnabled(tmpl.ID)
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||
}
|
||||
|
||||
// HandleImageToggle enables or disables a template image.
|
||||
func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
ensureImageEnabled(req.TemplateID)
|
||||
} else {
|
||||
removeImageEnabled(req.TemplateID)
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "OK"})
|
||||
}
|
||||
|
||||
// HandleEnabledImages returns only the enabled AND downloaded templates.
|
||||
// Used by container create / reinstall to filter available templates.
|
||||
func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
result := make([]lxc.Template, 0)
|
||||
for _, t := range templates {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
}
|
||||
|
||||
func ensureImageEnabled(id string) {
|
||||
// If the enabled list is empty, all templates are currently enabled by default.
|
||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||
if len(config.AppConfig.EnabledImages) == 0 {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
config.SaveConfig()
|
||||
return // Already contains all IDs including this one
|
||||
}
|
||||
found := false
|
||||
for _, eid := range config.AppConfig.EnabledImages {
|
||||
if eid == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, id)
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func removeImageEnabled(id string) {
|
||||
// If the enabled list is empty, populate it first with all templates,
|
||||
// then remove the one being disabled.
|
||||
if len(config.AppConfig.EnabledImages) == 0 {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
if t.ID != id {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
}
|
||||
config.SaveConfig()
|
||||
return
|
||||
}
|
||||
filtered := make([]string, 0, len(config.AppConfig.EnabledImages))
|
||||
for _, eid := range config.AppConfig.EnabledImages {
|
||||
if eid != id {
|
||||
filtered = append(filtered, eid)
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(config.AppConfig.EnabledImages) {
|
||||
config.AppConfig.EnabledImages = filtered
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
status := lxcManager.DetectIPv6Status()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
}
|
||||
|
||||
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c, err := lxcManager.AssignIPv6(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// HandleOversell handles GET/POST for oversell config
|
||||
func HandleOversell(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getOversell(w, r)
|
||||
case http.MethodPost:
|
||||
updateOversell(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func getOversell(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell})
|
||||
}
|
||||
|
||||
func updateOversell(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg config.OversellConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply KSM
|
||||
if cfg.KSMEnabled {
|
||||
exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
|
||||
exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run()
|
||||
} else {
|
||||
exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
|
||||
}
|
||||
|
||||
// Apply swappiness
|
||||
if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 {
|
||||
exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run()
|
||||
}
|
||||
|
||||
// Oversell multipliers are capacity-planning values. They must not increase
|
||||
// an individual container's CPU or RAM limits.
|
||||
reapplyContainerLimits()
|
||||
|
||||
config.AppConfig.Oversell = cfg
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg})
|
||||
}
|
||||
|
||||
// reapplyContainerLimits restores cgroup limits for all running containers from
|
||||
// their assigned container resources.
|
||||
func reapplyContainerLimits() {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
if err := lxcManager.ApplyContainerLimits(&c); err != nil {
|
||||
fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOversellStatus returns current oversell resource usage
|
||||
func HandleOversellStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
"ksm_active": isKSMEnabled(),
|
||||
"ksm_pages": getKSMPages(),
|
||||
"ksm_supported": isKSMSupported(),
|
||||
"swappiness": getSwappiness(),
|
||||
"reclaim_supported": isMemoryReclaimSupported(),
|
||||
"allocated_cpu": getAllocatedCPU(),
|
||||
"allocated_ram_mb": getAllocatedRAM(),
|
||||
"allocated_disk_gb": getAllocatedDisk(),
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
}
|
||||
|
||||
// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers.
|
||||
func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
result := reclaimContainerMemory()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result})
|
||||
}
|
||||
|
||||
func reclaimContainerMemory() map[string]interface{} {
|
||||
attempted := 0
|
||||
reclaimed := 0
|
||||
unsupported := 0
|
||||
errors := make([]string, 0)
|
||||
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
reclaimPath := findMemoryReclaimPath(c.LxcName())
|
||||
if reclaimPath == "" {
|
||||
unsupported++
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err))
|
||||
continue
|
||||
}
|
||||
reclaimed++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"attempted": attempted,
|
||||
"reclaimed": reclaimed,
|
||||
"unsupported": unsupported,
|
||||
"errors": errors,
|
||||
}
|
||||
}
|
||||
|
||||
func isKSMEnabled() bool {
|
||||
data, err := os.ReadFile("/sys/kernel/mm/ksm/run")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "1"
|
||||
}
|
||||
|
||||
func isKSMSupported() bool {
|
||||
if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getKSMPages() int64 {
|
||||
data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
return val
|
||||
}
|
||||
|
||||
func getSwappiness() int {
|
||||
data, err := os.ReadFile("/proc/sys/vm/swappiness")
|
||||
if err != nil {
|
||||
return 60
|
||||
}
|
||||
val, _ := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
return val
|
||||
}
|
||||
|
||||
func isMemoryReclaimSupported() bool {
|
||||
if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil {
|
||||
return true
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
if findMemoryReclaimPath(c.LxcName()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findMemoryReclaimPath(lxcName string) string {
|
||||
candidates := []string{
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getAllocatedCPU() float64 {
|
||||
total := 0.0
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += c.VCPU
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func getAllocatedRAM() int64 {
|
||||
total := int64(0)
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += int64(c.RAMMB)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func getAllocatedDisk() int64 {
|
||||
total := int64(0)
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += int64(c.DiskGB)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const minVCPU = 0.25
|
||||
|
||||
func validateContainerResourceRequest(vcpu float64, ramMB int, diskGB int) error {
|
||||
host := getHostInfo()
|
||||
|
||||
if vcpu <= 0 {
|
||||
return fmt.Errorf("vCPU must be greater than 0")
|
||||
}
|
||||
if vcpu < minVCPU {
|
||||
return fmt.Errorf("vCPU must be at least %.2f", minVCPU)
|
||||
}
|
||||
if math.Abs(vcpu*4-math.Round(vcpu*4)) > 0.000001 {
|
||||
return fmt.Errorf("vCPU must use 0.25 increments")
|
||||
}
|
||||
if host.CPU.Cores > 0 && vcpu > float64(host.CPU.Cores) {
|
||||
return fmt.Errorf("vCPU cannot exceed host CPU cores (%d)", host.CPU.Cores)
|
||||
}
|
||||
if host.RAM.TotalMB > 0 && ramMB > int(host.RAM.TotalMB) {
|
||||
return fmt.Errorf("memory cannot exceed host memory (%d MB)", host.RAM.TotalMB)
|
||||
}
|
||||
if host.Disk.TotalGB > 0 {
|
||||
maxDiskGB := int(math.Floor(host.Disk.TotalGB))
|
||||
if maxDiskGB < 1 {
|
||||
maxDiskGB = 1
|
||||
}
|
||||
if diskGB > maxDiskGB {
|
||||
return fmt.Errorf("disk cannot exceed host disk (%d GB)", maxDiskGB)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// SecurityAlert represents a detected abuse event.
|
||||
type SecurityAlert struct {
|
||||
ID string `json:"id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Type string `json:"type"` // port_scan, horizontal_scan, brute_force, ddos, spam, malware, mining, proxy, reflection
|
||||
Severity string `json:"severity"` // low, medium, high, critical
|
||||
SourceIP string `json:"source_ip"`
|
||||
TargetIP string `json:"target_ip"`
|
||||
TargetPort int `json:"target_port"`
|
||||
Detail string `json:"detail"`
|
||||
LogLine string `json:"log_line"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// SecurityScanner monitors container network activity for abuse patterns.
|
||||
type SecurityScanner struct {
|
||||
mu sync.Mutex
|
||||
alerts []SecurityAlert
|
||||
nextID int
|
||||
scanCount map[string]int
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
type connEntry struct {
|
||||
dstIP string
|
||||
dstPort int
|
||||
proto string
|
||||
state string
|
||||
line string
|
||||
}
|
||||
|
||||
type trafficStats struct {
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
synSentByDst map[string]int
|
||||
}
|
||||
|
||||
var scanner *SecurityScanner
|
||||
var scannerStarted bool
|
||||
|
||||
var bruteForcePorts = map[int]string{
|
||||
21: "FTP",
|
||||
22: "SSH",
|
||||
23: "Telnet",
|
||||
135: "MS-RPC",
|
||||
139: "NetBIOS",
|
||||
445: "SMB",
|
||||
3306: "MySQL",
|
||||
3389: "RDP",
|
||||
5432: "PostgreSQL",
|
||||
5900: "VNC",
|
||||
5901: "VNC",
|
||||
5985: "WinRM",
|
||||
5986: "WinRM",
|
||||
6379: "Redis",
|
||||
9200: "Elasticsearch",
|
||||
27017: "MongoDB",
|
||||
}
|
||||
|
||||
var smtpPorts = map[int]string{
|
||||
25: "SMTP",
|
||||
465: "SMTPS",
|
||||
587: "SMTP submission",
|
||||
2525: "SMTP alternate",
|
||||
}
|
||||
|
||||
var reflectionPorts = map[int]string{
|
||||
17: "QOTD",
|
||||
19: "Chargen",
|
||||
53: "DNS",
|
||||
69: "TFTP",
|
||||
111: "Portmap",
|
||||
123: "NTP",
|
||||
137: "NetBIOS",
|
||||
161: "SNMP",
|
||||
389: "CLDAP",
|
||||
500: "IKE",
|
||||
1900: "SSDP",
|
||||
3702: "WS-Discovery",
|
||||
4500: "IPsec NAT-T",
|
||||
5353: "mDNS",
|
||||
11211: "Memcached",
|
||||
}
|
||||
|
||||
var miningPorts = map[int]string{
|
||||
3333: "Stratum",
|
||||
3334: "Stratum",
|
||||
3335: "Stratum",
|
||||
4444: "Stratum",
|
||||
5555: "Stratum",
|
||||
7777: "Stratum",
|
||||
8888: "Stratum",
|
||||
9999: "Stratum",
|
||||
14433: "Stratum",
|
||||
14444: "Stratum",
|
||||
}
|
||||
|
||||
var proxyPorts = map[int]string{
|
||||
1080: "SOCKS",
|
||||
3128: "HTTP proxy",
|
||||
8118: "Privoxy",
|
||||
9001: "Tor OR",
|
||||
9030: "Tor directory",
|
||||
9050: "Tor SOCKS",
|
||||
1194: "OpenVPN",
|
||||
51820: "WireGuard",
|
||||
}
|
||||
|
||||
var malwarePorts = map[int]string{
|
||||
1337: "common backdoor",
|
||||
31337: "Back Orifice",
|
||||
4444: "Metasploit/reverse shell",
|
||||
5555: "Android debug/reverse shell",
|
||||
6666: "IRC botnet",
|
||||
6667: "IRC botnet",
|
||||
6697: "IRC over TLS",
|
||||
9050: "Tor/C2 proxy",
|
||||
}
|
||||
|
||||
func InitScanner() {
|
||||
if scannerStarted {
|
||||
return
|
||||
}
|
||||
scannerStarted = true
|
||||
scanner = newSecurityScanner()
|
||||
go scanner.monitorLoop()
|
||||
}
|
||||
|
||||
func newSecurityScanner() *SecurityScanner {
|
||||
return &SecurityScanner{
|
||||
alerts: make([]SecurityAlert, 0),
|
||||
scanCount: make(map[string]int),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func ensureScanner() *SecurityScanner {
|
||||
if scanner == nil {
|
||||
scanner = newSecurityScanner()
|
||||
}
|
||||
return scanner
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) monitorLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ss.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
ss.checkAllContainers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) checkAllContainers() {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" || c.IP == "" {
|
||||
continue
|
||||
}
|
||||
ss.checkContainer(c.Name, c.IP)
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
lines := readConntrackLines(ip)
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
stats := newTrafficStats()
|
||||
for _, line := range lines {
|
||||
conn, ok := parseConntrackLine(line, ip)
|
||||
if !ok || conn.dstIP == "" || conn.dstIP == ip {
|
||||
continue
|
||||
}
|
||||
stats.add(conn)
|
||||
}
|
||||
|
||||
if stats.total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ss.detectPortScans(name, ip, stats)
|
||||
ss.detectBruteForce(name, ip, stats)
|
||||
ss.detectSpam(name, ip, stats)
|
||||
ss.detectMassAbuse(name, ip, stats)
|
||||
ss.detectReflectionAbuse(name, ip, stats)
|
||||
ss.detectMining(name, ip, stats)
|
||||
ss.detectProxyAndTor(name, ip, stats)
|
||||
ss.detectMalware(name, ip, stats)
|
||||
}
|
||||
|
||||
func newTrafficStats() *trafficStats {
|
||||
return &trafficStats{
|
||||
destCounts: make(map[string]int),
|
||||
destPorts: make(map[string]map[int]int),
|
||||
portDestCounts: make(map[int]map[string]int),
|
||||
portTotalCounts: make(map[int]int),
|
||||
udpDestCounts: make(map[int]map[string]int),
|
||||
udpTotalCounts: make(map[int]int),
|
||||
synSentByDst: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *trafficStats) add(conn connEntry) {
|
||||
ts.total++
|
||||
ts.destCounts[conn.dstIP]++
|
||||
|
||||
if conn.dstPort > 0 {
|
||||
if ts.destPorts[conn.dstIP] == nil {
|
||||
ts.destPorts[conn.dstIP] = make(map[int]int)
|
||||
}
|
||||
ts.destPorts[conn.dstIP][conn.dstPort]++
|
||||
|
||||
if ts.portDestCounts[conn.dstPort] == nil {
|
||||
ts.portDestCounts[conn.dstPort] = make(map[string]int)
|
||||
}
|
||||
ts.portDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.portTotalCounts[conn.dstPort]++
|
||||
|
||||
if conn.proto == "udp" {
|
||||
if ts.udpDestCounts[conn.dstPort] == nil {
|
||||
ts.udpDestCounts[conn.dstPort] = make(map[string]int)
|
||||
}
|
||||
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.udpTotalCounts[conn.dstPort]++
|
||||
}
|
||||
}
|
||||
|
||||
if conn.state == "SYN_SENT" {
|
||||
ts.totalSynSent++
|
||||
ts.synSentByDst[conn.dstIP]++
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
|
||||
for dstIP, portCounts := range stats.destPorts {
|
||||
uniquePorts := len(portCounts)
|
||||
switch {
|
||||
case uniquePorts >= 20:
|
||||
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
|
||||
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
case uniquePorts >= 8:
|
||||
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
|
||||
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
for port, targets := range stats.portDestCounts {
|
||||
uniqueTargets := len(targets)
|
||||
if service, ok := bruteForcePorts[port]; ok {
|
||||
if uniqueTargets >= 30 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
|
||||
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 10 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if uniqueTargets >= 40 {
|
||||
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
|
||||
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 15 {
|
||||
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
|
||||
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats) {
|
||||
for dstIP, portCounts := range stats.destPorts {
|
||||
for port, count := range portCounts {
|
||||
service, sensitive := bruteForcePorts[port]
|
||||
if !sensitive {
|
||||
continue
|
||||
}
|
||||
|
||||
if count >= 20 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
"")
|
||||
} else if count >= 10 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
"")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
|
||||
total, targets := countPorts(stats.portTotalCounts, stats.portDestCounts, smtpPorts)
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if targets >= 10 || total >= 30 {
|
||||
ss.addAlert(name, "spam", "critical", ip, "*", 25,
|
||||
fmt.Sprintf("疑似垃圾邮件: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets),
|
||||
"")
|
||||
} else if targets >= 2 || total >= 5 {
|
||||
ss.addAlert(name, "spam", "high", ip, "*", 25,
|
||||
fmt.Sprintf("可疑邮件发送: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
|
||||
targets := len(stats.destCounts)
|
||||
switch {
|
||||
case targets >= 100:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
"")
|
||||
case targets >= 35:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
"")
|
||||
}
|
||||
|
||||
switch {
|
||||
case stats.total >= 500:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
"")
|
||||
case stats.total >= 200:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
"")
|
||||
}
|
||||
|
||||
if stats.totalSynSent >= 100 {
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
|
||||
"")
|
||||
}
|
||||
|
||||
for dstIP, count := range stats.synSentByDst {
|
||||
if count >= 50 {
|
||||
ss.addAlert(name, "ddos", "critical", ip, dstIP, 0,
|
||||
fmt.Sprintf("SYN 洪水: 单一目标半开连接 %d 条", count),
|
||||
"")
|
||||
} else if count >= 20 {
|
||||
ss.addAlert(name, "ddos", "high", ip, dstIP, 0,
|
||||
fmt.Sprintf("可疑 SYN 洪水: 单一目标半开连接 %d 条", count),
|
||||
"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *trafficStats) {
|
||||
for port, service := range reflectionPorts {
|
||||
total := stats.udpTotalCounts[port]
|
||||
targets := len(stats.udpDestCounts[port])
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if targets >= 30 || total >= 100 {
|
||||
ss.addAlert(name, "reflection", "critical", ip, "*", port,
|
||||
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
} else if targets >= 10 || total >= 30 {
|
||||
ss.addAlert(name, "reflection", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectMining(name, ip string, stats *trafficStats) {
|
||||
for port, service := range miningPorts {
|
||||
total := stats.portTotalCounts[port]
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
severity := "high"
|
||||
if total >= 5 {
|
||||
severity = "critical"
|
||||
}
|
||||
ss.addAlert(name, "mining", severity, ip, "*", port,
|
||||
fmt.Sprintf("疑似挖矿连接: %s/%d 当前连接 %d 条", service, port, total),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectProxyAndTor(name, ip string, stats *trafficStats) {
|
||||
for port, service := range proxyPorts {
|
||||
total := stats.portTotalCounts[port]
|
||||
targets := len(stats.portDestCounts[port])
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if port == 1194 || port == 51820 {
|
||||
if targets < 3 && total < 10 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
severity := "high"
|
||||
if targets >= 10 || total >= 30 {
|
||||
severity = "critical"
|
||||
}
|
||||
ss.addAlert(name, "proxy", severity, ip, "*", port,
|
||||
fmt.Sprintf("疑似代理/VPN/Tor 滥用: %s(%d) 当前连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
}
|
||||
|
||||
total8080 := stats.portTotalCounts[8080]
|
||||
targets8080 := len(stats.portDestCounts[8080])
|
||||
if targets8080 >= 5 || total8080 >= 20 {
|
||||
ss.addAlert(name, "proxy", "high", ip, "*", 8080,
|
||||
fmt.Sprintf("疑似开放代理流量: HTTP 代理常用端口 8080 当前连接 %d 条,覆盖 %d 个目标", total8080, targets8080),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectMalware(name, ip string, stats *trafficStats) {
|
||||
for port, label := range malwarePorts {
|
||||
total := stats.portTotalCounts[port]
|
||||
if total == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ss.addAlert(name, "malware", "critical", ip, "*", port,
|
||||
fmt.Sprintf("疑似恶意软件/C2 连接: %s 端口 %d 当前连接 %d 条", label, port, total),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
func readConntrackLines(ip string) []string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "conntrack", "-L", "-s", ip)
|
||||
output, err := cmd.Output()
|
||||
if err == nil && len(output) > 0 {
|
||||
return splitNonEmptyLines(string(output))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, path := range []string{"/proc/net/nf_conntrack", "/proc/net/ip_conntrack"} {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "src="+ip+" ") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitNonEmptyLines(raw string) []string {
|
||||
lines := make([]string, 0)
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func parseConntrackLine(line, containerIP string) (connEntry, bool) {
|
||||
srcIP := extractField(line, "src=")
|
||||
if srcIP != containerIP {
|
||||
return connEntry{}, false
|
||||
}
|
||||
|
||||
dstIP := extractField(line, "dst=")
|
||||
dstPort, _ := strconv.Atoi(extractField(line, "dport="))
|
||||
|
||||
return connEntry{
|
||||
dstIP: dstIP,
|
||||
dstPort: dstPort,
|
||||
proto: extractProtocol(line),
|
||||
state: extractConnState(line),
|
||||
line: line,
|
||||
}, true
|
||||
}
|
||||
|
||||
func extractProtocol(line string) string {
|
||||
for _, field := range strings.Fields(line) {
|
||||
switch field {
|
||||
case "tcp", "udp", "icmp", "icmpv6", "sctp":
|
||||
return field
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractConnState(line string) string {
|
||||
for _, field := range strings.Fields(line) {
|
||||
switch field {
|
||||
case "SYN_SENT", "SYN_RECV", "ESTABLISHED", "TIME_WAIT", "CLOSE", "CLOSE_WAIT", "FIN_WAIT", "LAST_ACK", "UNREPLIED":
|
||||
return field
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, ports map[int]string) (int, int) {
|
||||
total := 0
|
||||
targets := make(map[string]struct{})
|
||||
for port := range ports {
|
||||
total += totalCounts[port]
|
||||
for dstIP := range destCounts[port] {
|
||||
targets[dstIP] = struct{}{}
|
||||
}
|
||||
}
|
||||
return total, len(targets)
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-5 * time.Minute)
|
||||
|
||||
for i := range ss.alerts {
|
||||
a := &ss.alerts[i]
|
||||
if a.ContainerName != name || a.Type != alertType || a.TargetIP != dstIP || a.TargetPort != port {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse("2006-01-02 15:04:05", a.Timestamp)
|
||||
if err != nil || t.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
a.Count++
|
||||
a.Detail = detail
|
||||
a.LogLine = logLine
|
||||
a.Timestamp = now.Format("2006-01-02 15:04:05")
|
||||
if severityRank(severity) > severityRank(a.Severity) {
|
||||
a.Severity = severity
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ss.nextID++
|
||||
alert := SecurityAlert{
|
||||
ID: fmt.Sprintf("alert-%d", ss.nextID),
|
||||
ContainerName: name,
|
||||
Type: alertType,
|
||||
Severity: severity,
|
||||
SourceIP: srcIP,
|
||||
TargetIP: dstIP,
|
||||
TargetPort: port,
|
||||
Detail: detail,
|
||||
LogLine: logLine,
|
||||
Timestamp: now.Format("2006-01-02 15:04:05"),
|
||||
Count: 1,
|
||||
}
|
||||
|
||||
ss.alerts = append(ss.alerts, alert)
|
||||
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
|
||||
|
||||
if len(ss.alerts) > 200 {
|
||||
ss.alerts = ss.alerts[len(ss.alerts)-200:]
|
||||
}
|
||||
}
|
||||
|
||||
func severityRank(severity string) int {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return 4
|
||||
case "high":
|
||||
return 3
|
||||
case "medium":
|
||||
return 2
|
||||
case "low":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSecurityAlerts returns all security alerts.
|
||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
reversed := make([]SecurityAlert, len(ss.alerts))
|
||||
for i, a := range ss.alerts {
|
||||
reversed[len(ss.alerts)-1-i] = a
|
||||
}
|
||||
ss.mu.Unlock()
|
||||
|
||||
if reversed == nil {
|
||||
reversed = []SecurityAlert{}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
|
||||
}
|
||||
|
||||
// HandleSecurityCheck triggers immediate security check for a container.
|
||||
func HandleSecurityCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
c := config.FindContainerByName(req.ContainerName)
|
||||
if c == nil || c.IP == "" {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
|
||||
return
|
||||
}
|
||||
|
||||
ensureScanner().checkContainer(c.Name, c.IP)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
|
||||
}
|
||||
|
||||
// HandleSecurityLogs returns connection logs for a container.
|
||||
func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
containerName := r.URL.Query().Get("container")
|
||||
if containerName == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
|
||||
return
|
||||
}
|
||||
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.IP == "" {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
|
||||
}
|
||||
|
||||
func getConnectionLogs(ip string) []map[string]interface{} {
|
||||
logs := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, line := range readConntrackLines(ip) {
|
||||
srcIP := extractField(line, "src=")
|
||||
dstIP := extractField(line, "dst=")
|
||||
srcPort := extractField(line, "sport=")
|
||||
dstPort := extractField(line, "dport=")
|
||||
|
||||
sPort, _ := strconv.Atoi(srcPort)
|
||||
dPort, _ := strconv.Atoi(dstPort)
|
||||
|
||||
logs = append(logs, map[string]interface{}{
|
||||
"src_ip": srcIP,
|
||||
"dst_ip": dstIP,
|
||||
"src_port": sPort,
|
||||
"dst_port": dPort,
|
||||
"protocol": extractProtocol(line),
|
||||
"state": extractConnState(line),
|
||||
})
|
||||
|
||||
if len(logs) >= 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
func extractField(line, prefix string) string {
|
||||
idx := strings.Index(line, prefix)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
start := idx + len(prefix)
|
||||
end := start
|
||||
for end < len(line) && line[end] != ' ' && line[end] != '\t' {
|
||||
end++
|
||||
}
|
||||
return line[start:end]
|
||||
}
|
||||
|
||||
// HandleContainerSecuritySummary returns security status for dashboard.
|
||||
func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
critical := 0
|
||||
high := 0
|
||||
medium := 0
|
||||
low := 0
|
||||
for _, a := range ss.alerts {
|
||||
switch a.Severity {
|
||||
case "critical":
|
||||
critical++
|
||||
case "high":
|
||||
high++
|
||||
case "medium":
|
||||
medium++
|
||||
case "low":
|
||||
low++
|
||||
}
|
||||
}
|
||||
total := len(ss.alerts)
|
||||
ss.mu.Unlock()
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"total_alerts": total,
|
||||
"critical": critical,
|
||||
"high": high,
|
||||
"medium": medium,
|
||||
"low": low,
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LoginLog struct {
|
||||
Time string `json:"time"`
|
||||
Username string `json:"username"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
var loginLogs = make([]LoginLog, 0)
|
||||
|
||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||
config.AddLoginLog(username, ip, userAgent, success)
|
||||
|
||||
log := LoginLog{
|
||||
Time: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
Username: username,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Success: success,
|
||||
}
|
||||
loginLogs = append(loginLogs, log)
|
||||
if len(loginLogs) > 200 {
|
||||
loginLogs = loginLogs[len(loginLogs)-200:]
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreLoginLogs restores login logs from config
|
||||
func RestoreLoginLogs() {
|
||||
for _, l := range config.AppConfig.LoginLogs {
|
||||
loginLogs = append(loginLogs, LoginLog{
|
||||
Time: l.Time,
|
||||
Username: l.Username,
|
||||
IP: l.IP,
|
||||
UserAgent: l.UserAgent,
|
||||
Success: l.Success,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLoginLogs returns login history
|
||||
func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return in reverse (newest first)
|
||||
reversed := make([]LoginLog, len(loginLogs))
|
||||
for i, l := range loginLogs {
|
||||
reversed[len(loginLogs)-1-i] = l
|
||||
}
|
||||
if reversed == nil {
|
||||
reversed = []LoginLog{}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
|
||||
}
|
||||
|
||||
// HandleAdminPasswordChange changes admin password
|
||||
func HandleAdminPasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "新密码至少 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "当前密码不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
config.AppConfig.AdminPassHash = string(hash)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "密码修改成功"})
|
||||
}
|
||||
|
||||
// HandleAdminUsernameChange changes admin username
|
||||
func HandleAdminUsernameChange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
NewUsername string `json:"new_username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewUsername) < 3 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "用户名至少 3 位"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "密码不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
config.AppConfig.AdminUser = req.NewUsername
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "用户名修改成功"})
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type terminalResizeMessage struct {
|
||||
Type string `json:"type"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
type webSSHTicket struct {
|
||||
ContainerName string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var webSSHTickets = struct {
|
||||
sync.Mutex
|
||||
items map[string]webSSHTicket
|
||||
}{items: map[string]webSSHTicket{}}
|
||||
|
||||
func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
|
||||
return
|
||||
}
|
||||
if !isContainerAllowedForRequest(r, req.ContainerName) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
if config.FindContainerByName(req.ContainerName) == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ticket := randomHex(32)
|
||||
webSSHTickets.Lock()
|
||||
cleanupExpiredWebSSHTicketsLocked(time.Now())
|
||||
webSSHTickets.items[ticket] = webSSHTicket{
|
||||
ContainerName: req.ContainerName,
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webSSHTickets.Unlock()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"ticket": ticket},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
|
||||
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
containerName := r.URL.Query().Get("container")
|
||||
if containerName == "" {
|
||||
http.Error(w, "container name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !consumeWebSSHTicket(ticket, containerName) {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil {
|
||||
http.Error(w, "container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if c.Status != "running" {
|
||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.IP == "" {
|
||||
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
|
||||
c.IP = ip
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSSH upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
if c.SSHPassword == "" {
|
||||
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if err := lxcManager.EnsureSSH(c.ID); err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
}
|
||||
if c.SSHPassword == "" {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH password is empty after auto setup\r\n")
|
||||
return
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(c.SSHPassword),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 4 * time.Second,
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(c.IP, "22")
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to create SSH session: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdin: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdout: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stderr: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.RequestPty("xterm-256color", 40, 120, ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}); err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to request pty: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to start shell: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
writeWebSocketText(ws, nil, "\r\nSSH shell ready. Press Enter if the prompt is not visible.\r\n")
|
||||
_, _ = stdin.Write([]byte("\n"))
|
||||
|
||||
log.Printf("WebSSH connected for container %s -> %s", containerName, addr)
|
||||
|
||||
done := make(chan struct{}, 3)
|
||||
var writeMu sync.Mutex
|
||||
|
||||
go streamSSHOutput(ws, &writeMu, stdout, done)
|
||||
go streamSSHOutput(ws, &writeMu, stderr, done)
|
||||
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for {
|
||||
messageType, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if messageType == websocket.TextMessage {
|
||||
var resize terminalResizeMessage
|
||||
if err := json.Unmarshal(msg, &resize); err == nil && resize.Type == "resize" {
|
||||
if resize.Rows > 0 && resize.Cols > 0 {
|
||||
_ = session.WindowChange(resize.Rows, resize.Cols)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := stdin.Write(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-done
|
||||
_ = session.Signal(ssh.SIGTERM)
|
||||
log.Printf("WebSSH disconnected for container %s", containerName)
|
||||
}
|
||||
|
||||
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
writeMu.Lock()
|
||||
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
writeMu.Unlock()
|
||||
if writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
|
||||
if writeMu != nil {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
}
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||
}
|
||||
|
||||
func consumeWebSSHTicket(ticket, containerName string) bool {
|
||||
now := time.Now()
|
||||
webSSHTickets.Lock()
|
||||
defer webSSHTickets.Unlock()
|
||||
cleanupExpiredWebSSHTicketsLocked(now)
|
||||
item, ok := webSSHTickets.items[ticket]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(webSSHTickets.items, ticket)
|
||||
return item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
|
||||
for ticket, item := range webSSHTickets.items {
|
||||
if !now.Before(item.ExpiresAt) {
|
||||
delete(webSSHTickets.items, ticket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(bytesLen int) string {
|
||||
b := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func generateRandomStr(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)[:length]
|
||||
}
|
||||
|
||||
// HandleSubUserCreate creates a sub-user for a specific container
|
||||
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
c := containerByIdentifier(req.ContainerName)
|
||||
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
containerName := c.Name
|
||||
|
||||
// Check if sub-user already exists for this container
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
su := &config.AppConfig.SubUsers[i]
|
||||
for _, cn := range su.ContainerNames {
|
||||
if cn == containerName {
|
||||
if su.AccessCode == "" {
|
||||
su.AccessCode = generateRandomStr(8)
|
||||
}
|
||||
if su.PassHash == "" && su.Password != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
}
|
||||
if su.Password == "" {
|
||||
su.Password = generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
}
|
||||
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
config.SaveConfig()
|
||||
// Return existing
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Sub-user already exists",
|
||||
Data: *su,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new sub-user
|
||||
username := "user-" + generateRandomStr(8)
|
||||
password := generateRandomStr(16)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
|
||||
// Generate short access code (8 chars, for URL sharing)
|
||||
accessCode := generateRandomStr(8)
|
||||
|
||||
// Generate JWT for sub-user
|
||||
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
|
||||
subUser := config.SubUser{
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
Token: tokenStr,
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
||||
config.SaveConfig()
|
||||
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
|
||||
}
|
||||
|
||||
// HandleSubUserLogin handles sub-user login
|
||||
func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find sub-user
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
if su.Username == req.Username {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
|
||||
// Generate fresh token
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
if len(containerUUIDs) == 0 {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
|
||||
return
|
||||
}
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"token": tokenStr,
|
||||
"username": su.Username,
|
||||
"container_uuids": containerUUIDs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
|
||||
}
|
||||
|
||||
// HandleSubUserAccessCode handles access via short code + password (no token in URL)
|
||||
func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find sub-user by access code
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
if su.AccessCode == req.Code {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
|
||||
return
|
||||
}
|
||||
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
if len(containerUUIDs) == 0 {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
|
||||
return
|
||||
}
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"token": tokenStr,
|
||||
"username": su.Username,
|
||||
"container_uuids": containerUUIDs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
|
||||
}
|
||||
|
||||
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub_user": username,
|
||||
"container_uuids": containerUUIDs,
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
tokenStr, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
return tokenStr
|
||||
}
|
||||
|
||||
type subUserAccess struct {
|
||||
names map[string]bool
|
||||
uuids map[string]bool
|
||||
}
|
||||
|
||||
func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
claims, ok := claimsFromRequest(r)
|
||||
if !ok {
|
||||
return subUserAccess{}, false
|
||||
}
|
||||
if _, isSubUser := claims["sub_user"]; !isSubUser {
|
||||
return subUserAccess{}, false
|
||||
}
|
||||
|
||||
allowed := subUserAccess{
|
||||
names: make(map[string]bool),
|
||||
uuids: make(map[string]bool),
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]interface{}); ok {
|
||||
for _, cn := range containerNames {
|
||||
if name, ok := cn.(string); ok {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]string); ok {
|
||||
for _, name := range containerNames {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, item := range containerUUIDs {
|
||||
if uuid, ok := item.(string); ok {
|
||||
allowed.uuids[uuid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if containerUUIDs, ok := claims["container_uuids"].([]string); ok {
|
||||
for _, uuid := range containerUUIDs {
|
||||
allowed.uuids[uuid] = true
|
||||
}
|
||||
}
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
func containerByIdentifier(identifier string) *config.Container {
|
||||
return config.FindContainerByIdentifier(identifier)
|
||||
}
|
||||
|
||||
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
return true
|
||||
}
|
||||
c := containerByIdentifier(identifier)
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return isContainerAllowed(allowed, c)
|
||||
}
|
||||
|
||||
// HandleAuditLogs returns audit logs
|
||||
func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
logs := config.AppConfig.AuditLogs
|
||||
if logs == nil {
|
||||
logs = []config.AuditLog{}
|
||||
}
|
||||
// Return in reverse order (newest first)
|
||||
reversed := make([]config.AuditLog, len(logs))
|
||||
for i, l := range logs {
|
||||
reversed[len(logs)-1-i] = l
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
|
||||
}
|
||||
|
||||
// SubUserMiddleware checks if a request is from a sub-user and restricts container access
|
||||
func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
if path == "/api/tasks" && r.Method == http.MethodGet {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if path == "/api/containers" {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(path) > len("/api/containers/") {
|
||||
rest := path[len("/api/containers/"):]
|
||||
parts := splitPath(rest)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
c := containerByIdentifier(parts[0])
|
||||
if c == nil || !isContainerAllowed(allowed, c) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
if !isSubUserContainerActionAllowed(action, r.Method) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
return containers
|
||||
}
|
||||
filtered := make([]config.Container, 0, len(containers))
|
||||
for _, c := range containers {
|
||||
if isContainerAllowed(allowed, &c) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||
allowed, isSubUser := subUserAllowedContainers(r)
|
||||
if !isSubUser {
|
||||
return tasks
|
||||
}
|
||||
filtered := make([]*Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID])
|
||||
}
|
||||
|
||||
func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
if action == "" {
|
||||
return method == http.MethodGet
|
||||
}
|
||||
switch {
|
||||
case action == "usage" || action == "traffic" || action == "random-port":
|
||||
return method == http.MethodGet
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "port-mappings/"):
|
||||
return method == http.MethodPut
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func subUserContainerUUIDs(containerNames []string) []string {
|
||||
uuids := make([]string, 0, len(containerNames))
|
||||
for _, name := range containerNames {
|
||||
if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
|
||||
uuids = append(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
return uuids
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range splitBy(path, "/") {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func splitBy(s, sep string) []string {
|
||||
result := make([]string, 0)
|
||||
current := ""
|
||||
for _, c := range s {
|
||||
if string(c) == sep {
|
||||
result = append(result, current)
|
||||
current = ""
|
||||
} else {
|
||||
current += string(c)
|
||||
}
|
||||
}
|
||||
result = append(result, current)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SwapInfo struct {
|
||||
TotalMB int64 `json:"total_mb"`
|
||||
UsedMB int64 `json:"used_mb"`
|
||||
FreeMB int64 `json:"free_mb"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SwapFile string `json:"swap_file"`
|
||||
}
|
||||
|
||||
// HandleSwapInfo returns current swap status
|
||||
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
info := getSwapInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
|
||||
// HandleSwapManage creates/enables/disables swap
|
||||
func HandleSwapManage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Action string `json:"action"` // create, enable, disable, resize
|
||||
SizeMB int `json:"size_mb"` // for create/resize
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var msg string
|
||||
|
||||
switch req.Action {
|
||||
case "create":
|
||||
if req.SizeMB <= 0 {
|
||||
req.SizeMB = 2048
|
||||
}
|
||||
err := createSwap(req.SizeMB)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
|
||||
|
||||
case "enable":
|
||||
err := enableSwap()
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
msg = "SWAP 已启用"
|
||||
|
||||
case "disable":
|
||||
err := disableSwap()
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
msg = "SWAP 已禁用"
|
||||
|
||||
case "resize":
|
||||
if req.SizeMB <= 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
|
||||
return
|
||||
}
|
||||
disableSwap()
|
||||
createSwap(req.SizeMB)
|
||||
enableSwap()
|
||||
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
|
||||
return
|
||||
}
|
||||
|
||||
info := getSwapInfo()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
|
||||
}
|
||||
|
||||
func getSwapInfo() SwapInfo {
|
||||
info := SwapInfo{SwapFile: "/swapfile"}
|
||||
|
||||
// Read /proc/meminfo for swap stats
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
val, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
switch fields[0] {
|
||||
case "SwapTotal:":
|
||||
info.TotalMB = val / 1024
|
||||
case "SwapFree:":
|
||||
info.FreeMB = val / 1024
|
||||
}
|
||||
}
|
||||
|
||||
info.UsedMB = info.TotalMB - info.FreeMB
|
||||
if info.TotalMB > 0 {
|
||||
info.Enabled = true
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func createSwap(sizeMB int) error {
|
||||
swapFile := "/swapfile"
|
||||
|
||||
// Check if swap file already exists
|
||||
if _, err := os.Stat(swapFile); err == nil {
|
||||
// Remove old swap file
|
||||
exec.Command("swapoff", swapFile).Run()
|
||||
os.Remove(swapFile)
|
||||
}
|
||||
|
||||
// Create swap file
|
||||
cmd := exec.Command("dd", "if=/dev/zero", "of="+swapFile, "bs=1M", "count="+strconv.Itoa(sizeMB))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 swap 文件失败: %v, %s", err, string(output))
|
||||
}
|
||||
|
||||
// Set permissions
|
||||
os.Chmod(swapFile, 0600)
|
||||
|
||||
// Make swap
|
||||
cmd = exec.Command("mkswap", swapFile)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mkswap 失败: %v, %s", err, string(output))
|
||||
}
|
||||
|
||||
// Enable swap
|
||||
return enableSwap()
|
||||
}
|
||||
|
||||
func enableSwap() error {
|
||||
swapFile := "/swapfile"
|
||||
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("swap 文件不存在,请先创建")
|
||||
}
|
||||
|
||||
cmd := exec.Command("swapon", swapFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Check if already enabled
|
||||
if strings.Contains(string(output), "already") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("启用 swap 失败: %v, %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableSwap() error {
|
||||
swapFile := "/swapfile"
|
||||
cmd := exec.Command("swapoff", swapFile)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(output), "No such") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
type TaskType string
|
||||
|
||||
const (
|
||||
TaskCreate TaskType = "create"
|
||||
TaskStart TaskType = "start"
|
||||
TaskStop TaskType = "stop"
|
||||
TaskRestart TaskType = "restart"
|
||||
TaskDelete TaskType = "delete"
|
||||
TaskReinstall TaskType = "reinstall"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Type TaskType `json:"type"`
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
User string `json:"user,omitempty"` // who created this task
|
||||
}
|
||||
|
||||
type TaskQueue struct {
|
||||
mu sync.Mutex
|
||||
createQueue []*Task
|
||||
opQueue []*Task
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
createCond *sync.Cond
|
||||
opCond *sync.Cond
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
var globalQueue *TaskQueue
|
||||
|
||||
func init() {
|
||||
globalQueue = &TaskQueue{
|
||||
tasks: make(map[string]*Task),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
|
||||
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
|
||||
go globalQueue.createWorker()
|
||||
go globalQueue.opWorker()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueTask(task *Task) {
|
||||
q.tasks[task.ID] = task
|
||||
if task.Type == TaskCreate {
|
||||
q.createQueue = append(q.createQueue, task)
|
||||
q.createCond.Signal()
|
||||
} else {
|
||||
q.opQueue = append(q.opQueue, task)
|
||||
q.opCond.Signal()
|
||||
}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
ID: fmt.Sprintf("task-%d", id),
|
||||
Type: taskType,
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
}
|
||||
if cfg != nil {
|
||||
task.Config = *cfg
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
q.persistTasks()
|
||||
return []string{task.ID}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string) []string {
|
||||
return q.EnqueueBatchWithUser(taskType, ids, templateID, "admin")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
var result []string
|
||||
for _, id := range ids {
|
||||
c := config.FindContainer(id)
|
||||
name := ""
|
||||
if c != nil {
|
||||
name = c.Name
|
||||
}
|
||||
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
|
||||
}
|
||||
q.persistTasks()
|
||||
return result
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.enqueueBatchCreateList(configs)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, task := range q.tasks {
|
||||
if task.Type != TaskCreate || (task.Status != "pending" && task.Status != "running") {
|
||||
continue
|
||||
}
|
||||
name := task.Config.Name
|
||||
if name == "" {
|
||||
name = task.ContainerName
|
||||
}
|
||||
if name != "" {
|
||||
names[name] = true
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
|
||||
var result []string
|
||||
for _, cfg := range configs {
|
||||
cfgCopy := cfg
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
ID: fmt.Sprintf("task-%d", id),
|
||||
Type: TaskCreate,
|
||||
ContainerID: 0,
|
||||
ContainerName: cfgCopy.Name,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Config: cfgCopy,
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
result = append(result, task.ID)
|
||||
}
|
||||
q.persistTasks()
|
||||
return result
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskType TaskType, templateID string) string {
|
||||
return q.enqueueSingleWithUser(containerID, containerName, taskType, templateID, "admin")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
ID: fmt.Sprintf("task-%d", id),
|
||||
Type: taskType,
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
return task.ID
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
func (q *TaskQueue) createWorker() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.createQueue) == 0 {
|
||||
q.createCond.Wait()
|
||||
}
|
||||
task := q.createQueue[0]
|
||||
q.createQueue = q.createQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
|
||||
createdByTask := false
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
if task.Config.Name == "" {
|
||||
task.Status = "failed"
|
||||
task.Error = "container name is required"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c := config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
// 1) Download image + apply limits (lxc-create)
|
||||
err := lxcManager.CreateContainer(task.Config)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
createdByTask = true
|
||||
|
||||
// 2) Find created container by name
|
||||
c = config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
task.Status = "failed"
|
||||
task.Error = "created but not found in config"
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
|
||||
// 3) Start + initialize SSH/network in the same worker.
|
||||
// If init fails, destroy the container so no dead entry remains.
|
||||
startErr := lxcManager.StartContainer(c.ID)
|
||||
if startErr != nil {
|
||||
if createdByTask {
|
||||
lxcManager.DestroyContainer(c.ID)
|
||||
}
|
||||
task.Status = "failed"
|
||||
task.Error = startErr.Error()
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
|
||||
// including the follow-up initialization after a create succeeds.
|
||||
func (q *TaskQueue) opWorker() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.opQueue) == 0 {
|
||||
q.opCond.Wait()
|
||||
}
|
||||
task := q.opQueue[0]
|
||||
q.opQueue = q.opQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
|
||||
var err error
|
||||
err = resolveTaskContainer(task)
|
||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil {
|
||||
if lxc.IsExpired(*c) {
|
||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||
} else if lxc.IsTrafficExceeded(*c) {
|
||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = lxcManager.StartContainer(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = lxcManager.StopContainer(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = lxcManager.RestartContainer(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = lxcManager.DestroyContainer(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
auditUser := task.User
|
||||
if auditUser == "" {
|
||||
auditUser = "admin"
|
||||
}
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
}
|
||||
}
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTaskContainer(task *Task) error {
|
||||
if task.Type == TaskCreate {
|
||||
return nil
|
||||
}
|
||||
if task.ContainerID > 0 {
|
||||
if c := config.FindContainer(task.ContainerID); c != nil {
|
||||
if task.ContainerName == "" {
|
||||
task.ContainerName = c.Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil {
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("container not found: %s", task.ContainerName)
|
||||
}
|
||||
return fmt.Errorf("container not found: %d", task.ContainerID)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) persistTasks() {
|
||||
saved := make([]config.SavedTask, 0)
|
||||
for _, t := range q.tasks {
|
||||
// Only persist pending and running tasks to avoid
|
||||
// re-queuing already completed/failed tasks after restart.
|
||||
if t.Status != "pending" && t.Status != "running" {
|
||||
continue
|
||||
}
|
||||
cfgJSON, _ := json.Marshal(t.Config)
|
||||
saved = append(saved, config.SavedTask{
|
||||
ID: t.ID,
|
||||
Type: string(t.Type),
|
||||
ContainerID: t.ContainerID,
|
||||
ContainerName: t.ContainerName,
|
||||
Status: t.Status,
|
||||
Error: t.Error,
|
||||
CreatedAt: t.CreatedAt,
|
||||
TemplateID: t.TemplateID,
|
||||
Config: string(cfgJSON),
|
||||
User: t.User,
|
||||
})
|
||||
}
|
||||
config.SaveTasks(saved)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) GetTasks() []*Task {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
result := make([]*Task, 0, len(q.tasks))
|
||||
// Collect all task IDs, sort by creation time (extracted from ID number)
|
||||
for _, t := range q.tasks {
|
||||
result = append(result, t)
|
||||
}
|
||||
// Stable sort by ID number (task-N where N is sequential)
|
||||
for i := 0; i < len(result); i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if parseIDNum(result[i].ID) > parseIDNum(result[j].ID) {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleSingleTaskAction creates a task for a single container action
|
||||
func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, action string) {
|
||||
c := config.FindContainer(id)
|
||||
name := ""
|
||||
if c != nil {
|
||||
name = c.Name
|
||||
}
|
||||
|
||||
// Determine user from JWT claims
|
||||
user := "admin"
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
user = "user:" + subUser
|
||||
}
|
||||
}
|
||||
|
||||
var taskType TaskType
|
||||
var templateID string
|
||||
switch action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
case "stop":
|
||||
taskType = TaskStop
|
||||
case "restart":
|
||||
taskType = TaskRestart
|
||||
case "delete":
|
||||
taskType = TaskDelete
|
||||
case "reinstall":
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
templateID = req.TemplateID
|
||||
if templateID == "" {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil {
|
||||
templateID = c.Template
|
||||
}
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||
Success: true,
|
||||
Message: "Task queued",
|
||||
Data: map[string]interface{}{"task_id": ids[0], "container_name": name, "status": "pending"},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleBatchCreate handles batch container creation
|
||||
func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Containers []lxc.ContainerConfig `json:"containers"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(req.Containers) == 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "No containers requested"})
|
||||
return
|
||||
}
|
||||
|
||||
activeCreateNames := globalQueue.ActiveCreateNames()
|
||||
requestNames := make(map[string]bool)
|
||||
for i := range req.Containers {
|
||||
name := strings.TrimSpace(req.Containers[i].Name)
|
||||
req.Containers[i].Name = name
|
||||
if !config.IsValidContainerNameSyntax(name) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid container name: " + name})
|
||||
return
|
||||
}
|
||||
if requestNames[name] {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Duplicate container name in request: " + name})
|
||||
return
|
||||
}
|
||||
if config.FindContainerByName(name) != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container name already exists: " + name})
|
||||
return
|
||||
}
|
||||
if activeCreateNames[name] {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container creation already queued: " + name})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].VCPU <= 0 {
|
||||
req.Containers[i].VCPU = 1
|
||||
}
|
||||
if req.Containers[i].RAMMB < 128 {
|
||||
req.Containers[i].RAMMB = 512
|
||||
}
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreate(req.Containers)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
// HandleBatchAction handles batch container actions
|
||||
func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var taskType TaskType
|
||||
switch req.Action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
case "stop":
|
||||
taskType = TaskStop
|
||||
case "restart":
|
||||
taskType = TaskRestart
|
||||
case "delete":
|
||||
taskType = TaskDelete
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
// HandleTaskDelete deletes a specific task by ID
|
||||
func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
// URL: /api/tasks/{id}
|
||||
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
|
||||
if taskID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
|
||||
return
|
||||
}
|
||||
globalQueue.mu.Lock()
|
||||
delete(globalQueue.tasks, taskID)
|
||||
// Also remove from both queues if pending
|
||||
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
|
||||
for _, t := range globalQueue.createQueue {
|
||||
if t.ID != taskID {
|
||||
newCreate = append(newCreate, t)
|
||||
}
|
||||
}
|
||||
globalQueue.createQueue = newCreate
|
||||
newOp := make([]*Task, 0, len(globalQueue.opQueue))
|
||||
for _, t := range globalQueue.opQueue {
|
||||
if t.ID != taskID {
|
||||
newOp = append(newOp, t)
|
||||
}
|
||||
}
|
||||
globalQueue.opQueue = newOp
|
||||
globalQueue.persistTasks()
|
||||
globalQueue.mu.Unlock()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
|
||||
}
|
||||
|
||||
// HandleTasks returns the current task queue
|
||||
func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
tasks := globalQueue.GetTasks()
|
||||
tasks = filterTasksForRequest(r, tasks)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
|
||||
}
|
||||
|
||||
// RestoreTasks restores task queue from config
|
||||
func RestoreTasks() {
|
||||
for _, st := range config.AppConfig.Tasks {
|
||||
var cfg lxc.ContainerConfig
|
||||
if st.Config != "" {
|
||||
json.Unmarshal([]byte(st.Config), &cfg)
|
||||
}
|
||||
containerName := st.ContainerName
|
||||
if containerName == "" {
|
||||
containerName = cfg.Name
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = containerName
|
||||
}
|
||||
containerID := st.ContainerID
|
||||
if containerID <= 0 && containerName != "" {
|
||||
if c := config.FindContainerByName(containerName); c != nil {
|
||||
containerID = c.ID
|
||||
}
|
||||
}
|
||||
globalQueue.tasks[st.ID] = &Task{
|
||||
ID: st.ID,
|
||||
Type: TaskType(st.Type),
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: st.Status,
|
||||
Error: st.Error,
|
||||
CreatedAt: st.CreatedAt,
|
||||
TemplateID: st.TemplateID,
|
||||
Config: cfg,
|
||||
User: st.User,
|
||||
}
|
||||
if st.Status == "pending" || st.Status == "running" {
|
||||
// Reset running tasks back to pending so they get retried
|
||||
globalQueue.tasks[st.ID].Status = "pending"
|
||||
globalQueue.enqueueTask(globalQueue.tasks[st.ID])
|
||||
}
|
||||
if num := parseIDNum(st.ID); num >= globalQueue.nextID {
|
||||
globalQueue.nextID = num + 1
|
||||
}
|
||||
}
|
||||
// Clear persisted tasks from disk (they're now in memory)
|
||||
config.SaveTasks([]config.SavedTask{})
|
||||
}
|
||||
|
||||
func parseIDNum(id string) int {
|
||||
var num int
|
||||
for _, c := range id {
|
||||
if c >= '0' && c <= '9' {
|
||||
num = num*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return num
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
originURL, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
originHost := strings.ToLower(stripPort(originURL.Host))
|
||||
requestHost := strings.ToLower(stripPort(r.Host))
|
||||
return originHost != "" && originHost == requestHost
|
||||
},
|
||||
}
|
||||
|
||||
func stripPort(host string) string {
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
return parsedHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
Reference in New Issue
Block a user