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
+12
View File
@@ -0,0 +1,12 @@
module clicd
go 1.22.0
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0
golang.org/x/term v0.28.0
)
require golang.org/x/sys v0.29.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+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, "[]")
}
+426
View File
@@ -0,0 +1,426 @@
package cli
import (
"bufio"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
"clicd/internal/lxc"
)
var manager = lxc.NewManager()
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
for {
clearScreen()
printMenu()
fmt.Print("\nSelect action [1-9,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
switch strings.ToLower(input) {
case "1":
clearScreen()
cliListContainers()
waitEnter(reader)
case "2":
clearScreen()
cliCreateContainer(reader)
waitEnter(reader)
case "3":
clearScreen()
cliStartContainer(reader)
waitEnter(reader)
case "4":
clearScreen()
cliStopContainer(reader)
waitEnter(reader)
case "5":
clearScreen()
cliRestartContainer(reader)
waitEnter(reader)
case "6":
clearScreen()
cliDeleteContainer(reader)
waitEnter(reader)
case "7":
clearScreen()
cliReinstallContainer(reader)
waitEnter(reader)
case "8":
clearScreen()
cliResetPassword(reader)
waitEnter(reader)
case "9":
clearScreen()
cliToggleWebPanel()
waitEnter(reader)
case "0":
clearScreen()
cliShowInfo()
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("Bye")
return
default:
fmt.Println("Invalid selection")
}
}
}
func printMenu() {
webStatus := "start"
if isWebPanelRunning() {
webStatus = "stop"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web panel: %s (port %d)\n", func() string {
if isWebPanelRunning() {
return "running"
}
return "stopped"
}(), config.AppConfig.Port)
fmt.Println()
fmt.Println(" 1. List containers")
fmt.Println(" 2. Create container")
fmt.Println(" 3. Start container")
fmt.Println(" 4. Stop container")
fmt.Println(" 5. Restart container")
fmt.Println(" 6. Delete container")
fmt.Println(" 7. Reinstall container")
fmt.Println(" 8. Reset web admin password")
fmt.Printf(" 9. %s web panel\n", webStatus)
fmt.Println(" 0. System info")
fmt.Println(" q. Quit")
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to list containers: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\nNo containers")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "Name", "Status", "Template", "vCPU", "RAM(MB)", "Disk(GB)", "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
if c.SSHPort > 0 {
ssh = fmt.Sprintf("%d->22", c.SSHPort)
}
fmt.Printf("%-18s %-10s %-18s %-6.2f %-10d %-10d %-16s\n",
c.Name, c.Status, c.Template, c.VCPU, c.RAMMB, c.DiskGB, ssh)
}
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- Create container ---")
name := promptString(reader, "Container name", "")
if name == "" {
fmt.Println("Container name is required")
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
return
}
cfg := lxc.ContainerConfig{
Name: name,
TemplateID: templates[tmplIdx-1].ID,
VCPU: promptFloat(reader, "vCPU", 1),
RAMMB: promptInt(reader, "Memory (MB)", 512),
DiskGB: promptInt(reader, "Disk (GB)", 10),
NetworkBWMbps: promptInt(reader, "Network bandwidth (Mbps)", 100),
MonthlyTrafficGB: promptInt(reader, "Monthly traffic (GB)", 1000),
IOSpeedMBps: promptInt(reader, "IO speed (MB/s)", 500),
ExtraPorts: promptPortList(reader, "Extra NAT ports, comma separated"),
}
fmt.Printf("\nCreating container %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("Create failed: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("Container %s created successfully\n", name)
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
}
}
func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "start")
if id == 0 {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("Start failed: %v\n", err)
return
}
fmt.Printf("Container %s started\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "stop")
if id == 0 {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("Stop failed: %v\n", err)
return
}
fmt.Printf("Container %s stopped\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "restart")
if id == 0 {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("Restart failed: %v\n", err)
return
}
fmt.Printf("Container %s restarted\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "delete")
if id == 0 {
return
}
confirm := promptString(reader, fmt.Sprintf("Delete container %s? Type yes", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("Delete failed: %v\n", err)
return
}
fmt.Printf("Container %s deleted\n", name)
}
func cliReinstallContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "reinstall")
if id == 0 {
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
return
}
confirm := promptString(reader, fmt.Sprintf("Reinstall container %s? Type yes", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("Reinstall failed: %v\n", err)
return
}
fmt.Printf("Container %s reinstalled\n", name)
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "New admin password (at least 6 chars)", "")
if len(newPass) < 6 {
fmt.Println("Password must be at least 6 chars")
return
}
confirm := promptString(reader, "Confirm password", "")
if newPass != confirm {
fmt.Println("Passwords do not match")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("Reset failed: %v\n", err)
return
}
fmt.Println("Admin password reset. Restart the web service for it to take effect.")
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
cmd := exec.Command("systemctl", "stop", "clicd")
if err := cmd.Run(); err != nil {
fmt.Printf("Failed to stop web panel: %v\n", err)
return
}
fmt.Println("Web panel stopped. LXC containers are not affected.")
return
}
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Printf("Failed to start web panel: %v\n", err)
return
}
fmt.Println("Web panel started")
}
func isWebPanelRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "active"
}
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to read container status: %v\n", err)
}
total := len(containers)
running := 0
for _, container := range containers {
if container.Status == "running" {
running++
}
}
fmt.Println("\n--- System info ---")
fmt.Printf("Web port: %d\n", config.AppConfig.Port)
fmt.Printf("Admin user: %s\n", config.AppConfig.AdminUser)
fmt.Printf("Containers: %d\n", total)
fmt.Printf("Running: %d\n", running)
fmt.Printf("Stopped: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("Hostname: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC version: %s", string(output))
}
}
func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to list containers: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("No containers available")
return 0, ""
}
fmt.Printf("\n--- Select container to %s ---\n", action)
for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
}
idx := promptInt(reader, "Container", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("Invalid selection")
return 0, ""
}
c := containers[idx-1]
return c.ID, c.Name
}
func promptString(reader *bufio.Reader, label string, fallback string) string {
if fallback == "" {
fmt.Printf("%s: ", label)
} else {
fmt.Printf("%s [%s]: ", label, fallback)
}
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return fallback
}
return input
}
func promptInt(reader *bufio.Reader, label string, fallback int) int {
input := promptString(reader, label, strconv.Itoa(fallback))
value, err := strconv.Atoi(input)
if err != nil || value < 0 {
return fallback
}
return value
}
func promptFloat(reader *bufio.Reader, label string, fallback float64) float64 {
input := promptString(reader, label, strconv.FormatFloat(fallback, 'f', -1, 64))
value, err := strconv.ParseFloat(input, 64)
if err != nil || value < 0 {
return fallback
}
return value
}
func clearScreen() {
fmt.Print("\033[H\033[2J")
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\nPress Enter to return to menu...")
reader.ReadString('\n')
}
func promptPortList(reader *bufio.Reader, label string) []int {
input := promptString(reader, label, "")
if input == "" {
return nil
}
var ports []int
for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 {
fmt.Printf("Ignoring invalid port: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
}
return ports
}
+586
View File
@@ -0,0 +1,586 @@
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// PortMapping represents a port mapping rule
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort int `json:"host_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
// SavedTask for persisting task queue across restarts
type SavedTask struct {
ID string `json:"id"`
Type string `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 string `json:"config,omitempty"`
User string `json:"user,omitempty"`
}
// SavedLoginLog for persisting login logs
type SavedLoginLog struct {
Time string `json:"time"`
Username string `json:"username"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
}
// AuditLog represents an operation log entry
type AuditLog struct {
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
}
// OversellConfig controls host-level overselling behavior
type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
}
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID)
}
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
}
// DeleteApiKey removes an API key by ID
func DeleteApiKey(id string) {
filtered := make([]ApiKeyConfig, 0, len(AppConfig.ApiKeys))
for _, k := range AppConfig.ApiKeys {
if k.ID != id {
filtered = append(filtered, k)
}
}
AppConfig.ApiKeys = filtered
SaveConfig()
}
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"` // plaintext for display
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
Token string `json:"token"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
Oversell OversellConfig `json:"oversell"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
}
var configPath string
var AppConfig *ClicdConfig
func getConfigPath() string {
if configPath != "" {
return configPath
}
home, err := os.UserHomeDir()
if err != nil {
home = "/root"
}
return filepath.Join(home, ".clicd", "config.json")
}
func SetConfigPath(path string) {
configPath = path
}
func getDataDir() string {
home, err := os.UserHomeDir()
if err != nil {
home = "/root"
}
return filepath.Join(home, ".clicd")
}
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length]
}
func generateUUIDString() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return generateRandomString(32)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// NewContainerUUID returns a UUID that is unique within the current config.
func NewContainerUUID() string {
for {
uuid := generateUUIDString()
if FindContainerByUUID(uuid) == nil {
return uuid
}
}
}
// InitConfig initializes or loads the configuration
func InitConfig() (*ClicdConfig, error) {
cfgPath := getConfigPath()
dataDir := getDataDir()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err)
}
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
// First run: generate new config
adminUser := "admin"
adminPass := generateRandomString(16)
jwtSecret := generateRandomString(32)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
}
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil
}
// Load existing config
data, err := os.ReadFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %v", err)
}
AppConfig = &ClicdConfig{}
if err := json.Unmarshal(data, AppConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %v", err)
}
if AppConfig.Port == 0 {
AppConfig.Port = 8999
}
if AppConfig.NextVNCPort == 0 {
AppConfig.NextVNCPort = 5900
}
if AppConfig.NextSSHPort == 0 {
AppConfig.NextSSHPort = 22000
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
}
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
}
changed := ensureContainerUUIDs()
if ensureContainerPortMappingLimits() {
changed = true
}
if removeLegacyVNCMappings() {
changed = true
}
if changed {
if err := SaveConfig(); err != nil {
return nil, err
}
}
return AppConfig, nil
}
func ensureContainerUUIDs() bool {
changed := false
used := make(map[string]bool)
for i := range AppConfig.Containers {
uuid := AppConfig.Containers[i].UUID
if uuid == "" || used[uuid] {
for {
uuid = generateUUIDString()
if !used[uuid] {
break
}
}
AppConfig.Containers[i].UUID = uuid
changed = true
}
used[uuid] = true
}
return changed
}
func ensureContainerPortMappingLimits() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].PortMappingLimit <= 0 {
limit := len(AppConfig.Containers[i].PortMappings)
if limit < 2 {
limit = 2
}
AppConfig.Containers[i].PortMappingLimit = limit
changed = true
}
}
return changed
}
func removeLegacyVNCMappings() bool {
changed := false
for i := range AppConfig.Containers {
mappings := AppConfig.Containers[i].PortMappings
if len(mappings) == 0 {
continue
}
filtered := mappings[:0]
for _, pm := range mappings {
isLegacyVNC := strings.EqualFold(pm.Description, "VNC") || pm.ContainerPort == 5901
if isLegacyVNC {
changed = true
continue
}
filtered = append(filtered, pm)
}
AppConfig.Containers[i].PortMappings = filtered
}
return changed
}
// SaveConfig saves configuration to disk
func SaveConfig() error {
data, err := json.MarshalIndent(AppConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
return os.WriteFile(getConfigPath(), data, 0600)
}
// AddContainer adds a container to the config
func AddContainer(c Container) {
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
// AllocateContainerID allocates a new container ID
func AllocateContainerID() int {
id := AppConfig.NextContainerID
AppConfig.NextContainerID++
SaveConfig()
return id
}
// RemoveContainer removes a container from config by ID
func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers {
if c.ID == id {
removeSubUserContainerAccess(c.Name)
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
}
}
return false
}
func removeSubUserContainerAccess(containerName string) {
if containerName == "" || len(AppConfig.SubUsers) == 0 {
return
}
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
for _, su := range AppConfig.SubUsers {
filteredNames := make([]string, 0, len(su.ContainerNames))
for _, name := range su.ContainerNames {
if name != containerName {
filteredNames = append(filteredNames, name)
}
}
if len(filteredNames) == 0 {
continue
}
su.ContainerNames = filteredNames
filteredUsers = append(filteredUsers, su)
}
AppConfig.SubUsers = filteredUsers
}
// FindContainer finds a container by ID
func FindContainer(id int) *Container {
for i, c := range AppConfig.Containers {
if c.ID == id {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByUUID finds a container by UUID.
func FindContainerByUUID(uuid string) *Container {
for i, c := range AppConfig.Containers {
if c.UUID == uuid {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByName finds a container by name
func FindContainerByName(name string) *Container {
for i, c := range AppConfig.Containers {
if c.Name == name {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByIdentifier finds a container by ID, UUID, or name.
func FindContainerByIdentifier(identifier string) *Container {
if id, err := strconv.Atoi(identifier); err == nil {
if c := FindContainer(id); c != nil {
return c
}
}
if c := FindContainerByUUID(identifier); c != nil {
return c
}
return FindContainerByName(identifier)
}
// UpdateContainerStatus updates container status by ID
func UpdateContainerStatus(id int, status string) {
c := FindContainer(id)
if c != nil {
c.Status = status
SaveConfig()
}
}
// UpdateVNC refreshes all container statuses
func UpdateVNC(containers []Container) {
AppConfig.Containers = containers
SaveConfig()
}
// AllocateSSHPort allocates a new SSH port
func AllocateSSHPort() int {
port := AppConfig.NextSSHPort
AppConfig.NextSSHPort++
SaveConfig()
return port
}
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
func IsValidContainerName(name string) bool {
return IsValidContainerNameSyntax(name)
}
// IsValidContainerNameSyntax checks only the container name format.
func IsValidContainerNameSyntax(name string) bool {
if len(name) == 0 || len(name) > 63 {
return false
}
// Only allow alphanumeric, hyphens, underscores
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
// AddAuditLog adds an audit log entry
func AddAuditLog(action, target, detail, user string) {
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
}
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
if len(AppConfig.AuditLogs) > 500 {
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
}
SaveConfig()
}
// SaveTasks persists the task queue to config
func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks
SaveConfig()
}
// AddLoginLog persists a login log entry
func AddLoginLog(username, ip, userAgent string, success bool) {
log := SavedLoginLog{
Time: time.Now().Format("2006-01-02 15:04:05 MST"),
Username: username,
IP: ip,
UserAgent: userAgent,
Success: success,
}
AppConfig.LoginLogs = append(AppConfig.LoginLogs, log)
if len(AppConfig.LoginLogs) > 200 {
AppConfig.LoginLogs = AppConfig.LoginLogs[len(AppConfig.LoginLogs)-200:]
}
SaveConfig()
}
// ResetAdminPassword resets the admin password from CLI
func ResetAdminPassword(newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
AppConfig.AdminPassHash = string(hash)
return SaveConfig()
}
// CleanStaleContainers removes containers from config if their LXC directory doesn't exist
func CleanStaleContainers() {
valid := make([]Container, 0)
changed := false
for _, c := range AppConfig.Containers {
lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
changed = true
continue
}
valid = append(valid, c)
}
if changed {
AppConfig.Containers = valid
SaveConfig()
}
}
+137
View File
@@ -0,0 +1,137 @@
package lxc
import (
"fmt"
"time"
"clicd/internal/config"
)
// IsExpired checks if a container has passed its expiration date
func IsExpired(c config.Container) bool {
return isContainerExpired(c, time.Now())
}
// StopExpiredContainers stops running containers whose expiration date has passed.
func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) {
continue
}
status, err := m.GetContainerStatus(container.LxcName())
if err != nil {
status = container.Status
}
if status != "running" {
continue
}
fmt.Printf("Container %s (ID=%d) expired at %s, stopping...\n", container.Name, container.ID, container.ExpiresAt)
if err := m.StopContainer(container.ID); err != nil {
fmt.Printf("Warning: failed to stop expired container %s: %v\n", container.Name, err)
}
}
}
// StartExpiryScanner runs a background loop that tracks traffic & stops expired/over-traffic containers every 30 seconds
func (m *Manager) StartExpiryScanner() {
go func() {
for {
time.Sleep(30 * time.Second)
now := time.Now()
m.AccumulateTraffic() // track network traffic deltas
m.StopExpiredContainers(now)
m.StopTrafficExceededContainers(now)
}
}()
}
// StopTrafficExceededContainers stops running containers that have exceeded their monthly traffic limit
func (m *Manager) StopTrafficExceededContainers(now time.Time) {
currentMonth := now.Format("2006-01")
saved := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
continue
}
// Reset traffic if new month
if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth
saved = true
continue
}
// Check traffic limits
if isTrafficExceeded(*c) {
fmt.Printf("Container %s (ID=%d) exceeded traffic limit, stopping...\n", c.Name, c.ID)
if err := m.StopContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to stop traffic-exceeded container %s: %v\n", c.Name, err)
}
}
}
if saved {
config.SaveConfig()
}
}
func isTrafficExceeded(c config.Container) bool {
if c.TrafficMode == "in_out" {
inLimit := int64(c.TrafficInGB) * 1073741824
outLimit := int64(c.TrafficOutGB) * 1073741824
if inLimit > 0 && c.TrafficUsedRX >= inLimit {
return true
}
if outLimit > 0 && c.TrafficUsedTX >= outLimit {
return true
}
return false
}
totalLimit := int64(c.MonthlyTrafficGB) * 1073741824
return totalLimit > 0 && (c.TrafficUsedRX+c.TrafficUsedTX) >= totalLimit
}
// ResetTraffic resets traffic counters for a container
func (m *Manager) ResetTraffic(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = time.Now().Format("2006-01")
config.SaveConfig()
return nil
}
// IsTrafficExceeded checks if a container has exceeded its traffic limit
func IsTrafficExceeded(c config.Container) bool {
return isTrafficExceeded(c)
}
func isContainerExpired(container config.Container, now time.Time) bool {
expiresAt, ok := ParseExpiration(container.ExpiresAt)
return ok && !now.Before(expiresAt)
}
// ParseExpiration parses an expiration string. A YYYY-MM-DD value expires at the
// end of that local day, while RFC3339 values are treated as exact timestamps.
func ParseExpiration(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, true
}
if parsed, err := time.ParseInLocation("2006-01-02", value, time.Local); err == nil {
return parsed.Add(24 * time.Hour), true
}
return time.Time{}, false
}
+553
View File
@@ -0,0 +1,553 @@
package lxc
import (
"encoding/binary"
"fmt"
"math/big"
"net/netip"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"clicd/internal/config"
)
const ipv6GatewayLinkLocal = "fe80::1"
type IPv6PrefixInfo struct {
Interface string `json:"interface"`
Address string `json:"address"`
Prefix string `json:"prefix"`
PrefixLen int `json:"prefix_len"`
Gateway string `json:"gateway"`
IsTunnel bool `json:"is_tunnel"`
Source string `json:"source"`
}
type PublicIPInfo struct {
Address string `json:"address"`
Interface string `json:"interface"`
Prefix string `json:"prefix"`
IsTunnel bool `json:"is_tunnel"`
Source string `json:"source"`
}
type IPv6Status struct {
Available bool `json:"available"`
Reachable bool `json:"reachable"`
Reason string `json:"reason"`
Prefixes []IPv6PrefixInfo `json:"prefixes"`
}
func (m *Manager) DetectIPv6Status() IPv6Status {
status := IPv6Status{}
prefixes := DetectPublicIPv6Prefixes()
status.Prefixes = prefixes
if len(prefixes) == 0 {
status.Reason = "no usable public IPv6 prefix found; /128 single-address IPv6 is not assignable"
return status
}
status.Reachable = ipv6ConnectivityOK()
if !status.Reachable {
status.Reason = "host has an IPv6 prefix, but outbound IPv6 connectivity test failed"
return status
}
status.Available = true
status.Reason = "usable public IPv6 prefix detected"
return status
}
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
}
func DetectPublicIPv4() PublicIPInfo {
candidates := DetectPublicIPv4Candidates()
if len(candidates) == 0 {
return PublicIPInfo{}
}
return candidates[0]
}
func DetectPublicIPv4Candidates() []PublicIPInfo {
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "scope", "global").Output()
if err != nil {
return nil
}
defaultRoutes := detectIPv4DefaultRoutes()
defaultIfaces := map[string]bool{}
for _, route := range defaultRoutes {
defaultIfaces[route.Interface] = true
}
type candidate struct {
info PublicIPInfo
score int
}
var candidates []candidate
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[2] != "inet" {
continue
}
iface := normalizeIface(fields[1])
if isContainerLikeInterface(iface) {
continue
}
prefix, err := netip.ParsePrefix(fields[3])
if err != nil || !prefix.Addr().Is4() || !isPublicIPv4(prefix.Addr()) {
continue
}
key := iface + "|" + prefix.Addr().String()
if seen[key] {
continue
}
seen[key] = true
score := publicInterfaceScore(iface, defaultIfaces)
candidates = append(candidates, candidate{
info: PublicIPInfo{
Address: prefix.Addr().String(),
Interface: iface,
Prefix: prefix.Masked().String(),
IsTunnel: isTunnelLikeInterface(iface),
Source: "local",
},
score: score,
})
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
result := make([]PublicIPInfo, 0, len(candidates))
for _, c := range candidates {
result = append(result, c.info)
}
return result
}
func detectPublicIPv6Prefixes(defaultRoutes []routeInfo) []IPv6PrefixInfo {
out, err := exec.Command("ip", "-6", "-o", "addr", "show", "scope", "global").Output()
if err != nil {
return nil
}
defaultIfaces := map[string]bool{}
gateways := map[string]string{}
for _, route := range defaultRoutes {
defaultIfaces[route.Interface] = true
if route.Gateway != "" && gateways[route.Interface] == "" {
gateways[route.Interface] = route.Gateway
}
}
type candidate struct {
info IPv6PrefixInfo
score int
}
var candidates []candidate
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[2] != "inet6" {
continue
}
iface := normalizeIface(fields[1])
if isContainerLikeInterface(iface) {
continue
}
prefix, err := netip.ParsePrefix(fields[3])
if err != nil || !prefix.Addr().Is6() {
continue
}
addr := prefix.Addr()
if !isPublicIPv6(addr) {
continue
}
// Require at least 8 host bits. /128 is a single address, not a usable segment.
if prefix.Bits() > 120 {
continue
}
masked := prefix.Masked()
key := iface + "|" + masked.String()
if seen[key] {
continue
}
seen[key] = true
score := publicInterfaceScore(iface, defaultIfaces)
if masked.Bits() <= 64 {
score += 20
}
info := IPv6PrefixInfo{
Interface: iface,
Address: addr.String(),
Prefix: masked.String(),
PrefixLen: masked.Bits(),
Gateway: gateways[iface],
IsTunnel: isTunnelLikeInterface(iface),
Source: "local",
}
candidates = append(candidates, candidate{info: info, score: score})
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
result := make([]IPv6PrefixInfo, 0, len(candidates))
for _, c := range candidates {
result = append(result, c.info)
}
return result
}
type routeInfo struct {
Interface string
Gateway string
Metric int
}
func detectIPv4DefaultRoutes() []routeInfo {
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err != nil {
return nil
}
return parseDefaultRoutes(string(out))
}
func detectIPv6DefaultRoutes() []routeInfo {
out, err := exec.Command("ip", "-6", "route", "show", "default").Output()
if err != nil {
return nil
}
return parseDefaultRoutes(string(out))
}
func parseDefaultRoutes(output string) []routeInfo {
var routes []routeInfo
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "default" {
continue
}
route := routeInfo{Metric: 1024}
for i := 1; i < len(fields)-1; i++ {
switch fields[i] {
case "dev":
route.Interface = normalizeIface(fields[i+1])
case "via":
route.Gateway = fields[i+1]
case "metric":
metric, err := strconv.Atoi(fields[i+1])
if err == nil {
route.Metric = metric
}
}
}
if route.Interface != "" {
routes = append(routes, route)
}
}
sort.SliceStable(routes, func(i, j int) bool {
return routes[i].Metric < routes[j].Metric
})
return routes
}
func ipv6ConnectivityOK() bool {
targets := [][]string{
{"ping", "-6", "-c", "1", "-W", "2", "2606:4700:4700::1111"},
{"ping", "-6", "-c", "1", "-W", "2", "2001:4860:4860::8888"},
{"ping6", "-c", "1", "-W", "2", "2606:4700:4700::1111"},
}
for _, args := range targets {
if exec.Command(args[0], args[1:]...).Run() == nil {
return true
}
}
return false
}
func isContainerLikeInterface(iface string) bool {
prefixes := []string{
"lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali",
"kube", "dummy", "ifb", "zt", "zerotier",
}
for _, prefix := range prefixes {
if iface == prefix || strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func normalizeIface(iface string) string {
iface = strings.TrimSuffix(iface, ":")
if at := strings.Index(iface, "@"); at >= 0 {
iface = iface[:at]
}
return iface
}
func publicInterfaceScore(iface string, defaultIfaces map[string]bool) int {
score := 0
if defaultIfaces[iface] {
score += 100
}
if isTunnelLikeInterface(iface) {
score -= 120
} else {
score += 80
}
if isLikelyPhysicalInterface(iface) {
score += 40
}
if operState(iface) == "up" {
score += 10
}
return score
}
func isLikelyPhysicalInterface(iface string) bool {
prefixes := []string{"eth", "ens", "eno", "enp", "em", "bond", "team"}
for _, prefix := range prefixes {
if strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func isTunnelLikeInterface(iface string) bool {
lower := strings.ToLower(iface)
prefixes := []string{
"wg", "wgcf", "warp", "cloudflare", "tun", "tap", "tailscale", "ts",
"vpn", "ppp", "ipsec", "gre", "gretap", "sit", "he-", "nebula", "zt",
}
for _, prefix := range prefixes {
if lower == prefix || strings.HasPrefix(lower, prefix) {
return true
}
}
return strings.Contains(lower, "warp") || strings.Contains(lower, "cloudflare")
}
func operState(iface string) string {
data, err := os.ReadFile("/sys/class/net/" + iface + "/operstate")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func isPublicIPv4(addr netip.Addr) bool {
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
return false
}
raw := addr.As4()
if raw[0] == 100 && raw[1] >= 64 && raw[1] <= 127 {
return false
}
if raw[0] == 192 && raw[1] == 0 && raw[2] == 0 {
return false
}
return true
}
func isPublicIPv6(addr netip.Addr) bool {
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
return false
}
return !strings.HasPrefix(addr.String(), "2001:db8:")
}
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
status := m.DetectIPv6Status()
if !status.Available {
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason)
}
prefixInfo := status.Prefixes[0]
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
if err != nil {
return "", 0, "", err
}
used := map[string]bool{}
hostAddrs := map[string]bool{}
for _, p := range status.Prefixes {
hostAddrs[p.Address] = true
}
for _, c := range config.AppConfig.Containers {
if c.IPv6 != "" {
used[c.IPv6] = true
}
}
for offset := uint64(0x1000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
if err != nil || !prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
return candidate, prefix.Bits(), prefixInfo.Interface, nil
}
}
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
}
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
raw := base.As16()
value := big.NewInt(0).SetBytes(raw[:])
add := make([]byte, 8)
binary.BigEndian.PutUint64(add, offset)
value.Add(value, big.NewInt(0).SetBytes(add))
bytes := value.Bytes()
if len(bytes) > 16 {
return netip.Addr{}, fmt.Errorf("IPv6 address overflow")
}
padded := make([]byte, 16)
copy(padded[16-len(bytes):], bytes)
var out [16]byte
copy(out[:], padded)
return netip.AddrFrom16(out), nil
}
func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.IPv6 == "" {
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
if err != nil {
return nil, err
}
c.IPv6 = addr
c.IPv6PrefixLen = prefixLen
c.IPv6Interface = iface
config.SaveConfig()
}
if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
return nil, err
}
if err := m.ApplyIPv6(id); err != nil {
return nil, err
}
return c, nil
}
func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error {
configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read container config: %v", err)
}
lines := strings.Split(string(data), "\n")
next := make([]string, 0, len(lines)+4)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "# clicd managed: public IPv6") ||
strings.HasPrefix(trimmed, "lxc.net.0.ipv6.address") ||
strings.HasPrefix(trimmed, "lxc.net.0.ipv6.gateway") {
continue
}
next = append(next, line)
}
if ipv6 != "" {
next = append(next, "", "# clicd managed: public IPv6 routed /128")
next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6))
next = append(next, "lxc.net.0.ipv6.gateway = auto")
}
return os.WriteFile(configFile, []byte(strings.Join(next, "\n")), 0644)
}
func (m *Manager) ApplyIPv6(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
if c.IPv6 == "" {
return nil
}
if c.IPv6Interface == "" {
status := m.DetectIPv6Status()
if len(status.Prefixes) == 0 {
return fmt.Errorf("failed to detect IPv6 uplink for %s", c.IPv6)
}
c.IPv6Interface = status.Prefixes[0].Interface
c.IPv6PrefixLen = status.Prefixes[0].PrefixLen
config.SaveConfig()
}
if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
return err
}
status, _ := m.GetContainerStatus(c.LxcName())
if status != "running" {
return nil
}
cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c",
fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0",
shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal)))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output))
}
return nil
}
func ensureHostIPv6Routing(ipv6, uplink string) error {
if uplink == "" {
return fmt.Errorf("missing IPv6 uplink interface")
}
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".accept_ra=2")
runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".proxy_ndp=1")
runQuiet("ip", "link", "set", "lxcbr0", "up")
runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", "lxcbr0")
if out, err := exec.Command("ip", "-6", "route", "replace", ipv6+"/128", "dev", "lxcbr0").CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 host route: %v, output: %s", err, string(out))
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", ipv6, "dev", uplink).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureIPv6ForwardRules(ipv6)
return nil
}
func ensureIPv6ForwardRules(ipv6 string) {
rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
{"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"},
}
for _, rule := range rules {
check := append([]string{"-C"}, rule...)
add := append([]string{"-A"}, rule...)
if exec.Command("ip6tables", check...).Run() != nil {
exec.Command("ip6tables", add...).Run()
}
}
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
func (m *Manager) AssignedIPv6Count() int {
count := 0
for _, c := range config.AppConfig.Containers {
if strings.TrimSpace(c.IPv6) != "" {
count++
}
}
return count
}
func IPv6PrefixCapacity(prefixLen int) string {
if prefixLen <= 0 || prefixLen > 128 {
return "0"
}
hostBits := 128 - prefixLen
if hostBits > 32 {
return "large"
}
return strconv.FormatUint(uint64(1)<<uint(hostBits), 10)
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
package lxc
import (
"fmt"
"os/exec"
"strconv"
"clicd/internal/config"
)
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
func (m *Manager) ApplyPortMappings(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
if c.IP == "" {
return fmt.Errorf("container has no IP")
}
tag := clicdTag(id)
EnsureForwardRules()
m.CleanPortMappings(id)
for _, pm := range c.PortMappings {
cmd := exec.Command("iptables",
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
}
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
}
return nil
}
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
func EnsureForwardRules() {
rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
}
for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
if exec.Command("iptables", checkArgs...).Run() != nil {
exec.Command("iptables", args...).Run()
}
}
}
// CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id)
cmd := exec.Command("sh", "-c",
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
cmd.Run()
return nil
}
// SetupDefaultPortMappings creates default port mappings
func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
return []config.PortMapping{
{ContainerPort: 22, HostPort: sshPort, Protocol: "tcp", Description: "SSH"},
}
}
// AddPortMapping adds a NAT rule to a container
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
}
normalized, err := normalizePortMapping(c, -1, pm)
if err != nil {
return nil, err
}
c.PortMappings = append(c.PortMappings, normalized)
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
// UpdatePortMapping updates an existing NAT rule
func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if index < 0 || index >= len(c.PortMappings) {
return nil, fmt.Errorf("invalid port mapping index: %d", index)
}
normalized, err := normalizePortMapping(c, index, pm)
if err != nil {
return nil, err
}
c.PortMappings[index] = normalized
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
// DeletePortMapping removes a NAT rule
func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if index < 0 || index >= len(c.PortMappings) {
return nil, fmt.Errorf("invalid port mapping index: %d", index)
}
if c.PortMappings[index].Description == "SSH" {
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
}
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
func persistAndReloadMappings(m *Manager, c *config.Container) error {
config.SaveConfig()
if c.Status == "running" && c.IP != "" {
return m.ApplyPortMappings(c.ID)
}
return nil
}
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
return pm, fmt.Errorf("container port must be 1-65535")
}
if pm.Protocol == "" {
pm.Protocol = "tcp"
}
if pm.Description == "" {
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
}
if pm.HostPort <= 0 {
pm.HostPort = pm.ContainerPort
}
for i, existing := range c.PortMappings {
if i == skipIndex {
continue
}
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol)
}
}
return pm, nil
}
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
if count <= 0 {
return nil
}
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
used[pm.ContainerPort] = true
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
if !used[next] {
ports = append(ports, next)
}
next++
if next > 65535 || len(ports) >= count {
break
}
}
return ports
}
+74
View File
@@ -0,0 +1,74 @@
package lxc
// Template represents an LXC image template
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Variant string `json:"variant"`
Description string `json:"description"`
}
// GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template {
return []Template{
{
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS",
},
{
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
Description: "Ubuntu 22.04 LTS",
},
{
ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 (Bookworm)",
},
{
ID: "debian-bullseye", Name: "Debian 11",
Distro: "debian", Release: "bullseye", Arch: "amd64",
Description: "Debian 11 (Bullseye)",
},
{
ID: "alpine-3.21", Name: "Alpine 3.21",
Distro: "alpine", Release: "3.21", Arch: "amd64",
Description: "Alpine Linux 3.21",
},
{
ID: "centos-9-stream", Name: "CentOS 9 Stream",
Distro: "centos", Release: "9-Stream", Arch: "amd64",
Description: "CentOS 9 Stream",
},
{
ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
Description: "Rocky Linux 10",
},
}
}
// FindTemplate finds a template by ID
func FindTemplate(id string) *Template {
templates := GetTemplates()
for _, t := range templates {
if t.ID == id {
return &t
}
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package server
import (
"embed"
"io/fs"
"net/http"
)
//go:embed web/**
var embeddedWeb embed.FS
// GetEmbeddedFS returns the embedded frontend file system
func GetEmbeddedFS() http.FileSystem {
sub, err := fs.Sub(embeddedWeb, "web")
if err != nil {
return http.Dir("web")
}
return http.FS(sub)
}
+138
View File
@@ -0,0 +1,138 @@
package server
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"clicd/internal/api"
"clicd/internal/config"
"clicd/internal/lxc"
)
// webFS holds embedded frontend files
var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
// API Key management
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Static files
if webFS != nil {
fs := http.FileServer(webFS)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// API routes already handled above
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// Try to serve file
path := r.URL.Path
f, err := webFS.Open(path)
if err != nil {
// SPA fallback: serve index.html
indexFile, err := webFS.Open("index.html")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
defer indexFile.Close()
stat, _ := indexFile.Stat()
http.ServeContent(w, r, "index.html", stat.ModTime(), indexFile)
return
}
defer f.Close()
fs.ServeHTTP(w, r)
})
}
}
// Run starts the HTTP server
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux()
setupRoutes(mux)
addr := fmt.Sprintf("0.0.0.0:%d", config.AppConfig.Port)
log.Printf("CLICD Web Server starting on http://0.0.0.0:%d", config.AppConfig.Port)
log.Printf("Admin user: %s", config.AppConfig.AdminUser)
server := &http.Server{
Addr: addr,
Handler: mux,
}
return server.ListenAndServe()
}
func startExpiryMonitor() {
manager := lxc.NewManager()
go func() {
manager.StopExpiredContainers(time.Now())
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for now := range ticker.C {
manager.StopExpiredContainers(now)
}
}()
}
+1
View File
@@ -0,0 +1 @@
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"clicd/internal/api"
"clicd/internal/cli"
"clicd/internal/config"
"clicd/internal/lxc"
"clicd/internal/server"
"golang.org/x/term"
)
func main() {
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
isServerMode := false
isCliMode := false
noWebAutostart := false
for _, arg := range os.Args[1:] {
if arg == "server" || arg == "-s" || arg == "--server" {
isServerMode = true
}
if arg == "cli" || arg == "-c" || arg == "--cli" {
isCliMode = true
}
if arg == "--no-web" || arg == "--cli-only" {
noWebAutostart = true
isCliMode = true
}
}
// Initialize config
cfg, err := config.InitConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize config: %v\n", err)
os.Exit(1)
}
_ = cfg
if isServerMode || (!isTerminal && !isCliMode) {
// Restore persisted state
api.RestoreTasks()
api.RestoreLoginLogs()
// Start security scanner
api.InitScanner()
// Ensure iptables FORWARD rules allow LXC traffic
lxc.EnsureForwardRules()
// Start expiry scanner (stops expired containers every 30s)
manager := lxc.NewManager()
manager.StartExpiryScanner()
// Start usage monitor (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor()
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
// Pre-warm SSH for containers already running after host boot or service restart.
manager.StartSSHWarmupScanner()
// Run in server mode (frontend embedded in binary)
if err := server.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
} else {
// CLI mode normally keeps the web panel available. Use --no-web to avoid
// starting the systemd web service on locked-down hosts.
if !noWebAutostart && !isWebPanelSystemdRunning() {
startWebPanelSystemd()
}
// Run CLI interface
cli.Run()
}
}
func isWebPanelSystemdRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "active"
}
func startWebPanelSystemd() {
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
} else {
fmt.Println("Web 面板已自动启动")
}
}