Compare commits

...

8 Commits

Author SHA1 Message Date
MengMengCode 49d5a65357 release: v1.0.8 2026-06-06 12:57:46 +08:00
MengMengCode 8f32765ffe 修复了一些问题 2026-06-06 12:57:17 +08:00
MengMengCode 21b87d3d56 release: v1.0.7 2026-06-06 11:17:57 +08:00
MengMengCode 28f81a8f1c release: v1.0.6 2026-06-06 10:51:21 +08:00
MengMengCode 6dcd5bd06c 新添cli一键升级程序功能 2026-06-06 10:41:16 +08:00
MengMengCode 8b402fcfa1 添加管理员端路由管理功能,查看当前系统剩余的NAT4或者V6地址剩余情况 2026-06-06 10:30:48 +08:00
MengMengCode 8dd01fe714 修复一些已知问题,优化V6路由分配 2026-06-06 10:08:31 +08:00
MengMengCode a9784539ea 添加了快照功能支持,支持定时快照和回滚快照 2026-06-06 09:24:19 +08:00
43 changed files with 3495 additions and 367 deletions
+9
View File
@@ -35,6 +35,15 @@ jobs:
go-version: "1.22.x"
cache-dependency-path: backend/go.sum
- name: Set version from tag
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
else
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
fi
- name: Build
shell: bash
run: bash build.sh
+1
View File
@@ -63,3 +63,4 @@ backend/tmp/
.DS_Store
Thumbs.db
linux.txt
push-release.ps1
+10
View File
@@ -52,3 +52,13 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
![alt text](/img/image.png)
![alt text](/img/image-1.png)
![alt text](/img/image-2.png)
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
+36 -43
View File
@@ -2,6 +2,8 @@ package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"net"
@@ -11,8 +13,6 @@ import (
"time"
"clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type ApiKey struct {
@@ -116,6 +116,11 @@ func generateShortID() string {
// hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func legacyHashKey(key string) string {
b := make([]byte, 32)
for i := range key {
b[i%32] ^= key[i]
@@ -126,8 +131,10 @@ func hashKey(key string) string {
// validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys {
if k.KeyHash == hashed {
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
if k.IPWhitelist == "" {
return true
}
@@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool {
return false
}
func apiKeyFromRequest(r *http.Request) string {
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
return apiKey
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func isValidApiKeyRequest(r *http.Request) bool {
apiKey := apiKeyFromRequest(r)
if apiKey == "" {
return false
}
if !validateApiKey(apiKey, clientIP(r)) {
return false
}
updateApiKeyLastUsed(apiKey)
return true
}
// isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP)
@@ -211,52 +241,15 @@ func updateApiKeyLastUsed(rawKey string) {
}
}
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 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) {
apiKey := apiKeyFromRequest(r)
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
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)
}
+2 -5
View File
@@ -100,10 +100,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
return
}
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
}
ip := clientIP(r)
ua := r.Header.Get("User-Agent")
if req.Username != config.AppConfig.AdminUser {
@@ -192,7 +189,7 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) {
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
return
}
+25
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/config"
"clicd/internal/lxc"
"clicd/internal/version"
)
var lxcManager = lxc.NewManager()
@@ -69,6 +70,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost:
assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
@@ -105,6 +108,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return
}
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
if cfg.VCPU <= 0 {
cfg.VCPU = 1
}
@@ -121,6 +128,9 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
@@ -311,6 +321,10 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if isSubUserRequest(r) {
HandleEnabledImages(w, r)
return
}
templates := lxc.GetTemplates()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
}
@@ -438,3 +452,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
}
// HandleVersion returns the current CLICD version.
func HandleVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"version": version.Current(),
}})
}
+9
View File
@@ -272,6 +272,15 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
func isTemplateEnabledAndDownloaded(templateID string) bool {
tmpl := lxc.FindTemplate(templateID)
if tmpl == nil {
return false
}
enabledSet := getEnabledImageSet()
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
}
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.
+3
View File
@@ -34,6 +34,9 @@ func updateOversell(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if cfg.SubUserSnapshotLimit <= 0 {
cfg.SubUserSnapshotLimit = 3
}
// Apply KSM
if cfg.KSMEnabled {
+15
View File
@@ -0,0 +1,15 @@
package api
import (
"net"
"net/http"
"strings"
)
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
+151
View File
@@ -0,0 +1,151 @@
package api
import (
"net/http"
"sort"
"strconv"
"clicd/internal/config"
"clicd/internal/lxc"
)
type routeCapacity struct {
Used int `json:"used"`
Remaining string `json:"remaining"`
Total string `json:"total"`
}
type nat4Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
IP string `json:"ip"`
HostPort int `json:"host_port"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
Address string `json:"address"`
PrefixLen int `json:"prefix_len"`
Interface string `json:"interface"`
}
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
IPv6 routeCapacity `json:"ipv6"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
func HandleRouting(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
for _, c := range config.AppConfig.Containers {
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
usedPorts[pm.HostPort] = true
}
nat4Mappings = append(nat4Mappings, nat4Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
IP: c.IP,
HostPort: pm.HostPort,
ContainerPort: pm.ContainerPort,
Protocol: pm.Protocol,
Description: pm.Description,
})
}
if c.IPv6 != "" {
ipv6Assignments = append(ipv6Assignments, ipv6Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
})
}
}
sort.SliceStable(nat4Mappings, func(i, j int) bool {
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
}
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
nat4Used := len(usedPorts)
nat4Remaining := totalNAT4Ports - nat4Used
if nat4Remaining < 0 {
nat4Remaining = 0
}
prefixes := lxc.DetectPublicIPv6Prefixes()
ipv6Total := "0"
ipv6Remaining := "0"
if len(prefixes) > 0 {
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
}
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: routingResponse{
NAT4: routeCapacity{
Used: nat4Used,
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
Total: ipv6Total,
},
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
},
})
}
func subtractCapacity(total string, used int) string {
if total == "" || total == "0" {
return "0"
}
if total == "large" {
return "large"
}
parsed, err := strconv.ParseInt(total, 10, 64)
if err != nil {
return total
}
remaining := parsed - int64(used)
if remaining < 0 {
remaining = 0
}
return strconv.FormatInt(remaining, 10)
}
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"clicd/internal/config"
)
func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
}
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
switch {
case action == "snapshots" && r.Method == http.MethodGet:
listContainerSnapshots(w, r, containerID)
case action == "snapshots" && r.Method == http.MethodPost:
createContainerSnapshot(w, r, containerID)
case action == "snapshots/schedule" && r.Method == http.MethodPost:
updateSnapshotSchedule(w, r, containerID)
case action == "snapshots/quota" && r.Method == http.MethodPut:
updateSnapshotQuota(w, r, containerID)
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
restoreContainerSnapshot(w, r, containerID, snapshotID)
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
snapshotID := strings.TrimPrefix(action, "snapshots/")
deleteContainerSnapshot(w, r, containerID, snapshotID)
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot action not found"})
}
}
func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int) {
c := config.FindContainer(containerID)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
snapshots := config.ContainerSnapshots(containerID)
sortSnapshotsNewestFirst(snapshots)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"snapshots": snapshots,
"quota": config.ContainerSnapshotLimit(c),
"schedule": map[string]interface{}{
"enabled": c.SnapshotScheduleEnabled,
"interval_hours": c.SnapshotScheduleIntervalHours,
"time": c.SnapshotScheduleTime,
"last_run": c.SnapshotScheduleLastRun,
"next_run": c.SnapshotScheduleNextRun,
"created_by": c.SnapshotScheduleCreatedBy,
},
}})
}
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
user := requestUser(r)
if isSubUserRequest(r) {
c := config.FindContainer(containerID)
limit := config.ContainerSnapshotLimit(c)
if len(config.ContainerSnapshots(containerID)) >= limit {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Snapshot quota reached. Delete an old snapshot first."})
return
}
}
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.create", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: snapshot})
}
func updateSnapshotQuota(w http.ResponseWriter, r *http.Request, containerID int) {
if isSubUserRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot change snapshot quota"})
return
}
var req struct {
SnapshotLimit int `json:"snapshot_limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.SnapshotLimit <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot quota must be at least 1"})
return
}
c := config.FindContainer(containerID)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
c.SnapshotLimit = req.SnapshotLimit
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
return
}
user := requestUser(r)
config.AddAuditLog("snapshot.quota", c.Name, "limit="+strconv.Itoa(req.SnapshotLimit), user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"container": c,
"quota": c.SnapshotLimit,
}})
}
func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID int) {
var req struct {
Enabled bool `json:"enabled"`
IntervalHours int `json:"interval_hours"`
Time string `json:"time"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.IntervalHours <= 0 {
req.IntervalHours = 24
}
if req.IntervalHours < 24 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot schedule interval cannot be less than 24 hours"})
return
}
if req.Time == "" {
req.Time = "03:00"
}
user := requestUser(r)
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if req.Enabled {
config.AddAuditLog("snapshot.schedule", c.Name, "enabled", user)
} else {
config.AddAuditLog("snapshot.schedule", c.Name, "disabled", user)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"container": c,
}})
}
func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil || snapshot.ContainerID != containerID {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
user := requestUser(r)
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.delete", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot deleted"})
}
func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil || snapshot.ContainerID != containerID {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
user := requestUser(r)
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
config.AddAuditLog("snapshot.restore", snapshot.ContainerName, snapshot.ID, user)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot restored"})
}
func requestUser(r *http.Request) string {
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
return "user:" + subUser
}
if username, _ := claims["username"].(string); username != "" {
return username
}
}
return "admin"
}
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
sort.SliceStable(snapshots, func(i, j int) bool {
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
return tj.Before(ti)
})
}
+43 -3
View File
@@ -2,6 +2,7 @@ package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
@@ -73,7 +74,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
ticket := webSSHTicketFromRequest(r)
if ticket == "" {
http.Error(w, "ticket required", http.StatusUnauthorized)
return
@@ -114,7 +115,11 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container ip is not available", http.StatusBadRequest)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
responseHeader := http.Header{}
if protocol := webSSHTicketProtocol(r); protocol != "" {
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
}
ws, err := upgrader.Upgrade(w, r, responseHeader)
if err != nil {
log.Printf("WebSSH upgrade failed: %v", err)
return
@@ -141,7 +146,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
Auth: []ssh.AuthMethod{
ssh.Password(c.SSHPassword),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: containerHostKeyCallback(c),
Timeout: 4 * time.Second,
}
@@ -250,6 +255,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
log.Printf("WebSSH disconnected for container %s", containerName)
}
func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
sum := sha256.Sum256(key.Marshal())
fingerprint := hex.EncodeToString(sum[:])
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
return fmt.Errorf("container SSH host key mismatch")
}
if c.SSHHostKey == "" {
c.SSHHostKey = fingerprint
config.SaveConfig()
}
return nil
}
}
func webSSHTicketFromRequest(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol[len(prefix):]
}
}
return ""
}
func webSSHTicketProtocol(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol
}
}
return ""
}
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
defer func() { done <- struct{}{} }()
+80 -39
View File
@@ -21,6 +21,28 @@ func generateRandomStr(length int) string {
return hex.EncodeToString(b)[:length]
}
type subUserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
return subUserResponse{
ID: su.ID,
Username: su.Username,
Password: password,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt,
}
}
// HandleSubUserCreate creates a sub-user for a specific container
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -47,29 +69,24 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
// 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 {
for _, uuid := range su.ContainerUUIDs {
if uuid == c.UUID {
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)
}
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(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))
su.Password = ""
su.Token = ""
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
config.SaveConfig()
// Return existing
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Sub-user already exists",
Data: *su,
Message: "Sub-user password rotated",
Data: newSubUserResponse(*su, password),
})
return
}
@@ -84,16 +101,12 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
// 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,
ContainerUUIDs: []string{c.UUID},
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
@@ -102,7 +115,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
config.SaveConfig()
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: newSubUserResponse(subUser, password)})
}
// HandleSubUserLogin handles sub-user login
@@ -126,7 +139,7 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return
@@ -173,7 +186,7 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
return
}
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return
@@ -224,18 +237,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
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 {
@@ -359,15 +360,27 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
}
}
return filtered
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID])
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
}
func isSubUserContainerActionAllowed(action string, method string) bool {
@@ -377,6 +390,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
switch {
case action == "usage" || action == "traffic" || action == "random-port":
return method == http.MethodGet
case action == "snapshots":
return method == http.MethodGet || method == http.MethodPost
case action == "snapshots/schedule":
return method == http.MethodPost
case strings.HasPrefix(action, "snapshots/"):
return method == http.MethodDelete || method == http.MethodPost
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
@@ -386,16 +405,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
}
}
func activeSubUserContainerUUIDs(su *config.SubUser) []string {
uuids := make([]string, 0, len(su.ContainerUUIDs))
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
uuids = appendUniqueString(uuids, c.UUID)
}
}
if len(uuids) > 0 {
return uuids
}
return subUserContainerUUIDs(su.ContainerNames)
}
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)
uuids = appendUniqueString(uuids, c.UUID)
}
}
return uuids
}
func appendUniqueString(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func splitPath(path string) []string {
parts := make([]string, 0)
for _, p := range splitBy(path, "/") {
+14
View File
@@ -442,6 +442,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template
}
}
if !isTemplateEnabledAndDownloaded(templateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
@@ -504,6 +508,16 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
}
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
}
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
+495 -107
View File
@@ -2,14 +2,20 @@ package cli
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"clicd/internal/config"
"clicd/internal/lxc"
"clicd/internal/version"
)
var manager = lxc.NewManager()
@@ -20,12 +26,12 @@ func Run() {
for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("Failed to reload config: %v\n", err)
fmt.Printf("重新加载配置失败: %v\n", err)
waitEnter(reader)
}
clearScreen()
printMenu()
fmt.Print("\nSelect action [1-11,0/q]: ")
fmt.Print("\n请选择操作 [1-12,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
@@ -71,6 +77,10 @@ func Run() {
cliImportExistingContainers()
waitEnter(reader)
case "11":
clearScreen()
cliUpgradeSystem(reader)
waitEnter(reader)
case "12":
clearScreen()
cliUninstall(reader)
return
@@ -79,60 +89,62 @@ func Run() {
cliShowInfo()
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("Bye")
fmt.Println("再见")
return
default:
fmt.Println("Invalid selection")
fmt.Println("无效选择")
}
}
}
func printMenu() {
webStatus := "start"
webStatus := "启动"
if isWebPanelRunning() {
webStatus = "stop"
webStatus = "停止"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println(" CLICD - LXC 容器管理器")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web panel: %s (port %d)\n", func() string {
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
if isWebPanelRunning() {
return "running"
return "运行中"
}
return "stopped"
return "已停止"
}(), config.AppConfig.Port)
fmt.Printf(" 当前版本: %s\n", version.Current())
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(" 10. Import existing LXC containers")
fmt.Println(" 11. Uninstall CLICD")
fmt.Println(" 0. System info")
fmt.Println(" q. Quit")
fmt.Println(" 1. 查看容器列表")
fmt.Println(" 2. 创建容器")
fmt.Println(" 3. 开机容器")
fmt.Println(" 4. 关机容器")
fmt.Println(" 5. 重启容器")
fmt.Println(" 6. 删除容器")
fmt.Println(" 7. 重装容器系统")
fmt.Println(" 8. 重置 Web 管理员密码")
fmt.Printf(" 9. %s Web 面板\n", webStatus)
fmt.Println(" 10. 导入现有 LXC 容器")
fmt.Println(" 11. 检查并升级 CLICD")
fmt.Println(" 12. 卸载 CLICD")
fmt.Println(" 0. 系统信息")
fmt.Println(" q. 退出")
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to list containers: %v\n", err)
fmt.Printf("获取容器列表失败: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\nNo containers")
fmt.Println("\n暂无容器")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "Name", "Status", "Template", "vCPU", "RAM(MB)", "Disk(GB)", "SSH")
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
@@ -145,23 +157,23 @@ func cliListContainers() {
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- Create container ---")
fmt.Println("\n--- 创建容器 ---")
name := promptString(reader, "Container name", "")
name := promptString(reader, "容器名称", "")
if name == "" {
fmt.Println("Container name is required")
fmt.Println("容器名称不能为空")
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
fmt.Println("\n可用镜像:")
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)
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
fmt.Println("镜像选择无效")
return
}
@@ -169,22 +181,22 @@ func cliCreateContainer(reader *bufio.Reader) {
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"),
RAMMB: promptInt(reader, "内存 (MB)", 512),
DiskGB: promptInt(reader, "磁盘 (GB)", 10),
NetworkBWMbps: promptInt(reader, "网络带宽 (Mbps)", 100),
MonthlyTrafficGB: promptInt(reader, "月流量 (GB)", 1000),
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
}
fmt.Printf("\nCreating container %s ...\n", name)
fmt.Printf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("Create failed: %v\n", err)
fmt.Printf("创建失败: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("Container %s created successfully\n", name)
fmt.Printf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
}
@@ -192,126 +204,441 @@ func cliCreateContainer(reader *bufio.Reader) {
}
func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "start")
id, name := selectContainer(reader, "开机")
if id == 0 {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("Start failed: %v\n", err)
fmt.Printf("开机失败: %v\n", err)
return
}
fmt.Printf("Container %s started\n", name)
fmt.Printf("容器 %s 已开机\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "stop")
id, name := selectContainer(reader, "关机")
if id == 0 {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("Stop failed: %v\n", err)
fmt.Printf("关机失败: %v\n", err)
return
}
fmt.Printf("Container %s stopped\n", name)
fmt.Printf("容器 %s 已关机\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "restart")
id, name := selectContainer(reader, "重启")
if id == 0 {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("Restart failed: %v\n", err)
fmt.Printf("重启失败: %v\n", err)
return
}
fmt.Printf("Container %s restarted\n", name)
fmt.Printf("容器 %s 已重启\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "delete")
id, name := selectContainer(reader, "删除")
if id == 0 {
return
}
confirm := promptString(reader, fmt.Sprintf("Delete container %s? Type yes", name), "no")
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
fmt.Println("已取消")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("Delete failed: %v\n", err)
fmt.Printf("删除失败: %v\n", err)
return
}
fmt.Printf("Container %s deleted\n", name)
fmt.Printf("容器 %s 已删除\n", name)
restartWebPanelForConfigChange()
}
func cliReinstallContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "reinstall")
id, name := selectContainer(reader, "重装")
if id == 0 {
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
fmt.Println("\n可用镜像:")
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)
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
fmt.Println("镜像选择无效")
return
}
confirm := promptString(reader, fmt.Sprintf("Reinstall container %s? Type yes", name), "no")
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
fmt.Println("已取消")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("Reinstall failed: %v\n", err)
fmt.Printf("重装失败: %v\n", err)
return
}
fmt.Printf("Container %s reinstalled\n", name)
fmt.Printf("容器 %s 已重装\n", name)
restartWebPanelForConfigChange()
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "New admin password (at least 6 chars)", "")
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
if len(newPass) < 6 {
fmt.Println("Password must be at least 6 chars")
fmt.Println("密码至少需要 6 位")
return
}
confirm := promptString(reader, "Confirm password", "")
confirm := promptString(reader, "确认密码", "")
if newPass != confirm {
fmt.Println("Passwords do not match")
fmt.Println("两次输入的密码不一致")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("Reset failed: %v\n", err)
fmt.Printf("重置失败: %v\n", err)
return
}
fmt.Println("Admin password reset.")
fmt.Println("管理员密码已重置。")
restartWebPanelForConfigChange()
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
if err := stopService("clicd"); err != nil {
fmt.Printf("Failed to stop web panel: %v\n", err)
fmt.Printf("停止 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web panel stopped. LXC containers are not affected.")
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
return
}
if err := startService("clicd"); err != nil {
fmt.Printf("Failed to start web panel: %v\n", err)
fmt.Printf("启动 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web panel started")
fmt.Println("Web 面板已启动")
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func cliUpgradeSystem(reader *bufio.Reader) {
fmt.Println("\n--- 检查并升级 CLICD ---")
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
if os.Geteuid() != 0 {
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
return
}
repo := strings.TrimSpace(os.Getenv("CLICD_REPO"))
if repo == "" {
repo = version.Repo
}
current := version.Current()
fmt.Printf("当前版本: %s\n", current)
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
if err != nil {
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
return
}
latest := strings.TrimSpace(release.TagName)
if latest == "" {
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
return
}
fmt.Printf("最新版本: %s\n", latest)
if release.HTMLURL != "" {
fmt.Printf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
if assetURL == "" {
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
return
}
if sameVersion(current, latest) {
fmt.Println("当前已经是最新版本。")
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
if strings.ToLower(confirm) != "reinstall" {
fmt.Println("已取消。")
return
}
} else {
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
if strings.ToLower(confirm) != "upgrade" {
fmt.Println("已取消。")
return
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
fmt.Printf("升级失败: %v\n", err)
return
}
fmt.Printf("升级完成: %s -> %s\n", current, latest)
fmt.Println("原有数据已保留,Web 服务已重启。")
}
func fetchLatestRelease(repo string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
setGitHubRequestHeaders(req)
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
return fallback, nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
} else {
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
}
return fallback, nil
}
return nil, apiErr
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "clicd-updater/"+version.Current())
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("GitHub releases/latest 返回 %s", resp.Status)
}
tag := latestTagFromPath(resp.Request.URL.Path)
if tag == "" {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
}
const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{
TagName: tag,
Name: tag,
HTMLURL: fmt.Sprintf("https://github.com/%s/releases/tag/%s", repo, tag),
Assets: []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}{
{
Name: assetName,
BrowserDownloadURL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", repo, assetName),
},
},
}, nil
}
func latestTagFromPath(path string) string {
const marker = "/releases/tag/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
tag := strings.TrimSpace(path[idx+len(marker):])
if slash := strings.Index(tag, "/"); slash >= 0 {
tag = tag[:slash]
}
return tag
}
func setGitHubRequestHeaders(req *http.Request) {
req.Header.Set("User-Agent", "clicd-updater/"+version.Current())
token := strings.TrimSpace(os.Getenv("CLICD_GITHUB_TOKEN"))
if token == "" {
token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
func findReleaseAsset(release *githubRelease, name string) string {
for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" {
return asset.BrowserDownloadURL
}
}
return ""
}
func upgradeFromReleaseAsset(assetURL, latest string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
fmt.Println("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
}
fmt.Println("正在解压升级包...")
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
}
newBinary, err := findFile(tmpDir, "clicd")
if err != nil {
return err
}
backupDir := "/root/clicd-backups"
if err := os.MkdirAll(backupDir, 0700); err != nil {
return err
}
backupPath := filepath.Join(backupDir, fmt.Sprintf("clicd.%s.%s", strings.TrimPrefix(latest, "v"), time.Now().Format("20060102-150405")))
if _, err := os.Stat("/usr/local/bin/clicd"); err == nil {
if err := copyFile("/usr/local/bin/clicd", backupPath, 0755); err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
}
fmt.Println("正在替换二进制...")
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := "/usr/local/bin/clicd.new"
if err := copyFile(newBinary, tmpBin, 0755); err != nil {
return err
}
if err := os.Rename(tmpBin, "/usr/local/bin/clicd"); err != nil {
return err
}
if err := os.Chmod("/usr/local/bin/clicd", 0755); err != nil {
return err
}
if err := restartService("clicd"); err != nil {
return fmt.Errorf("二进制已替换,但重启 Web 服务失败: %w", err)
}
return nil
}
func downloadFile(url, dest string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
setGitHubRequestHeaders(req)
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败,HTTP %s", resp.Status)
}
out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func findFile(root, name string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if d.Name() == name {
found = path
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("升级包内未找到 clicd 二进制")
}
return found, nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func sameVersion(current, latest string) bool {
c := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(current)), "v")
l := strings.TrimPrefix(strings.TrimSpace(strings.ToLower(latest)), "v")
return c != "" && c == l
}
func isWebPanelRunning() bool {
@@ -330,21 +657,21 @@ func isWebPanelRunning() bool {
}
func cliImportExistingContainers() {
fmt.Println("\n--- Import existing LXC containers ---")
fmt.Println("This imports containers from /var/lib/lxc into CLICD config.")
fmt.Println("Imported containers keep their real LXC name so Web and CLI manage the same container.")
fmt.Println("\n--- 导入现有 LXC 容器 ---")
fmt.Println(" /var/lib/lxc 里的容器导入 CLICD 配置。")
fmt.Println("导入后会保留真实 LXC 名称,Web CLI 都能管理同一个容器。")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("Import failed: %v\n", err)
fmt.Printf("导入失败: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("No new ct-* containers found to import.")
fmt.Println("没有发现新的 ct-* 容器。")
return
}
fmt.Printf("Imported %d container(s):\n", len(imported))
fmt.Printf("已导入 %d 个容器:\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
@@ -352,19 +679,19 @@ func cliImportExistingContainers() {
}
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- Uninstall CLICD ---")
fmt.Println("This removes the CLICD service and /usr/local/bin/clicd.")
fmt.Println("This also deletes /root/.clicd, all LXC containers under /var/lib/lxc, and LXC image cache under /var/cache/lxc.")
fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。")
if os.Geteuid() != 0 {
fmt.Println("Uninstall must be run as root.")
fmt.Println("Run: sudo clicd cli --no-web")
fmt.Println("卸载需要 root 权限。")
fmt.Println("请运行: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "Type uninstall to continue", "no")
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("Canceled")
fmt.Println("已取消")
return
}
@@ -381,8 +708,8 @@ func cliUninstall(reader *bufio.Reader) {
reloadSysctl()
fmt.Println()
fmt.Println("CLICD has been uninstalled.")
fmt.Println("Removed service, binary, config, containers, and LXC image cache.")
fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。")
}
func destroyAllLXCContainers() {
@@ -399,7 +726,67 @@ func destroyAllLXCContainers() {
fmt.Printf("Destroying LXC container %s...\n", name)
runQuiet("lxc-stop", "-n", name, "-k")
runQuiet("lxc-destroy", "-n", name, "-f")
removePath("/var/lib/lxc/" + name)
removeLXCContainerPath("/var/lib/lxc/" + name)
}
}
func removeLXCContainerPath(path string) {
unmountPathTree(path)
detachLoopDevices(path)
if err := os.RemoveAll(path); err == nil {
fmt.Printf("Removed %s\n", path)
return
}
runQuiet("fuser", "-km", path+"/rootfs")
runQuiet("fuser", "-km", path)
unmountPathTree(path)
detachLoopDevices(path)
removePath(path)
}
func unmountPathTree(path string) {
if commandExists("findmnt") {
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", path).Output()
if err == nil {
mounts := strings.Split(strings.TrimSpace(string(out)), "\n")
for i := len(mounts) - 1; i >= 0; i-- {
mountpoint := strings.TrimSpace(mounts[i])
if mountpoint != "" {
runQuiet("umount", "-R", "-l", mountpoint)
runQuiet("umount", "-l", mountpoint)
}
}
}
}
runQuiet("umount", "-R", "-l", path+"/rootfs")
runQuiet("umount", "-l", path+"/rootfs")
runQuiet("umount", "-R", "-l", path)
runQuiet("umount", "-l", path)
}
func detachLoopDevices(path string) {
if !commandExists("losetup") {
return
}
images := []string{path + "/rootfs.img"}
if entries, err := os.ReadDir(path); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".img") {
images = append(images, path+"/"+entry.Name())
}
}
}
for _, image := range images {
out, err := exec.Command("losetup", "-j", image).Output()
if err != nil {
continue
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if idx := strings.Index(line, ":"); idx > 0 {
runQuiet("losetup", "-d", line[:idx])
}
}
}
}
@@ -449,10 +836,10 @@ func runQuiet(name string, args ...string) {
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
fmt.Printf("Web panel reload skipped: %v\n", err)
fmt.Printf("Web 面板重载跳过: %v\n", err)
return
}
fmt.Println("Web panel reloaded to pick up config changes.")
fmt.Println("Web 面板已重载并应用配置变更。")
}
func stopService(name string) error {
@@ -488,7 +875,7 @@ func restartService(name string) error {
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to read container status: %v\n", err)
fmt.Printf("读取容器状态失败: %v\n", err)
}
total := len(containers)
@@ -499,43 +886,44 @@ func cliShowInfo() {
}
}
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)
fmt.Println("\n--- 系统信息 ---")
fmt.Printf("CLICD 版本: %s\n", version.Current())
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
fmt.Printf("容器总数: %d\n", total)
fmt.Printf("运行中: %d\n", running)
fmt.Printf("已停止: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("Hostname: %s\n", hostname)
fmt.Printf("主机名: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC version: %s", string(output))
fmt.Printf("LXC 版本: %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)
fmt.Printf("获取容器列表失败: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("No containers available")
fmt.Println("暂无可用容器")
return 0, ""
}
fmt.Printf("\n--- Select container to %s ---\n", action)
fmt.Printf("\n--- 选择要%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)
idx := promptInt(reader, "容器", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("Invalid selection")
fmt.Println("选择无效")
return 0, ""
}
@@ -581,7 +969,7 @@ func clearScreen() {
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\nPress Enter to return to menu...")
fmt.Print("\n Enter 返回菜单...")
reader.ReadString('\n')
}
@@ -595,7 +983,7 @@ func promptPortList(reader *bufio.Reader, label string) []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))
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
+230 -45
View File
@@ -56,44 +56,53 @@ type AuditLog struct {
// 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
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
SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers
}
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
LXCName string `json:"lxc_name,omitempty"`
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"`
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
LXCName string `json:"lxc_name,omitempty"`
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"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
}
// LxcName returns the internal LXC container name (ct-{id})
@@ -130,14 +139,27 @@ func DeleteApiKey(id string) {
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"` // plaintext for display
Password string `json:"-"`
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
Token string `json:"token"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
type Snapshot struct {
ID string `json:"id"`
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
Scheduled bool `json:"scheduled"`
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
@@ -157,11 +179,14 @@ type ClicdConfig struct {
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
}
var configPath string
var AppConfig *ClicdConfig
const DefaultSnapshotLimit = 3
func getConfigPath() string {
if configPath != "" {
return configPath
@@ -249,12 +274,14 @@ func InitConfig() (*ClicdConfig, error) {
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
SubUserSnapshotLimit: 3,
},
Snapshots: []Snapshot{},
}
if err := SaveConfig(); err != nil {
@@ -304,10 +331,25 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
}
if AppConfig.Snapshots == nil {
AppConfig.Snapshots = make([]Snapshot, 0)
}
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 {
AppConfig.Oversell.SubUserSnapshotLimit = 3
}
changed := ensureContainerUUIDs()
if ensureContainerPortMappingLimits() {
changed = true
}
if ensureContainerSnapshotLimits() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
if migrateSubUsers() {
changed = true
}
if removeLegacyVNCMappings() {
changed = true
}
@@ -320,6 +362,21 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil
}
func ensureContainerSnapshotScheduleDefaults() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleIntervalHours < 24 {
AppConfig.Containers[i].SnapshotScheduleIntervalHours = 24
changed = true
}
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleTime == "" {
AppConfig.Containers[i].SnapshotScheduleTime = "03:00"
changed = true
}
}
return changed
}
func ensureContainerUUIDs() bool {
changed := false
used := make(map[string]bool)
@@ -355,6 +412,76 @@ func ensureContainerPortMappingLimits() bool {
return changed
}
func ensureContainerSnapshotLimits() bool {
changed := false
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
if legacyLimit <= 0 {
legacyLimit = DefaultSnapshotLimit
}
for i := range AppConfig.Containers {
if AppConfig.Containers[i].SnapshotLimit <= 0 {
AppConfig.Containers[i].SnapshotLimit = legacyLimit
changed = true
}
}
return changed
}
func migrateSubUsers() bool {
changed := false
for i := range AppConfig.SubUsers {
su := &AppConfig.SubUsers[i]
if su.PassHash == "" && su.Password != "" {
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
su.PassHash = string(hash)
changed = true
}
}
if su.Password != "" {
su.Password = ""
changed = true
}
if su.Token != "" {
su.Token = ""
changed = true
}
if len(su.ContainerUUIDs) == 0 && len(su.ContainerNames) > 0 {
for _, name := range su.ContainerNames {
if c := FindContainerByName(name); c != nil && c.UUID != "" {
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
}
}
if len(su.ContainerUUIDs) > 0 {
changed = true
}
}
}
return changed
}
func appendUniqueString(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func NormalizeSnapshotLimit(limit int) int {
if limit <= 0 {
return DefaultSnapshotLimit
}
return limit
}
func ContainerSnapshotLimit(c *Container) int {
if c == nil {
return DefaultSnapshotLimit
}
return NormalizeSnapshotLimit(c.SnapshotLimit)
}
func removeLegacyVNCMappings() bool {
changed := false
for i := range AppConfig.Containers {
@@ -407,7 +534,8 @@ func AllocateContainerID() int {
func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers {
if c.ID == id {
removeSubUserContainerAccess(c.Name)
removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id)
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
@@ -416,8 +544,58 @@ func RemoveContainer(id int) bool {
return false
}
func removeSubUserContainerAccess(containerName string) {
if containerName == "" || len(AppConfig.SubUsers) == 0 {
func AddSnapshot(snapshot Snapshot) {
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
SaveConfig()
}
func FindSnapshot(id string) *Snapshot {
for i := range AppConfig.Snapshots {
if AppConfig.Snapshots[i].ID == id {
return &AppConfig.Snapshots[i]
}
}
return nil
}
func RemoveSnapshot(id string) bool {
for i := range AppConfig.Snapshots {
if AppConfig.Snapshots[i].ID == id {
AppConfig.Snapshots = append(AppConfig.Snapshots[:i], AppConfig.Snapshots[i+1:]...)
SaveConfig()
return true
}
}
return false
}
func ContainerSnapshots(containerID int) []Snapshot {
result := make([]Snapshot, 0)
for _, snapshot := range AppConfig.Snapshots {
if snapshot.ContainerID == containerID {
result = append(result, snapshot)
}
}
return result
}
func removeContainerSnapshotMetadata(containerID int) {
filtered := make([]Snapshot, 0, len(AppConfig.Snapshots))
for _, snapshot := range AppConfig.Snapshots {
if snapshot.ContainerID != containerID {
filtered = append(filtered, snapshot)
}
}
AppConfig.Snapshots = filtered
}
func RemoveSubUserContainerAccess(containerName string, containerUUID string) {
removeSubUserContainerAccess(containerName, containerUUID)
SaveConfig()
}
func removeSubUserContainerAccess(containerName string, containerUUID string) {
if containerName == "" && containerUUID == "" || len(AppConfig.SubUsers) == 0 {
return
}
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
@@ -428,10 +606,17 @@ func removeSubUserContainerAccess(containerName string) {
filteredNames = append(filteredNames, name)
}
}
if len(filteredNames) == 0 {
filteredUUIDs := make([]string, 0, len(su.ContainerUUIDs))
for _, uuid := range su.ContainerUUIDs {
if uuid != containerUUID {
filteredUUIDs = append(filteredUUIDs, uuid)
}
}
if len(filteredNames) == 0 && len(filteredUUIDs) == 0 {
continue
}
su.ContainerNames = filteredNames
su.ContainerUUIDs = filteredUUIDs
filteredUsers = append(filteredUsers, su)
}
AppConfig.SubUsers = filteredUsers
+255 -1
View File
@@ -428,6 +428,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
return nil, err
}
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
if _, err := os.Stat(rootfsPath); err == nil {
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
}
}
if err := m.ApplyIPv6(id); err != nil {
return nil, err
}
@@ -477,6 +483,12 @@ func (m *Manager) ApplyIPv6(id int) error {
config.SaveConfig()
}
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
if _, err := os.Stat(rootfsPath); err == nil {
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
}
}
if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
return err
}
@@ -485,12 +497,16 @@ func (m *Manager) ApplyIPv6(id int) error {
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",
fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0 metric 100",
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))
}
removeIPv6NAT66(c.IPv6, c.IPv6Interface)
if !containerIPv6ConnectivityOK(c.LxcName()) {
ensureIPv6NAT66(c.IPv6, c.IPv6Interface)
}
return nil
}
@@ -513,6 +529,191 @@ func ensureHostIPv6Routing(ipv6, uplink string) error {
return nil
}
func installContainerIPv6Init(rootfsPath, ipv6 string) error {
if strings.TrimSpace(ipv6) == "" {
return nil
}
if _, err := netip.ParseAddr(ipv6); err != nil {
return fmt.Errorf("invalid IPv6 address %q: %w", ipv6, err)
}
scriptPath := filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init")
if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil {
return err
}
script := `#!/bin/sh
IPV6_ADDR=` + shellQuote(ipv6) + `
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
IFACE="${CLICD_IPV6_IFACE:-eth0}"
command -v ip >/dev/null 2>&1 || exit 0
i=0
while [ "$i" -lt 30 ]; do
if ip link show dev "$IFACE" >/dev/null 2>&1; then
break
fi
i=$((i + 1))
sleep 1
done
ip link set dev "$IFACE" up >/dev/null 2>&1 || true
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" >/dev/null 2>&1 || true
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 >/dev/null 2>&1 || true
exit 0
`
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
return err
}
osRelease := ""
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
osRelease = strings.ToLower(string(data))
}
hasSystemd := dirExists(filepath.Join(rootfsPath, "etc", "systemd", "system"))
hasOpenRC := fileExists(filepath.Join(rootfsPath, "sbin", "openrc-run")) || strings.Contains(osRelease, "alpine")
if hasSystemd {
if err := installContainerIPv6Systemd(rootfsPath); err != nil {
return err
}
}
if hasOpenRC {
if err := installContainerIPv6OpenRC(rootfsPath); err != nil {
return err
}
}
if !hasSystemd && !hasOpenRC {
if err := installContainerIPv6SysV(rootfsPath); err != nil {
return err
}
}
return nil
}
func installContainerIPv6Systemd(rootfsPath string) error {
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
return err
}
service := `[Unit]
Description=CLICD IPv6 setup
After=network-online.target network.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/clicd-ipv6-init
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
`
if err := os.WriteFile(servicePath, []byte(service), 0644); err != nil {
return err
}
wantsDir := filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0755); err != nil {
return err
}
return replaceSymlink("../clicd-ipv6.service", filepath.Join(wantsDir, "clicd-ipv6.service"))
}
func installContainerIPv6OpenRC(rootfsPath string) error {
initPath := filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6")
if err := os.MkdirAll(filepath.Dir(initPath), 0755); err != nil {
return err
}
initScript := `#!/sbin/openrc-run
name="CLICD IPv6 setup"
description="Apply CLICD IPv6 settings"
depend() {
after net networking
need net
}
start() {
ebegin "Applying CLICD IPv6"
/usr/local/sbin/clicd-ipv6-init
eend $?
}
`
if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil {
return err
}
runlevelDir := filepath.Join(rootfsPath, "etc", "runlevels", "default")
if err := os.MkdirAll(runlevelDir, 0755); err != nil {
return err
}
return replaceSymlink(filepath.Join("..", "..", "init.d", "clicd-ipv6"), filepath.Join(runlevelDir, "clicd-ipv6"))
}
func installContainerIPv6SysV(rootfsPath string) error {
initDir := filepath.Join(rootfsPath, "etc", "init.d")
if err := os.MkdirAll(initDir, 0755); err != nil {
return err
}
initPath := filepath.Join(initDir, "clicd-ipv6")
initScript := `#!/bin/sh
### BEGIN INIT INFO
# Provides: clicd-ipv6
# Required-Start: $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: CLICD IPv6 setup
### END INIT INFO
case "$1" in
start|restart|force-reload)
/usr/local/sbin/clicd-ipv6-init
;;
stop|status)
exit 0
;;
*)
echo "Usage: $0 {start|stop|restart|force-reload|status}"
exit 1
;;
esac
exit 0
`
if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil {
return err
}
for _, level := range []string{"2", "3", "4", "5"} {
rcDir := filepath.Join(rootfsPath, "etc", "rc"+level+".d")
if !dirExists(rcDir) {
continue
}
if err := replaceSymlink(filepath.Join("..", "init.d", "clicd-ipv6"), filepath.Join(rcDir, "S99clicd-ipv6")); err != nil {
return err
}
}
return nil
}
func replaceSymlink(target, linkPath string) error {
if current, err := os.Readlink(linkPath); err == nil && current == target {
return nil
}
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
return err
}
return os.Symlink(target, linkPath)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func ensureIPv6ForwardRules(ipv6 string) {
rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
@@ -527,6 +728,59 @@ func ensureIPv6ForwardRules(ipv6 string) {
}
}
func removeHostIPv6Routing(ipv6, uplink string) {
removeIPv6NAT66(ipv6, uplink)
removeIPv6ForwardRules(ipv6)
runQuiet("ip", "-6", "route", "del", ipv6+"/128", "dev", "lxcbr0")
if uplink != "" {
runQuiet("ip", "-6", "neigh", "del", "proxy", ipv6, "dev", uplink)
}
}
func removeIPv6ForwardRules(ipv6 string) {
rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
{"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"},
}
for _, rule := range rules {
del := append([]string{"-D"}, rule...)
for exec.Command("ip6tables", del...).Run() == nil {
}
}
}
func containerIPv6ConnectivityOK(lxcName string) bool {
targets := []string{"2606:4700:4700::1111", "2001:4860:4860::8888"}
for _, target := range targets {
if exec.Command("lxc-attach", "-n", lxcName, "--", "ping", "-6", "-c", "1", "-W", "2", target).Run() == nil {
return true
}
}
return false
}
func ensureIPv6NAT66(ipv6, uplink string) {
if ipv6 == "" || uplink == "" {
return
}
rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"}
check := append([]string{"-t", "nat", "-C"}, rule...)
add := append([]string{"-t", "nat", "-A"}, rule...)
if exec.Command("ip6tables", check...).Run() != nil {
exec.Command("ip6tables", add...).Run()
}
}
func removeIPv6NAT66(ipv6, uplink string) {
if ipv6 == "" || uplink == "" {
return
}
rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"}
del := append([]string{"-t", "nat", "-D"}, rule...)
for exec.Command("ip6tables", del...).Run() == nil {
}
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
+170 -22
View File
@@ -1,6 +1,7 @@
package lxc
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
@@ -105,19 +106,23 @@ func (m *Manager) updateAllRates() {
}
lxcName := c.LxcName()
// Read raw bytes
memUsage := readIntCommand(fmt.Sprintf(
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
"cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
// Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
// don't each fork lxc-info separately.
initPID := m.getContainerInitPID(lxcName)
cpuUsec := uint64(readIntCommand(fmt.Sprintf(
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName))))
// Read memory from cgroup directly (no shell fork)
memUsage := readCgroupFile(lxcName,
"/sys/fs/cgroup/lxc/%s/memory.current",
"/sys/fs/cgroup/lxc.payload.%s/memory.current",
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName)
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName)
// Read cpu usage from cgroup directly (no shell | awk fork)
cpuUsec := readCgroupCPUUsec(lxcName,
"/sys/fs/cgroup/lxc/%s/cpu.stat",
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
rxBytes, txBytes := getNetworkBytesForPID(initPID)
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
now := time.Now()
sample := containerUsageSample{
@@ -216,6 +221,7 @@ type ContainerConfig struct {
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
}
@@ -226,6 +232,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
}
if cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if !config.IsValidContainerName(cfg.Name) {
return fmt.Errorf("invalid container name: %s", cfg.Name)
@@ -351,6 +363,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
SSHPassword: sshPassword,
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
@@ -359,6 +372,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
if ipv6 != "" {
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
@@ -1411,6 +1429,9 @@ func (m *Manager) DestroyContainer(id int) error {
return fmt.Errorf("container not found: %d", id)
}
lxcName := c.LxcName()
if c.IPv6 != "" && c.IPv6Interface != "" {
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
}
if err := m.StopContainer(id); err != nil {
return fmt.Errorf("failed to stop container before destroy: %v", err)
@@ -1458,6 +1479,10 @@ func (m *Manager) DestroyContainer(id int) error {
}
return fmt.Errorf("container still exists after cleanup with status %s", status)
}
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir)
}
if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
@@ -1986,6 +2011,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
Status: status,
CreatedAt: time.Now().Format(time.RFC3339),
PortMappingLimit: 2,
SnapshotLimit: config.DefaultSnapshotLimit,
}
if status == "running" {
@@ -2088,6 +2114,11 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID)
if c.IPv6 != "" {
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
}
}
if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16)
}
@@ -2103,6 +2134,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Update template and keep everything else the same
c.Template = templateID
c.SSHHostKey = ""
c.Status = "running"
config.SaveConfig()
@@ -2218,10 +2250,38 @@ func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
dir := fmt.Sprintf("/proc/%s/net", pid)
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir)))
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
return uint64(rx), uint64(tx)
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
}
// readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
func readProcNetDev(path string) (uint64, uint64) {
data, err := os.ReadFile(path)
if err != nil {
return 0, 0
}
var rx, tx uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
// Skip header lines
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
continue
}
fields := strings.Fields(line)
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
// Skip loopback (face starts with "lo")
if len(fields) < 10 {
continue
}
if strings.HasPrefix(fields[0], "lo") {
continue
}
r, _ := strconv.ParseUint(fields[1], 10, 64)
t, _ := strconv.ParseUint(fields[9], 10, 64)
rx += r
tx += t
}
return rx, tx
}
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
@@ -2229,10 +2289,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
// /proc/PID/io format: "field_name: value" per line
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
if err != nil {
return 0, 0
}
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
readBytes, _ = strconv.ParseUint(val, 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
writeBytes, _ = strconv.ParseUint(val, 10, 64)
}
}
return readBytes, writeBytes
}
@@ -2245,6 +2317,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
return strings.TrimSpace(string(out))
}
// readCgroupFile tries each path template in order, reads the file directly (no shell),
// and returns the first valid int64 value.
func readCgroupFile(name string, paths ...string) int64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err == nil && val > 0 {
return val
}
}
return 0
}
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
func readCgroupCPUUsec(name string, paths ...string) uint64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "usage_usec ") {
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
if err == nil {
return val
}
}
}
}
return 0
}
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
func getNetworkBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
}
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
if err != nil {
return 0, 0
}
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
}
}
return readBytes, writeBytes
}
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
pid := m.getContainerInitPID(lxcName)
@@ -2372,6 +2511,7 @@ func (m *Manager) AccumulateTraffic() {
lastTrafficSnapshotMu.Lock()
defer lastTrafficSnapshotMu.Unlock()
changed := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
@@ -2385,17 +2525,25 @@ func (m *Manager) AccumulateTraffic() {
c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth
delete(lastTrafficSnapshot, c.LxcName())
changed = true
}
rx, tx := m.getContainerNetworkBytes(c.LxcName())
prev, exists := lastTrafficSnapshot[c.LxcName()]
// Only add the DELTA (increment since last snapshot)
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
c.TrafficUsedRX += int64(rx - prev.RXBytes)
c.TrafficUsedTX += int64(tx - prev.TXBytes)
deltaRX := int64(rx - prev.RXBytes)
deltaTX := int64(tx - prev.TXBytes)
if deltaRX > 0 || deltaTX > 0 {
c.TrafficUsedRX += deltaRX
c.TrafficUsedTX += deltaTX
changed = true
}
}
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
}
config.SaveConfig()
if changed {
config.SaveConfig()
}
}
// GetTrafficInfo returns traffic usage info for a container
+349
View File
@@ -0,0 +1,349 @@
package lxc
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"clicd/internal/config"
)
var snapshotMu sync.Mutex
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
snapshotMu.Lock()
defer snapshotMu.Unlock()
c := config.FindContainer(id)
if c == nil {
return config.Snapshot{}, fmt.Errorf("container not found: %d", id)
}
if scheduled && rotateLimit > 0 {
for {
existing := config.ContainerSnapshots(id)
if len(existing) < rotateLimit {
break
}
sortSnapshotsOldestFirst(existing)
if err := m.deleteSnapshotLocked(existing[0]); err != nil {
return config.Snapshot{}, err
}
}
}
lxcName := c.LxcName()
containerDir := filepath.Join(m.LxcPath, lxcName)
if _, err := os.Stat(containerDir); err != nil {
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
return config.Snapshot{}, err
}
wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir)
if err != nil {
os.RemoveAll(snapshotDir)
return config.Snapshot{}, err
}
if wasRunning {
defer func() {
if err := m.StartContainer(id); err != nil {
fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err)
}
}()
}
if err := copyTree(containerDir, snapshotDir); err != nil {
os.RemoveAll(snapshotDir)
return config.Snapshot{}, err
}
snapshot := config.Snapshot{
ID: snapshotID,
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: lxcName,
CreatedAt: now.Format("2006-01-02 15:04:05"),
CreatedBy: createdBy,
Scheduled: scheduled,
Path: snapshotDir,
SizeBytes: dirSizeBytes(snapshotDir),
}
config.AddSnapshot(snapshot)
return snapshot, nil
}
func (m *Manager) DeleteSnapshot(id string) error {
snapshotMu.Lock()
defer snapshotMu.Unlock()
snapshot := config.FindSnapshot(id)
if snapshot == nil {
return fmt.Errorf("snapshot not found: %s", id)
}
return m.deleteSnapshotLocked(*snapshot)
}
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
return fmt.Errorf("failed to delete snapshot files: %v", err)
}
}
config.RemoveSnapshot(snapshot.ID)
return nil
}
func (m *Manager) RestoreSnapshot(id string) error {
snapshotMu.Lock()
defer snapshotMu.Unlock()
snapshot := config.FindSnapshot(id)
if snapshot == nil {
return fmt.Errorf("snapshot not found: %s", id)
}
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
return fmt.Errorf("snapshot files not found: %v", err)
}
c := config.FindContainer(snapshot.ContainerID)
if c == nil {
return fmt.Errorf("container not found: %d", snapshot.ContainerID)
}
lxcName := c.LxcName()
containerDir := filepath.Join(m.LxcPath, lxcName)
if err := safePathUnder(containerDir, m.LxcPath); err != nil {
return err
}
wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir)
if err != nil {
return err
}
backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano()))
if err := safePathUnder(backupDir, m.LxcPath); err != nil {
return err
}
if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to move current container aside: %v", err)
}
if err := copyTree(snapshot.Path, containerDir); err != nil {
os.RemoveAll(containerDir)
_ = os.Rename(backupDir, containerDir)
return fmt.Errorf("failed to restore snapshot: %v", err)
}
_ = os.RemoveAll(backupDir)
config.UpdateContainerStatus(c.ID, "stopped")
if wasRunning {
return m.StartContainer(c.ID)
}
return nil
}
func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if intervalHours < 24 {
return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours")
}
if _, err := parseScheduleClock(scheduleTime); err != nil {
return nil, err
}
c.SnapshotScheduleEnabled = enabled
c.SnapshotScheduleIntervalHours = intervalHours
c.SnapshotScheduleTime = scheduleTime
c.SnapshotScheduleCreatedBy = createdBy
if enabled {
c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339)
} else {
c.SnapshotScheduleNextRun = ""
}
if err := config.SaveConfig(); err != nil {
return nil, err
}
return c, nil
}
func (m *Manager) StartSnapshotScheduler() {
go func() {
m.runDueSnapshotSchedules()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
m.runDueSnapshotSchedules()
}
}()
}
func (m *Manager) runDueSnapshotSchedules() {
now := time.Now()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.SnapshotScheduleEnabled {
continue
}
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
if err != nil || c.SnapshotScheduleNextRun == "" {
nextRun = now
}
if now.Before(nextRun) {
continue
}
createdBy := c.SnapshotScheduleCreatedBy
if createdBy == "" {
createdBy = "admin"
}
rotateLimit := 0
if strings.HasPrefix(createdBy, "user:") {
rotateLimit = config.ContainerSnapshotLimit(&c)
}
if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil {
fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err)
continue
}
if current := config.FindContainer(c.ID); current != nil {
interval := current.SnapshotScheduleIntervalHours
if interval < 24 {
interval = 24
}
next := nextRun.Add(time.Duration(interval) * time.Hour)
for !next.After(now) {
next = next.Add(time.Duration(interval) * time.Hour)
}
current.SnapshotScheduleLastRun = now.Format(time.RFC3339)
current.SnapshotScheduleNextRun = next.Format(time.RFC3339)
config.SaveConfig()
}
}
}
func parseScheduleClock(value string) (time.Duration, error) {
parts := strings.Split(value, ":")
if len(parts) != 2 {
return 0, fmt.Errorf("snapshot schedule time must be HH:MM")
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, fmt.Errorf("snapshot schedule hour must be 00-23")
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, fmt.Errorf("snapshot schedule minute must be 00-59")
}
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
}
func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time {
clock, err := parseScheduleClock(scheduleTime)
if err != nil {
clock = 3 * time.Hour
}
midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
next := midnight.Add(clock)
interval := time.Duration(intervalHours) * time.Hour
for !next.After(from) {
next = next.Add(interval)
}
return next
}
func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) {
status, _ := m.GetContainerStatus(lxcName)
wasRunning := status == "running"
if wasRunning {
if err := m.StopContainer(id); err != nil {
return false, err
}
time.Sleep(time.Second)
} else if c := config.FindContainer(id); c != nil {
m.CleanPortMappings(id)
m.cleanupBandwidthLimit(c.LxcName())
}
rootfs := filepath.Join(containerDir, "rootfs")
exec.Command("umount", "-R", "-l", rootfs).Run()
m.detachContainerMounts(containerDir)
m.detachContainerLoopDevices(containerDir)
return wasRunning, nil
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
}
func copyTree(src string, dst string) error {
if err := os.MkdirAll(dst, 0700); err != nil {
return err
}
output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
if err != nil {
output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
if err != nil {
return fmt.Errorf("cp failed: %v, output: %s", err, string(output))
}
}
return nil
}
func dirSizeBytes(path string) int64 {
out, err := exec.Command("du", "-s", "-B1", path).Output()
if err != nil {
return 0
}
parts := strings.Fields(string(out))
if len(parts) == 0 {
return 0
}
var size int64
fmt.Sscanf(parts[0], "%d", &size)
return size
}
func safePathUnder(path string, base string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
absBase, err := filepath.Abs(base)
if err != nil {
return err
}
if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
return nil
}
return fmt.Errorf("refusing unsafe path: %s", absPath)
}
func sortSnapshotsOldestFirst(snapshots []config.Snapshot) {
sort.SliceStable(snapshots, func(i, j int) bool {
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
return ti.Before(tj)
})
}
+45 -18
View File
@@ -3,13 +3,13 @@ package server
import (
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"clicd/internal/api"
"clicd/internal/config"
"clicd/internal/lxc"
)
// webFS holds embedded frontend files
@@ -18,12 +18,19 @@ 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", "*")
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusOK)
return
}
@@ -32,6 +39,34 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
func isAllowedOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHost(u.Host)
host := normalizeHost(requestHost)
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeHost(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
@@ -50,6 +85,8 @@ func setupRoutes(mux *http.ServeMux) {
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/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
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)))
@@ -73,6 +110,9 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
// Static files
if webFS != nil {
fs := http.FileServer(webFS)
@@ -107,7 +147,6 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux()
setupRoutes(mux)
@@ -124,15 +163,3 @@ func Run() error {
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
@@ -1 +0,0 @@
+16
View File
@@ -0,0 +1,16 @@
package version
var (
Version = "1.0.8"
Repo = "MengMengCode/CLICD"
)
func Current() string {
if Version == "" {
return "dev"
}
return Version
}
+3
View File
@@ -60,6 +60,9 @@ func main() {
// Start usage monitor (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor()
// Start scheduled snapshot scanner.
manager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
+2 -1
View File
@@ -52,7 +52,8 @@ go mod tidy
go mod download
# Build for Linux amd64
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/clicd" .
BUILD_VERSION="${CLICD_VERSION:-dev}"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
echo "Go backend built successfully"
+9 -1
View File
@@ -5,8 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title>
<script>
(function() {
var theme = localStorage.getItem('clicd_theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="bg-white text-black">
<body class="bg-white text-black dark:bg-gray-950 dark:text-white">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+6 -2
View File
@@ -10,6 +10,8 @@ import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -17,8 +19,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
</div>
)
}
@@ -57,6 +59,8 @@ function App() {
<Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="settings" element={<Settings />} />
@@ -24,6 +24,7 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
snapshot_limit: 3,
assign_ipv6: false,
expires_at: '',
}
@@ -94,7 +95,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
const containers: CreateContainerRequest[] = []
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
containers.push({
...boundedForm,
name,
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
extra_ports: [],
})
}
setLoading(true)
@@ -248,6 +255,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
</div>
</Field>
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
min={1}
max={999}
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@@ -325,6 +341,7 @@ function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB
vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="min-h-screen bg-gray-50 flex">
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
+35 -21
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
charts: ResourceChartConfig[]
}) {
return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
<h2 className="text-sm font-semibold text-gray-950"></h2>
<section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<h2 className="text-sm font-semibold text-gray-950 dark:text-white"></h2>
<div className="flex items-center gap-1.5">
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
<div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button
key={item}
onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
range === item
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
}`}
>
{rangeLabels[item]}
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
</div>
<button
onClick={onRefresh}
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
title="刷新"
>
<RefreshCw className="w-4 h-4" />
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
<div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
<span className="text-gray-500">{chart.icon}</span>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span>
</div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
</div>
<div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} />
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] text-gray-400">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
</div>
)
}
@@ -132,6 +135,9 @@ function LineAreaChart({
formatValue: (value: number) => string
unitLabel?: string
}) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const width = 520
const height = 150
const left = 50
@@ -158,12 +164,20 @@ function LineAreaChart({
const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1]
// Dark mode colors
const gridStroke = isDark ? '#374151' : '#e5e7eb'
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
const axisStroke = isDark ? '#9ca3af' : '#888'
const lineStroke = isDark ? '#f9fafb' : '#444'
const gradientTop = isDark ? '#f9fafb' : '#555'
const gradientBottom = isDark ? '#374151' : '#555'
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient>
</defs>
@@ -171,8 +185,8 @@ function LineAreaChart({
const y = top + (1 - tick) * innerHeight
return (
<g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
{formatValue(maxValue * tick)}
</text>
</g>
@@ -184,8 +198,8 @@ function LineAreaChart({
const ts = minTs + tick * span
return (
<g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
{formatTime(ts)}
</text>
</g>
@@ -193,15 +207,15 @@ function LineAreaChart({
})}
{unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
<text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
{unitLabel}
</text>
)}
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
+14 -7
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { useTheme } from '../contexts/ThemeContext'
interface RingStatProps {
value: number
@@ -10,11 +11,17 @@ interface RingStatProps {
}
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2}
r={radius}
fill="none"
stroke="#f3f4f6"
stroke={bgStroke}
strokeWidth={strokeWidth}
/>
{/* Progress ring */}
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2}
r={radius}
fill="none"
stroke="#000000"
stroke={progressStroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg>
{/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
<span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div>
</div>
<div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
{subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
</div>
</div>
)
@@ -96,8 +103,8 @@ export default function RingStats({
const hasSwap = swapTotal > 0
return (
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black dark:text-white mb-4"></h2>
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat
value={cpuPercent}
+95 -24
View File
@@ -1,18 +1,25 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
ChevronLeft,
ChevronRight,
Code2,
Camera,
LayoutDashboard,
LogOut,
Moon,
Package,
Route,
ScrollText,
Server,
Settings2,
ShieldAlert,
Sun,
UserCog,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon'
interface SidebarProps {
@@ -24,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const { logout, isSubUser } = useAuth()
const { theme, toggleTheme } = useTheme()
const [version, setVersion] = useState('')
useEffect(() => {
getVersion()
.then(res => {
if (res.data?.data?.version) {
setVersion(res.data.data.version)
}
})
.catch(() => {})
}, [])
const isContainerPage =
location.pathname.startsWith('/containers') ||
@@ -31,6 +50,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isSecurityPage = location.pathname.startsWith('/security')
@@ -38,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
return (
<aside
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
collapsed ? 'w-16' : 'w-60'
}`}
>
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
<AppIcon className="w-5 h-5" />
</div>
<span className="font-bold text-black text-sm">CLICD</span>
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div>
)}
{collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
<AppIcon className="w-5 h-5" />
</div>
)}
<button
onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500"
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
title="切换侧边栏"
>
{collapsed ? (
@@ -75,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/'
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<LayoutDashboard className="w-4 h-4" />
@@ -88,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Server className="w-4 h-4" />
@@ -101,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Package className="w-4 h-4" />
@@ -116,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Settings2 className="w-4 h-4" />
@@ -128,20 +149,44 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<ShieldAlert className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/snapshots')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSnapshotsPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Camera className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/routing')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isRoutingPage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Route className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<ScrollText className="w-4 h-4" />
@@ -152,8 +197,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Code2 className="w-4 h-4" />
@@ -164,8 +209,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<UserCog className="w-4 h-4" />
@@ -175,10 +220,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
)}
</nav>
<div className="border-t border-gray-200 p-2">
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
{/* Version */}
{version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{collapsed ? (
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
) : (
<span>v{version}</span>
)}
</div>
)}
{/* Logout */}
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>}
+2 -3
View File
@@ -19,11 +19,10 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = (ticket: string) => {
const buildWebSSHUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({
container: containerName,
ticket,
})
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
@@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
return
}
const ws = new WebSocket(buildWebSSHUrl(ticket))
const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
ws.binaryType = 'arraybuffer'
wsRef.current = ws
+1 -4
View File
@@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (savedToken) {
const payload = decodeTokenPayload(savedToken)
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0
? payload.container_uuids
: Array.isArray(payload?.container_names) ? payload.container_names : []
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : []
setToken(savedToken)
setUsername(nextUsername)
@@ -123,7 +121,6 @@ export function useAuth() {
type TokenPayload = {
username?: string
sub_user?: string
container_names?: string[]
container_uuids?: string[]
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('clicd_theme') as Theme | null
if (stored === 'dark' || stored === 'light') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('clicd_theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
+131 -3
View File
@@ -14,19 +14,147 @@ body {
color: #000000;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* ============ DARK MODE OVERRIDES ============ */
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
.dark body {
background-color: #030712;
color: #f9fafb;
}
/* Background overrides */
.dark .bg-white { background-color: #111827 !important; }
.dark .bg-gray-50 { background-color: #030712 !important; }
.dark .bg-gray-100 { background-color: #1f2937 !important; }
.dark .bg-gray-200 { background-color: #374151 !important; }
/* Text overrides */
.dark .text-black { color: #f9fafb !important; }
.dark .text-gray-950 { color: #f9fafb !important; }
.dark .text-gray-900 { color: #f3f4f6 !important; }
.dark .text-gray-800 { color: #e5e7eb !important; }
.dark .text-gray-700 { color: #d1d5db !important; }
.dark .text-gray-600 { color: #9ca3af !important; }
.dark .text-gray-500 { color: #9ca3af !important; }
.dark .text-gray-400 { color: #6b7280 !important; }
/* Border overrides */
.dark .border-gray-100 { border-color: #1f2937 !important; }
.dark .border-gray-200 { border-color: #374151 !important; }
.dark .border-gray-300 { border-color: #4b5563 !important; }
/* Divider overrides */
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
/* Hover background overrides */
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
/* Hover text overrides */
.dark .hover\:text-black:hover { color: #f9fafb !important; }
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
/* Shadow */
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
.dark .bg-black + span,
.dark button.bg-black { color: #111827 !important; }
.dark button.bg-black span { color: #111827 !important; }
/* Fix for CTA buttons (bg-black text-white) */
.dark button.bg-black,
.dark a.bg-black {
background-color: #f9fafb !important;
color: #111827 !important;
}
/* Fix nested text-white inside bg-black in dark mode */
.dark .bg-black .text-white,
.dark .bg-black.text-white {
color: #111827 !important;
}
/* Invert sidebar active state */
.dark button.bg-black.text-white,
.dark button.bg-black > span {
color: #111827 !important;
}
.dark button.bg-black svg {
color: #111827 !important;
}
/* Hover: bg-gray-800 in dark mode */
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
/* Status badge backgrounds */
.dark .bg-green-50 { background-color: #064e3b !important; }
.dark .bg-red-50 { background-color: #450a0a !important; }
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
.dark .text-red-600 { color: #fca5a5 !important; }
.dark .text-red-700 { color: #fca5a5 !important; }
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
.dark .border-t-black { border-top-color: #f9fafb !important; }
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
/* Placeholder */
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
/* Success/Error text standalone */
.dark .text-green-600 { color: #6ee7b7 !important; }
/* Modal backdrop */
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
/* Toggle / switch */
.dark .bg-gray-300 { background-color: #4b5563 !important; }
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
.dark .peer-checked\:bg-black:checked + *,
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
+8 -5
View File
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { DialogProvider } from './components/Dialog'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)
+381
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeft,
Camera,
Clock,
Copy,
Cpu,
HardDrive,
@@ -29,9 +31,12 @@ import {
Container,
ContainerUsage,
createSubUser,
createContainerSnapshot,
deleteContainer,
deleteContainerSnapshot,
deletePortMapping,
getContainer,
getContainerSnapshots,
getContainerUsage,
getHostInfo,
getTrafficInfo,
@@ -44,8 +49,13 @@ import {
restartContainer,
startContainer,
stopContainer,
Snapshot,
SnapshotSchedule,
Template,
updateContainerExpiry,
updateSnapshotQuota,
updateSnapshotSchedule,
restoreContainerSnapshot,
resetTraffic,
updateTrafficLimit,
updateResourceLimit,
@@ -125,6 +135,15 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showSnapshots, setShowSnapshots] = useState(false)
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [snapshotQuota, setSnapshotQuota] = useState(3)
const [snapshotQuotaDraft, setSnapshotQuotaDraft] = useState(3)
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
const [snapshotBusy, setSnapshotBusy] = useState('')
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const fetchContainer = useCallback(async () => {
if (!containerIdentifier) return
@@ -142,6 +161,21 @@ export default function ContainerDetail() {
}
}, [containerIdentifier, isSubUser])
const fetchSnapshots = useCallback(async () => {
if (!containerIdentifier) return
try {
const res = await getContainerSnapshots(containerIdentifier)
const data = res.data.data
const quota = data?.quota || container?.snapshot_limit || 3
setSnapshots(data?.snapshots || [])
setSnapshotQuota(quota)
setSnapshotQuotaDraft(quota)
setSnapshotSchedule(data?.schedule || null)
} catch (err) {
console.error('Failed to fetch snapshots:', err)
}
}, [containerIdentifier, container?.snapshot_limit])
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
if (!containerIdentifier || !currentContainer) return
@@ -198,6 +232,10 @@ export default function ContainerDetail() {
return () => window.clearInterval(timer)
}, [fetchUsage])
useEffect(() => {
if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots])
// Poll task status for this container
useEffect(() => {
if (!containerIdentifier) return
@@ -478,6 +516,108 @@ export default function ContainerDetail() {
}
}
const handleCreateSnapshot = async () => {
if (!containerIdentifier) return
if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return
}
if (container?.status === 'running') {
const confirmed = await dialog.confirm(
'拍摄快照',
`拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?`
)
if (!confirmed) return
}
setSnapshotBusy('create')
try {
await createContainerSnapshot(containerIdentifier)
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。')
} finally {
setSnapshotBusy('')
}
}
const openSnapshotSchedule = () => {
setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00',
})
setShowSnapshotSchedule(true)
}
const saveSnapshotSchedule = async (enabled: boolean) => {
if (!containerIdentifier) return
const intervalHours = snapshotScheduleDraft.intervalHours
const scheduleTime = snapshotScheduleDraft.time || '03:00'
if (enabled && intervalHours < 24) {
await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。')
return
}
setSnapshotBusy('schedule')
try {
await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime)
await Promise.all([fetchSnapshots(), fetchContainer()])
setShowSnapshotSchedule(false)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。')
} finally {
setSnapshotBusy('')
}
}
const saveSnapshotQuota = async () => {
if (!containerIdentifier || isSubUser) return
const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1))
setSnapshotBusy('quota')
try {
await updateSnapshotQuota(containerIdentifier, nextQuota)
setSnapshotQuota(nextQuota)
setSnapshotQuotaDraft(nextQuota)
setEditingSnapshotQuota(false)
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。')
} finally {
setSnapshotBusy('')
}
}
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
setSnapshotBusy(snapshot.id)
try {
await deleteContainerSnapshot(containerIdentifier, snapshot.id)
await fetchSnapshots()
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。')
} finally {
setSnapshotBusy('')
}
}
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
setSnapshotBusy(snapshot.id)
try {
await restoreContainerSnapshot(containerIdentifier, snapshot.id)
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。')
} finally {
setSnapshotBusy('')
}
}
const copyText = async (text: string) => {
try {
await copyText(text)
@@ -641,6 +781,10 @@ export default function ContainerDetail() {
NAT
</ActionButton>
</>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
{!isSubUser && (
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
<RefreshCw className="w-3.5 h-3.5" />
@@ -846,6 +990,171 @@ export default function ContainerDetail() {
</Modal>
)}
{showSnapshots && (
<Modal
title="快照"
onClose={() => {
setShowSnapshots(false)
setEditingSnapshotQuota(false)
}}
wide
extra={
<div className="flex items-center gap-2">
<button
onClick={openSnapshotSchedule}
disabled={!!snapshotBusy}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
snapshotSchedule?.enabled
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
} disabled:opacity-50`}
>
<Clock className="w-3.5 h-3.5" />
{snapshotBusy === 'schedule' ? '处理中...' : snapshotSchedule?.enabled ? '定时设置' : '定时快照'}
</button>
<button
onClick={handleCreateSnapshot}
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
>
<Camera className="w-3.5 h-3.5" />
{snapshotBusy === 'create' ? '创建中...' : '新建快照'}
</button>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
<div>
<span className="font-mono text-gray-900">
{snapshots.length}
</span>
</div>
<div className="flex items-center gap-2">
<span></span>
<span className="font-mono text-gray-900">{snapshotQuota}</span>
{!isSubUser && (
<button
onClick={() => {
setSnapshotQuotaDraft(snapshotQuota)
setEditingSnapshotQuota((value) => !value)
}}
className="inline-flex items-center gap-1 rounded border border-gray-300 bg-white px-2 py-1 text-[11px] text-gray-700 hover:bg-gray-50"
disabled={snapshotBusy === 'quota'}
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
<div>
<span className="text-gray-900">
{snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)}${snapshotSchedule.time || '03:00'} 执行` : '未开启'}
</span>
</div>
{snapshotSchedule?.next_run && (
<div><span className="font-mono text-gray-900">{formatDateTime(snapshotSchedule.next_run)}</span></div>
)}
</div>
{editingSnapshotQuota && !isSubUser && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="子用户每台容器快照上限">
<input
type="number"
min={1}
max={999}
value={snapshotQuotaDraft}
onChange={(event) => setSnapshotQuotaDraft(Math.max(1, Math.round(Number(event.target.value) || 1)))}
className="w-44 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
/>
</Field>
<div className="flex gap-2 pb-0.5">
<button
onClick={() => {
setEditingSnapshotQuota(false)
setSnapshotQuotaDraft(snapshotQuota)
}}
className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
disabled={snapshotBusy === 'quota'}
>
</button>
<button
onClick={saveSnapshotQuota}
disabled={snapshotBusy === 'quota'}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{snapshotBusy === 'quota' ? '保存中...' : '保存'}
</button>
</div>
</div>
)}
<SnapshotTable
snapshots={snapshots}
busy={snapshotBusy}
onRestore={handleRestoreSnapshot}
onDelete={handleDeleteSnapshot}
/>
</div>
</Modal>
)}
{showSnapshotSchedule && (
<Modal title="定时快照" onClose={() => setShowSnapshotSchedule(false)}>
<div className="space-y-4">
<Field label="自动快照周期">
<select
value={snapshotScheduleDraft.intervalHours}
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, intervalHours: Number(e.target.value) })}
className={inputClass}
>
<option value={24}>1 </option>
<option value={72}>3 </option>
<option value={168}>7 </option>
<option value={336}>14 </option>
</select>
</Field>
<Field label="执行时间">
<input
type="time"
value={snapshotScheduleDraft.time}
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })}
className={inputClass}
/>
</Field>
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
{`${formatScheduleInterval(snapshotScheduleDraft.intervalHours)}${snapshotScheduleDraft.time || '03:00'} 执行。`}
</div>
<div className="flex justify-between gap-3 pt-2">
{snapshotSchedule?.enabled ? (
<button
onClick={() => saveSnapshotSchedule(false)}
disabled={snapshotBusy === 'schedule'}
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50"
>
</button>
) : <div />}
<div className="flex gap-2">
<button onClick={() => setShowSnapshotSchedule(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button
onClick={() => saveSnapshotSchedule(true)}
disabled={snapshotBusy === 'schedule'}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
{snapshotBusy === 'schedule' ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
</Modal>
)}
{showNat && (
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
!isSubUser && canAddMapping && !showNatAdd && (
@@ -1229,6 +1538,65 @@ function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: {
)
}
function SnapshotTable({ snapshots, busy, onRestore, onDelete }: {
snapshots: Snapshot[]
busy: string
onRestore: (snapshot: Snapshot) => void
onDelete: (snapshot: Snapshot) => void
}) {
if (snapshots.length === 0) {
return <p className="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400"></p>
}
return (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full min-w-[760px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{snapshots.map((snapshot) => (
<tr key={snapshot.id}>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{snapshot.created_at}</td>
<td className="px-3 py-2">
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
{snapshot.scheduled ? '定时' : '手动'}
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-600">{snapshot.created_by || '-'}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1.5">
<button
onClick={() => onRestore(snapshot)}
disabled={!!busy}
className="rounded border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{busy === snapshot.id ? '处理中...' : '恢复'}
</button>
<button
onClick={() => onDelete(snapshot)}
disabled={!!busy}
className="rounded border border-red-200 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) {
if (mappings.length === 0) {
return <p className="text-sm text-gray-400"></p>
@@ -1389,6 +1757,19 @@ function formatExpiration(value?: string): string {
return value.length >= 10 ? value.slice(0, 10) : value
}
function formatDateTime(value?: string): string {
if (!value) return '-'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function formatScheduleInterval(hours: number): string {
if (hours === 24) return '1 天'
if (hours % 24 === 0) return `${hours / 24}`
return `${hours} 小时`
}
function formatRate(bytesPerSecond: number): string {
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
+7
View File
@@ -538,8 +538,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ssh_password: '',
port_mappings: [],
port_mapping_limit: 2,
snapshot_limit: cfg.snapshot_limit || 3,
created_at: '',
expires_at: cfg.expires_at,
snapshot_schedule_enabled: false,
snapshot_schedule_interval_hours: 24,
snapshot_schedule_time: '03:00',
snapshot_schedule_last_run: '',
snapshot_schedule_next_run: '',
snapshot_schedule_created_by: '',
isPlaceholder: true,
}
}
+289
View File
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useState } from 'react'
import { Network, RefreshCw, Route, Server } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo } from '../services/api'
export default function Routing() {
const navigate = useNavigate()
const [routing, setRouting] = useState<RoutingInfo | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [nat4Page, setNat4Page] = useState(1)
const [ipv6Page, setIPv6Page] = useState(1)
const fetchData = useCallback(async () => {
try {
const res = await getRoutingInfo()
setRouting(res.data.data || null)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
</div>
)
}
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
const pageSize = 10
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize))
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black"></h1>
<p className="mt-1 text-sm text-gray-500">宿 LXC NAT4 IPv6 </p>
</div>
<button
onClick={() => { setRefreshing(true); fetchData() }}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<CapacityCard
title="NAT4 端口"
icon={<Route className="h-5 w-5 text-gray-600" />}
remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0}
label="剩余端口 / 端口总数"
/>
<CapacityCard
title="IPv6 地址"
icon={<Network className="h-5 w-5 text-gray-600" />}
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
total={formatCapacity(routing?.ipv6.total || '0')}
used={routing?.ipv6.used || 0}
label={`剩余地址 / 地址总数 · ${ipv6Prefix}`}
/>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">NAT4 </div>
<div className="mt-1 text-xs text-gray-500"> {nat4Mappings.length} </div>
</div>
{nat4Mappings.length === 0 ? (
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">LXC </th>
<th className="px-4 py-3 text-left font-medium"> IPv4</th>
<th className="px-4 py-3 text-left font-medium">宿</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pagedNat4Mappings.map((mapping, index) => (
<tr key={`${mapping.container_id}-${mapping.host_port}-${mapping.protocol}-${index}`} className="hover:bg-gray-50">
<td className="px-4 py-3">
<button
onClick={() => navigate(`/container/${mapping.container_id}`)}
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
>
<Server className="h-4 w-4 text-gray-400" />
{mapping.container_name}
</button>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.lxc_name}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.ip || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_port}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.container_port}</td>
<td className="px-4 py-3 uppercase text-gray-600">{mapping.protocol || '-'}</td>
<td className="px-4 py-3 text-gray-600">{mapping.description || '-'}</td>
<td className="px-4 py-3"><StatusBadge status={mapping.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={currentNat4Page}
totalPages={nat4TotalPages}
totalItems={nat4Mappings.length}
pageSize={pageSize}
onPageChange={setNat4Page}
/>
</>
)}
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-200 px-4 py-3">
<div className="text-sm font-medium text-black">IPv6 </div>
<div className="mt-1 text-xs text-gray-500"> {ipv6Assignments.length} </div>
</div>
{ipv6Assignments.length === 0 ? (
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">LXC </th>
<th className="px-4 py-3 text-left font-medium">IPv6 </th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pagedIPv6Assignments.map((item) => (
<tr key={`${item.container_id}-${item.address}`} className="hover:bg-gray-50">
<td className="px-4 py-3">
<button
onClick={() => navigate(`/container/${item.container_id}`)}
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
>
<Server className="h-4 w-4 text-gray-400" />
{item.container_name}
</button>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">/{item.prefix_len || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={currentIPv6Page}
totalPages={ipv6TotalPages}
totalItems={ipv6Assignments.length}
pageSize={pageSize}
onPageChange={setIPv6Page}
/>
</>
)}
</div>
</div>
)
}
function Pagination({ page, totalPages, totalItems, pageSize, onPageChange }: {
page: number
totalPages: number
totalItems: number
pageSize: number
onPageChange: (page: number) => void
}) {
if (totalPages <= 1) return null
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, totalItems)
return (
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm">
<div className="text-xs text-gray-500">
{start}-{end} {totalItems}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<span className="min-w-16 text-center text-xs text-gray-500">
{page} / {totalPages}
</span>
<button
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
)
}
function CapacityCard({ title, icon, remaining, total, used, label }: {
title: string
icon: React.ReactNode
remaining: string
total: string
used: number
label: string
}) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="mt-2 flex items-end gap-2">
<span className="text-2xl font-semibold text-black">{remaining}</span>
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
</div>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-gray-100">
{icon}
</div>
</div>
<div className="mt-3 text-xs text-gray-500">{label}</div>
<div className="mt-1 text-xs text-gray-400"> {used}</div>
</div>
)
}
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
{icon}
</div>
<div className="text-sm font-medium text-gray-700">{text}</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const running = status === 'running'
return (
<span className={`rounded px-2 py-1 text-xs ${running ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{running ? '运行中' : (status || '未知')}
</span>
)
}
function formatCapacity(value: string): string {
if (value === 'large') return '充足'
return value
}
+108
View File
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from 'react'
import { Camera, RefreshCw, Server } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getSnapshots, Snapshot } from '../services/api'
export default function Snapshots() {
const navigate = useNavigate()
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const fetchData = useCallback(async () => {
try {
const res = await getSnapshots()
setSnapshots(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
</div>
)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"> {snapshots.length} </p>
</div>
<button
onClick={() => { setRefreshing(true); fetchData() }}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
{snapshots.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
<Camera className="h-7 w-7 text-gray-400" />
</div>
<div className="text-sm font-medium text-gray-700"></div>
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">LXC </th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{snapshots.map((snapshot) => (
<tr key={snapshot.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<button
onClick={() => navigate(`/container/${snapshot.container_id}`)}
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
>
<Server className="h-4 w-4 text-gray-400" />
{snapshot.container_name}
</button>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{snapshot.lxc_name}</td>
<td className="px-4 py-3 text-gray-700">{snapshot.created_at}</td>
<td className="px-4 py-3">
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
{snapshot.scheduled ? '定时' : '手动'}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function formatBytes(bytes: number): string {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
+108 -2
View File
@@ -71,8 +71,15 @@ export interface Container {
ssh_password: string
port_mappings: PortMapping[]
port_mapping_limit: number
snapshot_limit: number
created_at: string
expires_at: string
snapshot_schedule_enabled: boolean
snapshot_schedule_interval_hours: number
snapshot_schedule_time: string
snapshot_schedule_last_run: string
snapshot_schedule_next_run: string
snapshot_schedule_created_by: string
}
export interface Template {
@@ -100,6 +107,7 @@ export interface CreateContainerRequest {
io_speed_mbps: number
extra_ports: number[]
port_mapping_count: number
snapshot_limit: number
assign_ipv6: boolean
expires_at: string
}
@@ -275,6 +283,45 @@ export const getIPv6Status = () =>
export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
export interface RouteCapacity {
used: number
remaining: string
total: string
}
export interface NAT4Route {
container_id: number
container_name: string
lxc_name: string
status: string
ip: string
host_port: number
container_port: number
protocol: string
description: string
}
export interface IPv6Route {
container_id: number
container_name: string
lxc_name: string
status: string
address: string
prefix_len: number
interface: string
}
export interface RoutingInfo {
nat4: RouteCapacity
ipv6: RouteCapacity
nat4_mappings: NAT4Route[]
ipv6_assignments: IPv6Route[]
ipv6_prefixes: IPv6PrefixInfo[]
}
export const getRoutingInfo = () =>
api.get<APIResponse<RoutingInfo>>('/routing')
// Templates
export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates')
@@ -354,6 +401,62 @@ export const getOversellStatus = () =>
export const reclaimMemory = () =>
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
// Snapshots
export interface Snapshot {
id: string
container_id: number
container_name: string
lxc_name: string
created_at: string
created_by: string
scheduled: boolean
path: string
size_bytes: number
}
export interface SnapshotSchedule {
enabled: boolean
interval_hours: number
time: string
last_run: string
next_run: string
created_by: string
}
export interface ContainerSnapshotsResponse {
snapshots: Snapshot[]
quota: number
schedule: SnapshotSchedule
}
export const getSnapshots = () =>
api.get<APIResponse<Snapshot[]>>('/snapshots')
export const getContainerSnapshots = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
export const createContainerSnapshot = (id: ContainerIdentifier) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
export const restoreContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
api.post<APIResponse>(`/containers/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: 600000 })
export const updateSnapshotSchedule = (id: ContainerIdentifier, enabled: boolean, intervalHours: number, time: string) =>
api.post<APIResponse<{ container: Container; snapshot?: Snapshot }>>(
`/containers/${id}/snapshots/schedule`,
{ enabled, interval_hours: intervalHours, time },
{ timeout: 600000 }
)
export const updateSnapshotQuota = (id: ContainerIdentifier, snapshotLimit: number) =>
api.put<APIResponse<{ container: Container; quota: number }>>(
`/containers/${id}/snapshots/quota`,
{ snapshot_limit: snapshotLimit }
)
// WebSSH URL generator
export const getWebSSHUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -390,10 +493,9 @@ export const batchAction = (action: string, containers: number[], templateId?: s
export interface SubUser {
id: string
username: string
password: string
password?: string
container_names: string[]
container_uuids?: string[]
token: string
access_code: string
created_at: string
}
@@ -451,4 +553,8 @@ export const getSecuritySummary = () =>
export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
export default api
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
+69 -8
View File
@@ -71,6 +71,73 @@ remove_path() {
log "Removed $path"
}
unmount_path_tree() {
path="$1"
if [ ! -e "$path" ]; then
return
fi
if has_cmd findmnt; then
findmnt -R -n -o TARGET "$path" 2>/dev/null | sort -r | while IFS= read -r mountpoint; do
[ -n "$mountpoint" ] || continue
umount -R -l "$mountpoint" >/dev/null 2>&1 || umount -l "$mountpoint" >/dev/null 2>&1 || true
done
fi
umount -R -l "$path/rootfs" >/dev/null 2>&1 || umount -l "$path/rootfs" >/dev/null 2>&1 || true
umount -R -l "$path" >/dev/null 2>&1 || umount -l "$path" >/dev/null 2>&1 || true
}
detach_container_loop_devices() {
path="$1"
if ! has_cmd losetup; then
return
fi
for image in "$path"/rootfs.img "$path"/*.img; do
[ -e "$image" ] || continue
losetup -j "$image" 2>/dev/null | sed 's/:.*//' | while IFS= read -r loopdev; do
[ -n "$loopdev" ] || continue
losetup -d "$loopdev" >/dev/null 2>&1 || true
done
done
}
kill_path_users() {
path="$1"
if has_cmd fuser && [ -e "$path" ]; then
fuser -km "$path" >/dev/null 2>&1 || true
fi
}
remove_lxc_container_dir() {
container_dir="$1"
container_name="$(basename "$container_dir")"
if has_cmd lxc-stop; then
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
fi
if has_cmd lxc-destroy; then
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
fi
unmount_path_tree "$container_dir"
detach_container_loop_devices "$container_dir"
if rm -rf "$container_dir" >/dev/null 2>&1; then
log "Removed $container_dir"
return
fi
log "Retrying removal after terminating processes using $container_dir..."
kill_path_users "$container_dir/rootfs"
kill_path_users "$container_dir"
unmount_path_tree "$container_dir"
detach_container_loop_devices "$container_dir"
rm -rf "$container_dir"
log "Removed $container_dir"
}
uninstall_clicd() {
log "Uninstalling CLICD..."
@@ -89,14 +156,7 @@ uninstall_clicd() {
log "Destroying LXC containers under /var/lib/lxc..."
for container_dir in /var/lib/lxc/*; do
[ -d "$container_dir" ] || continue
container_name="$(basename "$container_dir")"
if has_cmd lxc-stop; then
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
fi
if has_cmd lxc-destroy; then
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
fi
remove_path "$container_dir"
remove_lxc_container_dir "$container_dir"
done
remove_path /etc/systemd/system/clicd.service
@@ -106,6 +166,7 @@ uninstall_clicd() {
remove_path /var/log/clicd.log
remove_path /var/log/clicd.err
remove_path /root/.clicd
unmount_path_tree /var/lib/lxc
remove_path /var/lib/lxc
remove_path /var/cache/lxc