first commit

This commit is contained in:
MengMengCode
2026-06-05 19:23:28 +08:00
commit e306d2d06b
66 changed files with 18825 additions and 0 deletions
+263
View File
@@ -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)
}
}
+213
View File
@@ -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)
})
}
+440
View File
@@ -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})
}
+376
View File
@@ -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}
}
+320
View File
@@ -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()
}
}
+21
View File
@@ -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})
}
+224
View File
@@ -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
}
+771
View File
@@ -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})
}
+146
View File
@@ -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: "用户名修改成功"})
}
+308
View File
@@ -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)
}
+422
View File
@@ -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
}
+189
View File
@@ -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
}
+650
View File
@@ -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
}
+35
View File
@@ -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, "[]")
}